From 45e6ef3356cbb7bd30d01c3a69fddc39468b25eb Mon Sep 17 00:00:00 2001 From: wisecolt Date: Mon, 26 Jan 2026 19:20:38 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(ui):=20=C3=A7=C3=B6p=20=C3=B6=C4=9Fele?= =?UTF-8?q?rini=20otomatik=20olarak=20yenile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/routes/Files.svelte | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/src/routes/Files.svelte b/client/src/routes/Files.svelte index 9b7f8b7..d660441 100644 --- a/client/src/routes/Files.svelte +++ b/client/src/routes/Files.svelte @@ -4,6 +4,7 @@ import { cleanFileName, extractTitleAndYear } from "../utils/filename.js"; import { refreshMovieCount } from "../stores/movieStore.js"; import { refreshTvShowCount } from "../stores/tvStore.js"; + import { fetchTrashItems } from "../stores/trashStore.js"; import { activeSearchTerm, setSearchScope, @@ -1158,7 +1159,7 @@ } await loadFiles(); - await Promise.all([refreshMovieCount(), refreshTvShowCount()]); + await Promise.all([refreshMovieCount(), refreshTvShowCount(), fetchTrashItems()]); if (errors.length > 0) { alert("Silme hatası: " + errors[0]); @@ -1407,7 +1408,7 @@ } await loadFiles(); - await Promise.all([refreshMovieCount(), refreshTvShowCount()]); + await Promise.all([refreshMovieCount(), refreshTvShowCount(), fetchTrashItems()]); selectedItems = new Set( [...selectedItems].filter((name) => name !== item.name), ); @@ -1712,6 +1713,7 @@ if (msg.type === "fileUpdate") { console.log("📸 Yeni thumbnail bildirimi:", msg.path); await loadFiles(); + fetchTrashItems().catch(() => null); } if (msg.type === "manualMatch") { console.log("🔗 Manuel eşleştirme bildirimi:", msg); From 0b99fce5a968b7094276a4fe118e77ea5d7f1e7b Mon Sep 17 00:00:00 2001 From: wisecolt Date: Mon, 26 Jan 2026 20:04:41 +0300 Subject: [PATCH 2/4] =?UTF-8?q?feat(transfers):=20mail.ru=20indirme=20dest?= =?UTF-8?q?e=C4=9Fi=20ekle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mail.ru video URL'lerini desteklemek için sunucu ve istemci tarafında gerekli değişiklikler yapıldı. - Sunucu tarafında Mail.ru URL çözümleme (yt-dlp) ve indirme (aria2c) işlevselliği eklendi. - /api/mailru/download uç noktası oluşturuldu. - Dockerfile'a aria2c bağımlılığı eklendi. - Kullanıcı arayüzü Mail.ru URL'lerini kabul edecek ve indirme ilerlemesini gösterecek şekilde güncellendi. - İndirilen dosyalar için otomatik küçük resim oluşturma eklendi. --- Dockerfile | 2 +- client/src/routes/Transfers.svelte | 44 ++- server/server.js | 507 ++++++++++++++++++++++++++++- 3 files changed, 542 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index e6df726..8cc139e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ RUN npm run build # Build server FROM node:22-slim -RUN apt-get update && apt-get install -y ffmpeg curl && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y ffmpeg curl aria2 && rm -rf /var/lib/apt/lists/* RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \ && chmod a+rx /usr/local/bin/yt-dlp WORKDIR /app/server diff --git a/client/src/routes/Transfers.svelte b/client/src/routes/Transfers.svelte index b6e6c14..b5c7884 100644 --- a/client/src/routes/Transfers.svelte +++ b/client/src/routes/Transfers.svelte @@ -98,8 +98,21 @@ } } + function normalizeMailRuUrl(value) { + if (!value || typeof value !== "string") return null; + try { + const url = new URL(value.trim()); + if (url.protocol !== "https:") return null; + const host = url.hostname.toLowerCase(); + if (!host.endsWith("mail.ru")) return null; + return url.toString(); + } catch { + return null; + } + } + async function handleUrlInput() { - const input = prompt("Magnet veya YouTube URL girin:"); + const input = prompt("Magnet, YouTube veya Mail.ru URL girin:"); if (!input) return; if (isMagnetLink(input)) { await apiFetch("/api/transfer", { @@ -125,8 +138,23 @@ await list(); return; } + const normalizedMailRu = normalizeMailRuUrl(input); + if (normalizedMailRu) { + const resp = await apiFetch("/api/mailru/download", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: normalizedMailRu }) + }); + if (!resp.ok) { + const data = await resp.json().catch(() => null); + alert(data?.error || "Mail.ru indirmesi başlatılamadı"); + return; + } + await list(); + return; + } alert( - "Yalnızca magnet linkleri veya https://www.youtube.com/watch?v=... formatındaki YouTube URL'leri destekleniyor." + "Yalnızca magnet linkleri, https://www.youtube.com/watch?v=... formatındaki YouTube URL'leri veya mail.ru linkleri destekleniyor." ); } @@ -556,7 +584,7 @@ class="thumb" on:load={(e) => e.target.classList.add("loaded")} /> - {:else if t.type === "youtube" && (!t.progress || t.progress <= 0)} + {:else if (t.type === "youtube" || t.type === "mailru") && (!t.progress || t.progress <= 0)}
@@ -570,9 +598,9 @@
{t.name}
- {#if t.type === "youtube"} + {#if t.type === "youtube" || t.type === "mailru"}
- Source: YouTube + Source: {t.type === "mailru" ? "Mail.ru" : "YouTube"}
Added: {formatDate(t.added)} @@ -580,7 +608,7 @@ {/if}
- {#if t.type !== "youtube"} + {#if t.type === "torrent" || !t.type}
- {#if t.type !== "youtube"} + {#if t.type === "torrent" || !t.type}
Hash: {t.infoHash} | Tracker: {t.tracker ?? "Unknown"} | Added: {t.added ? formatDate(t.added) : "Unknown"} @@ -639,7 +667,7 @@ {(t.progress * 100).toFixed(1)}% • {t.downloaded ? (t.downloaded / 1e6).toFixed(1) : 0} MB • {formatSpeed(t.downloadSpeed)} ↓ - {#if t.type !== "youtube"} + {#if t.type === "torrent" || !t.type} • {t.numPeers ?? 0} peers {/if} {:else} diff --git a/server/server.js b/server/server.js index a37bb75..5b96956 100644 --- a/server/server.js +++ b/server/server.js @@ -22,6 +22,7 @@ const upload = multer({ dest: path.join(__dirname, "uploads") }); const client = new WebTorrent(); const torrents = new Map(); const youtubeJobs = new Map(); +const mailruJobs = new Map(); let wss; const PORT = process.env.PORT || 3001; const DEBUG_CPU = process.env.DEBUG_CPU === "1"; @@ -92,6 +93,8 @@ const YT_ALLOWED_RESOLUTIONS = new Set([ const YT_EXTRACTOR_ARGS = process.env.YT_DLP_EXTRACTOR_ARGS || null; let resolvedYtDlpBinary = null; +const ARIA2C_BIN = process.env.ARIA2C_BIN || null; +let resolvedAria2cBinary = null; const TMDB_API_KEY = process.env.TMDB_API_KEY; const TMDB_BASE_URL = "https://api.themoviedb.org/3"; const TMDB_IMG_BASE = @@ -712,6 +715,30 @@ function getYtDlpBinary() { return resolvedYtDlpBinary; } +function getAria2cBinary() { + if (resolvedAria2cBinary) return resolvedAria2cBinary; + const candidates = [ + ARIA2C_BIN, + "/usr/bin/aria2c", + "/usr/local/bin/aria2c", + "aria2c" + ].filter(Boolean); + + for (const candidate of candidates) { + if (candidate.includes(path.sep) || candidate.startsWith("/")) { + if (fs.existsSync(candidate)) { + resolvedAria2cBinary = candidate; + return resolvedAria2cBinary; + } + continue; + } + resolvedAria2cBinary = candidate; + return resolvedAria2cBinary; + } + resolvedAria2cBinary = "aria2c"; + return resolvedAria2cBinary; +} + function normalizeYoutubeWatchUrl(value) { if (!value || typeof value !== "string") return null; try { @@ -1141,6 +1168,415 @@ function findYoutubeMediaFile(savePath, preferAudio = false) { return videos[0]; } +function normalizeMailRuUrl(value) { + if (!value || typeof value !== "string") return null; + try { + const urlObj = new URL(value.trim()); + if (urlObj.protocol !== "https:") return null; + const host = urlObj.hostname.toLowerCase(); + if (!host.endsWith("mail.ru")) return null; + return urlObj.toString(); + } catch (err) { + return null; + } +} + +function sanitizeFileName(name) { + const cleaned = String(name || "").trim(); + if (!cleaned) return "mailru_video.mp4"; + const replaced = cleaned.replace(/[\\/:*?"<>|]+/g, "_"); + return replaced || "mailru_video.mp4"; +} + +async function resolveMailRuDirectUrl(rawUrl) { + const normalized = normalizeMailRuUrl(rawUrl); + if (!normalized) return null; + try { + const urlObj = new URL(normalized); + const lowerPath = urlObj.pathname.toLowerCase(); + if (lowerPath.endsWith(".mp4")) return normalized; + } catch (err) { + return null; + } + + const binary = getYtDlpBinary(); + return await new Promise((resolve, reject) => { + const args = ["-g", "--no-playlist", normalized]; + const child = spawn(binary, args, { env: process.env }); + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", (err) => { + reject(err?.message || "yt-dlp çalıştırılamadı"); + }); + child.on("close", (code) => { + if (code !== 0) { + return reject(stderr || `yt-dlp ${code} kodu ile sonlandı`); + } + const lines = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const direct = lines.find((line) => line.startsWith("http")); + if (!direct) { + return reject("Mail.ru video URL'si çözümlenemedi"); + } + resolve(direct); + }); + }); +} + +function mailruSnapshot(job) { + const files = (job.files || []).map((file, index) => ({ + index, + name: file.name, + length: file.length + })); + return { + infoHash: job.id, + type: "mailru", + name: job.title || job.url, + progress: Math.min(1, job.progress || 0), + downloaded: job.downloaded || 0, + downloadSpeed: job.state === "downloading" ? job.downloadSpeed || 0 : 0, + uploadSpeed: 0, + numPeers: 0, + tracker: "mail.ru", + added: job.added, + savePath: job.savePath, + paused: false, + files, + selectedIndex: job.selectedIndex || 0, + thumbnail: job.thumbnail || null, + status: job.state + }; +} + +function appendMailRuLog(job, line) { + if (!job?.debug) return; + const lines = Array.isArray(job.debug.logs) ? job.debug.logs : []; + const split = String(line || "").split(/\r?\n/); + for (const l of split) { + if (!l.trim()) continue; + lines.push(l.trim()); + } + while (lines.length > 80) { + lines.shift(); + } + job.debug.logs = lines; +} + +function parseAria2cProgress(job, line) { + if (!line) return; + const cleaned = line.replace(/\u001b\[[0-9;]*m/g, "").trim(); + const primaryMatch = cleaned.match( + /\[#\d+\s+([\d.]+)([KMGTP]?i?B)\/([\d.]+)([KMGTP]?i?B)\((\d+)%\)\s+CN:\d+\s+DL:([\d.]+)([KMGTP]?i?B)/i + ); + const sizeMatch = cleaned.match( + /SIZE:([\d.]+)([KMGTP]?i?B)\/([\d.]+)([KMGTP]?i?B)\((\d+)%\).*DL:([\d.]+)([KMGTP]?i?B)/i + ); + const match = primaryMatch || sizeMatch; + if (!match) return; + + const downloadedValue = Number(match[1]) || 0; + const downloadedUnit = match[2]; + const totalValue = Number(match[3]) || 0; + const totalUnit = match[4]; + const percent = Number(match[5]) || 0; + const speedValue = Number(match[6]) || 0; + const speedUnit = match[7]; + + const totalBytes = bytesFromHuman(totalValue, totalUnit); + const downloadedBytes = Math.min( + totalBytes || Number.MAX_SAFE_INTEGER, + bytesFromHuman(downloadedValue, downloadedUnit) + ); + + job.totalBytes = totalBytes || job.totalBytes || 0; + job.downloaded = downloadedBytes || job.downloaded || 0; + job.progress = totalBytes > 0 ? downloadedBytes / totalBytes : percent / 100; + if (speedUnit) { + job.downloadSpeed = bytesFromHuman(speedValue, speedUnit); + } + scheduleSnapshotBroadcast(); +} + +function startMailRuProgressPolling(job) { + if (!job || job.pollTimer) return; + job.lastPollBytes = 0; + job.lastPollAt = Date.now(); + job.pollTimer = setInterval(() => { + if (!job || job.state !== "downloading") { + clearInterval(job.pollTimer); + job.pollTimer = null; + return; + } + const filePath = path.join(job.savePath, job.fileName || ""); + if (!job.fileName || !fs.existsSync(filePath)) return; + try { + const stats = fs.statSync(filePath); + const now = Date.now(); + const elapsed = Math.max(1, now - (job.lastPollAt || now)); + const delta = Math.max(0, stats.size - (job.lastPollBytes || 0)); + job.lastPollBytes = stats.size; + job.lastPollAt = now; + job.downloaded = stats.size; + if (job.totalBytes > 0) { + job.progress = Math.min(1, job.downloaded / job.totalBytes); + } + job.downloadSpeed = delta / (elapsed / 1000); + scheduleSnapshotBroadcast(); + } catch (err) { + /* no-op */ + } + }, 1000); +} + +function stopMailRuProgressPolling(job) { + if (job?.pollTimer) { + clearInterval(job.pollTimer); + job.pollTimer = null; + } +} + +function attachMailRuThumbnail(job, filePath, relPath) { + const { relThumb, absThumb } = getVideoThumbnailPaths(relPath); + if (fs.existsSync(absThumb)) { + job.thumbnail = thumbnailUrl(relThumb); + scheduleSnapshotBroadcast(); + return; + } + queueVideoThumbnail(filePath, relPath); + let attempts = 0; + const maxAttempts = 12; + const timer = setInterval(() => { + attempts += 1; + if (fs.existsSync(absThumb)) { + job.thumbnail = thumbnailUrl(relThumb); + scheduleSnapshotBroadcast(); + clearInterval(timer); + return; + } + if (attempts >= maxAttempts) { + clearInterval(timer); + } + }, 1000); +} + +function finalizeMailRuJob(job, exitCode) { + job.downloadSpeed = 0; + stopMailRuProgressPolling(job); + if (exitCode !== 0) { + job.state = "error"; + const tail = job.debug?.logs ? job.debug.logs.slice(-8) : []; + job.error = `aria2c ${exitCode} kodu ile sonlandı`; + if (tail.length) { + job.error += ` | ${tail.join(" | ")}`; + } + console.warn("❌ Mail.ru indirmesi hata:", { + jobId: job.id, + exitCode, + lastLines: tail + }); + scheduleSnapshotBroadcast(); + return; + } + + try { + const filePath = path.join(job.savePath, job.fileName); + if (!fs.existsSync(filePath)) { + job.state = "error"; + job.error = "Mail.ru dosyası bulunamadı"; + scheduleSnapshotBroadcast(); + return; + } + const stats = fs.statSync(filePath); + job.files = [ + { + index: 0, + name: job.fileName, + length: stats.size + } + ]; + job.selectedIndex = 0; + job.title = job.title || job.fileName; + job.downloaded = stats.size; + job.totalBytes = stats.size; + job.progress = 1; + job.state = "completed"; + job.error = null; + const relPath = path + .join(job.folderId, job.fileName) + .replace(/\\/g, "/"); + attachMailRuThumbnail(job, filePath, relPath); + broadcastFileUpdate(job.folderId); + scheduleSnapshotBroadcast(); + broadcastDiskSpace(); + console.log(`✅ Mail.ru indirmesi tamamlandı: ${job.title}`); + } catch (err) { + job.state = "error"; + job.error = err?.message || "Mail.ru indirimi tamamlanamadı"; + scheduleSnapshotBroadcast(); + } +} + +function launchMailRuJob(job) { + const binary = getAria2cBinary(); + const args = [ + "--enable-color=false", + "--summary-interval=1", + "--show-console-readout=true", + "--console-log-level=notice", + "--file-allocation=none", + "--allow-overwrite=true", + "-x", + "16", + "-s", + "16", + "-k", + "1M", + "-d", + job.savePath, + "-o", + job.fileName, + job.directUrl + ]; + + job.debug = { + binary, + args, + logs: [] + }; + + const child = spawn(binary, args, { + cwd: job.savePath, + env: process.env + }); + job.process = child; + + const handleChunk = (chunk) => { + const text = chunk.toString(); + appendMailRuLog(job, text); + const parts = text.split(/[\r\n]+/); + for (const raw of parts) { + const line = raw.trim(); + if (!line) continue; + parseAria2cProgress(job, line); + } + }; + + child.stdout.on("data", handleChunk); + child.stderr.on("data", handleChunk); + + child.on("close", (code) => finalizeMailRuJob(job, code)); + child.on("error", (err) => { + job.state = "error"; + job.downloadSpeed = 0; + appendMailRuLog(job, `spawn error: ${err?.message || err}`); + job.error = err?.message || "aria2c çalıştırılamadı"; + console.error("❌ Mail.ru aria2c spawn error:", { + jobId: job.id, + message: err?.message || err, + binary, + args + }); + scheduleSnapshotBroadcast(); + }); +} + +async function startMailRuDownload(url) { + const normalized = normalizeMailRuUrl(url); + if (!normalized) return null; + const folderId = `mailru_${Date.now().toString(36)}`; + const savePath = path.join(DOWNLOAD_DIR, folderId); + fs.mkdirSync(savePath, { recursive: true }); + + const job = { + id: folderId, + infoHash: folderId, + type: "mailru", + url: normalized, + directUrl: null, + folderId, + savePath, + added: Date.now(), + title: null, + fileName: null, + state: "resolving", + progress: 0, + downloaded: 0, + totalBytes: 0, + downloadSpeed: 0, + files: [], + selectedIndex: 0, + thumbnail: null, + process: null, + error: null, + debug: { binary: null, args: null, logs: [] } + }; + + mailruJobs.set(job.id, job); + scheduleSnapshotBroadcast(); + console.log(`▶️ Mail.ru indirmesi başlatıldı: ${job.url}`); + + try { + const directUrl = await resolveMailRuDirectUrl(normalized); + if (!directUrl) { + throw new Error("Mail.ru video URL'si çözümlenemedi"); + } + job.directUrl = directUrl; + const urlObj = new URL(directUrl); + const filename = sanitizeFileName(path.basename(urlObj.pathname)); + job.fileName = filename || `mailru_${Date.now()}.mp4`; + job.title = job.fileName; + job.state = "downloading"; + try { + const headResp = await fetch(directUrl, { method: "HEAD" }); + const length = Number(headResp.headers.get("content-length")) || 0; + if (length > 0) { + job.totalBytes = length; + } + } catch (err) { + /* no-op */ + } + if (!job.totalBytes) { + try { + const rangeResp = await fetch(directUrl, { + method: "GET", + headers: { Range: "bytes=0-0" } + }); + const contentRange = rangeResp.headers.get("content-range") || ""; + const total = contentRange.split("/")[1]; + const totalBytes = Number(total); + if (totalBytes > 0) { + job.totalBytes = totalBytes; + } + try { + await rangeResp.arrayBuffer(); + } catch { + /* no-op */ + } + } catch (err) { + /* no-op */ + } + } + startMailRuProgressPolling(job); + launchMailRuJob(job); + } catch (err) { + job.state = "error"; + job.error = err?.message || "Mail.ru indirimi başlatılamadı"; + scheduleSnapshotBroadcast(); + } + + return job; +} + function findYoutubeInfoJson(savePath) { const entries = fs.readdirSync(savePath, { withFileTypes: true }); const jsons = entries @@ -1289,6 +1725,34 @@ function removeYoutubeJob(jobId, { removeFiles = true } = {}) { return true; } +function removeMailRuJob(jobId, { removeFiles = true } = {}) { + const job = mailruJobs.get(jobId); + if (!job) return false; + if (job.process && !job.process.killed) { + try { + job.process.kill("SIGTERM"); + } catch (err) { + console.warn("Mail.ru job kill error:", err.message); + } + } + mailruJobs.delete(jobId); + let filesRemoved = false; + if (removeFiles && job.savePath && fs.existsSync(job.savePath)) { + try { + fs.rmSync(job.savePath, { recursive: true, force: true }); + filesRemoved = true; + } catch (err) { + console.warn("Mail.ru dosyası silinemedi:", err.message); + } + } + scheduleSnapshotBroadcast(); + if (filesRemoved) { + broadcastFileUpdate(job.folderId); + broadcastDiskSpace(); + } + return true; +} + function youtubeSnapshot(job) { const files = (job.files || []).map((file, index) => ({ index, @@ -4489,7 +4953,11 @@ function snapshot() { youtubeSnapshot(job) ); - const combined = [...torrentEntries, ...youtubeEntries]; + const mailruEntries = Array.from(mailruJobs.values()).map((job) => + mailruSnapshot(job) + ); + + const combined = [...torrentEntries, ...youtubeEntries, ...mailruEntries]; combined.sort((a, b) => (b.added || 0) - (a.added || 0)); return combined; } @@ -4913,7 +5381,20 @@ app.post("/api/torrents/:hash/select/:index", requireAuth, (req, res) => { const entry = torrents.get(req.params.hash); if (!entry) { const job = youtubeJobs.get(req.params.hash); - if (!job) return res.status(404).json({ error: "torrent bulunamadı" }); + if (!job) { + const mailJob = mailruJobs.get(req.params.hash); + if (!mailJob) { + return res.status(404).json({ error: "torrent bulunamadı" }); + } + const targetIndex = Number(req.params.index) || 0; + if (!mailJob.files?.[targetIndex]) { + return res + .status(400) + .json({ error: "Geçerli bir video dosyası bulunamadı" }); + } + mailJob.selectedIndex = targetIndex; + return res.json({ ok: true, selectedIndex: targetIndex }); + } const targetIndex = Number(req.params.index) || 0; if (!job.files?.[targetIndex]) { return res @@ -4935,6 +5416,10 @@ app.delete("/api/torrents/:hash", requireAuth, (req, res) => { if (ytRemoved) { return res.json({ ok: true, filesRemoved: true }); } + const mailRemoved = removeMailRuJob(req.params.hash, { removeFiles: true }); + if (mailRemoved) { + return res.json({ ok: true, filesRemoved: true }); + } return res.status(404).json({ error: "torrent bulunamadı" }); } @@ -6234,6 +6719,24 @@ app.post("/api/youtube/download", requireAuth, async (req, res) => { } }); +app.post("/api/mailru/download", requireAuth, async (req, res) => { + try { + const rawUrl = req.body?.url; + const job = await startMailRuDownload(rawUrl); + if (!job) { + return res + .status(400) + .json({ ok: false, error: "Geçerli bir Mail.ru URL'si gerekli." }); + } + res.json({ ok: true, jobId: job.id }); + } catch (err) { + res.status(500).json({ + ok: false, + error: err?.message || "Mail.ru indirimi başarısız oldu." + }); + } +}); + // --- 🎫 YouTube cookies yönetimi --- app.get("/api/youtube/cookies", requireAuth, (req, res) => { try { From 52bd325dc6985346aab9625ab1f067c6e133fc88 Mon Sep 17 00:00:00 2001 From: wisecolt Date: Mon, 26 Jan 2026 21:22:15 +0300 Subject: [PATCH 3/4] =?UTF-8?q?feat(ui):=20mail.ru=20linkleri=20i=C3=A7in?= =?UTF-8?q?=20e=C5=9Fle=C5=9Ftirme=20ve=20isim=20d=C3=BCzenlemesi=20eklend?= =?UTF-8?q?i?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dosya eşleştirme arayüzü bağımsız `MatchModal` bileşenine taşındı - `Files.svelte` ve `Transfers.svelte` yeni bileşen kullanılarak güncellendi - Mail.ru indirmeleri için dizi adı, sezon ve bölüm eşleştirme özelliği eklendi - `POST /api/mailru/match` endpointi ile metadata eşleştirme backend desteği sağlandı - Dosya isimleri "DiziAdi.S01E01.mp4" formatında kaydedilmeye başlandı --- client/src/components/MatchModal.svelte | 611 ++++++++++++++++++++++++ client/src/routes/Files.svelte | 557 ++------------------- client/src/routes/Transfers.svelte | 246 +++++++++- server/.ignoreFiles | 4 +- server/server.js | 135 +++++- 5 files changed, 1010 insertions(+), 543 deletions(-) create mode 100644 client/src/components/MatchModal.svelte diff --git a/client/src/components/MatchModal.svelte b/client/src/components/MatchModal.svelte new file mode 100644 index 0000000..98e3ff1 --- /dev/null +++ b/client/src/components/MatchModal.svelte @@ -0,0 +1,611 @@ + + +{#if show} +
+
+ + +
+

+ + {headerTitle} +

+
+ {#if fileName} + + + {fileLabel}: {fileName} + + {/if} + {#if fileName && sizeText} + | + {/if} + {#if sizeText} + + + {sizeText} + + {/if} +
+
+ +
+
+
+ + +
+ {#if showYearInput} +
+ + +
+ {/if} +
+ +
+ + {#if searching} +
+ + Aranıyor... +
+ {:else if results.length > 0} +
+ {#each results as result} +
onSelect(result)} + > +
+ {#if result.poster} + {result.title} + {:else} +
+ +
+ {/if} +
+
+
{result.title}
+
+ {#if result.year} + + + {result.year} + + {/if} + {#if result.runtime} + + + + {result.runtime} dk + + {/if} + {#if result.status} + + + + {result.status} + + {/if} +
+ {#if result.genres && result.genres.length > 0} +
+ {result.genres.slice(0, 3).join(", ")} +
+ {/if} + {#if result.cast && result.cast.length > 0} +
+ + {result.cast.join(", ")} +
+ {/if} + {#if result.overview} +
{result.overview}
+ {/if} +
+ {#if $$slots.resultActions} +
+ +
+ {/if} +
+ {/each} +
+ {:else if showEmpty} +
{emptyText}
+ {/if} +
+
+
+{/if} + + diff --git a/client/src/routes/Files.svelte b/client/src/routes/Files.svelte index d660441..1def298 100644 --- a/client/src/routes/Files.svelte +++ b/client/src/routes/Files.svelte @@ -1,5 +1,6 @@ + +
+
+
+

Anime

+
+ + +
+
+ + {#if loading} +
Loading shows…
+ {:else if error} +
{error}
+ {:else if shows.length === 0} +
No TV metadata found yet.
+ {:else if hasSearch && filteredShows.length === 0} +
Aramanıza uyan dizi bulunamadı.
+ {:else} +
+ {#each filteredShows as show} +
openShow(show)}> + {#if show.poster} +
+ {show.title} +
+ {:else} +
+ +
+ {/if} +
+
{show.title}
+ {#if show.year} +
{show.year}
+ {/if} +
+
+ {/each} +
+ {/if} +
+ +{#if selectedShow} +
+
+
+ +
+
+ {#if selectedShow.poster} + {selectedShow.title} + {:else} +
+ +
+ {/if} +
+
+

{selectedShow.title}

+
+ {#if selectedShow.year} + {selectedShow.year} + {/if} + {#if selectedShow.status} + • {selectedShow.status} + {/if} + {#if selectedSeason} + + • + {selectedSeason.name || `Season ${selectedSeason.seasonNumber}`} + + {/if} +
+ {#if selectedShow.genres?.length} +
+ {selectedShow.genres.join(" • ")} +
+ {/if} +
+ {selectedShow.overview || "No synopsis found."} +
+ {#if selectedRuntime || selectedVideoInfo || selectedAudioInfo || selectedAirDate} +
+ {#if selectedRuntime} +
+ + {selectedRuntime} +
+ {/if} + {#if selectedAirDate} +
+ + {selectedAirDate} +
+ {/if} + {#if selectedVideoInfo} +
+ + {selectedVideoInfo} +
+ {/if} + {#if selectedAudioInfo} +
+ + {selectedAudioInfo} +
+ {/if} +
+ {/if} + +
+
+ + {#if selectedShow.seasons?.length} +
+ {#each selectedShow.seasons as season} + + {/each} +
+ {/if} + +
+ {#if selectedSeason?.episodes?.length} + {#each selectedSeason.episodes as episode} +
playEpisodeFromCard(episode)} + > +
+ {#if episode.still} + {`${selectedShow.title} + {:else} +
+ +
+ {/if} +
+ +
+
+
+
+ {formatEpisodeCode(episode)} · {episode.title || "Untitled"} +
+
+ {#if episodeRuntime(episode)} + + + {episodeRuntime(episode)} + + {/if} + {#if formatAirDate(episode.aired)} + + + {formatAirDate(episode.aired)} + + {/if} + {#if formatVideoInfo(episode.mediaInfo?.video)} + + + {formatVideoInfo(episode.mediaInfo.video)} + + {/if} + {#if formatAudioInfo(episode.mediaInfo?.audio)} + + + {formatAudioInfo(episode.mediaInfo.audio)} + + {/if} +
+
+ {episode.overview || "No overview available."} +
+
+
+ {/each} + {:else} +
No episodes found for this season.
+ {/if} +
+
+
+{/if} + +{#if showPlayerModal && selectedVideo} + +{/if} + + diff --git a/client/src/routes/Transfers.svelte b/client/src/routes/Transfers.svelte index 04eba63..0da202a 100644 --- a/client/src/routes/Transfers.svelte +++ b/client/src/routes/Transfers.svelte @@ -282,7 +282,8 @@ try { const params = new URLSearchParams({ query, - type: "series" + type: "series", + scope: "anime" }); if (mailruMatchYear.trim()) { params.set("year", mailruMatchYear.trim()); diff --git a/client/src/stores/animeStore.js b/client/src/stores/animeStore.js new file mode 100644 index 0000000..5acc79a --- /dev/null +++ b/client/src/stores/animeStore.js @@ -0,0 +1,43 @@ +import { writable } from "svelte/store"; +import { apiFetch } from "../utils/api.js"; + +export const animeCount = writable(0); +let requestSeq = 0; +let lastValue = 0; +let zeroTimer = null; + +export async function refreshAnimeCount() { + const ticket = ++requestSeq; + try { + const resp = await apiFetch("/api/anime"); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const list = await resp.json(); + if (ticket !== requestSeq) return; + const nextVal = Array.isArray(list) ? list.length : 0; + if (nextVal > 0) { + if (zeroTimer) { + clearTimeout(zeroTimer); + zeroTimer = null; + } + lastValue = nextVal; + animeCount.set(nextVal); + } else if (lastValue > 0) { + if (zeroTimer) clearTimeout(zeroTimer); + const zeroTicket = requestSeq; + zeroTimer = setTimeout(() => { + if (zeroTicket === requestSeq) { + lastValue = 0; + animeCount.set(0); + } + zeroTimer = null; + }, 500); + } else { + lastValue = 0; + animeCount.set(0); + } + } catch (err) { + console.warn("⚠️ Anime sayacı güncellenemedi:", err?.message || err); + // Hata durumunda mevcut değeri koru, titreşimi önle + } +} + diff --git a/server/server.js b/server/server.js index d72213c..d1a33a2 100644 --- a/server/server.js +++ b/server/server.js @@ -38,6 +38,10 @@ if (!fs.existsSync(DOWNLOAD_DIR)) const TRASH_DIR = path.join(__dirname, "trash"); if (!fs.existsSync(TRASH_DIR)) fs.mkdirSync(TRASH_DIR, { recursive: true }); +const ROOT_TRASH_PREFIX = "__root__"; +const ROOT_TRASH_DIR = path.join(TRASH_DIR, "root"); +if (!fs.existsSync(ROOT_TRASH_DIR)) + fs.mkdirSync(ROOT_TRASH_DIR, { recursive: true }); // --- Thumbnail cache klasörü --- const CACHE_DIR = path.join(__dirname, "cache"); @@ -46,7 +50,10 @@ const VIDEO_THUMB_ROOT = path.join(THUMBNAIL_DIR, "videos"); const IMAGE_THUMB_ROOT = path.join(THUMBNAIL_DIR, "images"); const MOVIE_DATA_ROOT = path.join(CACHE_DIR, "movie_data"); const TV_DATA_ROOT = path.join(CACHE_DIR, "tv_data"); +const ANIME_DATA_ROOT = path.join(CACHE_DIR, "anime_data"); const YT_DATA_ROOT = path.join(CACHE_DIR, "yt_data"); +const ANIME_ROOT_FOLDER = "_anime"; +const ROOT_TRASH_REGISTRY = path.join(CACHE_DIR, "root-trash.json"); const MUSIC_EXTENSIONS = new Set([ ".mp3", ".m4a", @@ -65,6 +72,7 @@ for (const dir of [ IMAGE_THUMB_ROOT, MOVIE_DATA_ROOT, TV_DATA_ROOT, + ANIME_DATA_ROOT, YT_DATA_ROOT ]) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); @@ -1437,6 +1445,14 @@ function finalizeMailRuJob(job, exitCode) { const relPath = job.savePath === DOWNLOAD_DIR ? String(job.fileName || "") : path.join(job.folderId || "", job.fileName || "").replace(/\\/g, "/"); + const seriesInfo = job.match?.seriesInfo || null; + if (seriesInfo) { + extractMediaInfo(filePath) + .then((mediaInfo) => + ensureSeriesData(ANIME_ROOT_FOLDER, job.fileName, seriesInfo, mediaInfo) + ) + .catch(() => null); + } attachMailRuThumbnail(job, filePath, relPath); broadcastFileUpdate(relPath || "downloads"); scheduleSnapshotBroadcast(); @@ -2199,6 +2215,109 @@ function normalizeTrashPath(value) { return String(value).replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); } +function isRootTrashName(value) { + const normalized = normalizeTrashPath(value); + return ( + normalized === ROOT_TRASH_PREFIX || + normalized.startsWith(`${ROOT_TRASH_PREFIX}/`) + ); +} + +function parseRootTrashName(value) { + const normalized = normalizeTrashPath(value); + if (normalized === ROOT_TRASH_PREFIX) return ""; + if (!normalized.startsWith(`${ROOT_TRASH_PREFIX}/`)) return null; + return normalized.slice(ROOT_TRASH_PREFIX.length + 1); +} + +function readRootTrashRegistry() { + if (!fs.existsSync(ROOT_TRASH_REGISTRY)) return { items: [] }; + try { + const raw = JSON.parse(fs.readFileSync(ROOT_TRASH_REGISTRY, "utf-8")); + const items = Array.isArray(raw?.items) ? raw.items : []; + return { items: items.filter(Boolean) }; + } catch (err) { + console.warn(`⚠️ root-trash okunamadı (${ROOT_TRASH_REGISTRY}): ${err.message}`); + return { items: [] }; + } +} + +function writeRootTrashRegistry(registry) { + const items = Array.isArray(registry?.items) ? registry.items : []; + try { + fs.writeFileSync( + ROOT_TRASH_REGISTRY, + JSON.stringify({ updatedAt: Date.now(), items }, null, 2), + "utf-8" + ); + } catch (err) { + console.warn(`⚠️ root-trash yazılamadı (${ROOT_TRASH_REGISTRY}): ${err.message}`); + } +} + +function addRootTrashEntry(relPath, fullPath, stats) { + const safeRel = sanitizeRelative(relPath); + if (!safeRel || safeRel.includes("/")) return null; + if (!fullPath || !fs.existsSync(fullPath)) return null; + const registry = readRootTrashRegistry(); + const baseName = path.basename(safeRel); + const storedName = `${Date.now()}_${baseName}`.replace(/[\\/:*?"<>|]+/g, "_"); + const storedPath = path.join(ROOT_TRASH_DIR, storedName); + try { + fs.renameSync(fullPath, storedPath); + } catch (err) { + // Cross-device rename (EXDEV) durumunda kopyala+sil fallback'i uygula. + if (err?.code === "EXDEV") { + try { + if (stats?.isDirectory?.()) { + fs.cpSync(fullPath, storedPath, { recursive: true }); + fs.rmSync(fullPath, { recursive: true, force: true }); + } else { + fs.copyFileSync(fullPath, storedPath); + fs.rmSync(fullPath, { force: true }); + } + } catch (copyErr) { + console.warn( + `⚠️ root-trash EXDEV fallback hatası (${fullPath}): ${copyErr.message}` + ); + return null; + } + } else { + console.warn(`⚠️ root-trash taşıma hatası (${fullPath}): ${err.message}`); + return null; + } + } + const nextItems = registry.items.filter((item) => item.originalName !== baseName); + nextItems.push({ + originalName: baseName, + storedName, + deletedAt: Date.now(), + type: stats?.isDirectory?.() + ? "inode/directory" + : mime.lookup(fullPath) || "application/octet-stream" + }); + writeRootTrashRegistry({ items: nextItems }); + return nextItems[nextItems.length - 1]; +} + +function removeRootTrashEntry(originalName) { + const safeName = sanitizeRelative(originalName); + if (!safeName || safeName.includes("/")) return null; + const registry = readRootTrashRegistry(); + const kept = []; + let removed = null; + for (const item of registry.items) { + if (item.originalName === safeName && !removed) { + removed = item; + continue; + } + kept.push(item); + } + if (!removed) return null; + writeRootTrashRegistry({ items: kept }); + return removed; +} + function trashFlagPathFor(rootFolder) { const safeRoot = sanitizeRelative(rootFolder); if (!safeRoot) return null; @@ -2439,10 +2558,14 @@ function resolveMovieDataAbsolute(relPath) { function resolveTvDataAbsolute(relPath) { const normalized = sanitizeRelative(relPath); - const resolved = path.resolve(TV_DATA_ROOT, normalized); + const firstSegment = normalized.split("/").filter(Boolean)[0] || ""; + const { rootFolder } = parseTvSeriesKey(firstSegment); + const dataRoot = + rootFolder === ANIME_ROOT_FOLDER ? ANIME_DATA_ROOT : TV_DATA_ROOT; + const resolved = path.resolve(dataRoot, normalized); if ( - resolved !== TV_DATA_ROOT && - !resolved.startsWith(TV_DATA_ROOT + path.sep) + resolved !== dataRoot && + !resolved.startsWith(dataRoot + path.sep) ) { return null; } @@ -2720,6 +2843,34 @@ function parseSeriesInfo(rawName) { }; } +function parseAnimeSeriesInfo(rawName) { + if (!rawName) return null; + const parsed = parseSeriesInfo(rawName); + if (parsed) return parsed; + const withoutExt = String(rawName || "").replace(/\.[^/.]+$/, ""); + const match = withoutExt.match( + /(.+?)[\s._-]*S(\d{1,2})xE(\d{1,2})/i + ); + if (!match) return null; + const rawTitle = match[1] + .replace(/[._]+/g, " ") + .replace(/\s+-\s+/g, " - ") + .replace(/[-_]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!rawTitle) return null; + const season = Number(match[2]); + const episode = Number(match[3]); + if (!Number.isFinite(season) || !Number.isFinite(episode)) return null; + return { + title: titleCase(rawTitle), + searchTitle: rawTitle, + season, + episode, + key: `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}` + }; +} + async function tmdbFetch(endpoint, params = {}) { if (!TMDB_API_KEY) return null; const url = new URL(`${TMDB_BASE_URL}${endpoint}`); @@ -3312,9 +3463,39 @@ async function fetchTvdbEpisode(seriesId, season, episode) { return seasonEpisodes.get(episode) || null; } -async function fetchTvdbEpisodeExtended(episodeId) { +function tvdbPickTranslation(list, field, preferEn = false) { + if (!Array.isArray(list)) return null; + const preferred = preferEn + ? ["en", "eng", "english", "en-us"] + : ["tr", "tur", "turkish", "tr-tr", "tr_tur"]; + const fallback = preferEn + ? ["tr", "tur", "turkish", "tr-tr", "tr_tur"] + : ["en", "eng", "english", "en-us"]; + const pickByLang = (langs) => + langs + .map((lng) => lng.toLowerCase()) + .map((lng) => + list.find((item) => { + const code = String( + item?.language || + item?.iso6391 || + item?.iso_639_1 || + item?.locale || + item?.languageCode || + "" + ).toLowerCase(); + return code === lng; + }) + ) + .find(Boolean); + const match = pickByLang(preferred) || pickByLang(fallback); + if (!match) return null; + return match[field] ?? match.value ?? match.translation?.[field] ?? null; +} + +async function fetchTvdbEpisodeExtended(episodeId, preferEn = false) { if (!episodeId) return null; - const cacheKey = `episode-${episodeId}-extended`; + const cacheKey = `episode-${episodeId}-extended-${preferEn ? "en" : "tr"}`; if (tvdbEpisodeDetailCache.has(cacheKey)) return tvdbEpisodeDetailCache.get(cacheKey); const resp = await tvdbFetch( @@ -3364,44 +3545,21 @@ async function fetchTvdbEpisodeExtended(episodeId) { const nameTranslations = translations.nameTranslations || translations.names || []; - const pickTranslation = (list, field) => { - if (!Array.isArray(list)) return null; - const preferred = ["tr", "turkish", "tr-tr", "tr_tur"]; - const fallback = ["en", "english", "en-us", "eng"]; - const pickByLang = (langs) => - langs - .map((lng) => lng.toLowerCase()) - .map((lng) => - list.find((item) => { - const code = String( - item?.language || - item?.iso6391 || - item?.iso_639_1 || - item?.locale || - item?.languageCode || - "" - ).toLowerCase(); - return code === lng; - }) - ) - .find(Boolean); - const preferredMatch = pickByLang(preferred) || pickByLang(fallback); - if (!preferredMatch) return null; - return ( - preferredMatch[field] ?? - preferredMatch.value ?? - preferredMatch.translation?.[field] ?? - null - ); - }; - if (!base.overview) { - const localizedOverview = pickTranslation(overviewTranslations, "overview"); + const localizedOverview = tvdbPickTranslation( + overviewTranslations, + "overview", + preferEn + ); if (localizedOverview) base.overview = localizedOverview; } if (!base.name) { - const localizedName = pickTranslation(nameTranslations, "name"); + const localizedName = tvdbPickTranslation( + nameTranslations, + "name", + preferEn + ); if (localizedName) base.name = localizedName; } @@ -3504,7 +3662,7 @@ async function ensureSeriesData( } } - if (!seriesData && candidateKeys.length) { + if (!seriesData && candidateKeys.length && normalizedRoot !== ANIME_ROOT_FOLDER) { for (const key of candidateKeys) { const candidatePaths = tvSeriesPathsByKey(key); if (!fs.existsSync(candidatePaths.metadata)) continue; @@ -3521,7 +3679,11 @@ async function ensureSeriesData( } const legacyPaths = tvSeriesPaths(normalizedRoot); - if (!seriesData && fs.existsSync(legacyPaths.metadata)) { + if ( + !seriesData && + normalizedRoot !== ANIME_ROOT_FOLDER && + fs.existsSync(legacyPaths.metadata) + ) { try { seriesData = JSON.parse(fs.readFileSync(legacyPaths.metadata, "utf-8")) || {}; existingPaths = legacyPaths; @@ -3596,32 +3758,35 @@ async function ensureSeriesData( translations.overviewTranslations || translations.overviews || []; - const localizedName = - nameTranslations.find((t) => - ["tr", "tur", "turkish"].includes(String(t?.language || t?.iso6391).toLowerCase()) - )?.value || - nameTranslations.find((t) => - ["en", "eng", "english"].includes(String(t?.language || t?.iso6391).toLowerCase()) - )?.value || - null; - const localizedOverview = - overviewTranslations.find((t) => - ["tr", "tur", "turkish"].includes(String(t?.language || t?.iso6391).toLowerCase()) - )?.overview || - overviewTranslations.find((t) => - ["en", "eng", "english"].includes(String(t?.language || t?.iso6391).toLowerCase()) - )?.overview || - null; + const preferEn = rootFolder === ANIME_ROOT_FOLDER; + const localizedName = tvdbPickTranslation( + nameTranslations, + "name", + preferEn + ); + const localizedOverview = tvdbPickTranslation( + overviewTranslations, + "overview", + preferEn + ); - seriesData.name = - seriesData.name || - info.name || - info.seriesName || - localizedName || - seriesInfo.title; + if (preferEn && localizedName) { + seriesData.name = localizedName; + } else { + seriesData.name = + seriesData.name || + info.name || + info.seriesName || + localizedName || + seriesInfo.title; + } seriesData.slug = seriesData.slug || info.slug || info.slugged || null; - seriesData.overview = - seriesData.overview || info.overview || localizedOverview || ""; + if (preferEn && localizedOverview) { + seriesData.overview = localizedOverview; + } else { + seriesData.overview = + seriesData.overview || info.overview || localizedOverview || ""; + } const firstAired = info.firstAired || info.firstAirDate || @@ -3810,7 +3975,8 @@ async function ensureSeriesData( !detailedEpisode.name) ) { const extendedEpisode = await fetchTvdbEpisodeExtended( - detailedEpisode.id + detailedEpisode.id, + preferEn ); if (extendedEpisode) { detailedEpisode = { @@ -3997,9 +4163,20 @@ function parseTvSeriesKey(key) { return { rootFolder, seriesId: suffix || null, key: normalized }; } +function tvDataRootForRoot(rootFolder) { + const safeRoot = sanitizeRelative(rootFolder); + return safeRoot === ANIME_ROOT_FOLDER ? ANIME_DATA_ROOT : TV_DATA_ROOT; +} + +function tvDataRootForKey(key) { + const { rootFolder } = parseTvSeriesKey(key); + return tvDataRootForRoot(rootFolder); +} + function tvSeriesPathsByKey(key) { const normalizedKey = sanitizeRelative(key); - const dir = path.join(TV_DATA_ROOT, normalizedKey); + const dataRoot = tvDataRootForKey(normalizedKey); + const dir = path.join(dataRoot, normalizedKey); return { key: normalizedKey, dir, @@ -4008,7 +4185,8 @@ function tvSeriesPathsByKey(key) { backdrop: path.join(dir, "backdrop.jpg"), episodesDir: path.join(dir, "episodes"), seasonsDir: path.join(dir, "seasons"), - rootFolder: parseTvSeriesKey(normalizedKey).rootFolder + rootFolder: parseTvSeriesKey(normalizedKey).rootFolder, + dataRoot }; } @@ -4054,10 +4232,11 @@ function seasonAssetPaths(paths, seasonNumber) { function listTvSeriesKeysForRoot(rootFolder) { const normalizedRoot = rootFolder ? sanitizeRelative(rootFolder) : null; if (!normalizedRoot) return []; - if (!fs.existsSync(TV_DATA_ROOT)) return []; + const dataRoot = tvDataRootForRoot(normalizedRoot); + if (!fs.existsSync(dataRoot)) return []; const keys = []; try { - const entries = fs.readdirSync(TV_DATA_ROOT, { withFileTypes: true }); + const entries = fs.readdirSync(dataRoot, { withFileTypes: true }); for (const dirent of entries) { if (!dirent.isDirectory()) continue; const name = dirent.name; @@ -4070,7 +4249,7 @@ function listTvSeriesKeysForRoot(rootFolder) { } } catch (err) { console.warn( - `⚠️ TV metadata dizini listelenemedi (${TV_DATA_ROOT}): ${err.message}` + `⚠️ TV metadata dizini listelenemedi (${dataRoot}): ${err.message}` ); } return keys; @@ -4644,7 +4823,8 @@ function renameRootCaches(oldRoot, newRoot) { VIDEO_THUMB_ROOT, IMAGE_THUMB_ROOT, MOVIE_DATA_ROOT, - TV_DATA_ROOT + TV_DATA_ROOT, + ANIME_DATA_ROOT ]; for (const base of pairs) { @@ -5763,8 +5943,21 @@ app.delete("/api/file", requireAuth, (req, res) => { const safePath = sanitizeRelative(filePath); const fullPath = path.join(DOWNLOAD_DIR, safePath); - const folderId = (safePath.split(/[\/]/)[0] || "").trim(); - const rootDir = folderId ? path.join(DOWNLOAD_DIR, folderId) : null; + let folderId = (safePath.split(/[\/]/)[0] || "").trim(); + let rootDir = folderId ? path.join(DOWNLOAD_DIR, folderId) : null; + let folderIsDirectory = false; + if (rootDir && fs.existsSync(rootDir)) { + try { + folderIsDirectory = fs.statSync(rootDir).isDirectory(); + } catch (err) { + folderIsDirectory = false; + } + } + // Kök dosyalarda ilk segment dosya adıdır; klasör değilse root davranışı uygula + if (folderId && !folderIsDirectory) { + folderId = ""; + rootDir = null; + } let mediaFlags = { movies: false, tv: false }; let stats = null; @@ -5776,7 +5969,7 @@ app.delete("/api/file", requireAuth, (req, res) => { } if (!stats || !fs.existsSync(fullPath)) { - if (folderId && (!rootDir || !fs.existsSync(rootDir))) { + if (folderId && folderIsDirectory && (!rootDir || !fs.existsSync(rootDir))) { purgeRootFolder(folderId); broadcastFileUpdate(folderId); return res.json({ ok: true, alreadyRemoved: true }); @@ -5788,7 +5981,7 @@ app.delete("/api/file", requireAuth, (req, res) => { const isDirectory = stats.isDirectory(); const relWithinRoot = safePath.split(/[\\/]/).slice(1).join("/"); let trashEntry = null; - if (folderId && rootDir && fs.existsSync(rootDir) && fs.statSync(rootDir).isDirectory()) { + if (folderId && folderIsDirectory && rootDir) { const infoBeforeDelete = readInfoForRoot(folderId); mediaFlags = detectMediaFlagsForPath( infoBeforeDelete, @@ -5799,7 +5992,7 @@ app.delete("/api/file", requireAuth, (req, res) => { mediaFlags = { movies: false, tv: false }; } - if (folderId && rootDir && fs.existsSync(rootDir) && fs.statSync(rootDir).isDirectory()) { + if (folderId && folderIsDirectory && rootDir) { trashEntry = addTrashEntry(folderId, { path: relWithinRoot, originalPath: safePath, @@ -5827,11 +6020,17 @@ app.delete("/api/file", requireAuth, (req, res) => { } if (!folderId) { - // Kök klasöre ait olmayan dosyaları doğrudan sil - if (fs.existsSync(fullPath)) { - fs.rmSync(fullPath, { recursive: true, force: true }); + // Kök dosyaları root-trash sistemine taşı + const rootTrashEntry = addRootTrashEntry(safePath, fullPath, stats); + if (!rootTrashEntry) { + return res.status(500).json({ error: "Kök dosya çöpe taşınamadı" }); } + // Anime cache/metadata'dan ilgili bölümü kaldır + removeSeriesEpisode(ANIME_ROOT_FOLDER, safePath); removeThumbnailsForPath(safePath); + broadcastFileUpdate("downloads"); + broadcastDiskSpace(); + return res.json({ ok: true, filesRemoved: true, rootTrashed: true }); } if (folderId) { @@ -6307,6 +6506,42 @@ app.get("/api/trash", requireAuth, (req, res) => { } } + // Root trash öğelerini ekle (kök dosyalar için) + const rootRegistry = readRootTrashRegistry(); + for (const item of rootRegistry.items) { + const originalName = sanitizeRelative(item.originalName || ""); + const storedName = sanitizeRelative(item.storedName || ""); + if (!originalName || !storedName) continue; + const storedPath = path.join(ROOT_TRASH_DIR, storedName); + if (!fs.existsSync(storedPath)) { + removeRootTrashEntry(originalName); + continue; + } + let stat = null; + try { + stat = fs.statSync(storedPath); + } catch (err) { + continue; + } + const isDirectory = stat?.isDirectory?.() || false; + const type = isDirectory + ? "inode/directory" + : mime.lookup(originalName) || item.type || "application/octet-stream"; + const trashName = `${ROOT_TRASH_PREFIX}/${originalName}`; + result.push({ + name: trashName, + trashName, + size: stat?.size ?? 0, + type, + isDirectory, + thumbnail: null, + mediaInfo: null, + movedAt: Number(item.deletedAt) || Date.now(), + originalPath: originalName, + folderId: ROOT_TRASH_PREFIX + }); + } + result.sort((a, b) => (b.movedAt || 0) - (a.movedAt || 0)); res.json(result); } catch (err) { @@ -6316,15 +6551,91 @@ app.get("/api/trash", requireAuth, (req, res) => { }); // --- 🗑️ Çöpten geri yükleme API (.trash flag sistemi) --- -app.post("/api/trash/restore", requireAuth, (req, res) => { +app.post("/api/trash/restore", requireAuth, async (req, res) => { try { const { trashName } = req.body; - + if (!trashName) { return res.status(400).json({ error: "trashName gerekli" }); } - + const safeName = sanitizeRelative(trashName); + if (isRootTrashName(safeName)) { + const originalName = parseRootTrashName(safeName); + if (!originalName) { + return res.status(400).json({ error: "Geçersiz root trashName" }); + } + + const removed = removeRootTrashEntry(originalName); + if (!removed?.storedName) { + return res.status(404).json({ error: "Root çöp öğesi bulunamadı" }); + } + + const storedName = sanitizeRelative(removed.storedName); + const storedPath = path.join(ROOT_TRASH_DIR, storedName); + const targetPath = path.join(DOWNLOAD_DIR, originalName); + + if (!fs.existsSync(storedPath)) { + return res + .status(404) + .json({ error: "Root çöp dosyası bulunamadı" }); + } + + ensureDirForFile(targetPath); + try { + fs.renameSync(storedPath, targetPath); + } catch (err) { + if (err?.code === "EXDEV") { + let stat = null; + try { + stat = fs.statSync(storedPath); + } catch (statErr) { + return res.status(500).json({ error: "Root çöp dosyası okunamadı" }); + } + try { + if (stat?.isDirectory?.()) { + fs.cpSync(storedPath, targetPath, { recursive: true }); + fs.rmSync(storedPath, { recursive: true, force: true }); + } else { + fs.copyFileSync(storedPath, targetPath); + fs.rmSync(storedPath, { force: true }); + } + } catch (copyErr) { + console.warn( + `⚠️ root-trash restore EXDEV hatası (${storedPath}): ${copyErr.message}` + ); + return res + .status(500) + .json({ error: "Root çöp dosyası taşınamadı" }); + } + } else { + throw err; + } + } + + console.log(`♻️ Root öğe geri yüklendi: ${originalName}`); + + const animeSeriesInfo = parseAnimeSeriesInfo(originalName); + if (animeSeriesInfo) { + const mediaInfo = await extractMediaInfo(targetPath).catch(() => null); + await ensureSeriesData( + ANIME_ROOT_FOLDER, + originalName, + animeSeriesInfo, + mediaInfo + ); + } + + broadcastFileUpdate("downloads"); + broadcastDiskSpace(); + + return res.json({ + success: true, + message: "Öğe başarıyla geri yüklendi", + folderId: "downloads" + }); + } + const segments = safeName.split(/[\\/]/).filter(Boolean); if (!segments.length) { return res.status(400).json({ error: "Geçersiz trashName" }); @@ -6372,6 +6683,40 @@ app.delete("/api/trash", requireAuth, (req, res) => { } const safeName = sanitizeRelative(trashName); + if (isRootTrashName(safeName)) { + const originalName = parseRootTrashName(safeName); + if (!originalName) { + return res.status(400).json({ error: "Geçersiz root trashName" }); + } + + const removed = removeRootTrashEntry(originalName); + if (!removed?.storedName) { + return res.status(404).json({ error: "Root çöp öğesi bulunamadı" }); + } + + const storedName = sanitizeRelative(removed.storedName); + const storedPath = path.join(ROOT_TRASH_DIR, storedName); + if (fs.existsSync(storedPath)) { + try { + fs.rmSync(storedPath, { recursive: true, force: true }); + } catch (err) { + console.warn( + `⚠️ Root çöp öğesi silinemedi (${storedPath}): ${err.message}` + ); + } + } + + console.log(`🗑️ Root öğe kalıcı olarak silindi: ${originalName}`); + + broadcastFileUpdate("downloads"); + broadcastDiskSpace(); + + return res.json({ + success: true, + message: "Öğe tamamen silindi" + }); + } + const segments = safeName.split(/[\\/]/).filter(Boolean); if (!segments.length) { return res.status(400).json({ error: "Geçersiz trashName" }); @@ -6803,15 +7148,28 @@ app.post("/api/mailru/match", requireAuth, async (req, res) => { const safeSeason = Number(season) || 1; const safeEpisode = Number(episode) || 1; const title = metadata.title || metadata.name || "Anime"; + const seriesInfo = { + title, + searchTitle: title, + season: safeSeason, + episode: safeEpisode, + key: `S${String(safeSeason).padStart(2, "0")}E${String(safeEpisode).padStart(2, "0")}` + }; job.match = { id: metadata.id || null, title, season: safeSeason, episode: safeEpisode, - matchedAt: Date.now() + matchedAt: Date.now(), + rootFolder: ANIME_ROOT_FOLDER, + seriesInfo }; job.fileName = formatMailRuSeriesFilename(title, safeSeason, safeEpisode); job.title = job.fileName; + // Anime metadata'yı TVDB mantığıyla önceden hazırla (dosya henüz inmemiş olabilir) + ensureSeriesData(ANIME_ROOT_FOLDER, job.fileName, seriesInfo, null).catch( + () => null + ); const started = await beginMailRuDownload(job); if (!started) { return res.status(500).json({ ok: false, error: job.error || "Mail.ru indirimi başlatılamadı." }); @@ -6986,6 +7344,7 @@ app.get("/api/tvshows", requireAuth, (req, res) => { if (!paths || !fs.existsSync(paths.metadata)) continue; const { rootFolder } = parseTvSeriesKey(key); if (!rootFolder) continue; + if (rootFolder === ANIME_ROOT_FOLDER) continue; const infoForFolder = readInfoForRoot(rootFolder) || {}; const infoFiles = infoForFolder.files || {}; @@ -7326,6 +7685,369 @@ app.get("/api/tvshows", requireAuth, (req, res) => { } }); +function buildAnimeShows() { + if (!fs.existsSync(ANIME_DATA_ROOT)) { + return []; + } + + const dirEntries = fs + .readdirSync(ANIME_DATA_ROOT, { withFileTypes: true }) + .filter((d) => d.isDirectory()); + + const aggregated = new Map(); + + const mergeEpisode = (existing, incoming) => { + if (!existing) return incoming; + const merged = { ...existing, ...incoming }; + if (existing.still && !incoming.still) merged.still = existing.still; + if (!existing.still && incoming.still) merged.still = incoming.still; + if (existing.mediaInfo && !incoming.mediaInfo) merged.mediaInfo = existing.mediaInfo; + if (!existing.mediaInfo && incoming.mediaInfo) merged.mediaInfo = incoming.mediaInfo; + if (existing.overview && !incoming.overview) merged.overview = existing.overview; + return merged; + }; + + for (const dirent of dirEntries) { + const key = sanitizeRelative(dirent.name); + if (!key) continue; + const paths = tvSeriesPathsByKey(key); + if (!paths || !fs.existsSync(paths.metadata)) continue; + const { rootFolder } = parseTvSeriesKey(key); + if (rootFolder !== ANIME_ROOT_FOLDER) continue; + + let seriesData; + try { + seriesData = JSON.parse(fs.readFileSync(paths.metadata, "utf-8")); + } catch (err) { + console.warn(`⚠️ anime series.json okunamadı (${paths.metadata}): ${err.message}`); + continue; + } + + const seasonsObj = seriesData?.seasons || {}; + if (!Object.keys(seasonsObj).length) continue; + + let dataChanged = false; + const showId = seriesData.id ?? seriesData.tvdbId ?? seriesData.slug ?? seriesData.name ?? key; + const showKey = String(showId).toLowerCase(); + const record = + aggregated.get(showKey) || + (() => { + const base = { + id: seriesData.id ?? seriesData.tvdbId ?? key, + title: seriesData.name || "Anime", + overview: seriesData.overview || "", + year: seriesData.year || null, + status: seriesData.status || null, + poster: fs.existsSync(paths.poster) + ? encodeTvDataPath(paths.key, "poster.jpg") + : null, + backdrop: fs.existsSync(paths.backdrop) + ? encodeTvDataPath(paths.key, "backdrop.jpg") + : null, + genres: new Set( + Array.isArray(seriesData.genres) + ? seriesData.genres + .map((g) => (typeof g === "string" ? g : g?.name || null)) + .filter(Boolean) + : [] + ), + seasons: new Map(), + primaryFolder: ANIME_ROOT_FOLDER, + folders: new Set([ANIME_ROOT_FOLDER]) + }; + aggregated.set(showKey, base); + return base; + })(); + + if ( + seriesData.overview && + seriesData.overview.length > (record.overview?.length || 0) + ) { + record.overview = seriesData.overview; + } + if (!record.status && seriesData.status) record.status = seriesData.status; + if (!record.year || (seriesData.year && Number(seriesData.year) < Number(record.year))) { + record.year = seriesData.year || record.year; + } + if (!record.poster && fs.existsSync(paths.poster)) { + record.poster = encodeTvDataPath(paths.key, "poster.jpg"); + } + if (!record.backdrop && fs.existsSync(paths.backdrop)) { + record.backdrop = encodeTvDataPath(paths.key, "backdrop.jpg"); + } + if (Array.isArray(seriesData.genres)) { + seriesData.genres + .map((g) => (typeof g === "string" ? g : g?.name || null)) + .filter(Boolean) + .forEach((genre) => record.genres.add(genre)); + } + + for (const [seasonKey, rawSeason] of Object.entries(seasonsObj)) { + if (!rawSeason?.episodes) continue; + const seasonNumber = toFiniteNumber( + rawSeason.seasonNumber ?? rawSeason.number ?? seasonKey + ); + if (!Number.isFinite(seasonNumber)) continue; + + const seasonPaths = seasonAssetPaths(paths, seasonNumber); + const rawEpisodes = rawSeason.episodes || {}; + + for (const [episodeKey, rawEpisode] of Object.entries(rawEpisodes)) { + if (!rawEpisode || typeof rawEpisode !== "object") continue; + const relativeFile = (rawEpisode.file || "").replace(/\\/g, "/"); + if (!relativeFile) continue; + const absEpisodePath = path.join(DOWNLOAD_DIR, relativeFile); + const controlPath = `${absEpisodePath}.aria2`; + const isComplete = fs.existsSync(absEpisodePath) && !fs.existsSync(controlPath); + if (!isComplete) { + delete rawEpisodes[episodeKey]; + dataChanged = true; + } + } + + if (!Object.keys(rawSeason.episodes || {}).length) { + delete seasonsObj[seasonKey]; + dataChanged = true; + continue; + } + + let seasonRecord = record.seasons.get(seasonNumber); + if (!seasonRecord) { + seasonRecord = { + seasonNumber, + name: rawSeason.name || `Season ${seasonNumber}`, + overview: rawSeason.overview || "", + poster: rawSeason.poster || null, + tvdbId: rawSeason.tvdbId || null, + slug: rawSeason.slug || null, + episodeCount: rawSeason.episodeCount || null, + episodes: new Map() + }; + record.seasons.set(seasonNumber, seasonRecord); + } + + if (!seasonRecord.poster && fs.existsSync(seasonPaths.poster)) { + const relPoster = path.relative(paths.dir, seasonPaths.poster); + seasonRecord.poster = encodeTvDataPath(paths.key, relPoster); + } + + for (const [episodeKey, rawEpisode] of Object.entries(rawSeason.episodes)) { + if (!rawEpisode || typeof rawEpisode !== "object") continue; + const episodeNumber = toFiniteNumber( + rawEpisode.episodeNumber ?? rawEpisode.number ?? episodeKey + ); + if (!Number.isFinite(episodeNumber)) continue; + + const normalizedEpisode = { ...rawEpisode }; + normalizedEpisode.seasonNumber = seasonNumber; + normalizedEpisode.episodeNumber = episodeNumber; + if (!normalizedEpisode.code) { + normalizedEpisode.code = `S${String(seasonNumber).padStart(2, "0")}E${String( + episodeNumber + ).padStart(2, "0")}`; + } + + const relativeFile = (normalizedEpisode.file || "").replace(/\\/g, "/"); + if (relativeFile) { + const absVideo = path.join(DOWNLOAD_DIR, relativeFile); + const ext = path.extname(relativeFile).toLowerCase(); + if (fs.existsSync(absVideo) && VIDEO_EXTS.includes(ext)) { + normalizedEpisode.videoPath = relativeFile; + const stats = fs.statSync(absVideo); + normalizedEpisode.fileSize = Number(stats.size); + } else { + normalizedEpisode.videoPath = null; + } + } + + normalizedEpisode.folder = ANIME_ROOT_FOLDER; + + const existingEpisode = seasonRecord.episodes.get(episodeNumber); + seasonRecord.episodes.set( + episodeNumber, + mergeEpisode(existingEpisode, normalizedEpisode) + ); + } + + if (!seasonRecord.episodeCount && seasonRecord.episodes.size) { + seasonRecord.episodeCount = seasonRecord.episodes.size; + } + } + + if (dataChanged) { + try { + seriesData.seasons = seasonsObj; + seriesData.updatedAt = Date.now(); + fs.writeFileSync(paths.metadata, JSON.stringify(seriesData, null, 2), "utf-8"); + } catch (err) { + console.warn(`⚠️ anime series.json güncellenemedi (${paths.metadata}): ${err.message}`); + } + } + } + + const shows = Array.from(aggregated.values()) + .map((record) => { + const seasons = Array.from(record.seasons.values()) + .map((season) => { + const episodes = Array.from(season.episodes.values()) + .filter((episode) => { + if (!episode?.videoPath) return false; + const ext = path.extname(episode.videoPath).toLowerCase(); + if (!VIDEO_EXTS.includes(ext)) return false; + const absVideo = path.join(DOWNLOAD_DIR, episode.videoPath); + if (!fs.existsSync(absVideo)) return false; + const controlPath = `${absVideo}.aria2`; + return !fs.existsSync(controlPath); + }) + .sort((a, b) => a.episodeNumber - b.episodeNumber); + return { + seasonNumber: season.seasonNumber, + name: season.name || `Season ${season.seasonNumber}`, + overview: season.overview || "", + poster: season.poster || null, + tvdbSeasonId: season.tvdbId || null, + slug: season.slug || null, + episodeCount: season.episodeCount || episodes.length, + episodes + }; + }) + .sort((a, b) => a.seasonNumber - b.seasonNumber); + + return { + folder: record.primaryFolder, + id: record.id || record.title, + title: record.title, + overview: record.overview || "", + year: record.year || null, + genres: Array.from(record.genres).filter(Boolean), + status: record.status || null, + poster: record.poster || null, + backdrop: record.backdrop || null, + seasons, + folders: Array.from(record.folders) + }; + }) + .filter((show) => show.seasons.length > 0); + + shows.sort((a, b) => a.title.localeCompare(b.title, "en")); + return shows; +} + +async function rebuildAnimeMetadata({ clearCache = false } = {}) { + if (clearCache && fs.existsSync(ANIME_DATA_ROOT)) { + try { + fs.rmSync(ANIME_DATA_ROOT, { recursive: true, force: true }); + console.log("🧹 Anime cache temizlendi."); + } catch (err) { + console.warn( + `⚠️ Anime cache temizlenemedi (${ANIME_DATA_ROOT}): ${err.message}` + ); + } + } + + if (!fs.existsSync(ANIME_DATA_ROOT)) { + fs.mkdirSync(ANIME_DATA_ROOT, { recursive: true }); + } + if (clearCache) { + tvdbSeriesCache.clear(); + tvdbEpisodeCache.clear(); + tvdbEpisodeDetailCache.clear(); + } + + let processed = 0; + let rootEntries = []; + try { + rootEntries = fs.readdirSync(DOWNLOAD_DIR, { withFileTypes: true }); + } catch (err) { + console.warn(`⚠️ downloads kökü okunamadı: ${err.message}`); + } + + for (const dirent of rootEntries) { + if (!dirent.isFile()) continue; + const safeName = sanitizeRelative(dirent.name); + if (!safeName || safeName.startsWith(".")) continue; + if (safeName.endsWith(".aria2")) continue; + const absPath = path.join(DOWNLOAD_DIR, safeName); + if (!fs.existsSync(absPath)) continue; + const mimeType = mime.lookup(absPath) || ""; + if (!String(mimeType).startsWith("video/")) continue; + const seriesInfo = parseAnimeSeriesInfo(safeName); + if (!seriesInfo) continue; + const mediaInfo = await extractMediaInfo(absPath).catch(() => null); + await ensureSeriesData( + ANIME_ROOT_FOLDER, + safeName, + seriesInfo, + mediaInfo + ); + processed += 1; + } + + const shows = buildAnimeShows(); + for (const show of shows) { + for (const season of show.seasons || []) { + for (const episode of season.episodes || []) { + if (!episode?.videoPath) continue; + const absVideo = path.join(DOWNLOAD_DIR, episode.videoPath); + if (!fs.existsSync(absVideo)) continue; + const seriesInfo = { + title: show.title, + searchTitle: show.title, + season: season.seasonNumber, + episode: episode.episodeNumber, + key: episode.code || `S${String(season.seasonNumber).padStart(2, "0")}E${String( + episode.episodeNumber + ).padStart(2, "0")}` + }; + const mediaInfo = await extractMediaInfo(absVideo).catch(() => null); + await ensureSeriesData(ANIME_ROOT_FOLDER, episode.videoPath, seriesInfo, mediaInfo); + processed += 1; + } + } + } + return processed; +} + +app.get("/api/anime", requireAuth, (req, res) => { + try { + const shows = buildAnimeShows(); + res.json(shows); + } catch (err) { + console.error("🧿 Anime API error:", err); + res.status(500).json({ error: err.message }); + } +}); + +app.post("/api/anime/refresh", requireAuth, async (req, res) => { + if (!TVDB_API_KEY) { + return res + .status(400) + .json({ error: "TVDB API erişimi için gerekli anahtar tanımlı değil." }); + } + try { + const processed = await rebuildAnimeMetadata(); + res.json({ ok: true, processed }); + } catch (err) { + console.error("🧿 Anime refresh error:", err); + res.status(500).json({ error: err.message }); + } +}); + +app.post("/api/anime/rescan", requireAuth, async (req, res) => { + if (!TVDB_API_KEY) { + return res + .status(400) + .json({ error: "TVDB API erişimi için gerekli anahtar tanımlı değil." }); + } + try { + const processed = await rebuildAnimeMetadata({ clearCache: true }); + res.json({ ok: true, processed }); + } catch (err) { + console.error("🧿 Anime rescan error:", err); + res.status(500).json({ error: err.message }); + } +}); + function collectMusicEntries() { const entries = []; const dirEntries = fs @@ -7805,12 +8527,13 @@ app.get("/api/disk-space", requireAuth, async (req, res) => { // --- 🔍 TMDB/TVDB Arama Endpoint'i --- app.get("/api/search/metadata", requireAuth, async (req, res) => { try { - const { query, year, type } = req.query; - + const { query, year, type, scope } = req.query; + const preferEnForSeries = type === "series" && scope === "anime"; + if (!query) { return res.status(400).json({ error: "query parametresi gerekli" }); } - + if (type === "movie") { // TMDB Film Araması if (!TMDB_API_KEY) { @@ -7882,22 +8605,22 @@ app.get("/api/search/metadata", requireAuth, async (req, res) => { if (!TVDB_API_KEY) { return res.status(400).json({ error: "TVDB API key tanımlı değil" }); } - + const params = new URLSearchParams({ type: "series", query: query }); const resp = await tvdbFetch(`/search?${params.toString()}`); - + if (!resp || !resp.data) { return res.json({ results: [] }); } - + const allData = Array.isArray(resp.data) ? resp.data : []; - + const resultsWithDetails = await Promise.all( allData.slice(0, 20).map(async (item) => { try { const seriesId = item.tvdb_id || item.id; const extended = await fetchTvdbSeriesExtended(seriesId); - + if (extended) { const info = extended.series || extended; const artworks = Array.isArray(extended.artworks) ? extended.artworks : []; @@ -7905,11 +8628,28 @@ app.get("/api/search/metadata", requireAuth, async (req, res) => { const type = String(a?.type || a?.artworkType || "").toLowerCase(); return type.includes("poster") || type === "series" || type === "2"; }); - + + const translations = + extended.translations || info.translations || {}; + const nameTranslations = + translations.nameTranslations || translations.names || []; + const overviewTranslations = + translations.overviewTranslations || translations.overviews || []; + const localizedName = tvdbPickTranslation( + nameTranslations, + "name", + preferEnForSeries + ); + const localizedOverview = tvdbPickTranslation( + overviewTranslations, + "overview", + preferEnForSeries + ); + const genres = Array.isArray(info.genres) ? info.genres.map(g => typeof g === "string" ? g : g?.name || g?.genre).filter(Boolean) : []; - + // Yıl bilgisini çeşitli yerlerden al let seriesYear = null; if (info.year) { @@ -7921,12 +8661,12 @@ app.get("/api/search/metadata", requireAuth, async (req, res) => { const yearMatch = dateStr.match(/(\d{4})/); if (yearMatch) seriesYear = Number(yearMatch[1]); } - + return { id: seriesId, - title: info.name || item.name, + title: localizedName || info.name || item.name, year: seriesYear, - overview: info.overview || item.overview || "", + overview: localizedOverview || info.overview || item.overview || "", poster: posterArtwork?.image ? tvdbImageUrl(posterArtwork.image) : (item.image ? tvdbImageUrl(item.image) : null), genres: genres, status: info.status?.name || info.status || null, @@ -7936,7 +8676,7 @@ app.get("/api/search/metadata", requireAuth, async (req, res) => { } catch (err) { console.warn(`⚠️ Dizi detayı alınamadı:`, err.message); } - + // Fallback için yıl bilgisini al let itemYear = null; if (item.year) { @@ -7946,7 +8686,7 @@ app.get("/api/search/metadata", requireAuth, async (req, res) => { const yearMatch = dateStr.match(/(\d{4})/); if (yearMatch) itemYear = Number(yearMatch[1]); } - + return { id: item.tvdb_id || item.id, title: item.name || item.seriesName, @@ -7957,23 +8697,23 @@ app.get("/api/search/metadata", requireAuth, async (req, res) => { }; }) ); - + // Yıl filtresi detaylı bilgiler alındıktan SONRA uygula let filtered = resultsWithDetails.filter(Boolean); if (year && year.trim()) { const targetYear = Number(year); console.log(`🔍 TVDB Yıl filtresi uygulanıyor: ${targetYear}`); - + filtered = filtered.filter(item => { const itemYear = item.year ? Number(item.year) : null; const matches = itemYear && itemYear === targetYear; console.log(` - ${item.title}: yıl=${itemYear}, eşleşme=${matches}`); return matches; }); - + console.log(`🔍 Yıl filtresinden sonra: ${filtered.length} sonuç`); } - + res.json({ results: filtered.slice(0, 10) }); } else { res.status(400).json({ error: "type parametresi 'movie' veya 'series' olmalı" });