JWT, server modüler hale getirildi, Torrent durumu kalıcı hale getirildi.

This commit is contained in:
2025-11-29 01:42:43 +03:00
parent f4c9d4ca41
commit 08b25b418e
13 changed files with 759 additions and 285 deletions

221
server/modules/auth.js Normal file
View File

@@ -0,0 +1,221 @@
import express from "express";
import fs from "fs";
import path from "path";
import jwt from "jsonwebtoken";
import crypto from "crypto";
const DEFAULT_ACCESS_TTL = process.env.JWT_TTL || "15m";
const DEFAULT_REFRESH_TTL = process.env.JWT_REFRESH_TTL || "30d";
const ITERATIONS = 120000;
const KEY_LEN = 64;
const DIGEST = "sha512";
function ensureDir(target) {
const dir = path.dirname(target);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
function readJsonSafe(filePath, fallback = null) {
if (!fs.existsSync(filePath)) return fallback;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch (err) {
console.warn(`⚠️ JSON okunamadı (${filePath}): ${err.message}`);
return fallback;
}
}
function writeJsonSafe(filePath, data) {
try {
ensureDir(filePath);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
} catch (err) {
console.warn(`⚠️ JSON yazılamadı (${filePath}): ${err.message}`);
}
}
function buildPasswordHash(password, salt = crypto.randomBytes(16).toString("hex")) {
const hash = crypto
.pbkdf2Sync(password, salt, ITERATIONS, KEY_LEN, DIGEST)
.toString("hex");
return { hash, salt, iterations: ITERATIONS, keylen: KEY_LEN, digest: DIGEST };
}
function verifyPassword(password, user) {
if (!user?.password) return false;
const { salt, iterations, keylen, digest, hash } = user.password;
const candidate = crypto
.pbkdf2Sync(password, salt, iterations, keylen, digest)
.toString("hex");
return crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(hash));
}
function loadSecret(secretPath) {
if (process.env.JWT_SECRET) return process.env.JWT_SECRET;
if (secretPath && fs.existsSync(secretPath)) {
try {
return fs.readFileSync(secretPath, "utf-8").trim();
} catch (err) {
console.warn(`⚠️ JWT secret okunamadı (${secretPath}): ${err.message}`);
}
}
const generated = crypto.randomBytes(48).toString("hex");
if (secretPath) {
try {
ensureDir(secretPath);
fs.writeFileSync(secretPath, generated, "utf-8");
console.log("🔑 Yeni JWT secret oluşturuldu (diskte saklandı).");
} catch (err) {
console.warn(`⚠️ JWT secret yazılamadı (${secretPath}): ${err.message}`);
}
}
return generated;
}
function loadUsers(usersPath, defaultUser) {
let users = readJsonSafe(usersPath, []);
if (!Array.isArray(users)) users = [];
if (defaultUser?.username && defaultUser?.password) {
const exists = users.some((u) => u.username === defaultUser.username);
if (!exists) {
const password = buildPasswordHash(defaultUser.password);
users.push({
username: defaultUser.username,
role: defaultUser.role || "admin",
password
});
writeJsonSafe(usersPath, users);
console.log(`👤 Varsayılan kullanıcı eklendi: ${defaultUser.username}`);
}
}
return users;
}
export function createAuth({ usersPath, secretPath }) {
const secret = loadSecret(secretPath);
const users = loadUsers(usersPath, {
username: process.env.USERNAME || "admin",
password: process.env.PASSWORD || "dupe",
role: "admin"
});
function signToken(payload, opts = {}) {
const jwtOpts = {
issuer: "dupe",
audience: "dupe-clients",
expiresIn: opts.expiresIn || DEFAULT_ACCESS_TTL
};
// subject zaten payload.sub içinde ise tekrar opsiyonlara eklemeyelim
if (!payload.sub && payload.username) {
jwtOpts.subject = payload.username;
}
return jwt.sign(payload, secret, jwtOpts);
}
function verifyToken(token, expectedType = "access") {
try {
const decoded = jwt.verify(token, secret, {
issuer: "dupe",
audience: "dupe-clients"
});
if (expectedType && decoded.type !== expectedType) return null;
return decoded;
} catch (err) {
return null;
}
}
function issueTokens(user) {
const base = { sub: user.username, role: user.role || "user" };
const accessToken = signToken({ ...base, type: "access" }, { expiresIn: DEFAULT_ACCESS_TTL });
const refreshToken = signToken({ ...base, type: "refresh" }, { expiresIn: DEFAULT_REFRESH_TTL });
return { accessToken, refreshToken };
}
function requireAuth(req, res, next) {
const header = req.headers.authorization || "";
const bearer = header.startsWith("Bearer ") ? header.slice(7) : null;
const token = bearer || req.query.token;
if (!token) return res.status(401).json({ error: "Unauthorized" });
const decoded = verifyToken(token, req.path.startsWith("/media/") ? null : "access");
if (!decoded) return res.status(401).json({ error: "Unauthorized" });
req.user = decoded;
next();
}
function requireRole(role) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const roles = Array.isArray(role) ? role : [role];
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: "Forbidden" });
}
next();
};
}
function issueMediaToken(targetPath, ttlSeconds = 3600) {
const expiresIn = Math.min(Math.max(Number(ttlSeconds) || 3600, 60), 72 * 3600);
return signToken(
{ type: "media", path: targetPath || "*", role: "media" },
{ expiresIn }
);
}
function verifyMediaToken(token, requestedPath) {
const decoded = verifyToken(token, "media");
if (!decoded) return null;
if (decoded.path && decoded.path !== "*" && requestedPath) {
if (!requestedPath.startsWith(decoded.path)) return null;
}
return decoded;
}
const router = express.Router();
router.post("/api/login", (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) {
return res.status(400).json({ error: "username ve password gerekli" });
}
const user = users.find((u) => u.username === username);
if (!user || !verifyPassword(password, user)) {
return res.status(401).json({ error: "Invalid credentials" });
}
const { accessToken, refreshToken } = issueTokens(user);
res.json({
accessToken,
refreshToken,
user: { username: user.username, role: user.role || "user" }
});
});
router.post("/api/token/refresh", (req, res) => {
const { refreshToken } = req.body || {};
if (!refreshToken) {
return res.status(400).json({ error: "refreshToken gerekli" });
}
const decoded = verifyToken(refreshToken, "refresh");
if (!decoded) return res.status(401).json({ error: "Unauthorized" });
const accessToken = signToken(
{ sub: decoded.sub, role: decoded.role, type: "access" },
{ expiresIn: DEFAULT_ACCESS_TTL }
);
res.json({ accessToken });
});
router.get("/api/auth/profile", requireAuth, (req, res) => {
res.json({ user: { username: req.user.sub, role: req.user.role } });
});
return {
router,
requireAuth,
requireRole,
issueMediaToken,
verifyMediaToken,
verifyToken
};
}

38
server/modules/health.js Normal file
View File

@@ -0,0 +1,38 @@
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
async function checkBinary(binary) {
try {
const { stdout } = await execAsync(`which ${binary}`);
const location = stdout.trim();
return { name: binary, ok: Boolean(location), location: location || null };
} catch (err) {
return { name: binary, ok: false, error: err.message };
}
}
export async function buildHealthReport({ ffmpegPath = "ffmpeg", ffprobePath = "ffprobe", tmdbKey, tvdbKey, fanartKey }) {
const binaries = await Promise.all([
checkBinary(ffmpegPath),
checkBinary(ffprobePath)
]);
return {
binaries,
apis: {
tmdb: { configured: Boolean(tmdbKey) },
tvdb: { configured: Boolean(tvdbKey) },
fanart: { configured: Boolean(fanartKey) }
},
timestamp: new Date().toISOString()
};
}
export function healthRouter(getReport) {
return (req, res) => {
const report = typeof getReport === "function" ? getReport() : null;
res.json(report || { status: "unknown" });
};
}

61
server/modules/state.js Normal file
View File

@@ -0,0 +1,61 @@
import fs from "fs";
import path from "path";
export function discoverSavedTorrents(downloadDir) {
if (!downloadDir || !fs.existsSync(downloadDir)) return [];
const entries = fs.readdirSync(downloadDir, { withFileTypes: true });
const folders = entries.filter((e) => e.isDirectory()).map((e) => e.name);
const candidates = [];
for (const name of folders) {
const savePath = path.join(downloadDir, name);
const infoPath = path.join(savePath, "info.json");
if (!fs.existsSync(infoPath)) continue;
try {
const info = JSON.parse(fs.readFileSync(infoPath, "utf-8"));
const magnetURI = info.magnetURI || info.magnet || null;
const infoHash = info.infoHash || null;
candidates.push({
folder: name,
savePath,
infoPath,
infoHash,
magnetURI,
added: info.added || fs.statSync(infoPath).mtimeMs,
name: info.name || name
});
} catch (err) {
console.warn(`⚠️ info.json okunamadı (${infoPath}): ${err.message}`);
}
}
return candidates;
}
export function restoreTorrentsFromDisk({ downloadDir, client, register }) {
const candidates = discoverSavedTorrents(downloadDir);
const restored = [];
for (const candidate of candidates) {
if (!candidate.magnetURI) {
console.warn(`⚠️ ${candidate.folder} için magnetURI yok, atlanıyor.`);
continue;
}
try {
const torrent = client.add(candidate.magnetURI, {
path: candidate.savePath,
announce: []
});
if (typeof register === "function") {
register(torrent, {
savePath: candidate.savePath,
added: candidate.added,
restored: true
});
}
restored.push({ infoHash: torrent.infoHash || candidate.infoHash, folder: candidate.folder });
} catch (err) {
console.warn(`⚠️ ${candidate.folder} yeniden eklenemedi: ${err.message}`);
}
}
return restored;
}

View File

@@ -0,0 +1,35 @@
import { WebSocketServer } from "ws";
import url from "url";
function parseTokenFromRequest(req) {
try {
const parsed = url.parse(req.url, true);
return parsed.query?.token || null;
} catch (err) {
return null;
}
}
export function createWebsocketServer(server, { verifyToken, onMessage }) {
const wss = new WebSocketServer({ server });
wss.on("connection", (ws, req) => {
const token = parseTokenFromRequest(req);
const decoded = token && verifyToken ? verifyToken(token, "access") : null;
if (!decoded) {
ws.close();
return;
}
ws.user = decoded;
ws.on("message", (msg) => onMessage && onMessage(msg, ws));
ws.on("error", (err) => console.warn("🔌 WebSocket error:", err.message));
});
return wss;
}
export function broadcastJson(wss, payload) {
if (!wss) return;
const data = JSON.stringify(payload);
wss.clients.forEach((c) => c.readyState === 1 && c.send(data));
}

View File

@@ -8,6 +8,7 @@
"dependencies": {
"cors": "^2.8.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"mime-types": "^2.1.35",
"multer": "^1.4.5-lts.1",
"node-fetch": "^3.3.2",

View File

@@ -5,11 +5,14 @@ import WebTorrent from "webtorrent";
import fs from "fs";
import path from "path";
import mime from "mime-types";
import { WebSocketServer } from "ws";
import { fileURLToPath } from "url";
import { exec } from "child_process";
import crypto from "crypto"; // 🔒 basit token üretimi için
import { getSystemDiskInfo } from "./utils/diskSpace.js";
import { createAuth } from "./modules/auth.js";
import { buildHealthReport, healthRouter } from "./modules/health.js";
import { restoreTorrentsFromDisk } from "./modules/state.js";
import { createWebsocketServer, broadcastJson } from "./modules/websocket.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -148,6 +151,37 @@ function ensureDirForFile(filePath) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
const AUTH_DATA_DIR = path.join(__dirname, "data");
const USERS_FILE = path.join(AUTH_DATA_DIR, "users.json");
const JWT_SECRET_FILE = path.join(CACHE_DIR, "jwt-secret");
let healthSnapshot = null;
const auth = createAuth({ usersPath: USERS_FILE, secretPath: JWT_SECRET_FILE });
const { router: authRouter, requireAuth, requireRole, issueMediaToken, verifyToken } = auth;
app.use(authRouter);
buildHealthReport({
ffmpegPath: "ffmpeg",
ffprobePath: FFPROBE_PATH,
tmdbKey: TMDB_API_KEY,
tvdbKey: TVDB_API_KEY,
fanartKey: FANART_TV_API_KEY
})
.then((report) => {
healthSnapshot = report;
const missing = report.binaries.filter((b) => !b.ok);
if (missing.length) {
console.warn("⚠️ Eksik bağımlılıklar:", missing.map((m) => m.name).join(", "));
}
if (!TMDB_API_KEY || !TVDB_API_KEY) {
console.warn("⚠️ TMDB/TVDB anahtarları eksik, metadata özellikleri sınırlı olacak.");
}
})
.catch((err) => console.warn("⚠️ Sağlık kontrolü çalıştırılamadı:", err.message));
app.get("/api/health", requireAuth, healthRouter(() => healthSnapshot));
function tvdbImageUrl(pathSegment) {
if (!pathSegment) return null;
if (pathSegment.startsWith("http")) return pathSegment;
@@ -3244,9 +3278,9 @@ function snapshot() {
queueVideoThumbnail(path.join(savePath, bestVideo.path), relPath);
}
return {
infoHash: torrent.infoHash,
name: torrent.name,
return {
infoHash: torrent.infoHash,
name: torrent.name,
progress: torrent.progress,
downloaded: torrent.downloaded,
downloadSpeed: paused ? 0 : torrent.downloadSpeed, // Pause durumunda hız 0
@@ -3268,27 +3302,259 @@ function snapshot() {
);
}
// --- Basit kimlik doğrulama sistemi ---
const USERNAME = process.env.USERNAME;
const PASSWORD = process.env.PASSWORD;
let activeTokens = new Set();
function wireTorrent(torrent, { savePath, added, respond, restored = false }) {
torrents.set(torrent.infoHash, {
torrent,
selectedIndex: 0,
savePath,
added,
paused: false
});
app.post("/api/login", (req, res) => {
const { username, password } = req.body;
if (username === USERNAME && password === PASSWORD) {
const token = crypto.randomBytes(24).toString("hex");
activeTokens.add(token);
return res.json({ token });
}
res.status(401).json({ error: "Invalid credentials" });
});
torrent.on("ready", () => {
onTorrentReady({ torrent, savePath, added, respond, restored });
});
function requireAuth(req, res, next) {
const token = req.headers.authorization?.split(" ")[1] || req.query.token;
if (!token || !activeTokens.has(token))
return res.status(401).json({ error: "Unauthorized" });
next();
torrent.on("done", () => {
onTorrentDone({ torrent });
});
}
function onTorrentReady({ torrent, savePath, added, respond }) {
const selectedIndex = pickBestVideoFile(torrent);
torrents.set(torrent.infoHash, {
torrent,
selectedIndex,
savePath,
added,
paused: false
});
const rootFolder = path.basename(savePath);
upsertInfoFile(savePath, {
infoHash: torrent.infoHash,
name: torrent.name,
tracker: torrent.announce?.[0] || null,
added,
magnetURI: torrent.magnetURI,
createdAt: added,
folder: rootFolder
});
broadcastFileUpdate(rootFolder);
const payload = {
ok: true,
infoHash: torrent.infoHash,
name: torrent.name,
selectedIndex,
tracker: torrent.announce?.[0] || null,
added,
files: torrent.files.map((f, i) => ({
index: i,
name: f.name,
length: f.length
}))
};
if (typeof respond === "function") respond(payload);
broadcastSnapshot();
}
async function onTorrentDone({ torrent }) {
const entry = torrents.get(torrent.infoHash);
if (!entry) return;
console.log(`✅ Torrent tamamlandı: ${torrent.name}`);
const rootFolder = path.basename(entry.savePath);
const bestVideoIndex = pickBestVideoFile(torrent);
const bestVideo =
torrent.files[bestVideoIndex] || torrent.files[0] || null;
const displayName = bestVideo?.name || torrent.name || rootFolder;
const bestVideoPath = bestVideo?.path
? bestVideo.path.replace(/\\/g, "/")
: null;
const perFileMetadata = {};
const seriesEpisodes = {};
let primaryMediaInfo = null;
for (const file of torrent.files) {
const fullPath = path.join(entry.savePath, file.path);
const relPathWithRoot = path.join(rootFolder, file.path);
const normalizedRelPath = file.path.replace(/\\/g, "/");
const mimeType = mime.lookup(fullPath) || "";
const ext = path.extname(file.name).replace(/^\./, "").toLowerCase();
if (mimeType.startsWith("video/")) {
queueVideoThumbnail(fullPath, relPathWithRoot);
} else if (mimeType.startsWith("image/")) {
queueImageThumbnail(fullPath, relPathWithRoot);
}
let metaInfo = null;
if (
mimeType.startsWith("video/") ||
mimeType.startsWith("audio/") ||
mimeType.startsWith("image/")
) {
metaInfo = await extractMediaInfo(fullPath);
}
if (
!primaryMediaInfo &&
bestVideoPath &&
normalizedRelPath === bestVideoPath &&
metaInfo
) {
primaryMediaInfo = metaInfo;
}
perFileMetadata[normalizedRelPath] = {
size: file.length,
extension: ext || null,
mimeType,
mediaInfo: metaInfo
};
const seriesInfo = parseSeriesInfo(file.name);
if (seriesInfo) {
try {
const ensured = await ensureSeriesData(
rootFolder,
normalizedRelPath,
seriesInfo,
metaInfo
);
if (ensured?.show && ensured?.episode) {
seriesEpisodes[normalizedRelPath] = {
season: seriesInfo.season,
episode: seriesInfo.episode,
key: seriesInfo.key,
title: ensured.episode.title || seriesInfo.title,
showId: ensured.show.id || null,
showTitle: ensured.show.title || seriesInfo.title,
seasonName:
ensured.season?.name || `Season ${seriesInfo.season}`,
seasonId: ensured.season?.tvdbSeasonId || null,
seasonPoster: ensured.season?.poster || null,
overview: ensured.episode.overview || "",
aired: ensured.episode.aired || null,
runtime: ensured.episode.runtime || null,
still: ensured.episode.still || null,
episodeId: ensured.episode.tvdbEpisodeId || null,
slug: ensured.episode.slug || null
};
const fileEntry = perFileMetadata[normalizedRelPath] || {};
perFileMetadata[normalizedRelPath] = {
...fileEntry,
seriesMatch: {
id: ensured.show.id || null,
title: ensured.show.title || seriesInfo.title,
season: ensured.season?.seasonNumber ?? seriesInfo.season,
episode: ensured.episode.episodeNumber ?? seriesInfo.episode,
code: ensured.episode.code || seriesInfo.key,
poster: ensured.show.poster || null,
backdrop: ensured.show.backdrop || null,
seasonPoster: ensured.season?.poster || null,
aired: ensured.episode.aired || null,
runtime: ensured.episode.runtime || null,
tvdbEpisodeId: ensured.episode.tvdbEpisodeId || null,
matchedAt: Date.now()
}
};
}
} catch (err) {
console.warn(
`⚠️ TV metadata oluşturulamadı (${rootFolder} - ${file.name}): ${
err?.message || err
}`
);
}
}
}
// Eski thumbnail yapısını temizle
try {
const legacyThumb = path.join(entry.savePath, "thumbnail.jpg");
if (fs.existsSync(legacyThumb)) fs.rmSync(legacyThumb, { force: true });
const legacyDir = path.join(entry.savePath, "thumbnail");
if (fs.existsSync(legacyDir))
fs.rmSync(legacyDir, { recursive: true, force: true });
} catch (err) {
console.warn("⚠️ Eski thumbnail klasörü temizlenemedi:", err.message);
}
const infoUpdate = {
completedAt: Date.now(),
totalBytes: torrent.downloaded,
fileCount: torrent.files.length,
files: perFileMetadata,
magnetURI: torrent.magnetURI
};
if (bestVideoPath) infoUpdate.primaryVideoPath = bestVideoPath;
if (Object.keys(seriesEpisodes).length) {
infoUpdate.seriesEpisodes = seriesEpisodes;
}
const ensuredMedia = await ensureMovieData(
rootFolder,
displayName,
bestVideoPath,
primaryMediaInfo
);
if (ensuredMedia?.mediaInfo) {
infoUpdate.primaryMediaInfo = ensuredMedia.mediaInfo;
if (!infoUpdate.files) infoUpdate.files = perFileMetadata;
if (bestVideoPath) {
const entry = infoUpdate.files[bestVideoPath] || {};
infoUpdate.files[bestVideoPath] = {
...entry,
movieMatch: ensuredMedia.metadata
? {
id: ensuredMedia.metadata.id ?? null,
title:
ensuredMedia.metadata.title ||
ensuredMedia.metadata.matched_title ||
displayName,
year: ensuredMedia.metadata.release_date
? Number(
ensuredMedia.metadata.release_date.slice(0, 4)
)
: ensuredMedia.metadata.matched_year || null,
poster: ensuredMedia.metadata.poster_path || null,
backdrop: ensuredMedia.metadata.backdrop_path || null,
cacheKey: ensuredMedia.cacheKey || null,
matchedAt: Date.now()
}
: entry.movieMatch
};
}
}
upsertInfoFile(entry.savePath, infoUpdate);
broadcastFileUpdate(rootFolder);
// Torrent tamamlandığında disk space bilgisini güncelle
broadcastDiskSpace();
// Medya tespiti tamamlandığında özel bildirim gönder
if (Object.keys(seriesEpisodes).length > 0 || infoUpdate.primaryMediaInfo) {
if (wss) {
const data = JSON.stringify({
type: "mediaDetected",
rootFolder,
hasSeriesEpisodes: Object.keys(seriesEpisodes).length > 0,
hasMovieMatch: !!infoUpdate.primaryMediaInfo
});
wss.clients.forEach((c) => c.readyState === 1 && c.send(data));
}
}
broadcastSnapshot();
}
// Auth router ve middleware createAuth ile yüklendi
// --- Güvenli medya URL'i (TV için) ---
// Dönen URL segmentleri ayrı ayrı encode eder, slash'ları korur ve tam hostlu URL döner
app.get("/api/media-url", requireAuth, (req, res) => {
@@ -3299,9 +3565,7 @@ app.get("/api/media-url", requireAuth, (req, res) => {
const ttl = Math.min(Math.max(Number(req.query.ttl) || 3600, 60), 72 * 3600);
// Medya token oluştur
const mediaToken = crypto.randomBytes(16).toString("hex");
activeTokens.add(mediaToken);
setTimeout(() => activeTokens.delete(mediaToken), ttl * 1000);
const mediaToken = issueMediaToken(filePath, ttl);
// Her path segmentini ayrı encode et (slash korunur)
const encodedPath = String(filePath)
@@ -3336,243 +3600,10 @@ app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => {
// 🆕 Torrent eklendiği anda tarih kaydedelim
const added = Date.now();
torrents.set(torrent.infoHash, {
torrent,
selectedIndex: 0,
wireTorrent(torrent, {
savePath,
added,
paused: false
});
// --- Metadata geldiğinde ---
torrent.on("ready", () => {
const selectedIndex = pickBestVideoFile(torrent);
torrents.set(torrent.infoHash, {
torrent,
selectedIndex,
savePath,
added,
paused: false
});
const rootFolder = path.basename(savePath);
upsertInfoFile(savePath, {
infoHash: torrent.infoHash,
name: torrent.name,
tracker: torrent.announce?.[0] || null,
added,
createdAt: added,
folder: rootFolder
});
broadcastFileUpdate(rootFolder);
res.json({
ok: true,
infoHash: torrent.infoHash,
name: torrent.name,
selectedIndex,
tracker: torrent.announce?.[0] || null,
added,
files: torrent.files.map((f, i) => ({
index: i,
name: f.name,
length: f.length
}))
});
broadcastSnapshot();
});
// --- İndirme tamamlandığında thumbnail oluştur ---
torrent.on("done", async () => {
const entry = torrents.get(torrent.infoHash);
if (!entry) return;
console.log(`✅ Torrent tamamlandı: ${torrent.name}`);
const rootFolder = path.basename(entry.savePath);
const bestVideoIndex = pickBestVideoFile(torrent);
const bestVideo =
torrent.files[bestVideoIndex] || torrent.files[0] || null;
const displayName = bestVideo?.name || torrent.name || rootFolder;
const bestVideoPath = bestVideo?.path
? bestVideo.path.replace(/\\/g, "/")
: null;
const perFileMetadata = {};
const seriesEpisodes = {};
let primaryMediaInfo = null;
for (const file of torrent.files) {
const fullPath = path.join(entry.savePath, file.path);
const relPathWithRoot = path.join(rootFolder, file.path);
const normalizedRelPath = file.path.replace(/\\/g, "/");
const mimeType = mime.lookup(fullPath) || "";
const ext = path.extname(file.name).replace(/^\./, "").toLowerCase();
if (mimeType.startsWith("video/")) {
queueVideoThumbnail(fullPath, relPathWithRoot);
} else if (mimeType.startsWith("image/")) {
queueImageThumbnail(fullPath, relPathWithRoot);
}
let metaInfo = null;
if (
mimeType.startsWith("video/") ||
mimeType.startsWith("audio/") ||
mimeType.startsWith("image/")
) {
metaInfo = await extractMediaInfo(fullPath);
}
if (
!primaryMediaInfo &&
bestVideoPath &&
normalizedRelPath === bestVideoPath &&
metaInfo
) {
primaryMediaInfo = metaInfo;
}
perFileMetadata[normalizedRelPath] = {
size: file.length,
extension: ext || null,
mimeType,
mediaInfo: metaInfo
};
const seriesInfo = parseSeriesInfo(file.name);
if (seriesInfo) {
try {
const ensured = await ensureSeriesData(
rootFolder,
normalizedRelPath,
seriesInfo,
metaInfo
);
if (ensured?.show && ensured?.episode) {
seriesEpisodes[normalizedRelPath] = {
season: seriesInfo.season,
episode: seriesInfo.episode,
key: seriesInfo.key,
title: ensured.episode.title || seriesInfo.title,
showId: ensured.show.id || null,
showTitle: ensured.show.title || seriesInfo.title,
seasonName:
ensured.season?.name || `Season ${seriesInfo.season}`,
seasonId: ensured.season?.tvdbSeasonId || null,
seasonPoster: ensured.season?.poster || null,
overview: ensured.episode.overview || "",
aired: ensured.episode.aired || null,
runtime: ensured.episode.runtime || null,
still: ensured.episode.still || null,
episodeId: ensured.episode.tvdbEpisodeId || null,
slug: ensured.episode.slug || null
};
const fileEntry = perFileMetadata[normalizedRelPath] || {};
perFileMetadata[normalizedRelPath] = {
...fileEntry,
seriesMatch: {
id: ensured.show.id || null,
title: ensured.show.title || seriesInfo.title,
season: ensured.season?.seasonNumber ?? seriesInfo.season,
episode: ensured.episode.episodeNumber ?? seriesInfo.episode,
code: ensured.episode.code || seriesInfo.key,
poster: ensured.show.poster || null,
backdrop: ensured.show.backdrop || null,
seasonPoster: ensured.season?.poster || null,
aired: ensured.episode.aired || null,
runtime: ensured.episode.runtime || null,
tvdbEpisodeId: ensured.episode.tvdbEpisodeId || null,
matchedAt: Date.now()
}
};
}
} catch (err) {
console.warn(
`⚠️ TV metadata oluşturulamadı (${rootFolder} - ${file.name}): ${
err?.message || err
}`
);
}
}
}
// Eski thumbnail yapısını temizle
try {
const legacyThumb = path.join(entry.savePath, "thumbnail.jpg");
if (fs.existsSync(legacyThumb)) fs.rmSync(legacyThumb, { force: true });
const legacyDir = path.join(entry.savePath, "thumbnail");
if (fs.existsSync(legacyDir))
fs.rmSync(legacyDir, { recursive: true, force: true });
} catch (err) {
console.warn("⚠️ Eski thumbnail klasörü temizlenemedi:", err.message);
}
const infoUpdate = {
completedAt: Date.now(),
totalBytes: torrent.downloaded,
fileCount: torrent.files.length,
files: perFileMetadata
};
if (bestVideoPath) infoUpdate.primaryVideoPath = bestVideoPath;
if (Object.keys(seriesEpisodes).length) {
infoUpdate.seriesEpisodes = seriesEpisodes;
}
const ensuredMedia = await ensureMovieData(
rootFolder,
displayName,
bestVideoPath,
primaryMediaInfo
);
if (ensuredMedia?.mediaInfo) {
infoUpdate.primaryMediaInfo = ensuredMedia.mediaInfo;
if (!infoUpdate.files) infoUpdate.files = perFileMetadata;
if (bestVideoPath) {
const entry = infoUpdate.files[bestVideoPath] || {};
infoUpdate.files[bestVideoPath] = {
...entry,
movieMatch: ensuredMedia.metadata
? {
id: ensuredMedia.metadata.id ?? null,
title:
ensuredMedia.metadata.title ||
ensuredMedia.metadata.matched_title ||
displayName,
year: ensuredMedia.metadata.release_date
? Number(
ensuredMedia.metadata.release_date.slice(0, 4)
)
: ensuredMedia.metadata.matched_year || null,
poster: ensuredMedia.metadata.poster_path || null,
backdrop: ensuredMedia.metadata.backdrop_path || null,
cacheKey: ensuredMedia.cacheKey || null,
matchedAt: Date.now()
}
: entry.movieMatch
};
}
}
upsertInfoFile(entry.savePath, infoUpdate);
broadcastFileUpdate(rootFolder);
// Torrent tamamlandığında disk space bilgisini güncelle
broadcastDiskSpace();
// Medya tespiti tamamlandığında özel bildirim gönder
if (Object.keys(seriesEpisodes).length > 0 || infoUpdate.primaryMediaInfo) {
if (wss) {
const data = JSON.stringify({
type: "mediaDetected",
rootFolder,
hasSeriesEpisodes: Object.keys(seriesEpisodes).length > 0,
hasMovieMatch: !!infoUpdate.primaryMediaInfo
});
wss.clients.forEach((c) => c.readyState === 1 && c.send(data));
}
}
broadcastSnapshot();
respond: (payload) => res.json(payload)
});
} catch (err) {
res.status(500).json({ error: err.message });
@@ -5525,6 +5556,16 @@ app.get("/stream/:hash", requireAuth, (req, res) => {
console.log("🗄️ Download path:", DOWNLOAD_DIR);
// Sunucu açılışında mevcut torrentleri yeniden ekle
const restored = restoreTorrentsFromDisk({
downloadDir: DOWNLOAD_DIR,
client,
register: (torrent, ctx) => wireTorrent(torrent, ctx)
});
if (restored.length) {
console.log(`♻️ ${restored.length} torrent yeniden eklendi.`);
}
// --- ✅ Client build (frontend) dosyalarını sun ---
const publicDir = path.join(__dirname, "public");
@@ -5540,15 +5581,11 @@ const server = app.listen(PORT, () =>
console.log(`🐔 du.pe server ${PORT} portunda çalışıyor`)
);
wss = new WebSocketServer({ server });
wss = createWebsocketServer(server, { verifyToken });
wss.on("connection", (ws) => {
ws.send(JSON.stringify({ type: "progress", torrents: snapshot() }));
// Bağlantı kurulduğunda disk space bilgisi gönder
broadcastDiskSpace();
ws.on("error", (error) => {
console.error("🔌 WebSocket error:", error);
});
});
// --- ⏱️ Her 2 saniyede bir aktif torrent durumu yayınla ---