import express from "express"; import cors from "cors"; import multer from "multer"; import WebTorrent from "webtorrent"; import fs from "fs"; import path from "path"; import mime from "mime-types"; import { fileURLToPath } from "url"; import { exec, spawn } 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); const app = express(); const upload = multer({ dest: path.join(__dirname, "uploads") }); const client = new WebTorrent(); const torrents = new Map(); const youtubeJobs = new Map(); let wss; const PORT = process.env.PORT || 3001; // --- İndirilen dosyalar için klasör oluştur --- const DOWNLOAD_DIR = path.join(__dirname, "downloads"); if (!fs.existsSync(DOWNLOAD_DIR)) fs.mkdirSync(DOWNLOAD_DIR, { recursive: true }); // --- Çöp klasörü oluştur --- const TRASH_DIR = path.join(__dirname, "trash"); if (!fs.existsSync(TRASH_DIR)) fs.mkdirSync(TRASH_DIR, { recursive: true }); // --- Thumbnail cache klasörü --- const CACHE_DIR = path.join(__dirname, "cache"); const THUMBNAIL_DIR = path.join(CACHE_DIR, "thumbnails"); 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 YT_DATA_ROOT = path.join(CACHE_DIR, "yt_data"); const MUSIC_EXTENSIONS = new Set([ ".mp3", ".m4a", ".aac", ".flac", ".wav", ".ogg", ".oga", ".opus", ".mka" ]); for (const dir of [ THUMBNAIL_DIR, VIDEO_THUMB_ROOT, IMAGE_THUMB_ROOT, MOVIE_DATA_ROOT, TV_DATA_ROOT, YT_DATA_ROOT ]) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } const VIDEO_THUMBNAIL_TIME = process.env.VIDEO_THUMBNAIL_TIME || "00:00:05"; const VIDEO_EXTS = [".mp4", ".webm", ".mkv", ".mov", ".m4v"]; const generatingThumbnails = new Set(); const INFO_FILENAME = "info.json"; const YT_ID_REGEX = /^[A-Za-z0-9_-]{11}$/; const YT_DLP_BIN = process.env.YT_DLP_BIN || null; let resolvedYtDlpBinary = null; const TMDB_API_KEY = process.env.TMDB_API_KEY; const TMDB_BASE_URL = "https://api.themoviedb.org/3"; const TMDB_IMG_BASE = process.env.TMDB_IMAGE_BASE || "https://image.tmdb.org/t/p/original"; const TVDB_API_KEY = process.env.TTVDB_API_KEY || process.env.TVDB_API_KEY || null; const TVDB_USER_TOKEN = "mock_api_key" const TVDB_BASE_URL = "https://api4.thetvdb.com/v4"; const TVDB_IMAGE_BASE = process.env.TVDB_IMAGE_BASE || "https://artworks.thetvdb.com"; const FANART_TV_API_KEY = process.env.FANART_TV_API_KEY || null; const FANART_TV_BASE_URL = "https://webservice.fanart.tv/v3"; const FFPROBE_PATH = process.env.FFPROBE_PATH || "ffprobe"; const FFPROBE_MAX_BUFFER = Number(process.env.FFPROBE_MAX_BUFFER) > 0 ? Number(process.env.FFPROBE_MAX_BUFFER) : 10 * 1024 * 1024; const AVATAR_PATH = path.join(__dirname, "..", "client", "src", "assets", "avatar.png"); app.use(cors()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use("/downloads", express.static(DOWNLOAD_DIR)); // --- En uygun video dosyasını seç --- function pickBestVideoFile(torrent) { const videos = torrent.files .map((f, i) => ({ i, f })) .filter(({ f }) => VIDEO_EXTS.includes(path.extname(f.name).toLowerCase())); if (!videos.length) return 0; videos.sort((a, b) => b.f.length - a.f.length); return videos[0].i; } function enumerateVideoFiles(rootFolder) { const safe = sanitizeRelative(rootFolder); if (!safe) return []; const baseDir = path.join(DOWNLOAD_DIR, safe); if (!fs.existsSync(baseDir)) return []; const videos = []; const stack = [""]; while (stack.length) { const currentRel = stack.pop(); const currentDir = path.join(baseDir, currentRel); let dirEntries = []; try { dirEntries = fs.readdirSync(currentDir, { withFileTypes: true }); } catch (err) { console.warn(`⚠️ Klasör okunamadı (${currentDir}): ${err.message}`); continue; } for (const entry of dirEntries) { const name = entry.name; if (name.startsWith(".")) continue; if (name === INFO_FILENAME) continue; const relPath = path.join(currentRel, name); const absPath = path.join(currentDir, name); if (entry.isDirectory()) { if (isPathTrashed(safe, relPath, true)) continue; stack.push(relPath); continue; } if (!entry.isFile()) continue; if (isPathTrashed(safe, relPath, false)) continue; const ext = path.extname(name).toLowerCase(); if (!VIDEO_EXTS.includes(ext)) continue; let size = 0; try { size = fs.statSync(absPath).size; } catch (err) { console.warn(`⚠️ Dosya boyutu alınamadı (${absPath}): ${err.message}`); } videos.push({ relPath: relPath.replace(/\\/g, "/"), size }); } } videos.sort((a, b) => b.size - a.size); return videos; } function ensureDirForFile(filePath) { const dir = path.dirname(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); const avatarUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 3 * 1024 * 1024 }, fileFilter: (req, file, cb) => { const type = (file.mimetype || "").toLowerCase(); const allowed = ["image/png", "image/jpeg", "image/jpg"]; if (!allowed.includes(type)) { return cb(new Error("INVALID_FILE_TYPE")); } cb(null, true); } }); 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)); // --- Profil bilgisi --- app.get("/api/profile", requireAuth, (req, res) => { const username = req.user?.sub || req.user?.username || "user"; const role = req.user?.role || "user"; const avatarExists = fs.existsSync(AVATAR_PATH); res.json({ username, role, avatarExists, avatarUrl: avatarExists ? "/api/profile/avatar" : null }); }); app.get("/api/profile/avatar", requireAuth, (req, res) => { if (!fs.existsSync(AVATAR_PATH)) { return res.status(404).json({ error: "Avatar bulunamadı" }); } const stat = fs.statSync(AVATAR_PATH); const etag = `W/"${stat.size}-${stat.mtimeMs}"`; if (req.headers["if-none-match"] === etag) { return res.status(304).end(); } res.setHeader("Content-Type", "image/png"); res.setHeader("Cache-Control", "public, max-age=2592000, stale-while-revalidate=86400"); res.setHeader("ETag", etag); res.setHeader("Last-Modified", stat.mtime.toUTCString()); fs.createReadStream(AVATAR_PATH).pipe(res); }); app.post( "/api/profile/avatar", requireAuth, (req, res, next) => { avatarUpload.single("avatar")(req, res, (err) => { if (err) { const isSize = err.code === "LIMIT_FILE_SIZE"; const message = isSize ? "Dosya boyutu 3MB'ı aşmamalı." : "Geçersiz dosya tipi. Sadece jpg, jpeg veya png."; return res.status(400).json({ error: message }); } next(); }); }, (req, res) => { try { if (!req.file?.buffer) { return res.status(400).json({ error: "Dosya yüklenemedi" }); } const buffer = req.file.buffer; if (!isAllowedImage(buffer)) { return res.status(400).json({ error: "Sadece jpeg/jpg/png kabul edilir" }); } if (!isPng(buffer)) { return res .status(400) .json({ error: "Lütfen kırptıktan sonra PNG olarak yükleyin." }); } ensureDirForFile(AVATAR_PATH); fs.writeFileSync(AVATAR_PATH, buffer); res.json({ success: true, avatarUrl: "/api/profile/avatar" }); } catch (err) { console.error("Avatar yükleme hatası:", err); res.status(500).json({ error: "Avatar kaydedilemedi" }); } } ); function tvdbImageUrl(pathSegment) { if (!pathSegment) return null; if (pathSegment.startsWith("http")) return pathSegment; if (pathSegment.startsWith("/")) return `${TVDB_IMAGE_BASE}${pathSegment}`; return `${TVDB_IMAGE_BASE}/${pathSegment}`; } async function downloadTvdbImage(imagePath, targetPath) { const url = tvdbImageUrl(imagePath); if (!url) return false; try { const resp = await fetch(url); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); ensureDirForFile(targetPath); const arr = await resp.arrayBuffer(); fs.writeFileSync(targetPath, Buffer.from(arr)); return true; } catch (err) { console.warn(`⚠️ TVDB görsel indirilemedi (${url}): ${err.message}`); return false; } } async function fetchFanartTvImages(thetvdbId) { if (!FANART_TV_API_KEY || !thetvdbId) return null; const url = `${FANART_TV_BASE_URL}/tv/${thetvdbId}?api_key=${FANART_TV_API_KEY}`; try { const resp = await fetch(url); if (!resp.ok) { console.warn(`⚠️ Fanart.tv isteği başarısız (${url}): ${resp.status}`); return null; } const data = await resp.json(); console.log("🖼️ Fanart.tv backdrop araması:", { thetvdbId, hasShowbackground: Boolean(data.showbackground) }); return data; } catch (err) { console.warn(`⚠️ Fanart.tv isteği hatası (${url}): ${err.message}`); return null; } } async function downloadFanartTvImage(imageUrl, targetPath) { if (!imageUrl) return false; try { const resp = await fetch(imageUrl); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); ensureDirForFile(targetPath); const arr = await resp.arrayBuffer(); fs.writeFileSync(targetPath, Buffer.from(arr)); return true; } catch (err) { console.warn(`⚠️ Fanart.tv görsel indirilemedi (${imageUrl}): ${err.message}`); return false; } } function titleCase(value) { if (!value) return ""; return value .toLowerCase() .split(/\s+/) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(" "); } function normalizeTvdbId(value) { if (value === null || value === undefined) return null; if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string") { const match = value.match(/\d+/); if (match) { const num = Number(match[0]); if (Number.isFinite(num)) return num; } } return null; } function normalizeTvdbEpisode(raw) { if (!raw || typeof raw !== "object") return null; const seasonNumber = toFiniteNumber( raw.seasonNumber ?? raw.season ?? raw.airedSeason ?? raw.season_number ?? raw.seasonNum ); const episodeNumber = toFiniteNumber( raw.number ?? raw.episodeNumber ?? raw.airedEpisodeNumber ?? raw.episode_number ?? raw.episodeNum ); return { id: normalizeTvdbId( raw.id ?? raw.tvdb_id ?? raw.episodeId ?? raw.episode_id ), seasonId: normalizeTvdbId(raw.seasonId ?? raw.season_id ?? raw.parentId), seriesId: normalizeTvdbId(raw.seriesId ?? raw.series_id), seasonNumber: Number.isFinite(seasonNumber) ? seasonNumber : null, episodeNumber: Number.isFinite(episodeNumber) ? episodeNumber : null, name: raw.name ?? raw.episodeName ?? raw.title ?? raw.episodeTitle ?? null, overview: raw.overview ?? raw.description ?? raw.synopsis ?? raw.plot ?? "", image: raw.image ?? raw.filename ?? raw.fileName ?? raw.thumb ?? raw.thumbnail ?? raw.imageUrl ?? raw.image_url ?? null, aired: raw.aired ?? raw.firstAired ?? raw.airDate ?? raw.air_date ?? raw.released ?? null, runtime: toFiniteNumber( raw.runtime ?? raw.length ?? raw.duration ?? raw.runTime ?? raw.runtimeMinutes ?? raw.runtime_minutes ), slug: raw.slug ?? null, translations: raw.translations || null, raw }; } function normalizeTvdbSeason(raw) { if (!raw || typeof raw !== "object") return null; const seasonNumber = toFiniteNumber( raw.number ?? raw.seasonNumber ?? raw.season ?? raw.airedSeason ?? raw.seasonNum ); return { id: normalizeTvdbId(raw.id ?? raw.tvdb_id ?? raw.seasonId ?? raw.season_id), number: Number.isFinite(seasonNumber) ? seasonNumber : null, name: raw.name ?? raw.title ?? raw.translation ?? null, overview: raw.overview ?? raw.description ?? "", image: raw.image ?? raw.poster ?? raw.filename ?? raw.fileName ?? raw.thumb ?? raw.thumbnail ?? null, translations: raw.translations || null, raw }; } function toDimension(value) { const num = Number(value); return Number.isFinite(num) ? num : null; } function artworkKind(value) { return String(value || "").toLowerCase(); } function isBackgroundArtwork(entry) { const candidates = [ entry?.type, entry?.artworkType, entry?.artworkTypeSlug, entry?.type2, entry?.name, entry?.artwork ]; return candidates .map(artworkKind) .some((kind) => kind.includes("background") || kind.includes("fanart") || kind.includes("landscape") || kind === "1" ); } function selectBackgroundArtwork(entries) { if (!Array.isArray(entries) || !entries.length) return null; const candidates = entries.filter(isBackgroundArtwork); if (!candidates.length) return null; const normalized = candidates.map((entry) => { const width = toDimension(entry.width); const height = toDimension(entry.height); const area = width && height ? width * height : null; return { entry, width, height, area }; }); const landscape = normalized .filter((item) => item.width && item.height && item.width >= item.height) .sort((a, b) => (b.area || 0) - (a.area || 0)); if (landscape.length) return landscape[0].entry; normalized.sort((a, b) => (b.area || 0) - (a.area || 0)); return normalized[0].entry; } async function fetchTvdbArtworks(seriesId, typeSlug) { if (!seriesId || !typeSlug) return []; const resp = await tvdbFetch(`/series/${seriesId}/artworks/${typeSlug}`); if (!resp) return []; if (Array.isArray(resp.data)) return resp.data; if (Array.isArray(resp.artworks)) return resp.artworks; return []; } function infoFilePath(savePath) { return path.join(savePath, INFO_FILENAME); } function readInfoFile(savePath) { const target = infoFilePath(savePath); if (!fs.existsSync(target)) return null; try { return JSON.parse(fs.readFileSync(target, "utf-8")); } catch (err) { console.warn(`⚠️ info.json okunamadı (${target}): ${err.message}`); return null; } } function upsertInfoFile(savePath, partial) { const target = infoFilePath(savePath); try { ensureDirForFile(target); let current = {}; if (fs.existsSync(target)) { try { current = JSON.parse(fs.readFileSync(target, "utf-8")) || {}; } catch (err) { console.warn(`⚠️ info.json parse edilemedi (${target}): ${err.message}`); } } const timestamp = Date.now(); const next = { ...current, ...partial, updatedAt: timestamp }; if (partial && Object.prototype.hasOwnProperty.call(partial, "files")) { if (partial.files && typeof partial.files === "object") { next.files = partial.files; } else { delete next.files; } } else if (current.files && next.files === undefined) { next.files = current.files; } if (!next.createdAt) { next.createdAt = current.createdAt ?? partial?.createdAt ?? timestamp; } if (!next.added && partial?.added) { next.added = partial.added; } if (!next.folder) { next.folder = path.basename(savePath); } fs.writeFileSync(target, JSON.stringify(next, null, 2), "utf-8"); return next; } catch (err) { console.warn(`⚠️ info.json yazılamadı (${target}): ${err.message}`); return null; } } function readInfoForRoot(rootFolder) { const safe = sanitizeRelative(rootFolder); if (!safe) return null; const target = path.join(DOWNLOAD_DIR, safe, INFO_FILENAME); if (!fs.existsSync(target)) return null; try { return JSON.parse(fs.readFileSync(target, "utf-8")); } catch (err) { console.warn(`⚠️ info.json okunamadı (${target}): ${err.message}`); return null; } } function sanitizeRelative(relPath) { return relPath.replace(/^[\\/]+/, ""); } function isPng(buffer) { return ( buffer && buffer.length >= 8 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47 ); } function isJpeg(buffer) { return buffer && buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xd8; } function isAllowedImage(buffer) { return isPng(buffer) || isJpeg(buffer); } function determineMediaType({ tracker, movieMatch, seriesEpisode, categories, relPath }) { if (seriesEpisode) return "tv"; if (movieMatch) return "movie"; if ( Array.isArray(categories) && categories.some((cat) => String(cat).toLowerCase() === "music") ) { return "music"; } if (relPath) { const ext = path.extname(relPath).toLowerCase(); if (MUSIC_EXTENSIONS.has(ext)) return "music"; } return "video"; } function getYtDlpBinary() { if (resolvedYtDlpBinary) return resolvedYtDlpBinary; const candidates = [ YT_DLP_BIN, "/usr/local/bin/yt-dlp", path.join(__dirname, "..", ".pipx", "bin", "yt-dlp"), "yt-dlp" ].filter(Boolean); for (const candidate of candidates) { if (candidate.includes(path.sep) || candidate.startsWith("/")) { if (fs.existsSync(candidate)) { resolvedYtDlpBinary = candidate; return resolvedYtDlpBinary; } continue; } // bare command name, trust PATH resolvedYtDlpBinary = candidate; return resolvedYtDlpBinary; } resolvedYtDlpBinary = "yt-dlp"; return resolvedYtDlpBinary; } function normalizeYoutubeWatchUrl(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 !== "youtube.com" && host !== "www.youtube.com") return null; if (urlObj.pathname !== "/watch") return null; const videoId = urlObj.searchParams.get("v"); if (!videoId || !YT_ID_REGEX.test(videoId)) return null; return `https://www.youtube.com/watch?v=${videoId}`; } catch (err) { return null; } } function startYoutubeDownload(url) { const normalized = normalizeYoutubeWatchUrl(url); if (!normalized) return null; const videoId = new URL(normalized).searchParams.get("v"); const folderId = `yt_${videoId}_${Date.now().toString(36)}`; const savePath = path.join(DOWNLOAD_DIR, folderId); fs.mkdirSync(savePath, { recursive: true }); const job = { id: folderId, infoHash: folderId, type: "youtube", url: normalized, videoId, folderId, savePath, added: Date.now(), title: null, state: "downloading", progress: 0, downloaded: 0, totalBytes: 0, downloadSpeed: 0, stages: [], currentStage: null, completedBytes: 0, files: [], selectedIndex: 0, thumbnail: null, process: null, error: null, debug: { binary: null, args: null, logs: [] } }; youtubeJobs.set(job.id, job); launchYoutubeJob(job); console.log(`▶️ YouTube indirmesi başlatıldı: ${job.url}`); broadcastSnapshot(); return job; } function appendYoutubeLog(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 launchYoutubeJob(job) { const binary = getYtDlpBinary(); const args = [ "-f", "bv+ba/b", "--write-thumbnail", "--convert-thumbnails", "jpg", "--write-info-json", job.url ]; 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(); appendYoutubeLog(job, text); for (const raw of text.split(/\r?\n/)) { const line = raw.trim(); if (!line) continue; processYoutubeOutput(job, line); } }; child.stdout.on("data", handleChunk); child.stderr.on("data", handleChunk); child.on("close", (code) => finalizeYoutubeJob(job, code)); child.on("error", (err) => { job.state = "error"; job.downloadSpeed = 0; appendYoutubeLog(job, `spawn error: ${err?.message || err}`); job.error = err?.message || "yt-dlp çalıştırılamadı"; console.error("❌ yt-dlp spawn error:", { jobId: job.id, message: err?.message || err, binary, args }); broadcastSnapshot(); }); } function processYoutubeOutput(job, line) { const destMatch = line.match(/^\[download\]\s+Destination:\s+(.+)$/i); if (destMatch) { startYoutubeStage(job, destMatch[1].trim()); return; } const progressMatch = line.match( /^\[download\]\s+([\d.]+)%\s+of\s+([\d.]+)\s*([KMGTP]?i?B)(?:\s+at\s+([\d.]+)\s*([KMGTP]?i?B)\/s)?/i ); if (progressMatch) { updateYoutubeProgress(job, progressMatch); } } function startYoutubeStage(job, fileName) { if (job.currentStage && !job.currentStage.done) { if (job.currentStage.totalBytes) { job.completedBytes += job.currentStage.totalBytes; } job.currentStage.done = true; } const stage = { name: fileName, totalBytes: null, downloadedBytes: 0, done: false }; job.currentStage = stage; job.stages.push(stage); broadcastSnapshot(); } function updateYoutubeProgress(job, match) { const percent = Number(match[1]) || 0; const totalValue = Number(match[2]) || 0; const totalUnit = (match[3] || "B").trim(); const totalBytes = bytesFromHuman(totalValue, totalUnit); const downloadedBytes = Math.min( totalBytes, Math.round((percent / 100) * totalBytes) ); const speedValue = Number(match[4]); const speedUnit = match[5]; if (job.currentStage) { job.currentStage.totalBytes = totalBytes; job.currentStage.downloadedBytes = downloadedBytes; if (percent >= 100 && !job.currentStage.done) { job.currentStage.done = true; job.completedBytes += totalBytes; } } job.totalBytes = job.stages.reduce( (sum, stage) => sum + (stage.totalBytes || 0), 0 ); const activeBytes = job.currentStage && !job.currentStage.done ? job.currentStage.downloadedBytes : 0; const denominator = job.totalBytes || totalBytes || 0; job.downloaded = job.completedBytes + activeBytes; job.progress = denominator > 0 ? job.downloaded / denominator : 0; if (Number.isFinite(speedValue) && speedUnit) { job.downloadSpeed = bytesFromHuman(speedValue, speedUnit); } broadcastSnapshot(); } async function finalizeYoutubeJob(job, exitCode) { job.downloadSpeed = 0; if (exitCode !== 0) { job.state = "error"; const tail = job.debug?.logs ? job.debug.logs.slice(-8) : []; job.error = `yt-dlp ${exitCode} kodu ile sonlandı`; if (tail.length) { job.error += ` | ${tail.join(" | ")}`; } console.warn("❌ yt-dlp çıkış kodu hata:", { jobId: job.id, exitCode, binary: job.debug?.binary, args: job.debug?.args, lastLines: tail }); broadcastSnapshot(); return; } try { if (job.currentStage && !job.currentStage.done && job.currentStage.totalBytes) { job.completedBytes += job.currentStage.totalBytes; job.currentStage.done = true; } const infoJson = findYoutubeInfoJson(job.savePath); const videoFile = findYoutubeVideoFile(job.savePath); if (!videoFile) { job.state = "error"; job.error = "Video dosyası bulunamadı"; console.warn("❌ yt-dlp çıktı video bulunamadı:", { jobId: job.id, savePath: job.savePath, lastLines: job.debug?.logs?.slice(-8) || [] }); broadcastSnapshot(); return; } const absVideo = path.join(job.savePath, videoFile); const stats = fs.statSync(absVideo); const mediaInfo = await extractMediaInfo(absVideo).catch(() => null); const relativeName = videoFile.replace(/\\/g, "/"); job.files = [ { index: 0, name: relativeName, length: stats.size } ]; job.selectedIndex = 0; job.title = deriveYoutubeTitle(videoFile, job.videoId); job.downloaded = stats.size; job.totalBytes = stats.size; job.progress = 1; job.state = "completed"; const metadataPayload = await writeYoutubeMetadata( job, absVideo, mediaInfo, infoJson ); const payloadWithThumb = updateYoutubeThumbnail(job, metadataPayload) || metadataPayload; const mediaType = payloadWithThumb?.type || "video"; const categories = payloadWithThumb?.categories || null; upsertInfoFile(job.savePath, { infoHash: job.id, name: job.title, tracker: "youtube", added: job.added, folder: job.folderId, type: mediaType, categories, files: { [relativeName]: { size: stats.size, extension: path.extname(relativeName).replace(/^\./, ""), mediaInfo, youtube: { url: job.url, videoId: job.videoId }, categories, type: mediaType } }, primaryVideoPath: relativeName, primaryMediaInfo: mediaInfo }); broadcastFileUpdate(job.folderId); broadcastSnapshot(); broadcastDiskSpace(); console.log(`✅ YouTube indirmesi tamamlandı: ${job.title}`); } catch (err) { job.state = "error"; job.error = err?.message || "YouTube indirimi tamamlanamadı"; broadcastSnapshot(); } } function findYoutubeVideoFile(savePath) { const entries = fs.readdirSync(savePath, { withFileTypes: true }); const videos = entries .filter((entry) => entry.isFile()) .map((entry) => entry.name) .filter((name) => VIDEO_EXTS.includes(path.extname(name).toLowerCase())); if (!videos.length) return null; videos.sort((a, b) => { const aSize = fs.statSync(path.join(savePath, a)).size; const bSize = fs.statSync(path.join(savePath, b)).size; return bSize - aSize; }); return videos[0]; } function findYoutubeInfoJson(savePath) { const entries = fs.readdirSync(savePath, { withFileTypes: true }); const jsons = entries .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".info.json") ) .map((entry) => entry.name) .sort(); return jsons[0] || null; } function deriveYoutubeTitle(fileName, videoId) { const base = fileName.replace(path.extname(fileName), ""); const pattern = videoId ? new RegExp(`\\[${videoId}\\]`, "i") : null; const cleaned = pattern ? base.replace(pattern, "") : base; return cleaned.replace(/[-_.]+$/g, "").trim() || base; } async function writeYoutubeMetadata(job, videoPath, mediaInfo, infoJsonFile) { const targetDir = path.join(YT_DATA_ROOT, job.folderId); fs.mkdirSync(targetDir, { recursive: true }); let infoJson = null; if (infoJsonFile) { try { infoJson = JSON.parse( fs.readFileSync(path.join(job.savePath, infoJsonFile), "utf-8") ); } catch (err) { console.warn("YouTube info.json okunamadı:", err.message); } } const categories = Array.isArray(infoJson?.categories) ? infoJson.categories : null; const derivedType = determineMediaType({ tracker: "youtube", movieMatch: null, seriesEpisode: null, categories, relPath: job.files?.[0]?.name || null }); const payload = { id: job.id, title: job.title, url: job.url, videoId: job.videoId, added: job.added, folderId: job.folderId, file: job.files?.[0]?.name || null, mediaInfo, type: derivedType, categories, ytMeta: infoJson || null }; fs.writeFileSync( path.join(targetDir, "metadata.json"), JSON.stringify(payload, null, 2), "utf-8" ); return payload; } function updateYoutubeThumbnail(job, metadataPayload = null) { const thumbs = fs .readdirSync(job.savePath, { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".jpg")); if (!thumbs.length) return; const source = path.join(job.savePath, thumbs[0].name); const targetDir = path.join(YT_DATA_ROOT, job.folderId); fs.mkdirSync(targetDir, { recursive: true }); const target = path.join(targetDir, "thumbnail.jpg"); try { fs.copyFileSync(source, target); job.thumbnail = `/yt-data/${job.folderId}/thumbnail.jpg?t=${Date.now()}`; } catch (err) { console.warn("Thumbnail kopyalanamadı:", err.message); } try { const metaPath = path.join(YT_DATA_ROOT, job.folderId, "metadata.json"); let payload = metadataPayload; if (!payload && fs.existsSync(metaPath)) { payload = JSON.parse(fs.readFileSync(metaPath, "utf-8")); } if (payload) { payload.thumbnail = job.thumbnail; fs.writeFileSync(metaPath, JSON.stringify(payload, null, 2), "utf-8"); return payload; } } catch (err) { console.warn("YT metadata güncellenemedi:", err.message); } return metadataPayload || null; } function removeYoutubeJob(jobId, { removeFiles = true } = {}) { const job = youtubeJobs.get(jobId); if (!job) return false; if (job.process && !job.process.killed) { try { job.process.kill("SIGTERM"); } catch (err) { console.warn("YT job kill error:", err.message); } } youtubeJobs.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("YT dosyası silinemedi:", err.message); } } const cacheDir = path.join(YT_DATA_ROOT, job.folderId); if (removeFiles && fs.existsSync(cacheDir)) { try { fs.rmSync(cacheDir, { recursive: true, force: true }); } catch (err) { console.warn("YT cache silinemedi:", err.message); } } broadcastSnapshot(); if (filesRemoved) { broadcastFileUpdate(job.folderId); broadcastDiskSpace(); } return true; } function youtubeSnapshot(job) { const files = (job.files || []).map((file, index) => ({ index, name: file.name, length: file.length })); return { infoHash: job.id, type: "youtube", 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: null, added: job.added, savePath: job.savePath, paused: false, files, selectedIndex: job.selectedIndex || 0, thumbnail: job.thumbnail, status: job.state }; } function relPathToSegments(relPath) { return sanitizeRelative(relPath).split(/[\\/]/).filter(Boolean); } function rootFromRelPath(relPath) { const segments = relPathToSegments(relPath); return segments[0] || null; } function getVideoThumbnailPaths(relPath) { const parsed = path.parse(relPath); const relThumb = path.join("videos", parsed.dir, `${parsed.name}.jpg`); const absThumb = path.join(THUMBNAIL_DIR, relThumb); return { relThumb, absThumb }; } function getImageThumbnailPaths(relPath) { const parsed = path.parse(relPath); const relThumb = path.join( "images", parsed.dir, `${parsed.name}${parsed.ext || ".jpg"}` ); const absThumb = path.join(THUMBNAIL_DIR, relThumb); return { relThumb, absThumb }; } function thumbnailUrl(relThumb) { const safe = relThumb .split(path.sep) .filter(Boolean) .map(encodeURIComponent) .join("/"); return `/thumbnails/${safe}`; } function markGenerating(absThumb, add) { if (add) generatingThumbnails.add(absThumb); else generatingThumbnails.delete(absThumb); } function toFiniteNumber(value) { const num = Number(value); return Number.isFinite(num) ? num : null; } function parseFrameRate(value) { if (!value) return null; if (typeof value === "number") return Number.isFinite(value) ? value : null; const parts = String(value).split("/"); if (parts.length === 2) { const numerator = Number(parts[0]); const denominator = Number(parts[1]); if ( Number.isFinite(numerator) && Number.isFinite(denominator) && denominator !== 0 ) { return Number((numerator / denominator).toFixed(3)); } } const num = Number(value); return Number.isFinite(num) ? num : null; } const HUMAN_SIZE_UNITS = { B: 1, KB: 1000, MB: 1000 ** 2, GB: 1000 ** 3, TB: 1000 ** 4, KIB: 1024, MIB: 1024 ** 2, GIB: 1024 ** 3, TIB: 1024 ** 4 }; function bytesFromHuman(value, unit = "B") { if (!Number.isFinite(value)) return 0; if (!unit) return value; const normalized = unit.replace(/\s+/g, "").toUpperCase(); const scale = HUMAN_SIZE_UNITS[normalized] || 1; return value * scale; } async function extractMediaInfo(filePath, retryCount = 0) { if (!filePath || !fs.existsSync(filePath)) return null; // Farklı ffprobe stratejileri const strategies = [ // Standart yöntem `${FFPROBE_PATH} -v quiet -print_format json -show_format -show_streams "${filePath}"`, // Daha toleranslı yöntem `${FFPROBE_PATH} -v error -print_format json -show_format -show_streams "${filePath}"`, // Sadece format bilgisi `${FFPROBE_PATH} -v error -print_format json -show_format "${filePath}"`, // En basit yöntem `${FFPROBE_PATH} -v fatal -print_format json -show_format -show_streams "${filePath}"` ]; const strategyIndex = Math.min(retryCount, strategies.length - 1); const cmd = strategies[strategyIndex]; return new Promise((resolve) => { exec( cmd, { maxBuffer: FFPROBE_MAX_BUFFER }, (err, stdout, stderr) => { if (err) { // Hata durumunda yeniden dene if (retryCount < strategies.length - 1) { console.warn( `⚠️ ffprobe çalıştırılamadı (${filePath}): ${err.message}. Yeniden deneme (${retryCount + 1}/${strategies.length - 1})` ); setTimeout(() => extractMediaInfo(filePath, retryCount + 1).then(resolve), 1000 * (retryCount + 1)); return; } // Tüm denemeler başarısız oldu console.error(`❌ ffprobe çalıştırılamadı (${filePath}): ${err.message}`); if (stderr) { console.error(`🔍 ffprobe stderr: ${stderr}`); } // Dosya boyutu kontrolü try { const stats = fs.statSync(filePath); const fileSizeMB = stats.size / (1024 * 1024); if (fileSizeMB < 1) { console.warn(`⚠️ Dosya çok küçük olabilir (${fileSizeMB.toFixed(2)}MB): ${filePath}`); } } catch (statErr) { console.warn(`⚠️ Dosya bilgileri alınamadı: ${statErr.message}`); } return resolve(null); } try { const parsed = JSON.parse(stdout); const streams = Array.isArray(parsed?.streams) ? parsed.streams : []; const format = parsed?.format || {}; const videoStream = streams.find((s) => s.codec_type === "video") || null; const audioStream = streams.find((s) => s.codec_type === "audio") || null; const mediaInfo = { format: { duration: toFiniteNumber(format.duration), size: toFiniteNumber(format.size), bitrate: toFiniteNumber(format.bit_rate) }, video: videoStream ? { codec: videoStream.codec_name || null, profile: videoStream.profile || null, width: toFiniteNumber(videoStream.width), height: toFiniteNumber(videoStream.height), resolution: videoStream.height ? `${videoStream.height}p` : videoStream.width ? `${videoStream.width}px` : null, bitrate: toFiniteNumber(videoStream.bit_rate), frameRate: parseFrameRate( videoStream.avg_frame_rate || videoStream.r_frame_rate ), pixelFormat: videoStream.pix_fmt || null } : null, audio: audioStream ? { codec: audioStream.codec_name || null, channels: toFiniteNumber(audioStream.channels), channelLayout: audioStream.channel_layout || null, bitrate: toFiniteNumber(audioStream.bit_rate), sampleRate: toFiniteNumber(audioStream.sample_rate) } : null }; resolve(mediaInfo); } catch (parseErr) { // Parse hatası durumunda yeniden dene if (retryCount < strategies.length - 1) { console.warn( `⚠️ ffprobe çıktısı parse edilemedi (${filePath}): ${parseErr.message}. Yeniden deneme (${retryCount + 1}/${strategies.length - 1})` ); setTimeout(() => extractMediaInfo(filePath, retryCount + 1).then(resolve), 1000 * (retryCount + 1)); return; } console.error( `❌ ffprobe çıktısı parse edilemedi (${filePath}): ${parseErr.message}` ); resolve(null); } } ); }); } function queueVideoThumbnail(fullPath, relPath, retryCount = 0) { const { relThumb, absThumb } = getVideoThumbnailPaths(relPath); if (fs.existsSync(absThumb) || generatingThumbnails.has(absThumb)) return; ensureDirForFile(absThumb); markGenerating(absThumb, true); // Farklı ffmpeg stratejileri deneme const strategies = [ // Standart yöntem `ffmpeg -y -ss ${VIDEO_THUMBNAIL_TIME} -i "${fullPath}" -frames:v 1 -vf "scale=320:-1" -q:v 2 "${absThumb}"`, // Daha toleranslı yöntem - hata ayıklama modu `ffmpeg -y -ss ${VIDEO_THUMBNAIL_TIME} -i "${fullPath}" -frames:v 1 -vf "scale=320:-1" -q:v 2 -err_detect ignore_err "${absThumb}"`, // Dosya sonundan başlayarak deneme `ffmpeg -y -ss 5 -i "${fullPath}" -frames:v 1 -vf "scale=320:-1" -q:v 2 -avoid_negative_ts make_zero "${absThumb}"`, // En basit yöntem - sadece kare yakalama `ffmpeg -y -i "${fullPath}" -frames:v 1 -vf "scale=320:-1" -q:v 3 "${absThumb}"` ]; const strategyIndex = Math.min(retryCount, strategies.length - 1); const cmd = strategies[strategyIndex]; exec(cmd, (err, stdout, stderr) => { markGenerating(absThumb, false); if (err) { // Hata durumunda yeniden dene if (retryCount < strategies.length - 1) { console.warn(`⚠️ Video thumbnail oluşturulamadı (${fullPath}): ${err.message}. Yeniden deneme (${retryCount + 1}/${strategies.length - 1})`); setTimeout(() => queueVideoThumbnail(fullPath, relPath, retryCount + 1), 1000 * (retryCount + 1)); return; } // Tüm denemeler başarısız oldu console.error(`❌ Video thumbnail oluşturulamadı (${fullPath}): ${err.message}`); if (stderr) { console.error(`🔍 ffmpeg stderr: ${stderr}`); } // Bozuk dosya kontrolü try { const stats = fs.statSync(fullPath); const fileSizeMB = stats.size / (1024 * 1024); if (fileSizeMB < 1) { console.warn(`⚠️ Dosya çok küçük olabilir (${fileSizeMB.toFixed(2)}MB): ${fullPath}`); } } catch (statErr) { console.warn(`⚠️ Dosya bilgileri alınamadı: ${statErr.message}`); } return; } console.log(`🎞️ Video thumbnail oluşturuldu: ${absThumb}`); const root = rootFromRelPath(relPath); if (root) broadcastFileUpdate(root); }); } function queueImageThumbnail(fullPath, relPath, retryCount = 0) { const { relThumb, absThumb } = getImageThumbnailPaths(relPath); if (fs.existsSync(absThumb) || generatingThumbnails.has(absThumb)) return; ensureDirForFile(absThumb); markGenerating(absThumb, true); const outputExt = path.extname(absThumb).toLowerCase(); const needsQuality = outputExt === ".jpg" || outputExt === ".jpeg"; // Farklı ffmpeg stratejileri deneme const strategies = [ // Standart yöntem `ffmpeg -y -i "${fullPath}" -vf "scale=320:-1"${needsQuality ? ' -q:v 5' : ''} "${absThumb}"`, // Daha toleranslı yöntem `ffmpeg -y -i "${fullPath}" -vf "scale=320:-1"${needsQuality ? ' -q:v 7' : ''} -err_detect ignore_err "${absThumb}"`, // En basit yöntem `ffmpeg -y -i "${fullPath}" -vf "scale=320:-1" "${absThumb}"` ]; const strategyIndex = Math.min(retryCount, strategies.length - 1); const cmd = strategies[strategyIndex]; exec(cmd, (err, stdout, stderr) => { markGenerating(absThumb, false); if (err) { // Hata durumunda yeniden dene if (retryCount < strategies.length - 1) { console.warn(`⚠️ Resim thumbnail oluşturulamadı (${fullPath}): ${err.message}. Yeniden deneme (${retryCount + 1}/${strategies.length - 1})`); setTimeout(() => queueImageThumbnail(fullPath, relPath, retryCount + 1), 1000 * (retryCount + 1)); return; } // Tüm denemeler başarısız oldu console.error(`❌ Resim thumbnail oluşturulamadı (${fullPath}): ${err.message}`); if (stderr) { console.error(`🔍 ffmpeg stderr: ${stderr}`); } return; } console.log(`🖼️ Resim thumbnail oluşturuldu: ${absThumb}`); const root = rootFromRelPath(relPath); if (root) broadcastFileUpdate(root); }); } function removeThumbnailsForPath(relPath) { const normalized = sanitizeRelative(relPath); if (!normalized) return; const parsed = path.parse(normalized); const candidates = [ path.join(VIDEO_THUMB_ROOT, parsed.dir, `${parsed.name}.jpg`), path.join(IMAGE_THUMB_ROOT, parsed.dir, `${parsed.name}${parsed.ext}`) ]; for (const candidate of candidates) { try { if (fs.existsSync(candidate)) fs.rmSync(candidate, { recursive: true, force: true }); } catch (err) { console.warn(`⚠️ Thumbnail silinemedi (${candidate}): ${err.message}`); } } const potentialDirs = [ path.join(VIDEO_THUMB_ROOT, parsed.dir), path.join(IMAGE_THUMB_ROOT, parsed.dir) ]; for (const dirPath of potentialDirs) { cleanupEmptyDirs(dirPath); } } function cleanupEmptyDirs(startDir) { let dir = startDir; while ( dir && dir.startsWith(THUMBNAIL_DIR) && fs.existsSync(dir) ) { try { const stat = fs.lstatSync(dir); if (!stat.isDirectory()) break; const entries = fs.readdirSync(dir); if (entries.length > 0) break; fs.rmdirSync(dir); } catch (err) { console.warn(`⚠️ Thumbnail klasörü temizlenemedi (${dir}): ${err.message}`); break; } const parent = path.dirname(dir); if ( !parent || parent === dir || parent.length < THUMBNAIL_DIR.length || parent === THUMBNAIL_DIR ) { break; } dir = parent; } } // --- 🗑️ .trash yardımcı fonksiyonları --- const trashStateCache = new Map(); function normalizeTrashPath(value) { if (value === null || value === undefined) return ""; return String(value).replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); } function trashFlagPathFor(rootFolder) { const safeRoot = sanitizeRelative(rootFolder); if (!safeRoot) return null; return path.join(DOWNLOAD_DIR, safeRoot, ".trash"); } function readTrashRegistry(rootFolder) { const flagPath = trashFlagPathFor(rootFolder); if (!flagPath || !fs.existsSync(flagPath)) return null; try { const raw = JSON.parse(fs.readFileSync(flagPath, "utf-8")); if (!raw || typeof raw !== "object") return null; if (!Array.isArray(raw.items)) raw.items = []; raw.items = raw.items .map((item) => { if (!item || typeof item !== "object") return null; const normalizedPath = normalizeTrashPath(item.path); return { ...item, path: normalizedPath, originalPath: item.originalPath || normalizedPath, deletedAt: Number(item.deletedAt) || Date.now(), isDirectory: Boolean(item.isDirectory), type: item.type || (item.isDirectory ? "inode/directory" : null) }; }) .filter(Boolean); return raw; } catch (err) { console.warn(`⚠️ .trash dosyası okunamadı (${flagPath}): ${err.message}`); return null; } } function writeTrashRegistry(rootFolder, registry) { const flagPath = trashFlagPathFor(rootFolder); if (!flagPath) return; const items = Array.isArray(registry?.items) ? registry.items.filter(Boolean) : []; if (!items.length) { try { if (fs.existsSync(flagPath)) fs.rmSync(flagPath, { force: true }); } catch (err) { console.warn(`⚠️ .trash kaldırılırken hata (${flagPath}): ${err.message}`); } trashStateCache.delete(rootFolder); return; } const payload = { updatedAt: Date.now(), items: items.map((item) => ({ ...item, path: normalizeTrashPath(item.path), originalPath: item.originalPath || normalizeTrashPath(item.path), deletedAt: Number(item.deletedAt) || Date.now(), isDirectory: Boolean(item.isDirectory), type: item.type || (item.isDirectory ? "inode/directory" : null) })) }; try { fs.writeFileSync(flagPath, JSON.stringify(payload, null, 2), "utf-8"); } catch (err) { console.warn(`⚠️ .trash yazılamadı (${flagPath}): ${err.message}`); } trashStateCache.delete(rootFolder); } function addTrashEntry(rootFolder, entry) { if (!rootFolder || !entry) return null; const safeRoot = sanitizeRelative(rootFolder); if (!safeRoot) return null; const registry = readTrashRegistry(safeRoot) || { items: [] }; const normalizedPath = normalizeTrashPath(entry.path); const isDirectory = Boolean(entry.isDirectory); const timestamp = Number(entry.deletedAt) || Date.now(); const type = entry.type || (isDirectory ? "inode/directory" : mime.lookup(entry.originalPath || normalizedPath) || "application/octet-stream"); let items = registry.items.filter((item) => { const itemPath = normalizeTrashPath(item.path); if (isDirectory) { if (!itemPath) return false; if (itemPath === normalizedPath) return false; if (itemPath.startsWith(`${normalizedPath}/`)) return false; return true; } // üst klasör çöpteyse tekrar eklemeye gerek yok if (item.isDirectory) { const normalizedItemPath = itemPath; if ( !normalizedPath || normalizedPath === normalizedItemPath || normalizedPath.startsWith(`${normalizedItemPath}/`) ) { return true; } } return itemPath !== normalizedPath; }); if (!isDirectory) { const ancestor = items.find( (item) => item.isDirectory && (normalizeTrashPath(item.path) === "" || normalizedPath === normalizeTrashPath(item.path) || normalizedPath.startsWith(`${normalizeTrashPath(item.path)}/`)) ); if (ancestor) { trashStateCache.delete(safeRoot); return ancestor; } } const newEntry = { ...entry, path: normalizedPath, originalPath: entry.originalPath || (normalizedPath ? `${safeRoot}/${normalizedPath}` : safeRoot), deletedAt: timestamp, isDirectory, type }; items.push(newEntry); writeTrashRegistry(safeRoot, { ...registry, items }); return newEntry; } function removeTrashEntry(rootFolder, relPath) { if (!rootFolder) return null; const safeRoot = sanitizeRelative(rootFolder); if (!safeRoot) return null; const registry = readTrashRegistry(safeRoot); if (!registry || !Array.isArray(registry.items)) return null; const normalized = normalizeTrashPath(relPath); const removed = []; const kept = []; for (const item of registry.items) { const itemPath = normalizeTrashPath(item.path); if ( itemPath === normalized || (item.isDirectory && normalized && normalized.startsWith(`${itemPath}/`)) ) { removed.push(item); continue; } kept.push(item); } if (!removed.length) return null; writeTrashRegistry(safeRoot, { ...registry, items: kept }); return removed[0]; } function getTrashStateForRoot(rootFolder) { if (!rootFolder) return null; if (trashStateCache.has(rootFolder)) return trashStateCache.get(rootFolder); const registry = readTrashRegistry(rootFolder); if (!registry || !Array.isArray(registry.items) || !registry.items.length) { trashStateCache.set(rootFolder, null); return null; } const directories = []; const files = new Set(); for (const item of registry.items) { const normalizedPath = normalizeTrashPath(item.path); if (item.isDirectory) { directories.push(normalizedPath); } else { files.add(normalizedPath); } } directories.sort((a, b) => a.length - b.length); const result = { registry, directories, files }; trashStateCache.set(rootFolder, result); return result; } function isPathTrashed(rootFolder, relPath, isDirectory = false) { if (!rootFolder) return false; const state = getTrashStateForRoot(rootFolder); if (!state) return false; const normalized = normalizeTrashPath(relPath); for (const dirPath of state.directories) { if (!dirPath) return true; if (normalized === dirPath) return true; if (normalized.startsWith(`${dirPath}/`)) return true; } if (!isDirectory && state.files.has(normalized)) { return true; } return false; } function resolveThumbnailAbsolute(relThumbPath) { const normalized = sanitizeRelative(relThumbPath); const resolved = path.resolve(THUMBNAIL_DIR, normalized); if ( resolved !== THUMBNAIL_DIR && !resolved.startsWith(THUMBNAIL_DIR + path.sep) ) { return null; } return resolved; } function resolveMovieDataAbsolute(relPath) { const normalized = sanitizeRelative(relPath); const resolved = path.resolve(MOVIE_DATA_ROOT, normalized); if ( resolved !== MOVIE_DATA_ROOT && !resolved.startsWith(MOVIE_DATA_ROOT + path.sep) ) { return null; } return resolved; } function resolveTvDataAbsolute(relPath) { const normalized = sanitizeRelative(relPath); const resolved = path.resolve(TV_DATA_ROOT, normalized); if ( resolved !== TV_DATA_ROOT && !resolved.startsWith(TV_DATA_ROOT + path.sep) ) { return null; } return resolved; } function serveCachedFile(req, res, filePath, { maxAgeSeconds = 86400 } = {}) { if (!fs.existsSync(filePath)) { return res.status(404).send("Dosya bulunamadı"); } let stats; try { stats = fs.statSync(filePath); } catch (err) { return res.status(500).send("Dosya okunamadı"); } const mtime = stats.mtimeMs; const etag = `"${stats.size}-${Number(mtime).toString(16)}"`; const ifNoneMatch = req.headers["if-none-match"]; const ifModifiedSince = req.headers["if-modified-since"] ? new Date(req.headers["if-modified-since"]).getTime() : null; if (ifNoneMatch && ifNoneMatch === etag) { return res.status(304).end(); } if (ifModifiedSince && ifModifiedSince >= mtime) { return res.status(304).end(); } res.setHeader( "Cache-Control", `public, max-age=${maxAgeSeconds}, stale-while-revalidate=${maxAgeSeconds}` ); res.setHeader("ETag", etag); res.setHeader("Last-Modified", new Date(mtime).toUTCString()); return res.sendFile(filePath); } function resolveYoutubeDataAbsolute(relPath) { const normalized = sanitizeRelative(relPath); const resolved = path.resolve(YT_DATA_ROOT, normalized); if ( resolved !== YT_DATA_ROOT && !resolved.startsWith(YT_DATA_ROOT + path.sep) ) { return null; } return resolved; } function removeAllThumbnailsForRoot(rootFolder) { const safe = sanitizeRelative(rootFolder); if (!safe) return; const targets = [ path.join(VIDEO_THUMB_ROOT, safe), path.join(IMAGE_THUMB_ROOT, safe) ]; for (const target of targets) { try { if (fs.existsSync(target)) { fs.rmSync(target, { recursive: true, force: true }); cleanupEmptyDirs(path.dirname(target)); } } catch (err) { console.warn(`⚠️ Thumbnail klasörü silinemedi (${target}): ${err.message}`); } } } function movieDataKey(rootFolder, videoRelPath = null) { const normalizedRoot = rootFolder ? sanitizeRelative(rootFolder) : null; if (!normalizedRoot) return null; if (!videoRelPath) return normalizedRoot; const normalizedVideo = normalizeTrashPath(videoRelPath); if (!normalizedVideo) return normalizedRoot; const hash = crypto .createHash("sha1") .update(normalizedVideo) .digest("hex") .slice(0, 12); const baseSegment = normalizedVideo .split("/") .filter(Boolean) .pop() || "video"; const safeSegment = baseSegment .replace(/[^a-z0-9]+/gi, "-") .replace(/^-+|-+$/g, "") .toLowerCase() .slice(0, 60); const suffix = safeSegment ? `${safeSegment}-${hash}` : hash; return `${normalizedRoot}__${suffix}`; } function movieDataLegacyDir(rootFolder) { const normalizedRoot = sanitizeRelative(rootFolder); if (!normalizedRoot) return null; return path.join(MOVIE_DATA_ROOT, normalizedRoot); } function movieDataDir(rootFolder, videoRelPath = null) { const key = movieDataKey(rootFolder, videoRelPath); if (!key) return MOVIE_DATA_ROOT; return path.join(MOVIE_DATA_ROOT, key); } function movieDataPaths(rootFolder, videoRelPath = null) { const dir = movieDataDir(rootFolder, videoRelPath); return { dir, metadata: path.join(dir, "metadata.json"), poster: path.join(dir, "poster.jpg"), backdrop: path.join(dir, "backdrop.jpg"), key: movieDataKey(rootFolder, videoRelPath) }; } function movieDataPathsByKey(key) { const dir = path.join(MOVIE_DATA_ROOT, key); return { dir, metadata: path.join(dir, "metadata.json"), poster: path.join(dir, "poster.jpg"), backdrop: path.join(dir, "backdrop.jpg"), key }; } function isTmdbMetadata(metadata) { if (!metadata) return false; if (typeof metadata.id === "number") return true; if (metadata._dupe?.source === "tmdb") return true; return false; } function parseTitleAndYear(rawName) { if (!rawName) return { title: null, year: null }; const withoutExt = rawName.replace(/\.[^/.]+$/, ""); const cleaned = withoutExt.replace(/[\[\]\(\)\-]/g, " ").replace(/[._]/g, " "); const tokens = cleaned .split(/\s+/) .map((t) => t.trim()) .filter(Boolean); if (!tokens.length) { return { title: withoutExt.trim(), year: null }; } const ignoredExact = new Set( [ "hdrip", "hdr", "webrip", "webdl", "web", "dl", "bluray", "bdrip", "dvdrip", "remux", "multi", "audio", "aac", "ddp", "dts", "xvid", "x264", "x265", "x266", "h264", "h265", "hevc", "hdr10", "hdr10plus", "amzn", "nf", "netflix", "disney", "imax", "atmos", "dubbed", "dublado", "ita", "eng", "turkce", "multi-audio", "eazy", "tbmovies", "tbm", "bone" ].map((t) => t.toLowerCase()) ); const yearIndex = tokens.findIndex((token) => /^(19|20)\d{2}$/.test(token)); if (yearIndex > 0) { const yearToken = tokens[yearIndex]; const candidateTokens = tokens.slice(0, yearIndex); const filteredTitleTokens = candidateTokens.filter((token) => { const lower = token.toLowerCase(); if (ignoredExact.has(lower)) return false; if (/^\d{3,4}p$/.test(lower)) return false; if (/^(ac3|aac\d*|ddp\d*|dts|dubbed|dual|multi|hc)$/.test(lower)) return false; if (/^(x|h)?26[45]$/.test(lower)) return false; if (lower.includes("hdrip") || lower.includes("web-dl")) return false; if (lower.includes("multi-audio")) return false; return true; }); const titleTokens = filteredTitleTokens.length ? filteredTitleTokens : candidateTokens; const title = titleTokens.join(" ").replace(/\s+/g, " ").trim(); return { title: title || withoutExt.trim(), year: Number(yearToken) }; } let year = null; const filtered = []; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; const lower = token.toLowerCase(); if (!year && /^(19|20)\d{2}$/.test(lower)) { year = Number(lower); continue; } if (/^\d{3,4}p$/.test(lower)) continue; if (lower === "web" && tokens[i + 1]?.toLowerCase() === "dl") { i += 1; continue; } if (/^(ac3|aac\d*|ddp\d*|dts|dubbed|dual|multi|hc)$/.test(lower)) continue; if (/^(x|h)?26[45]$/.test(lower)) continue; if (ignoredExact.has(lower)) continue; if (lower.includes("hdrip") || lower.includes("web-dl")) continue; if (lower.includes("multi-audio")) continue; filtered.push(token); } const title = filtered.join(" ").replace(/\s+/g, " ").trim(); return { title: title || withoutExt.trim(), year }; } function parseSeriesInfo(rawName) { if (!rawName) return null; const withoutExt = rawName.replace(/\.[^/.]+$/, ""); const match = withoutExt.match(/(.+?)[\s._-]*S(\d{1,2})[\s._-]*E(\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}`); url.searchParams.set("api_key", TMDB_API_KEY); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null && value !== "") { url.searchParams.set(key, value); } } try { const resp = await fetch(url); if (!resp.ok) { console.warn(`⚠️ TMDB isteği başarısız (${url}): ${resp.status}`); return null; } return await resp.json(); } catch (err) { console.warn(`⚠️ TMDB isteği başarısız (${url}): ${err.message}`); return null; } } async function fetchMovieMetadata(title, year) { if (!TMDB_API_KEY || !title) return null; console.log("🎬 TMDB araması yapılıyor:", { title, year }); const search = await tmdbFetch("/search/movie", { query: title, year: year || undefined, include_adult: false, language: "en-US" }); if (!search?.results?.length) { console.log("🎬 TMDB sonucu bulunamadı:", { title, year }); return null; } const match = search.results[0]; const details = await tmdbFetch(`/movie/${match.id}`, { language: "en-US", append_to_response: "release_dates,credits,translations" }); if (!details) return null; if (details.translations?.translations?.length) { const translations = details.translations.translations; const turkish = translations.find( (t) => t.iso_639_1 === "tr" && t.data ); if (turkish?.data) { const data = turkish.data; if (data.overview) details.overview = data.overview; if (data.title) details.title = data.title; if (data.tagline) details.tagline = data.tagline; } } console.log("🎬 TMDB sonucu bulundu:", { title: match.title || match.name, year: match.release_date ? match.release_date.slice(0, 4) : null, id: match.id }); return { ...details, poster_path: match.poster_path || details.poster_path, backdrop_path: match.backdrop_path || details.backdrop_path, matched_title: match.title || match.name || title, matched_year: match.release_date ? Number(match.release_date.slice(0, 4)) : year || null }; } async function downloadImage(url, targetPath) { if (!url) return false; try { const resp = await fetch(url); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const arrayBuffer = await resp.arrayBuffer(); ensureDirForFile(targetPath); fs.writeFileSync(targetPath, Buffer.from(arrayBuffer)); return true; } catch (err) { console.warn(`⚠️ Görsel indirilemedi (${url}): ${err.message}`); return false; } } let tvdbAuthState = { token: null, expires: 0 }; const TVDB_TOKEN_TTL = 1000 * 60 * 60 * 20; // 20 saat async function getTvdbToken(force = false) { if (!TVDB_API_KEY) return null; const trimmedUserToken = (TVDB_USER_TOKEN || "").trim(); const now = Date.now(); if ( !force && tvdbAuthState.token && now < tvdbAuthState.expires - 60 * 1000 ) { return tvdbAuthState.token; } if (trimmedUserToken) { try { const resp = await fetch(`${TVDB_BASE_URL}/login`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ apikey: TVDB_API_KEY, userkey: trimmedUserToken }) }); if (resp.ok) { const json = await resp.json(); const token = json?.data?.token; if (token) { console.log("📺 TVDB token alındı (login)."); tvdbAuthState = { token, expires: Date.now() + TVDB_TOKEN_TTL }; return token; } console.warn("⚠️ TVDB login yanıtında token bulunamadı, API key'e düşülüyor."); } else { console.warn( `⚠️ TVDB login başarısız (${resp.status}); API key ile devam edilecek.` ); } } catch (err) { console.warn( `⚠️ TVDB login hatası (${err.message}); API key ile devam edilecek.` ); } } tvdbAuthState = { token: TVDB_API_KEY, expires: Date.now() + TVDB_TOKEN_TTL }; console.log("📺 TVDB token (API key) kullanılıyor."); return tvdbAuthState.token; } async function tvdbFetch(pathname, options = {}, retry = true) { if (!TVDB_API_KEY) return null; const token = await getTvdbToken(); if (!token) return null; const url = pathname.startsWith("http") ? pathname : `${TVDB_BASE_URL}${pathname}`; const headers = { Accept: "application/json", ...(options.headers || {}), Authorization: `Bearer ${token}` }; let body = options.body; if (body && typeof body === "object" && !(body instanceof Buffer)) { body = JSON.stringify(body); headers["Content-Type"] = "application/json"; } try { const resp = await fetch(url, { ...options, headers, body }); const rawText = await resp.text(); let json = null; if (rawText) { try { json = JSON.parse(rawText); } catch (err) { console.warn( `⚠️ TVDB yanıtı JSON parse edilemedi (${url}): ${err.message}` ); } } console.log("📺 TVDB fetch:", { url: String(url), status: resp.status, hasData: Boolean(json?.data), message: json?.status?.message ?? null }); if (resp.status === 401 && retry) { await getTvdbToken(true); return tvdbFetch(pathname, options, false); } if (!resp.ok) { console.warn(`⚠️ TVDB isteği başarısız (${url}): ${resp.status}`); return null; } return json; } catch (err) { console.warn(`⚠️ TVDB isteği hatası (${url}): ${err.message}`); return null; } } function guessPrimaryVideo(rootFolder) { const videos = enumerateVideoFiles(rootFolder); return videos.length ? videos[0].relPath : null; } async function ensureMovieData( rootFolder, displayName, bestVideoPath, precomputedMediaInfo = null ) { const normalizedRoot = sanitizeRelative(rootFolder); if (!TMDB_API_KEY) { return { mediaInfo: precomputedMediaInfo || null, metadata: null, cacheKey: null, videoPath: bestVideoPath ? normalizeTrashPath(bestVideoPath) : null }; } console.log("🎬 ensureMovieData çağrıldı:", { rootFolder, displayName, bestVideoPath }); const isSeriesPattern = /S\d{1,2}E\d{1,2}/i.test(displayName || "") || (bestVideoPath && /S\d{1,2}E\d{1,2}/i.test(bestVideoPath)); if (isSeriesPattern) { console.log( "🎬 TMDB atlandı (TV bölümü tespit edildi, movie_data oluşturulmadı):", { rootFolder, displayName } ); removeMovieData(normalizedRoot || rootFolder); return { mediaInfo: precomputedMediaInfo || null, metadata: null, cacheKey: null, videoPath: bestVideoPath ? normalizeTrashPath(bestVideoPath) : null }; } let normalizedVideoPath = bestVideoPath ? normalizeTrashPath(bestVideoPath) : null; if (!normalizedVideoPath) { normalizedVideoPath = guessPrimaryVideo(normalizedRoot || rootFolder); } if (!normalizedVideoPath) { console.log( "🎬 TMDB jeçildi (uygun video dosyası bulunamadı):", normalizedRoot || rootFolder ); removeMovieData(normalizedRoot || rootFolder); return { mediaInfo: precomputedMediaInfo || null, metadata: null, cacheKey: null, videoPath: null }; } const rootForPaths = normalizedRoot || rootFolder; const paths = movieDataPaths(rootForPaths, normalizedVideoPath); const legacyPaths = movieDataPaths(rootForPaths); let metadata = null; if (fs.existsSync(paths.metadata)) { try { metadata = JSON.parse(fs.readFileSync(paths.metadata, "utf-8")); console.log("🎬 Mevcut metadata bulundu:", rootFolder); } catch (err) { console.warn( `⚠️ metadata.json okunamadı (${paths.metadata}): ${err.message}` ); } } else if ( legacyPaths.metadata !== paths.metadata && fs.existsSync(legacyPaths.metadata) ) { try { metadata = JSON.parse(fs.readFileSync(legacyPaths.metadata, "utf-8")); console.log("🎬 Legacy metadata bulundu:", legacyPaths.metadata); } catch (err) { console.warn( `⚠️ metadata.json okunamadı (${legacyPaths.metadata}): ${err.message}` ); } } let fetchedMetadata = false; const hasTmdbMetadata = isTmdbMetadata(metadata); if (!hasTmdbMetadata) { const searchCandidates = []; if (normalizedVideoPath) { const videoFileName = path.basename(normalizedVideoPath); const { title: videoTitle, year: videoYear } = parseTitleAndYear(videoFileName); if (videoTitle) { searchCandidates.push({ title: videoTitle, year: videoYear, source: "video" }); } } if (displayName) { const { title: folderTitle, year: folderYear } = parseTitleAndYear(displayName); if (folderTitle) { searchCandidates.push({ title: folderTitle, year: folderYear, source: "folder" }); } else { searchCandidates.push({ title: displayName, year: null, source: "folderRaw" }); } } const tried = new Set(); for (const candidate of searchCandidates) { const key = `${candidate.title}::${candidate.year ?? ""}`; if (tried.has(key)) continue; tried.add(key); console.log("🎬 TMDB araması için analiz:", candidate); const fetched = await fetchMovieMetadata(candidate.title, candidate.year); if (fetched) { metadata = fetched; fetchedMetadata = true; break; } } } if (!isTmdbMetadata(metadata)) { console.log( "🎬 TMDB verisi bulunamadı, movie_data oluşturulmadı:", rootFolder ); removeMovieData(normalizedRoot, normalizedVideoPath); return { mediaInfo: precomputedMediaInfo || null, metadata: null, cacheKey: null, videoPath: normalizedVideoPath }; } ensureDirForFile(paths.metadata); let videoPath = (normalizedVideoPath && normalizedVideoPath.trim()) || metadata._dupe?.videoPath || guessPrimaryVideo(rootFolder) || null; if (videoPath) videoPath = videoPath.replace(/\\/g, "/"); const videoAbsPath = videoPath ? path.join(DOWNLOAD_DIR, normalizedRoot, videoPath) : null; let mediaInfo = metadata._dupe?.mediaInfo || precomputedMediaInfo || null; if (!mediaInfo && videoAbsPath && fs.existsSync(videoAbsPath)) { mediaInfo = await extractMediaInfo(videoAbsPath); if (mediaInfo) { console.log("🎬 Video teknik bilgileri elde edildi:", { rootFolder, mediaInfo }); } } const posterUrl = metadata.poster_path ? `${TMDB_IMG_BASE}${metadata.poster_path}` : null; const backdropUrl = metadata.backdrop_path ? `${TMDB_IMG_BASE}${metadata.backdrop_path}` : null; const enriched = { ...metadata, _dupe: { ...(metadata._dupe || {}), folder: normalizedRoot, videoPath, mediaInfo, cacheKey: paths.key, displayName: displayName || null, source: "tmdb", fetchedAt: fetchedMetadata ? Date.now() : metadata._dupe?.fetchedAt || Date.now() } }; fs.writeFileSync(paths.metadata, JSON.stringify(enriched, null, 2), "utf-8"); if (posterUrl && (!fs.existsSync(paths.poster) || fetchedMetadata)) { await downloadImage(posterUrl, paths.poster); } if (backdropUrl && (!fs.existsSync(paths.backdrop) || fetchedMetadata)) { await downloadImage(backdropUrl, paths.backdrop); } console.log(`🎬 TMDB verisi hazır: ${rootFolder}`, { videoPath, cacheKey: paths.key }); if ( legacyPaths.metadata !== paths.metadata && fs.existsSync(legacyPaths.metadata) ) { try { fs.rmSync(legacyPaths.dir, { recursive: true, force: true }); console.log(`🧹 Legacy movie metadata kaldırıldı: ${legacyPaths.dir}`); } catch (err) { console.warn( `⚠️ Legacy movie metadata kaldırılamadı (${legacyPaths.dir}): ${err.message}` ); } } return { mediaInfo, metadata: enriched, cacheKey: paths.key, videoPath }; } function removeMovieData(rootFolder, videoRelPath = null) { const normalizedRoot = rootFolder ? sanitizeRelative(rootFolder) : null; if (!normalizedRoot) return; const targetDirs = new Set(); if (videoRelPath) { targetDirs.add(movieDataDir(normalizedRoot, videoRelPath)); } else { targetDirs.add(movieDataLegacyDir(normalizedRoot)); try { const entries = fs.readdirSync(MOVIE_DATA_ROOT, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; if ( entry.name === normalizedRoot || entry.name.startsWith(`${normalizedRoot}__`) ) { targetDirs.add(path.join(MOVIE_DATA_ROOT, entry.name)); } } } catch (err) { console.warn( `⚠️ Movie metadata dizini listelenemedi (${MOVIE_DATA_ROOT}): ${err.message}` ); } } for (const dir of targetDirs) { if (!dir) continue; if (!fs.existsSync(dir)) continue; try { fs.rmSync(dir, { recursive: true, force: true }); console.log(`🧹 Movie metadata silindi: ${dir}`); } catch (err) { console.warn(`⚠️ Movie metadata temizlenemedi (${dir}): ${err.message}`); } } } const tvdbSeriesCache = new Map(); const tvdbEpisodeCache = new Map(); const tvdbEpisodeDetailCache = new Map(); async function searchTvdbSeries(title) { if (!title) return null; const key = title.toLowerCase(); if (tvdbSeriesCache.has(key)) return tvdbSeriesCache.get(key); console.log("📺 TVDB seri araması yapılıyor:", title); const params = new URLSearchParams({ type: "series", query: title }); const resp = await tvdbFetch(`/search?${params.toString()}`); const series = resp?.data?.[0] || null; tvdbSeriesCache.set(key, series); return series; } async function fetchTvdbSeriesExtended(seriesId) { if (!seriesId) return null; const cacheKey = `series-${seriesId}`; if (tvdbSeriesCache.has(cacheKey)) return tvdbSeriesCache.get(cacheKey); console.log("📺 TVDB extended isteniyor:", seriesId); const resp = await tvdbFetch( `/series/${seriesId}/extended?meta=episodes,artworks,translations&short=false` ); const data = resp?.data || null; tvdbSeriesCache.set(cacheKey, data); return data; } async function fetchTvdbEpisodesForSeason(seriesId, seasonNumber) { if (!seriesId || !Number.isFinite(seasonNumber)) return new Map(); const cacheKey = `${seriesId}-S${seasonNumber}`; if (tvdbEpisodeCache.has(cacheKey)) return tvdbEpisodeCache.get(cacheKey); console.log("📺 TVDB sezon bölümleri çekiliyor:", { seriesId, seasonNumber }); let page = 0; const seasonMap = new Map(); while (page < 50) { const resp = await tvdbFetch( `/series/${seriesId}/episodes/default?page=${page}&lang=eng` ); const payload = resp?.data; const items = Array.isArray(payload) ? payload : Array.isArray(payload?.episodes) ? payload.episodes : []; if (!Array.isArray(items) || !items.length) break; for (const item of items) { const normalized = normalizeTvdbEpisode(item); if (!normalized) continue; const season = normalized.seasonNumber; const episode = normalized.episodeNumber; if (!Number.isFinite(season) || !Number.isFinite(episode)) continue; const key = `${seriesId}-S${season}`; let seasonEntry = tvdbEpisodeCache.get(key); if (!seasonEntry) { seasonEntry = new Map(); tvdbEpisodeCache.set(key, seasonEntry); } if (!seasonEntry.has(episode)) seasonEntry.set(episode, normalized); if (!seasonMap.has(episode) && season === seasonNumber) { seasonMap.set(episode, normalized); } } const links = resp?.links || payload?.links || {}; const nextRaw = links?.next !== undefined ? links.next : links?.nextPage ?? null; const nextPage = toFiniteNumber(nextRaw); if (!Number.isFinite(nextPage) || nextPage <= page) break; page = nextPage; } tvdbEpisodeCache.set(cacheKey, seasonMap); return seasonMap; } async function fetchTvdbEpisode(seriesId, season, episode) { const cacheKey = `${seriesId}-S${season}`; const cache = tvdbEpisodeCache.get(cacheKey); if (cache?.has(episode)) return cache.get(episode); console.log("📺 TVDB tekil bölüm çekiliyor:", { seriesId, season, episode }); const seasonEpisodes = await fetchTvdbEpisodesForSeason(seriesId, season); return seasonEpisodes.get(episode) || null; } async function fetchTvdbEpisodeExtended(episodeId) { if (!episodeId) return null; const cacheKey = `episode-${episodeId}-extended`; if (tvdbEpisodeDetailCache.has(cacheKey)) return tvdbEpisodeDetailCache.get(cacheKey); const resp = await tvdbFetch( `/episodes/${episodeId}/extended?meta=artworks,translations&short=false` ); const payload = resp?.data || null; if (!payload) { tvdbEpisodeDetailCache.set(cacheKey, null); return null; } const base = normalizeTvdbEpisode(payload.episode || payload) || normalizeTvdbEpisode(payload); if (!base) { tvdbEpisodeDetailCache.set(cacheKey, null); return null; } const artworks = Array.isArray(payload.artworks) ? payload.artworks : Array.isArray(payload.episode?.artworks) ? payload.episode.artworks : []; if (!base.image) { const stillArtwork = artworks.find((a) => { const type = String( a?.type || a?.artworkType || a?.name || "" ).toLowerCase(); return ( type.includes("still") || type.includes("screencap") || type.includes("episode") || type.includes("thumb") ); }); if (stillArtwork?.image) base.image = stillArtwork.image; } const translations = payload.translations || payload.episode?.translations || {}; const overviewTranslations = translations.overviewTranslations || translations.overviews || []; 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"); if (localizedOverview) base.overview = localizedOverview; } if (!base.name) { const localizedName = pickTranslation(nameTranslations, "name"); if (localizedName) base.name = localizedName; } tvdbEpisodeDetailCache.set(cacheKey, base); return base; } function ensureSeasonContainer(seriesData, seasonNumber) { if (!seriesData.seasons) seriesData.seasons = {}; const key = String(seasonNumber); if (!seriesData.seasons[key]) { seriesData.seasons[key] = { seasonNumber, name: `Season ${seasonNumber}`, episodes: {} }; } const container = seriesData.seasons[key]; if (!container.episodes) container.episodes = {}; if (container.seasonNumber === undefined) { container.seasonNumber = seasonNumber; } if (!container.name) { container.name = `Season ${seasonNumber}`; } if (!("poster" in container)) container.poster = null; if (!("overview" in container)) container.overview = ""; if (!("slug" in container)) container.slug = null; if (!("tvdbId" in container)) container.tvdbId = null; if (!("episodeCount" in container)) container.episodeCount = null; return container; } function encodeTvDataPath(rootOrKey, relativePath) { if (!rootOrKey) return null; const base = String(rootOrKey); const normalizedKey = base.includes("__") ? sanitizeRelative(base) : tvSeriesKey(rootOrKey); if (!normalizedKey) return null; const encodedBase = normalizedKey .split(path.sep) .map(encodeURIComponent) .join("/"); if (!relativePath) return `/tv-data/${encodedBase}`; const encodedRel = String(relativePath) .split(path.sep) .map(encodeURIComponent) .join("/"); return `/tv-data/${encodedBase}/${encodedRel}`; } async function ensureSeriesData( rootFolder, relativeFilePath, seriesInfo, mediaInfo ) { if (!TVDB_API_KEY || !seriesInfo) { console.log("📺 TVDB atlandı (eksik anahtar ya da seriesInfo yok):", { rootFolder, relativeFilePath }); return null; } console.log("📺 ensureSeriesData başladı:", { rootFolder, relativeFilePath, seriesInfo }); const normalizedRoot = sanitizeRelative(rootFolder); const normalizedFile = normalizeTrashPath(relativeFilePath); const candidateKeys = listTvSeriesKeysForRoot(normalizedRoot); let seriesData = null; let existingPaths = null; for (const key of candidateKeys) { const candidatePaths = tvSeriesPathsByKey(key); if (!fs.existsSync(candidatePaths.metadata)) continue; try { const data = JSON.parse(fs.readFileSync(candidatePaths.metadata, "utf-8")) || {}; const seasons = data?.seasons || {}; const matchesEpisode = Object.values(seasons).some((season) => season?.episodes && Object.values(season.episodes).some((episode) => episode?.file === normalizedFile) ); if (matchesEpisode) { seriesData = data; existingPaths = candidatePaths; break; } } catch (err) { console.warn( `⚠️ series.json okunamadı (${candidatePaths.metadata}): ${err.message}` ); } } const legacyPaths = tvSeriesPaths(normalizedRoot); if (!seriesData && fs.existsSync(legacyPaths.metadata)) { try { seriesData = JSON.parse(fs.readFileSync(legacyPaths.metadata, "utf-8")) || {}; existingPaths = legacyPaths; } catch (err) { console.warn( `⚠️ series.json okunamadı (${legacyPaths.metadata}): ${err.message}` ); } } if (!seriesData) { seriesData = {}; } let seriesId = seriesData.id ?? seriesData.tvdbId ?? null; if (!seriesId) { const searchResult = await searchTvdbSeries(seriesInfo.searchTitle); console.log("📺 TVDB arama sonucu:", { search: seriesInfo.searchTitle, resultId: searchResult?.id ?? searchResult?.tvdb_id ?? null }); const normalizedId = normalizeTvdbId( searchResult?.tvdb_id ?? searchResult?.id ?? searchResult?.seriesId ); if (!normalizedId) { console.warn("⚠️ TVDB seri kimliği çözülmedi:", searchResult); return null; } seriesId = normalizedId; seriesData.id = seriesId; seriesData.tvdbId = seriesId; } let targetPaths = existingPaths && existingPaths.key?.includes("__") ? existingPaths : null; if (!targetPaths) { targetPaths = buildTvSeriesPaths( normalizedRoot, seriesId, seriesInfo.title ); if (!targetPaths && existingPaths) { targetPaths = existingPaths; } } if (!targetPaths) return null; const showDir = targetPaths.dir; const seriesMetaPath = targetPaths.metadata; const extended = await fetchTvdbSeriesExtended(seriesId); const container = extended || {}; console.log("📺 TVDB extended yükleme:", { seriesId, keys: Object.keys(container || {}) }); const info = (container.series && typeof container.series === "object" ? container.series : container) || {}; const translations = container.translations || info.translations || {}; const nameTranslations = translations.nameTranslations || translations.names || []; const overviewTranslations = 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; 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 || ""; const firstAired = info.firstAired || info.firstAirDate || info.first_air_date || info.premiere || null; if (!seriesData.firstAired) seriesData.firstAired = firstAired || null; if (!seriesData.year && firstAired) { const yearMatch = String(firstAired).match(/(\d{4})/); if (yearMatch) seriesData.year = Number(yearMatch[1]); } if (!seriesData.year) { for (const candidate of [info.year, info.startYear, info.start_year]) { const numeric = Number(candidate); if (Number.isFinite(numeric) && numeric > 0) { seriesData.year = numeric; break; } } } if ( !seriesData.genres || !Array.isArray(seriesData.genres) || !seriesData.genres.length ) { seriesData.genres = Array.isArray(info.genres) ? info.genres .map((g) => typeof g === "string" ? g : g?.name || g?.genre || g?.slug || null ) .filter(Boolean) : []; } if (!seriesData.status) { const status = info.status?.name || info.status || container.status || null; seriesData.status = typeof status === "string" ? status : null; } seriesData._dupe = { ...(seriesData._dupe || {}), folder: normalizedRoot, seriesId, key: targetPaths.key }; seriesData.updatedAt = Date.now(); const artworksRaw = Array.isArray(container.artworks) ? container.artworks : Array.isArray(info.artworks) ? info.artworks : []; const posterArtwork = artworksRaw.find((a) => { const type = String( a?.type || a?.artworkType || a?.type2 || a?.name || a?.artwork ).toLowerCase(); return type.includes("poster") || type === "series" || type === "2"; }) || artworksRaw[0]; // Fanart.tv'den backdrop al let backdropImage = null; const fanartData = await fetchFanartTvImages(seriesData.id); if (fanartData?.showbackground && Array.isArray(fanartData.showbackground) && fanartData.showbackground.length > 0) { // İlk showbackground resmini al const firstBackdrop = fanartData.showbackground[0]; backdropImage = firstBackdrop.url; } // Eğer Fanart.tv'de backdrop yoksa, TVDB'i fallback olarak kullan if (!backdropImage) { let backdropArtwork = selectBackgroundArtwork(artworksRaw); if (!backdropArtwork) { const backgroundArtworks = await fetchTvdbArtworks(seriesData.id, "background"); backdropArtwork = selectBackgroundArtwork(backgroundArtworks); } if (!backdropArtwork) { const fanartArtworks = await fetchTvdbArtworks(seriesData.id, "fanart"); backdropArtwork = selectBackgroundArtwork(fanartArtworks); } if (backdropArtwork) { backdropImage = backdropArtwork?.image || backdropArtwork?.file || backdropArtwork?.fileName || backdropArtwork?.thumbnail || backdropArtwork?.url || null; } } const posterImage = posterArtwork?.image || posterArtwork?.file || posterArtwork?.fileName || posterArtwork?.thumbnail || posterArtwork?.url || null; const posterPath = path.join(showDir, "poster.jpg"); const backdropPath = path.join(showDir, "backdrop.jpg"); if (posterImage && !fs.existsSync(posterPath)) { await downloadTvdbImage(posterImage, posterPath); } if (backdropImage && !fs.existsSync(backdropPath)) { // Eğer backdropImage Fanart.tv'den geldiyse, onun indirme fonksiyonunu kullan if (fanartData?.showbackground && backdropImage.startsWith('http')) { await downloadFanartTvImage(backdropImage, backdropPath); } else { await downloadTvdbImage(backdropImage, backdropPath); } } const seasonPaths = seasonAssetPaths(targetPaths, seriesInfo.season); const seasonsRaw = Array.isArray(container.seasons) ? container.seasons : Array.isArray(container.series?.seasons) ? container.series.seasons : []; const normalizedSeasons = seasonsRaw .map(normalizeTvdbSeason) .filter(Boolean); const seasonMeta = normalizedSeasons.find( (season) => season.number === seriesInfo.season ); const seasonKey = String(seriesInfo.season); const seasonContainer = ensureSeasonContainer(seriesData, seriesInfo.season); if (seasonMeta) { if (seasonMeta.name) seasonContainer.name = seasonMeta.name; if (seasonMeta.overview) seasonContainer.overview = seasonMeta.overview; if (seasonMeta.id && !seasonContainer.tvdbId) { seasonContainer.tvdbId = seasonMeta.id; } if (seasonMeta.slug && !seasonContainer.slug) { seasonContainer.slug = seasonMeta.slug; } if ( seasonMeta.raw?.episodeCount !== undefined && seasonMeta.raw?.episodeCount !== null ) { const count = Number(seasonMeta.raw.episodeCount); if (Number.isFinite(count)) seasonContainer.episodeCount = count; } const seasonImage = seasonMeta.image || seasonMeta.raw?.image || seasonMeta.raw?.poster || seasonMeta.raw?.filename || seasonMeta.raw?.fileName || null; if (seasonImage && !fs.existsSync(seasonPaths.poster)) { await downloadTvdbImage(seasonImage, seasonPaths.poster); } } if (fs.existsSync(seasonPaths.poster)) { const relPoster = path.relative(showDir, seasonPaths.poster); if (!relPoster.startsWith("..")) { seasonContainer.poster = encodeTvDataPath(targetPaths.key, relPoster); } } if (!seasonContainer.overview) seasonContainer.overview = ""; const episodeData = await fetchTvdbEpisode( seriesData.id, seriesInfo.season, seriesInfo.episode ); let detailedEpisode = episodeData; if ( detailedEpisode?.id && (!detailedEpisode.overview || !detailedEpisode.image || !detailedEpisode.name) ) { const extendedEpisode = await fetchTvdbEpisodeExtended( detailedEpisode.id ); if (extendedEpisode) { detailedEpisode = { ...detailedEpisode, ...extendedEpisode, image: extendedEpisode.image || detailedEpisode.image, overview: extendedEpisode.overview || detailedEpisode.overview, name: extendedEpisode.name || detailedEpisode.name, aired: extendedEpisode.aired || detailedEpisode.aired, runtime: extendedEpisode.runtime || detailedEpisode.runtime }; } } const episodeDetails = detailedEpisode || episodeData || {}; if ( episodeDetails && episodeData && episodeDetails !== episodeData ) { const seasonCacheKey = `${seriesData.id}-S${seriesInfo.season}`; const seasonCache = tvdbEpisodeCache.get(seasonCacheKey); if (seasonCache) { seasonCache.set(seriesInfo.episode, episodeDetails); } } const episodeKey = String(seriesInfo.episode); const stillDir = path.join( showDir, "episodes", `season-${String(seriesInfo.season).padStart(2, "0")}` ); const stillFileName = `episode-${String(seriesInfo.episode).padStart(2, "0")}.jpg`; const stillPath = path.join(stillDir, stillFileName); const episodeImage = episodeDetails.image || episodeDetails.filename || episodeDetails.thumb || episodeDetails.thumbnail || episodeDetails.imageUrl || (episodeDetails.raw && ( episodeDetails.raw.image || episodeDetails.raw.filename || episodeDetails.raw.thumb || episodeDetails.raw.thumbnail )) || null; if (episodeImage && !fs.existsSync(stillPath)) { await downloadTvdbImage(episodeImage, stillPath); } const episodeCode = seriesInfo.key || `S${String(seriesInfo.season).padStart(2, "0")}E${String( seriesInfo.episode ).padStart(2, "0")}`; const episodeTitle = episodeDetails.name || episodeDetails.raw?.name || episodeDetails.raw?.episodeName || `Episode ${seriesInfo.episode}`; const episodeOverview = episodeDetails.overview || episodeDetails.raw?.overview || ""; const episodeRuntime = toFiniteNumber(episodeDetails.runtime) || toFiniteNumber(episodeDetails.raw?.runtime) || toFiniteNumber(episodeDetails.raw?.length) || null; const episodeAirDate = episodeDetails.aired || episodeDetails.raw?.aired || episodeDetails.raw?.firstAired || null; const episodeSlug = episodeDetails.slug || episodeDetails.raw?.slug || null; const episodeTvdbId = normalizeTvdbId( episodeDetails.id ?? episodeDetails.raw?.id ); const episodeSeasonId = normalizeTvdbId( episodeDetails.seasonId ?? episodeDetails.raw?.seasonId ); seasonContainer.episodes[episodeKey] = { episodeNumber: seriesInfo.episode, seasonNumber: seriesInfo.season, code: episodeCode, title: episodeTitle, overview: episodeOverview, runtime: episodeRuntime, aired: episodeAirDate, still: fs.existsSync(stillPath) ? encodeTvDataPath(targetPaths.key, path.relative(showDir, stillPath)) : null, file: normalizedFile, mediaInfo: mediaInfo || null, tvdbEpisodeId: episodeTvdbId, slug: episodeSlug, seasonId: seasonContainer.tvdbId || episodeSeasonId || null }; seasonContainer.episodeCount = Object.keys(seasonContainer.episodes).length; seasonContainer.updatedAt = Date.now(); ensureDirForFile(seriesMetaPath); fs.writeFileSync(seriesMetaPath, JSON.stringify(seriesData, null, 2), "utf-8"); if ( existingPaths && existingPaths.key !== targetPaths.key && fs.existsSync(existingPaths.dir) ) { try { fs.rmSync(existingPaths.dir, { recursive: true, force: true }); console.log( `🧹 Legacy TV metadata kaldırıldı: ${existingPaths.dir}` ); } catch (err) { console.warn( `⚠️ Legacy TV metadata kaldırılamadı (${existingPaths.dir}): ${err.message}` ); } } return { show: { id: seriesData.id || null, title: seriesData.name || seriesInfo.title, year: seriesData.year || null, overview: seriesData.overview || "", poster: fs.existsSync(path.join(showDir, "poster.jpg")) ? encodeTvDataPath(targetPaths.key, "poster.jpg") : null, backdrop: fs.existsSync(path.join(showDir, "backdrop.jpg")) ? encodeTvDataPath(targetPaths.key, "backdrop.jpg") : null }, season: { seasonNumber: seasonContainer.seasonNumber, name: seasonContainer.name || `Season ${seriesInfo.season}`, overview: seasonContainer.overview || "", poster: seasonContainer.poster || null, tvdbSeasonId: seasonContainer.tvdbId || null, slug: seasonContainer.slug || null }, episode: seasonContainer.episodes[episodeKey], cacheKey: targetPaths.key }; } function tvSeriesKey(rootFolder, seriesId = null, fallbackTitle = null) { const normalizedRoot = rootFolder ? sanitizeRelative(rootFolder) : null; if (!normalizedRoot) return null; let suffix = null; if (seriesId) { suffix = String(seriesId).toLowerCase(); } else if (fallbackTitle) { const slug = String(fallbackTitle) .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 60); if (slug) { const hash = crypto.createHash("sha1").update(slug).digest("hex"); suffix = `${slug}-${hash.slice(0, 8)}`; } } return suffix ? `${normalizedRoot}__${suffix}` : normalizedRoot; } function parseTvSeriesKey(key) { const normalized = sanitizeRelative(String(key || "")); if (!normalized.includes("__")) { return { rootFolder: normalized, seriesId: null, key: normalized }; } const [rootFolder, suffix] = normalized.split("__", 2); return { rootFolder, seriesId: suffix || null, key: normalized }; } function tvSeriesPathsByKey(key) { const normalizedKey = sanitizeRelative(key); const dir = path.join(TV_DATA_ROOT, normalizedKey); return { key: normalizedKey, dir, metadata: path.join(dir, "series.json"), poster: path.join(dir, "poster.jpg"), backdrop: path.join(dir, "backdrop.jpg"), episodesDir: path.join(dir, "episodes"), seasonsDir: path.join(dir, "seasons"), rootFolder: parseTvSeriesKey(normalizedKey).rootFolder }; } function tvSeriesPaths(rootFolderOrKey, seriesId = null, fallbackTitle = null) { if ( seriesId === null && fallbackTitle === null && String(rootFolderOrKey || "").includes("__") ) { return tvSeriesPathsByKey(rootFolderOrKey); } const key = tvSeriesKey(rootFolderOrKey, seriesId, fallbackTitle); if (!key) return null; return tvSeriesPathsByKey(key); } function tvSeriesDir(rootFolderOrKey, seriesId = null, fallbackTitle = null) { const paths = tvSeriesPaths(rootFolderOrKey, seriesId, fallbackTitle); return paths?.dir || null; } function buildTvSeriesPaths(rootFolder, seriesId = null, fallbackTitle = null) { const paths = tvSeriesPaths(rootFolder, seriesId, fallbackTitle); if (!paths) return null; if (!fs.existsSync(paths.dir)) fs.mkdirSync(paths.dir, { recursive: true }); if (!fs.existsSync(paths.episodesDir)) fs.mkdirSync(paths.episodesDir, { recursive: true }); if (!fs.existsSync(paths.seasonsDir)) fs.mkdirSync(paths.seasonsDir, { recursive: true }); return paths; } function seasonAssetPaths(paths, seasonNumber) { const padded = String(seasonNumber).padStart(2, "0"); const dir = path.join(paths.dir, "seasons", `season-${padded}`); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); return { dir, poster: path.join(dir, "poster.jpg") }; } function listTvSeriesKeysForRoot(rootFolder) { const normalizedRoot = rootFolder ? sanitizeRelative(rootFolder) : null; if (!normalizedRoot) return []; if (!fs.existsSync(TV_DATA_ROOT)) return []; const keys = []; try { const entries = fs.readdirSync(TV_DATA_ROOT, { withFileTypes: true }); for (const dirent of entries) { if (!dirent.isDirectory()) continue; const name = dirent.name; if ( name === normalizedRoot || name.startsWith(`${normalizedRoot}__`) ) { keys.push(name); } } } catch (err) { console.warn( `⚠️ TV metadata dizini listelenemedi (${TV_DATA_ROOT}): ${err.message}` ); } return keys; } function removeSeriesData(rootFolder, seriesId = null) { const keys = seriesId ? [tvSeriesKey(rootFolder, seriesId)].filter(Boolean) : listTvSeriesKeysForRoot(rootFolder); for (const key of keys) { const dir = tvSeriesDir(key); if (dir && fs.existsSync(dir)) { try { fs.rmSync(dir, { recursive: true, force: true }); console.log(`🧹 TV metadata silindi: ${dir}`); } catch (err) { console.warn(`⚠️ TV metadata temizlenemedi (${dir}): ${err.message}`); } } } } function removeSeriesEpisode(rootFolder, relativeFilePath) { if (!rootFolder || !relativeFilePath) return; const keys = listTvSeriesKeysForRoot(rootFolder); if (!keys.length) return; for (const key of keys) { const paths = tvSeriesPathsByKey(key); if (!fs.existsSync(paths.metadata)) continue; let seriesData; try { seriesData = JSON.parse(fs.readFileSync(paths.metadata, "utf-8")); } catch (err) { console.warn( `⚠️ series.json okunamadı (${paths.metadata}): ${err.message}` ); continue; } const seasons = seriesData?.seasons || {}; let removed = false; for (const [seasonKey, season] of Object.entries(seasons)) { if (!season?.episodes) continue; let seasonChanged = false; for (const episodeKey of Object.keys(season.episodes)) { const episode = season.episodes[episodeKey]; if (episode?.file === relativeFilePath) { delete season.episodes[episodeKey]; seasonChanged = true; removed = true; } } if ( seasonChanged && (!season.episodes || Object.keys(season.episodes).length === 0) ) { delete seasons[seasonKey]; } } if (!removed) continue; if (!Object.keys(seasons).length) { removeSeriesData(seriesData._dupe?.folder || rootFolder, seriesData.id); continue; } seriesData.seasons = seasons; seriesData.updatedAt = Date.now(); try { fs.writeFileSync(paths.metadata, JSON.stringify(seriesData, null, 2), "utf-8"); } catch (err) { console.warn( `⚠️ series.json güncellenemedi (${paths.metadata}): ${err.message}` ); } try { const seasonDirs = [paths.episodesDir, paths.seasonsDir]; for (const dir of seasonDirs) { if (!dir || !fs.existsSync(dir)) continue; cleanupEmptyDirs(dir); } } catch (err) { console.warn( `⚠️ TV metadata klasörü temizlenemedi (${paths.dir}): ${err.message}` ); } } } function purgeRootFolder(rootFolder) { const safe = sanitizeRelative(rootFolder); if (!safe) return false; const rootDir = path.join(DOWNLOAD_DIR, safe); let removedDir = false; if (fs.existsSync(rootDir)) { try { fs.rmSync(rootDir, { recursive: true, force: true }); removedDir = true; console.log(`🧹 Kök klasör temizlendi: ${rootDir}`); } catch (err) { console.warn(`⚠️ Kök klasör silinemedi (${rootDir}): ${err.message}`); } } removeAllThumbnailsForRoot(safe); removeMovieData(safe); removeSeriesData(safe); const infoPath = path.join(DOWNLOAD_DIR, safe, INFO_FILENAME); if (fs.existsSync(infoPath)) { try { fs.rmSync(infoPath, { force: true }); } catch (err) { console.warn(`⚠️ info.json kaldırılamadı (${infoPath}): ${err.message}`); } } return removedDir; } function pruneInfoEntry(rootFolder, relativePath) { if (!rootFolder) return; const info = readInfoForRoot(rootFolder); if (!info) return; let changed = false; if (relativePath && info.files && info.files[relativePath]) { delete info.files[relativePath]; if (Object.keys(info.files).length === 0) delete info.files; changed = true; } if ( relativePath && info.seriesEpisodes && info.seriesEpisodes[relativePath] ) { delete info.seriesEpisodes[relativePath]; if (Object.keys(info.seriesEpisodes).length === 0) { delete info.seriesEpisodes; } changed = true; } if (relativePath && info.primaryVideoPath === relativePath) { delete info.primaryVideoPath; delete info.primaryMediaInfo; changed = true; } if (changed) { const safe = sanitizeRelative(rootFolder); if (!safe) return; const target = path.join(DOWNLOAD_DIR, safe, INFO_FILENAME); try { info.updatedAt = Date.now(); fs.writeFileSync(target, JSON.stringify(info, null, 2), "utf-8"); } catch (err) { console.warn(`⚠️ info.json güncellenemedi (${target}): ${err.message}`); } } } function pruneInfoForDirectory(rootFolder, relativeDir) { if (!rootFolder) return; const info = readInfoForRoot(rootFolder); if (!info) return; const normalizedDir = normalizeTrashPath(relativeDir); const prefix = normalizedDir ? `${normalizedDir}/` : ""; const removedEpisodePaths = []; let changed = false; if (info.files && typeof info.files === "object") { for (const key of Object.keys(info.files)) { if (key === normalizedDir || (prefix && key.startsWith(prefix))) { delete info.files[key]; changed = true; } } if (Object.keys(info.files).length === 0) delete info.files; } if (info.seriesEpisodes && typeof info.seriesEpisodes === "object") { for (const key of Object.keys(info.seriesEpisodes)) { if (key === normalizedDir || (prefix && key.startsWith(prefix))) { removedEpisodePaths.push(key); delete info.seriesEpisodes[key]; changed = true; } } if (Object.keys(info.seriesEpisodes).length === 0) { delete info.seriesEpisodes; } } if (info.primaryVideoPath) { if ( info.primaryVideoPath === normalizedDir || (prefix && info.primaryVideoPath.startsWith(prefix)) ) { delete info.primaryVideoPath; delete info.primaryMediaInfo; delete info.movieMatch; changed = true; } } if (changed) { const safe = sanitizeRelative(rootFolder); if (!safe) return; const target = path.join(DOWNLOAD_DIR, safe, INFO_FILENAME); try { info.updatedAt = Date.now(); fs.writeFileSync(target, JSON.stringify(info, null, 2), "utf-8"); } catch (err) { console.warn(`⚠️ info.json güncellenemedi (${target}): ${err.message}`); } } // TV metadata dosyalarından da temizle for (const relPath of removedEpisodePaths) { try { removeSeriesEpisode(rootFolder, relPath); } catch (err) { console.warn( `⚠️ Serie metadata temizlenemedi (${rootFolder}/${relPath}): ${err.message}` ); } } } function writeInfoForRoot(rootFolder, info) { if (!rootFolder || !info) return; const safe = sanitizeRelative(rootFolder); if (!safe) return; const target = path.join(DOWNLOAD_DIR, safe, INFO_FILENAME); try { info.updatedAt = Date.now(); fs.writeFileSync(target, JSON.stringify(info, null, 2), "utf-8"); } catch (err) { console.warn(`⚠️ info.json güncellenemedi (${target}): ${err.message}`); } } function moveInfoDataBetweenRoots(oldRoot, newRoot, oldRel, newRel, isDirectory) { if (!oldRoot || !newRoot) return false; const oldInfo = readInfoForRoot(oldRoot); if (!oldInfo) return false; let newInfo = readInfoForRoot(newRoot); if (!newInfo) { const rootDir = path.join(DOWNLOAD_DIR, sanitizeRelative(newRoot)); newInfo = upsertInfoFile(rootDir, { folder: newRoot, createdAt: Date.now(), updatedAt: Date.now() }) || readInfoForRoot(newRoot) || { folder: newRoot }; } const normalizedOldRel = normalizeTrashPath(oldRel); const normalizedNewRel = normalizeTrashPath(newRel); const shouldMove = (normalizedKey) => { if (!normalizedOldRel) return true; if (normalizedKey === normalizedOldRel) return true; return ( isDirectory && normalizedOldRel && normalizedKey.startsWith(`${normalizedOldRel}/`) ); }; const mapKey = (normalizedKey) => { const suffix = normalizedOldRel ? normalizedKey.slice(normalizedOldRel.length).replace(/^\/+/, "") : normalizedKey; if (!normalizedNewRel) return suffix; return `${normalizedNewRel}${suffix ? `/${suffix}` : ""}`; }; let moved = false; if (oldInfo.files && typeof oldInfo.files === "object") { const remaining = {}; for (const [key, value] of Object.entries(oldInfo.files)) { const normalizedKey = normalizeTrashPath(key); if (!shouldMove(normalizedKey)) { remaining[key] = value; continue; } const nextKey = mapKey(normalizedKey); if (!newInfo.files || typeof newInfo.files !== "object") { newInfo.files = {}; } newInfo.files[nextKey] = value; moved = true; } if (Object.keys(remaining).length > 0) oldInfo.files = remaining; else delete oldInfo.files; } if (oldInfo.seriesEpisodes && typeof oldInfo.seriesEpisodes === "object") { const remainingEpisodes = {}; for (const [key, value] of Object.entries(oldInfo.seriesEpisodes)) { const normalizedKey = normalizeTrashPath(key); if (!shouldMove(normalizedKey)) { remainingEpisodes[key] = value; continue; } const nextKey = mapKey(normalizedKey); if ( !newInfo.seriesEpisodes || typeof newInfo.seriesEpisodes !== "object" ) { newInfo.seriesEpisodes = {}; } newInfo.seriesEpisodes[nextKey] = value; moved = true; } if (Object.keys(remainingEpisodes).length > 0) { oldInfo.seriesEpisodes = remainingEpisodes; } else { delete oldInfo.seriesEpisodes; } } if (oldInfo.primaryVideoPath) { const normalizedPrimary = normalizeTrashPath(oldInfo.primaryVideoPath); if (shouldMove(normalizedPrimary)) { const nextPrimary = mapKey(normalizedPrimary); newInfo.primaryVideoPath = nextPrimary; if (oldInfo.primaryMediaInfo !== undefined) { newInfo.primaryMediaInfo = oldInfo.primaryMediaInfo; delete oldInfo.primaryMediaInfo; } if (oldInfo.movieMatch !== undefined) { newInfo.movieMatch = oldInfo.movieMatch; delete oldInfo.movieMatch; } delete oldInfo.primaryVideoPath; moved = true; } } if (!moved) return false; writeInfoForRoot(oldRoot, oldInfo); writeInfoForRoot(newRoot, newInfo); return true; } function renameInfoPaths(rootFolder, oldRel, newRel) { if (!rootFolder) return; const info = readInfoForRoot(rootFolder); if (!info) return; const oldPrefix = normalizeTrashPath(oldRel); const newPrefix = normalizeTrashPath(newRel); if (!oldPrefix || oldPrefix === newPrefix) return; const transformKey = (key) => { const normalizedKey = normalizeTrashPath(key); if ( normalizedKey === oldPrefix || normalizedKey.startsWith(`${oldPrefix}/`) ) { const suffix = normalizedKey.slice(oldPrefix.length).replace(/^\/+/, ""); return newPrefix ? `${newPrefix}${suffix ? `/${suffix}` : ""}` : suffix; } return normalizedKey; }; let changed = false; if (info.files && typeof info.files === "object") { const nextFiles = {}; for (const [key, value] of Object.entries(info.files)) { const nextKey = transformKey(key); if (nextKey !== key) changed = true; nextFiles[nextKey] = value; } info.files = nextFiles; } if (info.seriesEpisodes && typeof info.seriesEpisodes === "object") { const nextEpisodes = {}; for (const [key, value] of Object.entries(info.seriesEpisodes)) { const nextKey = transformKey(key); if (nextKey !== key) changed = true; nextEpisodes[nextKey] = value; } info.seriesEpisodes = nextEpisodes; } if ( info.primaryVideoPath && (info.primaryVideoPath === oldPrefix || info.primaryVideoPath.startsWith(`${oldPrefix}/`)) ) { const suffix = info.primaryVideoPath.slice(oldPrefix.length).replace(/^\/+/, ""); info.primaryVideoPath = newPrefix ? `${newPrefix}${suffix ? `/${suffix}` : ""}` : suffix; changed = true; } if (changed) { const safe = sanitizeRelative(rootFolder); if (!safe) return; const target = path.join(DOWNLOAD_DIR, safe, INFO_FILENAME); try { info.updatedAt = Date.now(); fs.writeFileSync(target, JSON.stringify(info, null, 2), "utf-8"); } catch (err) { console.warn(`⚠️ info.json güncellenemedi (${target}): ${err.message}`); } } } function renameSeriesDataPaths(rootFolder, oldRel, newRel) { if (!rootFolder) return; const oldPrefix = normalizeTrashPath(oldRel); const newPrefix = normalizeTrashPath(newRel); if (!oldPrefix || oldPrefix === newPrefix) return; const keys = listTvSeriesKeysForRoot(rootFolder); for (const key of keys) { const metadataPath = tvSeriesPathsByKey(key).metadata; if (!fs.existsSync(metadataPath)) continue; let seriesData; try { seriesData = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); } catch (err) { console.warn(`⚠️ series.json okunamadı (${metadataPath}): ${err.message}`); continue; } const transform = (value) => { const normalized = normalizeTrashPath(value); if ( normalized === oldPrefix || normalized.startsWith(`${oldPrefix}/`) ) { const suffix = normalized.slice(oldPrefix.length).replace(/^\/+/, ""); return newPrefix ? `${newPrefix}${suffix ? `/${suffix}` : ""}` : suffix; } return value; }; let changed = false; const seasons = seriesData?.seasons || {}; for (const season of Object.values(seasons)) { if (!season?.episodes) continue; for (const episode of Object.values(season.episodes)) { if (!episode || typeof episode !== "object") continue; if (episode.file) { const nextFile = transform(episode.file); if (nextFile !== episode.file) { episode.file = nextFile; changed = true; } } if (episode.videoPath) { const nextVideo = transform(episode.videoPath); if (nextVideo !== episode.videoPath) { episode.videoPath = nextVideo; changed = true; } } } } if (changed) { try { fs.writeFileSync(metadataPath, JSON.stringify(seriesData, null, 2), "utf-8"); } catch (err) { console.warn( `⚠️ series.json güncellenemedi (${metadataPath}): ${err.message}` ); } } } } function removeThumbnailsForDirectory(rootFolder, relativeDir) { const normalizedRoot = sanitizeRelative(rootFolder); if (!normalizedRoot) return; const normalizedDir = normalizeTrashPath(relativeDir); const segments = normalizedDir ? normalizedDir.split("/") : []; const videoThumbDir = path.join( VIDEO_THUMB_ROOT, normalizedRoot, ...segments ); const imageThumbDir = path.join( IMAGE_THUMB_ROOT, normalizedRoot, ...segments ); for (const dir of [videoThumbDir, imageThumbDir]) { if (!dir.startsWith(THUMBNAIL_DIR)) continue; if (fs.existsSync(dir)) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (err) { console.warn(`⚠️ Thumbnail klasörü silinemedi (${dir}): ${err.message}`); } } cleanupEmptyDirs(path.dirname(dir)); } } function renameTrashEntries(rootFolder, oldRel, newRel) { if (!rootFolder) return; const registry = readTrashRegistry(rootFolder); if (!registry || !Array.isArray(registry.items)) return; const oldPrefix = normalizeTrashPath(oldRel); const newPrefix = normalizeTrashPath(newRel); if (!oldPrefix || oldPrefix === newPrefix) return; let changed = false; const updatedItems = registry.items.map((item) => { const itemPath = normalizeTrashPath(item.path); if ( itemPath === oldPrefix || itemPath.startsWith(`${oldPrefix}/`) ) { const suffix = itemPath.slice(oldPrefix.length).replace(/^\/+/, ""); const nextPath = newPrefix ? `${newPrefix}${suffix ? `/${suffix}` : ""}` : suffix; changed = true; return { ...item, path: nextPath, originalPath: nextPath ? `${rootFolder}/${nextPath}` : rootFolder }; } return item; }); if (changed) { writeTrashRegistry(rootFolder, { ...registry, items: updatedItems }); } } function renameRootCaches(oldRoot, newRoot) { const pairs = [ VIDEO_THUMB_ROOT, IMAGE_THUMB_ROOT, MOVIE_DATA_ROOT, TV_DATA_ROOT ]; for (const base of pairs) { const from = path.join(base, oldRoot); if (!fs.existsSync(from)) continue; const to = path.join(base, newRoot); try { fs.mkdirSync(path.dirname(to), { recursive: true }); fs.renameSync(from, to); } catch (err) { console.warn( `⚠️ Kök cache klasörü yeniden adlandırılamadı (${from} -> ${to}): ${err.message}` ); } } } function broadcastFileUpdate(rootFolder) { if (!wss) return; const data = JSON.stringify({ type: "fileUpdate", path: rootFolder }); wss.clients.forEach((c) => c.readyState === 1 && c.send(data)); } function broadcastDiskSpace() { if (!wss) return; getSystemDiskInfo(DOWNLOAD_DIR).then(diskInfo => { const data = JSON.stringify({ type: "diskSpace", data: diskInfo }); wss.clients.forEach((c) => c.readyState === 1 && c.send(data)); }).catch(err => { console.error("❌ Disk space broadcast error:", err.message); }); } function broadcastSnapshot() { if (!wss) return; const data = JSON.stringify({ type: "progress", torrents: snapshot() }); wss.clients.forEach((c) => c.readyState === 1 && c.send(data)); } let mediaRescanTask = null; let pendingMediaRescan = { movies: false, tv: false }; let lastMediaRescanReason = "manual"; function queueMediaRescan({ movies = false, tv = false, reason = "manual" } = {}) { if (!movies && !tv) return; pendingMediaRescan.movies = pendingMediaRescan.movies || movies; pendingMediaRescan.tv = pendingMediaRescan.tv || tv; lastMediaRescanReason = reason; if (!mediaRescanTask) { mediaRescanTask = runQueuedMediaRescan().finally(() => { mediaRescanTask = null; }); } } async function runQueuedMediaRescan() { while (pendingMediaRescan.movies || pendingMediaRescan.tv) { const targets = { ...pendingMediaRescan }; pendingMediaRescan = { movies: false, tv: false }; const reason = lastMediaRescanReason; console.log( `🔁 Medya taraması tetiklendi (${reason}) -> movies:${targets.movies} tv:${targets.tv}` ); try { if (targets.movies) { if (TMDB_API_KEY) { await rebuildMovieMetadata({ clearCache: true }); } else { console.warn("⚠️ TMDB anahtarı tanımsız olduğu için film taraması atlandı."); } } if (targets.tv) { if (TVDB_API_KEY) { await rebuildTvMetadata({ clearCache: true }); } else { console.warn("⚠️ TVDB anahtarı tanımsız olduğu için dizi taraması atlandı."); } } if (targets.movies || targets.tv) { broadcastFileUpdate("media-library"); } } catch (err) { console.error("❌ Medya kütüphanesi taraması başarısız:", err?.message || err); } } } function detectMediaFlagsForPath(info, relWithinRoot, isDirectory) { const flags = { movies: false, tv: false }; if (!info || typeof info !== "object") return flags; const normalized = normalizeTrashPath(relWithinRoot); const matchesPath = (candidate) => { const normalizedCandidate = normalizeTrashPath(candidate); if (!normalized) return true; if (isDirectory) { return ( normalizedCandidate === normalized || normalizedCandidate.startsWith(`${normalized}/`) ); } return normalizedCandidate === normalized; }; const files = info.files || {}; for (const [key, meta] of Object.entries(files)) { if (!meta) continue; if (!matchesPath(key)) continue; if (meta.movieMatch) flags.movies = true; if (meta.seriesMatch) flags.tv = true; } const episodes = info.seriesEpisodes || {}; for (const [key] of Object.entries(episodes)) { if (!matchesPath(key)) continue; flags.tv = true; break; } if (!normalized || isDirectory) { if (info.movieMatch || info.primaryVideoPath) { flags.movies = true; } if ( info.seriesEpisodes && Object.keys(info.seriesEpisodes).length && !flags.tv ) { flags.tv = true; } } return flags; } function inferMediaFlagsFromTrashEntry(entry) { if (!entry) return { movies: false, tv: false }; if ( entry.mediaFlags && typeof entry.mediaFlags === "object" && ("movies" in entry.mediaFlags || "tv" in entry.mediaFlags) ) { return { movies: Boolean(entry.mediaFlags.movies), tv: Boolean(entry.mediaFlags.tv) }; } const normalized = normalizeTrashPath(entry.path || entry.originalPath || ""); if (!normalized || entry.isDirectory) { return { movies: true, tv: true }; } const ext = path.extname(normalized).toLowerCase(); if (!VIDEO_EXTS.includes(ext)) { return { movies: false, tv: false }; } const base = path.basename(normalized); const seriesCandidate = parseSeriesInfo(base); if (seriesCandidate) { return { movies: false, tv: true }; } return { movies: true, tv: false }; } // --- Snapshot (thumbnail dahil, tracker + tarih eklendi) --- function snapshot() { const torrentEntries = Array.from(torrents.values()).map( ({ torrent, selectedIndex, savePath, added, paused }) => { const rootFolder = path.basename(savePath); const bestVideoIndex = pickBestVideoFile(torrent); const bestVideo = torrent.files[bestVideoIndex]; let thumbnail = null; if (bestVideo) { const relPath = path.join(rootFolder, bestVideo.path); const { relThumb, absThumb } = getVideoThumbnailPaths(relPath); if (fs.existsSync(absThumb)) thumbnail = thumbnailUrl(relThumb); else if (torrent.progress === 1) queueVideoThumbnail(path.join(savePath, bestVideo.path), relPath); } return { infoHash: torrent.infoHash, type: "torrent", name: torrent.name, progress: torrent.progress, downloaded: torrent.downloaded, downloadSpeed: paused ? 0 : torrent.downloadSpeed, // Pause durumunda hız 0 uploadSpeed: paused ? 0 : torrent.uploadSpeed, // Pause durumunda hız 0 numPeers: paused ? 0 : torrent.numPeers, // Pause durumunda peer sayısı 0 tracker: torrent.announce?.[0] || null, added, savePath, // 🆕 BURASI! paused: paused || false, // Pause durumunu ekle files: torrent.files.map((f, i) => ({ index: i, name: f.name, length: f.length })), selectedIndex, thumbnail }; } ); const youtubeEntries = Array.from(youtubeJobs.values()).map((job) => youtubeSnapshot(job) ); const combined = [...torrentEntries, ...youtubeEntries]; combined.sort((a, b) => (b.added || 0) - (a.added || 0)); return combined; } function wireTorrent(torrent, { savePath, added, respond, restored = false }) { torrents.set(torrent.infoHash, { torrent, selectedIndex: 0, savePath, added, paused: false }); torrent.on("ready", () => { onTorrentReady({ torrent, savePath, added, respond, restored }); }); 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, type: determineMediaType({ tracker: torrent.announce?.[0] || null, movieMatch: null, seriesEpisode: null, categories: null, relPath: normalizedRelPath }) }; 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() } }; perFileMetadata[normalizedRelPath].type = determineMediaType({ tracker: torrent.announce?.[0] || null, movieMatch: null, seriesEpisode: seriesEpisodes[normalizedRelPath], categories: null, relPath: normalizedRelPath }); } } 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 }; const movieType = determineMediaType({ tracker: torrent.announce?.[0] || null, movieMatch: ensuredMedia.metadata, seriesEpisode: seriesEpisodes[bestVideoPath] || null, categories: null, relPath: bestVideoPath }); perFileMetadata[bestVideoPath] = { ...(perFileMetadata[bestVideoPath] || {}), type: movieType }; infoUpdate.files[bestVideoPath].type = movieType; } } 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)); } } if (bestVideoPath && infoUpdate.files && infoUpdate.files[bestVideoPath]) { const rootType = determineMediaType({ tracker: torrent.announce?.[0] || null, movieMatch: infoUpdate.files[bestVideoPath].movieMatch || null, seriesEpisode: seriesEpisodes[bestVideoPath] || null, categories: null, relPath: bestVideoPath }); infoUpdate.type = rootType; infoUpdate.files[bestVideoPath].type = infoUpdate.files[bestVideoPath].type || rootType; } 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) => { const filePath = req.query.path; if (!filePath) return res.status(400).json({ error: "path parametresi gerekli" }); // TTL saniye olarak (default 3600 = 1 saat). Min 60s, max 72h const ttl = Math.min(Math.max(Number(req.query.ttl) || 3600, 60), 72 * 3600); // Medya token oluştur const mediaToken = issueMediaToken(filePath, ttl); // Her path segmentini ayrı encode et (slash korunur) const encodedPath = String(filePath) .split(/[\\/]/) .filter(Boolean) .map((s) => encodeURIComponent(s)) .join("/"); const host = req.get("host") || "localhost"; const protocol = req.protocol || (req.secure ? "https" : "http"); const absoluteUrl = `${protocol}://${host}/media/${encodedPath}?token=${mediaToken}`; console.log("Generated media URL:", { original: filePath, url: absoluteUrl, ttl }); res.json({ url: absoluteUrl, token: mediaToken, expiresIn: ttl }); }); // --- Torrent veya magnet ekleme --- app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => { try { let source = req.body.magnet; if (req.file) source = fs.readFileSync(req.file.path); if (!source) return res.status(400).json({ error: "magnet veya .torrent gerekli" }); // Her torrent için ayrı klasör const savePath = path.join(DOWNLOAD_DIR, Date.now().toString()); fs.mkdirSync(savePath, { recursive: true }); const torrent = client.add(source, { announce: [], path: savePath }); // 🆕 Torrent eklendiği anda tarih kaydedelim const added = Date.now(); wireTorrent(torrent, { savePath, added, respond: (payload) => res.json(payload) }); } catch (err) { res.status(500).json({ error: err.message }); } }); // --- Thumbnail endpoint --- app.get("/thumbnails/:path(*)", requireAuth, (req, res) => { const relThumb = req.params.path || ""; const fullPath = resolveThumbnailAbsolute(relThumb); if (!fullPath) return res.status(400).send("Geçersiz thumbnail yolu"); return serveCachedFile(req, res, fullPath, { maxAgeSeconds: 60 * 60 * 24 }); }); app.get("/movie-data/:path(*)", requireAuth, (req, res) => { const relPath = req.params.path || ""; const fullPath = resolveMovieDataAbsolute(relPath); if (!fullPath) return res.status(400).send("Geçersiz movie data yolu"); return serveCachedFile(req, res, fullPath, { maxAgeSeconds: 60 * 60 * 24 }); }); app.get("/yt-data/:path(*)", requireAuth, (req, res) => { const relPath = req.params.path || ""; const fullPath = resolveYoutubeDataAbsolute(relPath); if (!fullPath) return res.status(400).send("Geçersiz yt data yolu"); return serveCachedFile(req, res, fullPath, { maxAgeSeconds: 60 * 60 * 24 }); }); app.get("/tv-data/:path(*)", requireAuth, (req, res) => { const relPath = req.params.path || ""; const fullPath = resolveTvDataAbsolute(relPath); if (!fullPath) return res.status(400).send("Geçersiz tv data yolu"); return serveCachedFile(req, res, fullPath, { maxAgeSeconds: 60 * 60 * 24 }); }); // --- Torrentleri listele --- app.get("/api/torrents", requireAuth, (req, res) => { res.json(snapshot()); }); // --- Seçili dosya değiştir --- 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ı" }); const targetIndex = Number(req.params.index) || 0; if (!job.files?.[targetIndex]) { return res .status(400) .json({ error: "Geçerli bir video dosyası bulunamadı" }); } job.selectedIndex = targetIndex; return res.json({ ok: true, selectedIndex: targetIndex }); } entry.selectedIndex = Number(req.params.index) || 0; res.json({ ok: true, selectedIndex: entry.selectedIndex }); }); // --- Torrent silme (disk dahil) --- app.delete("/api/torrents/:hash", requireAuth, (req, res) => { const entry = torrents.get(req.params.hash); if (!entry) { const ytRemoved = removeYoutubeJob(req.params.hash, { removeFiles: true }); if (ytRemoved) { return res.json({ ok: true, filesRemoved: true }); } return res.status(404).json({ error: "torrent bulunamadı" }); } const { torrent, savePath } = entry; const isComplete = torrent?.done || (torrent?.progress ?? 0) >= 1; const rootFolder = savePath ? path.basename(savePath) : null; torrent.destroy(() => { torrents.delete(req.params.hash); if (!isComplete) { if (savePath && fs.existsSync(savePath)) { try { fs.rmSync(savePath, { recursive: true, force: true }); console.log(`🗑️ ${savePath} klasörü silindi`); } catch (err) { console.warn(`⚠️ ${savePath} silinemedi:`, err.message); } } if (rootFolder) { purgeRootFolder(rootFolder); broadcastFileUpdate(rootFolder); } } else { console.log( `ℹ️ ${req.params.hash} torrent'i tamamlandığı için yalnızca Transfers listesinden kaldırıldı; dosyalar tutuldu.` ); } broadcastSnapshot(); res.json({ ok: true, filesRemoved: !isComplete }); }); }); function getPieceCount(torrent) { if (!torrent) return 0; if (Array.isArray(torrent.pieces)) return torrent.pieces.length; const pieces = torrent.pieces; if (pieces && typeof pieces.length === "number") return pieces.length; return 0; } function pauseTorrentEntry(entry) { const torrent = entry?.torrent; if (!torrent || torrent._destroyed || entry.paused) return false; entry.previousSelection = entry.selectedIndex; const pieceCount = getPieceCount(torrent); if (pieceCount > 0 && typeof torrent.deselect === "function") { try { torrent.deselect(0, pieceCount - 1, 0); } catch (err) { console.warn("Torrent deselect failed during pause:", err.message); } } if (Array.isArray(torrent.files)) { for (const file of torrent.files) { if (file && typeof file.deselect === "function") { try { file.deselect(); } catch (err) { console.warn( `File deselect failed during pause (${torrent.infoHash}):`, err.message ); } } } } if (typeof torrent.pause === "function") { try { torrent.pause(); } catch (err) { console.warn("Torrent pause method failed:", err.message); } } entry.paused = true; entry.pausedAt = Date.now(); return true; } function resumeTorrentEntry(entry) { const torrent = entry?.torrent; if (!torrent || torrent._destroyed || !entry.paused) return false; const pieceCount = getPieceCount(torrent); if (pieceCount > 0 && typeof torrent.select === "function") { try { torrent.select(0, pieceCount - 1, 0); } catch (err) { console.warn("Torrent select failed during resume:", err.message); } } if (Array.isArray(torrent.files)) { const preferredIndex = entry.previousSelection !== undefined ? entry.previousSelection : entry.selectedIndex ?? 0; const targetFile = torrent.files[preferredIndex] || torrent.files[0] || null; if (targetFile && typeof targetFile.select === "function") { try { targetFile.select(); } catch (err) { console.warn( `File select failed during resume (${torrent.infoHash}):`, err.message ); } } } if (typeof torrent.resume === "function") { try { torrent.resume(); } catch (err) { console.warn("Torrent resume method failed:", err.message); } } entry.paused = false; delete entry.pausedAt; return true; } // --- Tüm torrentleri durdur/devam ettir --- app.post("/api/torrents/toggle-all", requireAuth, (req, res) => { try { const { action } = req.body; // 'pause' veya 'resume' if (!action || (action !== 'pause' && action !== 'resume')) { return res.status(400).json({ error: "action 'pause' veya 'resume' olmalı" }); } let updatedCount = 0; const pausedTorrents = new Set(); for (const [infoHash, entry] of torrents.entries()) { if (!entry?.torrent || entry.torrent._destroyed) continue; try { const changed = action === "pause" ? pauseTorrentEntry(entry) : resumeTorrentEntry(entry); if (changed) updatedCount++; if (entry.paused) pausedTorrents.add(infoHash); } catch (err) { console.warn( `⚠️ Torrent ${infoHash} ${action} işleminde hata:`, err.message ); } } global.pausedTorrents = pausedTorrents; broadcastSnapshot(); res.json({ ok: true, action, updatedCount, totalCount: torrents.size }); } catch (err) { console.error("❌ Toggle all torrents error:", err.message); res.status(500).json({ error: err.message }); } }); // --- Tek torrent'i durdur/devam ettir --- app.post("/api/torrents/:hash/toggle", requireAuth, (req, res) => { try { const { action } = req.body; // 'pause' veya 'resume' const infoHash = req.params.hash; if (!action || (action !== 'pause' && action !== 'resume')) { return res.status(400).json({ error: "action 'pause' veya 'resume' olmalı" }); } const entry = torrents.get(infoHash); if (!entry || !entry.torrent || entry.torrent._destroyed) { if (youtubeJobs.has(infoHash)) { return res .status(400) .json({ error: "YouTube indirmeleri duraklatılamaz" }); } return res.status(404).json({ error: "torrent bulunamadı" }); } const changed = action === "pause" ? pauseTorrentEntry(entry) : resumeTorrentEntry(entry); if (!changed) { const message = action === "pause" ? "Torrent zaten durdurulmuş" : "Torrent zaten devam ediyor"; return res.json({ ok: true, action, infoHash, paused: entry.paused, message }); } const pausedTorrents = new Set(); for (const [hash, item] of torrents.entries()) { if (item?.paused) pausedTorrents.add(hash); } global.pausedTorrents = pausedTorrents; broadcastSnapshot(); res.json({ ok: true, action, infoHash, paused: entry.paused }); } catch (err) { console.error("❌ Toggle single torrent error:", err.message); res.status(500).json({ error: err.message }); } }); // --- GENEL MEDYA SUNUMU (🆕 resimler + videolar) --- app.get("/media/:path(*)", requireAuth, (req, res) => { // URL'deki encode edilmiş karakterleri decode et let relPath = req.params.path || ""; try { relPath = decodeURIComponent(relPath); } catch (err) { console.warn("Failed to decode media path:", relPath, err.message); } // sanitizeRelative sadece baştaki slash'ları temizler; buradan sonra ekstra kontrol yapıyoruz const safeRel = sanitizeRelative(relPath); if (!safeRel) { console.error("Invalid media path after sanitize:", relPath); return res.status(400).send("Invalid path"); } const fullPath = path.join(DOWNLOAD_DIR, safeRel); if (!fs.existsSync(fullPath)) { console.error("File not found:", fullPath); return res.status(404).send("File not found"); } const stat = fs.statSync(fullPath); const fileSize = stat.size; const type = mime.lookup(fullPath) || "application/octet-stream"; const isVideo = String(type).startsWith("video/"); const range = req.headers.range; // CORS headers ekle res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Range, Accept-Ranges, Content-Type"); if (isVideo && range) { const [startStr, endStr] = range.replace(/bytes=/, "").split("-"); const start = parseInt(startStr, 10); const end = endStr ? parseInt(endStr, 10) : fileSize - 1; const chunkSize = end - start + 1; const file = fs.createReadStream(fullPath, { start, end }); const head = { "Content-Range": `bytes ${start}-${end}/${fileSize}`, "Accept-Ranges": "bytes", "Content-Length": chunkSize, "Content-Type": type }; res.writeHead(206, head); file.pipe(res); } else { const head = { "Content-Length": fileSize, "Content-Type": type, "Accept-Ranges": isVideo ? "bytes" : "none" }; res.writeHead(200, head); fs.createReadStream(fullPath).pipe(res); } }); // --- 🗑️ Tekil dosya veya torrent klasörüne .trash flag'i ekleme --- app.delete("/api/file", requireAuth, (req, res) => { const filePath = req.query.path; if (!filePath) return res.status(400).json({ error: "path gerekli" }); 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 mediaFlags = { movies: false, tv: false }; let stats = null; try { stats = fs.statSync(fullPath); } catch (err) { const message = err?.message || String(err); console.warn(`⚠️ Silme işlemi sırasında stat alınamadı (${fullPath}): ${message}`); } if (!stats || !fs.existsSync(fullPath)) { if (folderId && (!rootDir || !fs.existsSync(rootDir))) { purgeRootFolder(folderId); broadcastFileUpdate(folderId); return res.json({ ok: true, alreadyRemoved: true }); } return res.status(404).json({ error: "Dosya bulunamadı" }); } try { const isDirectory = stats.isDirectory(); const relWithinRoot = safePath.split(/[\\/]/).slice(1).join("/"); let trashEntry = null; if (folderId && rootDir) { const infoBeforeDelete = readInfoForRoot(folderId); mediaFlags = detectMediaFlagsForPath( infoBeforeDelete, relWithinRoot, isDirectory ); } else { mediaFlags = { movies: false, tv: false }; } if (folderId && rootDir) { trashEntry = addTrashEntry(folderId, { path: relWithinRoot, originalPath: safePath, isDirectory, deletedAt: Date.now(), type: isDirectory ? "inode/directory" : mime.lookup(fullPath) || "application/octet-stream", mediaFlags: { ...mediaFlags } }); if (isDirectory) { pruneInfoForDirectory(folderId, relWithinRoot); } else { pruneInfoEntry(folderId, relWithinRoot); removeSeriesEpisode(folderId, relWithinRoot); } } if (isDirectory) { console.log(`🗑️ Klasör çöpe taşındı (işaretlendi): ${safePath}`); } else { console.log(`🗑️ Dosya çöpe taşındı (işaretlendi): ${fullPath}`); removeThumbnailsForPath(safePath); } if (!folderId) { // Kök klasöre ait olmayan dosyaları doğrudan sil if (fs.existsSync(fullPath)) { fs.rmSync(fullPath, { recursive: true, force: true }); } removeThumbnailsForPath(safePath); } if (folderId) { broadcastFileUpdate(folderId); trashStateCache.delete(folderId); } if (folderId) { let matchedInfoHash = null; for (const [infoHash, entry] of torrents.entries()) { const lastDir = path.basename(entry.savePath); if (lastDir === folderId) { matchedInfoHash = infoHash; break; } } if (matchedInfoHash) { const entry = torrents.get(matchedInfoHash); entry?.torrent?.destroy(() => { torrents.delete(matchedInfoHash); console.log(`🧹 Torrent kaydı da temizlendi: ${matchedInfoHash}`); broadcastSnapshot(); // Torrent silindiğinde disk space bilgisini güncelle broadcastDiskSpace(); }); } else { broadcastSnapshot(); } } else { broadcastSnapshot(); } if ( folderId && (mediaFlags.movies || mediaFlags.tv) ) { queueMediaRescan({ movies: mediaFlags.movies, tv: mediaFlags.tv, reason: "trash-add" }); } res.json({ ok: true, filesRemoved: true }); } catch (err) { console.error("❌ Dosya silinemedi:", err.message); res.status(500).json({ error: err.message }); } }); // --- 🚚 Dosya veya klasörü hedef klasöre taşıma --- app.post("/api/file/move", requireAuth, (req, res) => { try { const { sourcePath, targetDirectory } = req.body || {}; if (!sourcePath) { return res.status(400).json({ error: "sourcePath gerekli" }); } const normalizedSource = normalizeTrashPath(sourcePath); if (!normalizedSource) { return res.status(400).json({ error: "Geçersiz sourcePath" }); } const sourceFullPath = path.join(DOWNLOAD_DIR, normalizedSource); if (!fs.existsSync(sourceFullPath)) { return res.status(404).json({ error: "Kaynak öğe bulunamadı" }); } const sourceStats = fs.statSync(sourceFullPath); const isDirectory = sourceStats.isDirectory(); const normalizedTargetDir = targetDirectory ? normalizeTrashPath(targetDirectory) : ""; if (normalizedTargetDir) { const targetDirFullPath = path.join(DOWNLOAD_DIR, normalizedTargetDir); if (!fs.existsSync(targetDirFullPath)) { return res.status(404).json({ error: "Hedef klasör bulunamadı" }); } } const posixPath = path.posix; const sourceName = posixPath.basename(normalizedSource); const newRelativePath = normalizedTargetDir ? posixPath.join(normalizedTargetDir, sourceName) : sourceName; if (newRelativePath === normalizedSource) { return res.json({ success: true, unchanged: true }); } if ( isDirectory && (newRelativePath === normalizedSource || newRelativePath.startsWith(`${normalizedSource}/`)) ) { return res .status(400) .json({ error: "Bir klasörü kendi içine taşıyamazsın." }); } const newFullPath = path.join(DOWNLOAD_DIR, newRelativePath); if (fs.existsSync(newFullPath)) { return res .status(409) .json({ error: "Hedef konumda aynı isimde bir öğe zaten var" }); } const destinationParent = path.dirname(newFullPath); if (!fs.existsSync(destinationParent)) { fs.mkdirSync(destinationParent, { recursive: true }); } const sourceRoot = rootFromRelPath(normalizedSource); const destRoot = rootFromRelPath(newRelativePath); const sourceSegments = relPathToSegments(normalizedSource); const destSegments = relPathToSegments(newRelativePath); const sourceRelWithinRoot = sourceSegments.slice(1).join("/"); const destRelWithinRoot = destSegments.slice(1).join("/"); if (sourceRoot && !sourceRelWithinRoot) { return res .status(400) .json({ error: "Kök klasör bu yöntemle taşınamaz" }); } fs.renameSync(sourceFullPath, newFullPath); const sameRoot = sourceRoot && destRoot && sourceRoot === destRoot && sourceRoot !== null; const movedAcrossRoots = sourceRoot && destRoot && sourceRoot !== destRoot && sourceRoot !== null; if (sameRoot) { renameInfoPaths(sourceRoot, sourceRelWithinRoot, destRelWithinRoot); renameSeriesDataPaths(sourceRoot, sourceRelWithinRoot, destRelWithinRoot); renameTrashEntries(sourceRoot, sourceRelWithinRoot, destRelWithinRoot); if (isDirectory) { removeThumbnailsForDirectory(sourceRoot, sourceRelWithinRoot); } else { removeThumbnailsForPath(normalizedSource); } trashStateCache.delete(sourceRoot); } else { if (movedAcrossRoots) { moveInfoDataBetweenRoots( sourceRoot, destRoot, sourceRelWithinRoot, destRelWithinRoot, isDirectory ); if (isDirectory) { removeThumbnailsForDirectory(sourceRoot, sourceRelWithinRoot); } else { removeThumbnailsForPath(normalizedSource); } if (sourceRoot) trashStateCache.delete(sourceRoot); if (destRoot) trashStateCache.delete(destRoot); } else if (sourceRoot) { if (isDirectory) { pruneInfoForDirectory(sourceRoot, sourceRelWithinRoot); removeThumbnailsForDirectory(sourceRoot, sourceRelWithinRoot); } else { pruneInfoEntry(sourceRoot, sourceRelWithinRoot); removeThumbnailsForPath(normalizedSource); } trashStateCache.delete(sourceRoot); } } if (sourceRoot) { broadcastFileUpdate(sourceRoot); } if (destRoot && destRoot !== sourceRoot) { broadcastFileUpdate(destRoot); } res.json({ success: true, newPath: newRelativePath, rootFolder: destRoot || null, isDirectory, movedAcrossRoots: Boolean(movedAcrossRoots) }); } catch (err) { console.error("❌ File move error:", err); res.status(500).json({ error: err.message }); } }); // --- 📁 Dosya gezgini (🆕 type ve url alanları eklendi; resim thumb'ı) --- app.get("/api/files", requireAuth, (req, res) => { // --- 🧩 .ignoreFiles içeriğini oku --- let ignoreList = []; const ignorePath = path.join(__dirname, ".ignoreFiles"); if (fs.existsSync(ignorePath)) { try { const raw = fs.readFileSync(ignorePath, "utf-8"); ignoreList = raw .split("\n") .map((l) => l.trim().toLowerCase()) .filter((l) => l && !l.startsWith("#")); } catch (err) { console.warn("⚠️ .ignoreFiles okunamadı:", err.message); } } // --- 🔍 Yardımcı fonksiyon: dosya ignoreList'te mi? --- const isIgnored = (name) => { const lower = name.toLowerCase(); const ext = path.extname(lower).replace(".", ""); return ignoreList.some( (ignored) => lower === ignored || lower.endsWith(ignored) || lower.endsWith(`.${ignored}`) || ext === ignored.replace(/^\./, "") ); }; const infoCache = new Map(); const getInfo = (relPath) => { const root = rootFromRelPath(relPath); if (!root) return null; if (!infoCache.has(root)) { infoCache.set(root, readInfoForRoot(root)); } return infoCache.get(root); }; // --- 📁 Klasörleri dolaş --- const walk = (dir) => { let result = []; const list = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of list) { const full = path.join(dir, entry.name); const rel = path.relative(DOWNLOAD_DIR, full); // 🔥 Ignore kontrolü (hem dosya hem klasör için) if (isIgnored(entry.name) || isIgnored(rel)) continue; if (entry.isDirectory()) { const safeRel = sanitizeRelative(rel); if (!safeRel) continue; const rootFolder = rootFromRelPath(safeRel); const relWithinRoot = safeRel.split(/[\\/]/).slice(1).join("/"); // 🗑️ Çöpte işaretli klasörleri atla if (isPathTrashed(rootFolder, relWithinRoot, true)) continue; const dirInfo = getInfo(safeRel) || {}; const added = dirInfo.added ?? dirInfo.createdAt ?? null; const completedAt = dirInfo.completedAt ?? null; const tracker = dirInfo.tracker ?? null; const torrentName = dirInfo.name ?? null; const infoHash = dirInfo.infoHash ?? null; const baseName = safeRel.split(/[\\/]/).pop(); const isRoot = !relWithinRoot; const displayName = isRoot && tracker === "youtube" && torrentName ? torrentName : baseName; result.push({ name: safeRel, displayName, size: 0, type: "inode/directory", isDirectory: true, rootFolder, added, completedAt, tracker, torrentName, infoHash, mediaCategory: dirInfo.type || null, extension: null, mediaInfo: null, primaryVideoPath: null, primaryMediaInfo: null, movieMatch: null, seriesEpisode: null, thumbnail: null, }); result = result.concat(walk(full)); } else { if (entry.name.toLowerCase() === INFO_FILENAME) continue; const safeRel = sanitizeRelative(rel); const rootFolder = rootFromRelPath(safeRel); const relWithinRoot = safeRel.split(/[\\/]/).slice(1).join("/"); // 🗑️ Çöpte işaretli dosyaları atla if (isPathTrashed(rootFolder, relWithinRoot, false)) continue; const size = fs.statSync(full).size; const type = mime.lookup(full) || "application/octet-stream"; const urlPath = safeRel .split(/[\\/]/) .map(encodeURIComponent) .join("/"); const url = `/media/${urlPath}`; const isImage = String(type).startsWith("image/"); const isVideo = String(type).startsWith("video/"); let thumb = null; if (isVideo) { const { relThumb, absThumb } = getVideoThumbnailPaths(safeRel); if (fs.existsSync(absThumb)) thumb = thumbnailUrl(relThumb); else queueVideoThumbnail(full, safeRel); } if (isImage) { const { relThumb, absThumb } = getImageThumbnailPaths(safeRel); if (fs.existsSync(absThumb)) thumb = thumbnailUrl(relThumb); else queueImageThumbnail(full, safeRel); } const info = getInfo(safeRel) || {}; const added = info.added ?? info.createdAt ?? null; const completedAt = info.completedAt ?? null; const tracker = info.tracker ?? null; const torrentName = info.name ?? null; const infoHash = info.infoHash ?? null; const fileMeta = relWithinRoot ? info.files?.[relWithinRoot] || null : null; const extensionForFile = fileMeta?.extension || path.extname(entry.name).replace(/^\./, "").toLowerCase() || null; const mediaInfoForFile = fileMeta?.mediaInfo || null; const seriesEpisodeInfo = relWithinRoot ? info.seriesEpisodes?.[relWithinRoot] || null : null; let mediaCategory = fileMeta?.type || null; if (!mediaCategory) { const canInheritFromInfo = !relWithinRoot || isVideo; if (canInheritFromInfo && info.type) { mediaCategory = info.type; } } const isPrimaryVideo = !!info.primaryVideoPath && info.primaryVideoPath === relWithinRoot; const displayName = entry.name; result.push({ name: safeRel, displayName, size, type, url, thumbnail: thumb, rootFolder, added, completedAt, tracker, torrentName, infoHash, mediaCategory, extension: extensionForFile, mediaInfo: mediaInfoForFile, primaryVideoPath: info.primaryVideoPath || null, primaryMediaInfo: info.primaryMediaInfo || null, movieMatch: isPrimaryVideo ? info.movieMatch || null : null, seriesEpisode: seriesEpisodeInfo }); } } return result; }; try { const files = walk(DOWNLOAD_DIR); res.json(files); } catch (err) { console.error("📁 Files API error:", err); res.status(500).json({ error: err.message }); } }); // --- 🗑️ Çöp listesi API (.trash flag sistemi) --- app.get("/api/trash", requireAuth, (req, res) => { try { const result = []; const roots = fs .readdirSync(DOWNLOAD_DIR, { withFileTypes: true }) .filter((dirent) => dirent.isDirectory()); for (const dirent of roots) { const rootFolder = sanitizeRelative(dirent.name); if (!rootFolder) continue; const state = getTrashStateForRoot(rootFolder); if (!state || !Array.isArray(state.registry?.items)) continue; const info = readInfoForRoot(rootFolder) || {}; for (const item of state.registry.items) { const relWithinRoot = normalizeTrashPath(item.path); const displayPath = relWithinRoot ? `${rootFolder}/${relWithinRoot}` : rootFolder; const fullPath = path.join(DOWNLOAD_DIR, displayPath); if (!fs.existsSync(fullPath)) { removeTrashEntry(rootFolder, relWithinRoot); continue; } let stat = null; try { stat = fs.statSync(fullPath); } catch (err) { console.warn( `⚠️ Çöp öğesi stat okunamadı (${fullPath}): ${err.message}` ); } const isDirectory = item.isDirectory || stat?.isDirectory() || false; const type = isDirectory ? "inode/directory" : mime.lookup(fullPath) || item.type || "application/octet-stream"; const size = stat?.size ?? 0; let thumbnail = null; let mediaInfo = null; if (!isDirectory) { const isVideo = String(type).startsWith("video/"); const isImage = String(type).startsWith("image/"); if (isVideo) { const { relThumb, absThumb } = getVideoThumbnailPaths(displayPath); if (fs.existsSync(absThumb)) { thumbnail = thumbnailUrl(relThumb); } } else if (isImage) { const { relThumb, absThumb } = getImageThumbnailPaths(displayPath); if (fs.existsSync(absThumb)) { thumbnail = thumbnailUrl(relThumb); } } const metaKey = relWithinRoot || null; if (metaKey && info.files && info.files[metaKey]) { mediaInfo = info.files[metaKey].mediaInfo || null; } } result.push({ name: displayPath, trashName: displayPath, size, type, isDirectory, thumbnail, mediaInfo, movedAt: Number(item.deletedAt) || Date.now(), originalPath: displayPath, folderId: rootFolder }); } } result.sort((a, b) => (b.movedAt || 0) - (a.movedAt || 0)); res.json(result); } catch (err) { console.error("🗑️ Trash API error:", err); res.status(500).json({ error: err.message }); } }); // --- 🗑️ Çöpten geri yükleme API (.trash flag sistemi) --- app.post("/api/trash/restore", requireAuth, (req, res) => { try { const { trashName } = req.body; if (!trashName) { return res.status(400).json({ error: "trashName gerekli" }); } const safeName = sanitizeRelative(trashName); const segments = safeName.split(/[\\/]/).filter(Boolean); if (!segments.length) { return res.status(400).json({ error: "Geçersiz trashName" }); } const rootFolder = segments[0]; const relWithinRoot = segments.slice(1).join("/"); const removed = removeTrashEntry(rootFolder, relWithinRoot); if (!removed) { return res.status(404).json({ error: "Çöp öğesi bulunamadı" }); } const mediaFlags = inferMediaFlagsFromTrashEntry(removed); console.log(`♻️ Öğe geri yüklendi: ${safeName}`); broadcastFileUpdate(rootFolder); if (mediaFlags.movies || mediaFlags.tv) { queueMediaRescan({ movies: mediaFlags.movies, tv: mediaFlags.tv, reason: "trash-restore" }); } res.json({ success: true, message: "Öğe başarıyla geri yüklendi", folderId: rootFolder }); } catch (err) { console.error("❌ Restore error:", err); res.status(500).json({ error: err.message }); } }); // --- 🗑️ Çöpü tamamen silme API (.trash flag sistemi) --- app.delete("/api/trash", requireAuth, (req, res) => { try { const trashName = req.body?.trashName || req.query?.trashName || req.params?.trashName; if (!trashName) { return res.status(400).json({ error: "trashName gerekli" }); } const safeName = sanitizeRelative(trashName); const segments = safeName.split(/[\\/]/).filter(Boolean); if (!segments.length) { return res.status(400).json({ error: "Geçersiz trashName" }); } const rootFolder = segments[0]; const relWithinRoot = segments.slice(1).join("/"); const removed = removeTrashEntry(rootFolder, relWithinRoot); if (!removed) { return res.status(404).json({ error: "Çöp öğesi bulunamadı" }); } const fullPath = path.join(DOWNLOAD_DIR, safeName); if (fs.existsSync(fullPath)) { try { fs.rmSync(fullPath, { recursive: true, force: true }); } catch (err) { console.warn(`⚠️ Çöp öğesi silinemedi (${fullPath}): ${err.message}`); } } if (!relWithinRoot) { purgeRootFolder(rootFolder); } else if (removed.isDirectory) { pruneInfoForDirectory(rootFolder, relWithinRoot); } else { pruneInfoEntry(rootFolder, relWithinRoot); removeThumbnailsForPath(safeName); removeSeriesEpisode(rootFolder, relWithinRoot); } console.log(`🗑️ Öğe kalıcı olarak silindi: ${safeName}`); broadcastFileUpdate(rootFolder); broadcastDiskSpace(); res.json({ success: true, message: "Öğe tamamen silindi" }); } catch (err) { console.error("❌ Delete trash error:", err); res.status(500).json({ error: err.message }); } }); // --- 🎬 Film listesi --- app.get("/api/movies", requireAuth, (req, res) => { try { if (!fs.existsSync(MOVIE_DATA_ROOT)) { return res.json([]); } const entries = fs .readdirSync(MOVIE_DATA_ROOT, { withFileTypes: true }) .filter((d) => d.isDirectory()); const movies = entries .map((dirent) => { const key = dirent.name; const paths = movieDataPathsByKey(key); if (!fs.existsSync(paths.metadata)) return null; try { const metadata = JSON.parse( fs.readFileSync(paths.metadata, "utf-8") ); if (!isTmdbMetadata(metadata)) { try { fs.rmSync(paths.dir, { recursive: true, force: true }); } catch (err) { console.warn( `⚠️ Movie metadata temizlenemedi (${paths.dir}): ${err.message}` ); } return null; } const dupe = metadata._dupe || {}; const rootFolder = dupe.folder || key; const videoPath = dupe.videoPath || metadata.videoPath || null; const encodedKey = key .split(path.sep) .map(encodeURIComponent) .join("/"); const posterExists = fs.existsSync(paths.poster); const backdropExists = fs.existsSync(paths.backdrop); const releaseDate = metadata.release_date || metadata.first_air_date; const year = releaseDate ? Number(releaseDate.slice(0, 4)) : metadata.matched_year || null; const runtimeMinutes = metadata.runtime ?? (Array.isArray(metadata.episode_run_time) && metadata.episode_run_time.length ? metadata.episode_run_time[0] : null); const cacheKey = paths.key; return { folder: rootFolder, cacheKey, id: metadata.id ?? `${rootFolder}:${videoPath || "unknown"}:${cacheKey || "cache"}`, title: metadata.title || metadata.matched_title || rootFolder, originalTitle: metadata.original_title || null, year, runtime: runtimeMinutes || null, overview: metadata.overview || "", voteAverage: metadata.vote_average || null, voteCount: metadata.vote_count || null, genres: Array.isArray(metadata.genres) ? metadata.genres.map((g) => g.name) : [], poster: posterExists ? `/movie-data/${encodedKey}/poster.jpg` : null, backdrop: backdropExists ? `/movie-data/${encodedKey}/backdrop.jpg` : null, videoPath, mediaInfo: dupe.mediaInfo || null, metadata }; } catch (err) { console.warn( `⚠️ metadata.json okunamadı (${paths.metadata}): ${err.message}` ); return null; } }) .filter(Boolean); movies.sort((a, b) => { const yearA = a.year || 0; const yearB = b.year || 0; if (yearA !== yearB) return yearB - yearA; return a.title.localeCompare(b.title); }); res.json(movies); } catch (err) { console.error("🎬 Movies API error:", err); res.status(500).json({ error: err.message }); } }); async function rebuildMovieMetadata({ clearCache = false, resetSeriesData = false } = {}) { if (!TMDB_API_KEY) { throw new Error("TMDB API key tanımlı değil."); } if (clearCache && fs.existsSync(MOVIE_DATA_ROOT)) { try { fs.rmSync(MOVIE_DATA_ROOT, { recursive: true, force: true }); console.log("🧹 Movie cache temizlendi."); } catch (err) { console.warn( `⚠️ Movie cache temizlenemedi (${MOVIE_DATA_ROOT}): ${err.message}` ); } } fs.mkdirSync(MOVIE_DATA_ROOT, { recursive: true }); const dirEntries = fs .readdirSync(DOWNLOAD_DIR, { withFileTypes: true }) .filter((d) => d.isDirectory()); const processed = []; for (const dirent of dirEntries) { const folder = sanitizeRelative(dirent.name); if (!folder) continue; const rootDir = path.join(DOWNLOAD_DIR, folder); if (!fs.existsSync(rootDir)) continue; try { const info = readInfoForRoot(folder) || {}; const infoFiles = info.files || {}; const filesUpdate = { ...infoFiles }; const videoEntries = enumerateVideoFiles(folder); const videoSet = new Set(videoEntries.map((item) => item.relPath)); if (!videoEntries.length) { removeMovieData(folder); if (resetSeriesData) { removeSeriesData(folder); } const update = { primaryVideoPath: null, primaryMediaInfo: null, movieMatch: null, files: { ...filesUpdate } }; for (const value of Object.values(update.files)) { if (value?.seriesMatch) delete value.seriesMatch; } // Clear movieMatch for legacy entries for (const key of Object.keys(update.files)) { if (update.files[key]?.movieMatch) { delete update.files[key].movieMatch; } } upsertInfoFile(rootDir, update); console.log( `ℹ️ Movie taraması atlandı (video bulunamadı): ${folder}` ); processed.push(folder); continue; } removeMovieData(folder); if (resetSeriesData) { removeSeriesData(folder); } const matches = []; for (const { relPath, size } of videoEntries) { const absVideo = path.join(rootDir, relPath); let mediaInfo = filesUpdate[relPath]?.mediaInfo || null; if (!mediaInfo && fs.existsSync(absVideo)) { try { mediaInfo = await extractMediaInfo(absVideo); } catch (err) { console.warn( `⚠️ Media info alınamadı (${absVideo}): ${err?.message || err}` ); } } const ensured = await ensureMovieData( folder, path.basename(relPath), relPath, mediaInfo ); if (ensured?.mediaInfo) { mediaInfo = ensured.mediaInfo; } const fileEntry = { ...(filesUpdate[relPath] || {}), size: size ?? filesUpdate[relPath]?.size ?? null, mediaInfo: mediaInfo || null }; if (ensured?.metadata) { const meta = ensured.metadata; const releaseYear = meta.release_date ? Number(meta.release_date.slice(0, 4)) : meta.matched_year || null; fileEntry.movieMatch = { id: meta.id ?? null, title: meta.title || meta.matched_title || path.basename(relPath), year: releaseYear, poster: meta.poster_path || null, backdrop: meta.backdrop_path || null, cacheKey: ensured.cacheKey, matchedAt: Date.now() }; matches.push({ relPath, match: fileEntry.movieMatch }); } else if (fileEntry.movieMatch) { delete fileEntry.movieMatch; } if (ensured?.show && ensured?.episode) { const episode = ensured.episode; const show = ensured.show; const season = ensured.season; fileEntry.seriesMatch = { id: show?.id ?? null, title: show?.title || seriesInfo.title, season: season?.seasonNumber ?? seriesInfo.season, episode: episode?.episodeNumber ?? seriesInfo.episode, code: episode?.code || seriesInfo.key, poster: show?.poster || null, backdrop: show?.backdrop || null, seasonPoster: season?.poster || null, aired: episode?.aired || null, runtime: episode?.runtime || null, tvdbEpisodeId: episode?.tvdbEpisodeId || null, cacheKey: ensured.cacheKey || null, matchedAt: Date.now() }; } else if (fileEntry.seriesMatch) { delete fileEntry.seriesMatch; } filesUpdate[relPath] = fileEntry; } // Temizlenmiş video listesinde yer almayanların film eşleşmesini temizle for (const key of Object.keys(filesUpdate)) { if (videoSet.has(key)) continue; if (filesUpdate[key]?.movieMatch) { delete filesUpdate[key].movieMatch; } if (filesUpdate[key]?.seriesMatch) { delete filesUpdate[key].seriesMatch; } } const update = { files: filesUpdate }; if (videoEntries.length) { const primaryRel = videoEntries[0].relPath; update.primaryVideoPath = primaryRel; update.primaryMediaInfo = filesUpdate[primaryRel]?.mediaInfo || null; const primaryMatch = matches.find((item) => item.relPath === primaryRel)?.match || matches[0]?.match || null; update.movieMatch = primaryMatch || null; } else { update.primaryVideoPath = null; update.primaryMediaInfo = null; update.movieMatch = null; } upsertInfoFile(rootDir, update); processed.push(folder); } catch (err) { console.error( `❌ Movie metadata yeniden oluşturulamadı (${folder}):`, err?.message || err ); } } return processed; } app.post("/api/movies/refresh", requireAuth, async (req, res) => { if (!TMDB_API_KEY) { return res.status(400).json({ error: "TMDB API key tanımlı değil." }); } try { const processed = await rebuildMovieMetadata(); res.json({ ok: true, processed }); } catch (err) { console.error("🎬 Movies refresh error:", err); res.status(500).json({ error: err.message }); } }); app.post("/api/movies/rescan", requireAuth, async (req, res) => { if (!TMDB_API_KEY) { return res.status(400).json({ error: "TMDB API key tanımlı değil." }); } try { const processed = await rebuildMovieMetadata({ clearCache: true }); res.json({ ok: true, processed }); } catch (err) { console.error("🎬 Movies rescan error:", err); res.status(500).json({ error: err.message }); } }); app.post("/api/youtube/download", requireAuth, async (req, res) => { try { const rawUrl = req.body?.url; const job = startYoutubeDownload(rawUrl); if (!job) { return res .status(400) .json({ ok: false, error: "Geçerli bir YouTube URL'si gerekli." }); } res.json({ ok: true, jobId: job.id }); } catch (err) { res.status(500).json({ ok: false, error: err?.message || "YouTube indirimi başarısız oldu." }); } }); // --- 📺 TV dizileri listesi --- app.get("/api/tvshows", requireAuth, (req, res) => { try { if (!fs.existsSync(TV_DATA_ROOT)) { return res.json([]); } const dirEntries = fs .readdirSync(TV_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) continue; const infoForFolder = readInfoForRoot(rootFolder) || {}; const infoFiles = infoForFolder.files || {}; const infoEpisodes = infoForFolder.seriesEpisodes || {}; const infoEpisodeIndex = new Map(); for (const [relPath, meta] of Object.entries(infoEpisodes)) { if (!meta) continue; const seasonNumber = toFiniteNumber( meta.season ?? meta.seasonNumber ?? meta.seasonNum ); const episodeNumber = toFiniteNumber( meta.episode ?? meta.episodeNumber ?? meta.episodeNum ); if (!Number.isFinite(seasonNumber) || !Number.isFinite(episodeNumber)) continue; const normalizedRel = normalizeTrashPath(relPath); const ext = path.extname(normalizedRel).toLowerCase(); if (!VIDEO_EXTS.includes(ext)) continue; const absVideo = normalizedRel ? path.join(DOWNLOAD_DIR, rootFolder, normalizedRel) : null; if (!absVideo || !fs.existsSync(absVideo)) continue; infoEpisodeIndex.set(`${seasonNumber}-${episodeNumber}`, { relPath: normalizedRel, meta }); } let seriesData; try { seriesData = JSON.parse(fs.readFileSync(paths.metadata, "utf-8")); } catch (err) { console.warn( `⚠️ series.json okunamadı (${paths.metadata}): ${err.message}` ); continue; } const seasonsObj = seriesData?.seasons || {}; if (!Object.keys(seasonsObj).length) { removeSeriesData(rootFolder, seriesData.id ?? seriesData.tvdbId ?? null); continue; } let dataChanged = false; const showId = seriesData.id ?? seriesData.tvdbId ?? seriesData.slug ?? seriesData.name ?? rootFolder; const showKey = String(showId).toLowerCase(); const record = aggregated.get(showKey) || (() => { const base = { id: seriesData.id ?? seriesData.tvdbId ?? rootFolder, title: seriesData.name || rootFolder, 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: rootFolder, folders: new Set([rootFolder]) }; aggregated.set(showKey, base); return base; })(); record.folders.add(rootFolder); 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) { const absEpisodePath = path.join( DOWNLOAD_DIR, rootFolder, relativeFile ); if (!fs.existsSync(absEpisodePath)) { 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); } else { if (!seasonRecord.name && rawSeason.name) seasonRecord.name = rawSeason.name; if (!seasonRecord.overview && rawSeason.overview) seasonRecord.overview = rawSeason.overview; if (!seasonRecord.poster && rawSeason.poster) seasonRecord.poster = rawSeason.poster; if (!seasonRecord.tvdbId && rawSeason.tvdbId) seasonRecord.tvdbId = rawSeason.tvdbId; if (!seasonRecord.slug && rawSeason.slug) seasonRecord.slug = rawSeason.slug; if (!seasonRecord.episodeCount && rawSeason.episodeCount) seasonRecord.episodeCount = rawSeason.episodeCount; } 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 infoEpisode = infoEpisodeIndex.get(`${seasonNumber}-${episodeNumber}`); if (infoEpisode?.relPath) { const normalizedRel = infoEpisode.relPath.replace(/^\/+/, ""); const withRoot = `${rootFolder}/${normalizedRel}`.replace(/^\/+/, ""); normalizedEpisode.file = normalizedRel; normalizedEpisode.videoPath = withRoot; const fileMeta = infoFiles[normalizedRel]; if (fileMeta?.mediaInfo && !normalizedEpisode.mediaInfo) { normalizedEpisode.mediaInfo = fileMeta.mediaInfo; } if (fileMeta?.size) { normalizedEpisode.fileSize = Number(fileMeta.size); } } const relativeFile = normalizedEpisode.file || normalizedEpisode.videoPath || ""; const rawVideoPath = normalizedEpisode.videoPath || relativeFile || ""; let videoPath = rawVideoPath.replace(/\\/g, "/").replace(/^\.\//, ""); if (videoPath) { const isExternal = /^https?:\/\//i.test(videoPath); const needsFolderPrefix = !isExternal && !videoPath.startsWith(`${rootFolder}/`) && !videoPath.startsWith(`/${rootFolder}/`); if (needsFolderPrefix) { videoPath = `${rootFolder}/${videoPath}`.replace(/\\/g, "/"); } const finalPath = videoPath.replace(/^\/+/, ""); if (finalPath !== rawVideoPath) { dataChanged = true; } normalizedEpisode.videoPath = finalPath; } else if (relativeFile) { normalizedEpisode.videoPath = `${rootFolder}/${relativeFile}` .replace(/\\/g, "/") .replace(/^\/+/, ""); if (normalizedEpisode.videoPath !== rawVideoPath) { dataChanged = true; } } if (normalizedEpisode.videoPath && !/^https?:\/\//i.test(normalizedEpisode.videoPath)) { const ext = path.extname(normalizedEpisode.videoPath).toLowerCase(); if (!VIDEO_EXTS.includes(ext)) { const absVideo = path.join(DOWNLOAD_DIR, normalizedEpisode.videoPath); if ( !fs.existsSync(absVideo) || !VIDEO_EXTS.includes(path.extname(absVideo).toLowerCase()) ) { normalizedEpisode.videoPath = null; } } if (normalizedEpisode.videoPath) { const absVideo = path.join(DOWNLOAD_DIR, normalizedEpisode.videoPath); if (!fs.existsSync(absVideo)) { normalizedEpisode.videoPath = null; } if (fs.existsSync(absVideo)) { const stats = fs.statSync(absVideo); normalizedEpisode.fileSize = Number(stats.size); } } } normalizedEpisode.folder = rootFolder; 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( `⚠️ 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; if (/^https?:\/\//i.test(episode.videoPath)) return true; const ext = path.extname(episode.videoPath).toLowerCase(); if (!VIDEO_EXTS.includes(ext)) return false; const absVideo = path.join(DOWNLOAD_DIR, episode.videoPath); return fs.existsSync(absVideo); }) .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")); res.json(shows); } catch (err) { console.error("📺 TvShows API error:", err); res.status(500).json({ error: err.message }); } }); function collectMusicEntries() { const entries = []; const dirEntries = fs .readdirSync(DOWNLOAD_DIR, { withFileTypes: true }) .filter((dirent) => dirent.isDirectory()); for (const dirent of dirEntries) { const folder = sanitizeRelative(dirent.name); if (!folder) continue; // Klasörün tamamı çöpe taşınmışsa atla if (isPathTrashed(folder, "", true)) continue; const info = readInfoForRoot(folder) || {}; const files = info.files || {}; const fileKeys = Object.keys(files); if (!fileKeys.length) continue; let targetPath = info.primaryVideoPath; if (targetPath && files[targetPath]?.type !== "music") { targetPath = null; } if (!targetPath) { targetPath = fileKeys.find((key) => files[key]?.type === "music") || fileKeys[0]; } if (!targetPath) continue; const fileMeta = files[targetPath]; // Hedef dosya çöpteyse atla if (isPathTrashed(folder, targetPath, false)) continue; const mediaType = fileMeta?.type || info.type || null; if (mediaType !== "music") continue; const metadataPath = path.join(YT_DATA_ROOT, folder, "metadata.json"); let metadata = null; if (fs.existsSync(metadataPath)) { try { metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); } catch (err) { console.warn(`⚠️ YT metadata okunamadı (${metadataPath}): ${err.message}`); } } const keysArray = fileKeys; const fileIndex = Math.max(keysArray.indexOf(targetPath), 0); const infoHash = info.infoHash || folder; const title = info.name || metadata?.title || path.basename(targetPath) || folder; const thumbnail = metadata?.thumbnail || (metadata ? `/yt-data/${folder}/thumbnail.jpg` : null); entries.push({ id: `${folder}:${targetPath}`, folder, infoHash, fileIndex, filePath: targetPath, title, added: info.added || info.createdAt || null, size: fileMeta?.size || 0, url: metadata?.url || fileMeta?.youtube?.url || fileMeta?.youtube?.videoId ? `https://www.youtube.com/watch?v=${fileMeta.youtube.videoId}` : null, thumbnail, categories: metadata?.categories || fileMeta?.categories || null }); } entries.sort((a, b) => (b.added || 0) - (a.added || 0)); return entries; } app.get("/api/music", requireAuth, (req, res) => { try { const entries = collectMusicEntries(); res.json(entries); } catch (err) { console.error("🎵 Music API error:", err); res.status(500).json({ error: err.message }); } }); async function rebuildTvMetadata({ clearCache = false } = {}) { if (!TVDB_API_KEY) { throw new Error("TVDB API erişimi için gerekli anahtar tanımlı değil."); } if (clearCache && fs.existsSync(TV_DATA_ROOT)) { try { fs.rmSync(TV_DATA_ROOT, { recursive: true, force: true }); console.log("🧹 TV cache temizlendi."); } catch (err) { console.warn( `⚠️ TV cache temizlenemedi (${TV_DATA_ROOT}): ${err.message}` ); } } fs.mkdirSync(TV_DATA_ROOT, { recursive: true }); if (clearCache) { tvdbSeriesCache.clear(); tvdbEpisodeCache.clear(); tvdbEpisodeDetailCache.clear(); } const dirEntries = fs .readdirSync(DOWNLOAD_DIR, { withFileTypes: true }) .filter((d) => d.isDirectory()); const processed = []; for (const dirent of dirEntries) { const folder = sanitizeRelative(dirent.name); if (!folder) continue; const rootDir = path.join(DOWNLOAD_DIR, folder); if (!fs.existsSync(rootDir)) continue; try { const info = readInfoForRoot(folder) || {}; const infoFiles = info.files || {}; const filesUpdate = { ...infoFiles }; const detected = {}; const walkDir = async (currentDir, relativeBase = "") => { let entries = []; try { entries = fs.readdirSync(currentDir, { withFileTypes: true }); } catch (err) { console.warn( `⚠️ Klasör okunamadı (${currentDir}): ${err.message}` ); return; } for (const entry of entries) { const relPath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name; const absPath = path.join(currentDir, entry.name); if (entry.isDirectory()) { if (isPathTrashed(folder, relPath, true)) continue; await walkDir(absPath, relPath); continue; } if (!entry.isFile()) continue; if (isPathTrashed(folder, relPath, false)) continue; if (entry.name.toLowerCase() === INFO_FILENAME) continue; const ext = path.extname(entry.name).toLowerCase(); if (!VIDEO_EXTS.includes(ext)) continue; const seriesInfo = parseSeriesInfo(entry.name); if (!seriesInfo) continue; const normalizedRel = relPath.replace(/\\/g, "/"); let mediaInfo = filesUpdate?.[normalizedRel]?.mediaInfo || null; if (!mediaInfo) { try { mediaInfo = await extractMediaInfo(absPath); } catch (err) { console.warn( `⚠️ Media info alınamadı (${absPath}): ${err?.message || err}` ); } } try { const ensured = await ensureSeriesData( folder, normalizedRel, seriesInfo, mediaInfo ); if (ensured?.show && ensured?.episode) { detected[normalizedRel] = { 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 statSize = (() => { try { return fs.statSync(absPath).size; } catch (err) { return filesUpdate?.[normalizedRel]?.size ?? null; } })(); const fileEntry = { ...(filesUpdate[normalizedRel] || {}), size: statSize, mediaInfo: mediaInfo || null, 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() } }; filesUpdate[normalizedRel] = fileEntry; } else { const entry = { ...(filesUpdate[normalizedRel] || {}), mediaInfo: mediaInfo || null }; if (entry.seriesMatch) delete entry.seriesMatch; filesUpdate[normalizedRel] = entry; } } catch (err) { console.warn( `⚠️ TV metadata yenilenemedi (${folder} - ${entry.name}): ${ err?.message || err }` ); } } }; await walkDir(rootDir); for (const key of Object.keys(filesUpdate)) { if (detected[key]) continue; if (filesUpdate[key]?.seriesMatch) { delete filesUpdate[key].seriesMatch; } } const episodeCount = Object.keys(detected).length; if (episodeCount > 0) { const update = { seriesEpisodes: detected, files: filesUpdate }; upsertInfoFile(rootDir, update); } else if (clearCache) { for (const key of Object.keys(filesUpdate)) { if (filesUpdate[key]?.seriesMatch) { delete filesUpdate[key].seriesMatch; } } upsertInfoFile(rootDir, { seriesEpisodes: {}, files: filesUpdate }); } processed.push({ folder, episodes: episodeCount }); } catch (err) { console.error( `❌ TV metadata yeniden oluşturulamadı (${folder}):`, err?.message || err ); } } return processed; } app.post("/api/tvshows/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 rebuildTvMetadata(); res.json({ ok: true, processed }); } catch (err) { console.error("📺 TvShows refresh error:", err); res.status(500).json({ error: err.message }); } }); app.post("/api/tvshows/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 rebuildTvMetadata({ clearCache: true }); res.json({ ok: true, processed }); } catch (err) { console.error("📺 TvShows rescan error:", err); res.status(500).json({ error: err.message }); } }); // --- Stream endpoint (torrent içinden) --- app.get("/stream/:hash", requireAuth, (req, res) => { const range = req.headers.range; const entry = torrents.get(req.params.hash); if (entry) { const selected = entry.torrent.files[entry.selectedIndex] || entry.torrent.files[0]; return streamTorrentFile(selected, range, res); } const job = youtubeJobs.get(req.params.hash); if (job && job.files?.length) { const index = job.selectedIndex || 0; const fileEntry = job.files[index] || job.files[0]; if (!fileEntry) return res.status(404).end(); const absPath = path.join(job.savePath, fileEntry.name); return streamLocalFile(absPath, range, res); } const info = readInfoForRoot(req.params.hash); if (info && info.files) { const fileKeys = Object.keys(info.files); if (fileKeys.length) { const idx = Number(req.query.index) || 0; const targetKey = fileKeys[idx] || fileKeys[0]; const absPath = path.join( DOWNLOAD_DIR, req.params.hash, targetKey.replace(/\\/g, "/") ); if (fs.existsSync(absPath)) { return streamLocalFile(absPath, range, res); } } } return res.status(404).end(); }); function streamTorrentFile(file, range, res) { const total = file.length; const type = mime.lookup(file.name) || "video/mp4"; if (!range) { res.writeHead(200, { "Content-Length": total, "Content-Type": type, "Accept-Ranges": "bytes" }); return file.createReadStream().pipe(res); } const [s, e] = range.replace(/bytes=/, "").split("-"); const start = parseInt(s, 10); const end = e ? parseInt(e, 10) : total - 1; res.writeHead(206, { "Content-Range": `bytes ${start}-${end}/${total}`, "Accept-Ranges": "bytes", "Content-Length": end - start + 1, "Content-Type": type }); const stream = file.createReadStream({ start, end }); stream.on("error", (err) => console.warn("Stream error:", err.message)); res.on("close", () => stream.destroy()); stream.pipe(res); } function streamLocalFile(filePath, range, res) { if (!fs.existsSync(filePath)) return res.status(404).end(); const total = fs.statSync(filePath).size; const type = mime.lookup(filePath) || "video/mp4"; if (!range) { res.writeHead(200, { "Content-Length": total, "Content-Type": type, "Accept-Ranges": "bytes" }); return fs.createReadStream(filePath).pipe(res); } const [s, e] = range.replace(/bytes=/, "").split("-"); const start = parseInt(s, 10); const end = e ? parseInt(e, 10) : total - 1; res.writeHead(206, { "Content-Range": `bytes ${start}-${end}/${total}`, "Accept-Ranges": "bytes", "Content-Length": end - start + 1, "Content-Type": type }); const stream = fs.createReadStream(filePath, { start, end }); stream.on("error", (err) => console.warn("Stream error:", err.message)); res.on("close", () => stream.destroy()); stream.pipe(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"); if (fs.existsSync(publicDir)) { app.use(express.static(publicDir)); app.get("*", (req, res, next) => { if (req.path.startsWith("/api")) return next(); res.sendFile(path.join(publicDir, "index.html")); }); } const server = app.listen(PORT, () => console.log(`🐔 du.pe server ${PORT} portunda çalışıyor`) ); 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(); }); // --- ⏱️ Her 2 saniyede bir aktif torrent durumu yayınla --- setInterval(() => { if (torrents.size > 0) { broadcastSnapshot(); } }, 2000); // --- ⏱️ Her 30 saniyede bir disk space bilgisi yayınla --- setInterval(() => { broadcastDiskSpace(); }, 30000); // --- Disk space bilgisi --- app.get("/api/disk-space", requireAuth, async (req, res) => { try { // Downloads klasörü yoksa oluştur if (!fs.existsSync(DOWNLOAD_DIR)) { fs.mkdirSync(DOWNLOAD_DIR, { recursive: true }); } const diskInfo = await getSystemDiskInfo(DOWNLOAD_DIR); res.json(diskInfo); } catch (err) { console.error("❌ Disk space error:", err.message); res.status(500).json({ error: err.message }); } }); // --- 🔍 TMDB/TVDB Arama Endpoint'i --- app.get("/api/search/metadata", requireAuth, async (req, res) => { try { const { query, year, type } = req.query; if (!query) { return res.status(400).json({ error: "query parametresi gerekli" }); } if (type === "movie") { // TMDB Film Araması if (!TMDB_API_KEY) { return res.status(400).json({ error: "TMDB API key tanımlı değil" }); } const params = new URLSearchParams({ api_key: TMDB_API_KEY, query: query, language: "en-US", include_adult: false }); if (year) { params.set("year", year); } const response = await fetch(`${TMDB_BASE_URL}/search/movie?${params}`); if (!response.ok) { throw new Error(`TMDB API error: ${response.status}`); } const data = await response.json(); // Her film için detaylı bilgi çek const resultsWithDetails = await Promise.all( (data.results || []).slice(0, 10).map(async (item) => { try { const detailResponse = await fetch( `${TMDB_BASE_URL}/movie/${item.id}?api_key=${TMDB_API_KEY}&append_to_response=credits&language=en-US` ); if (detailResponse.ok) { const details = await detailResponse.json(); const cast = (details.credits?.cast || []).slice(0, 3).map(c => c.name); const genres = (details.genres || []).map(g => g.name); return { id: item.id, title: item.title, year: item.release_date ? item.release_date.slice(0, 4) : null, overview: item.overview || "", poster: item.poster_path ? `${TMDB_IMG_BASE}${item.poster_path}` : null, runtime: details.runtime || null, genres: genres, cast: cast, type: "movie" }; } } catch (err) { console.warn(`⚠️ Film detayı alınamadı (${item.id}):`, err.message); } return { id: item.id, title: item.title, year: item.release_date ? item.release_date.slice(0, 4) : null, overview: item.overview || "", poster: item.poster_path ? `${TMDB_IMG_BASE}${item.poster_path}` : null, type: "movie" }; }) ); res.json({ results: resultsWithDetails }); } else if (type === "series") { // TVDB Dizi Araması 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 : []; const posterArtwork = artworks.find(a => { const type = String(a?.type || a?.artworkType || "").toLowerCase(); return type.includes("poster") || type === "series" || type === "2"; }); 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) { seriesYear = Number(info.year); } else if (item.year) { seriesYear = Number(item.year); } else if (info.first_air_date || info.firstAired) { const dateStr = String(info.first_air_date || info.firstAired); const yearMatch = dateStr.match(/(\d{4})/); if (yearMatch) seriesYear = Number(yearMatch[1]); } return { id: seriesId, title: info.name || item.name, year: seriesYear, overview: 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, type: "series" }; } } catch (err) { console.warn(`⚠️ Dizi detayı alınamadı:`, err.message); } // Fallback için yıl bilgisini al let itemYear = null; if (item.year) { itemYear = Number(item.year); } else if (item.first_air_date || item.firstAired) { const dateStr = String(item.first_air_date || item.firstAired); const yearMatch = dateStr.match(/(\d{4})/); if (yearMatch) itemYear = Number(yearMatch[1]); } return { id: item.tvdb_id || item.id, title: item.name || item.seriesName, year: itemYear, overview: item.overview || "", poster: item.image ? tvdbImageUrl(item.image) : null, type: "series" }; }) ); // 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ı" }); } } catch (err) { console.error("❌ Metadata search error:", err); res.status(500).json({ error: err.message }); } }); // --- 🔗 Manuel Eşleştirme Endpoint'i --- app.post("/api/match/manual", requireAuth, async (req, res) => { try { const { filePath, metadata, type, season, episode } = req.body; if (!filePath || !metadata || !type) { return res.status(400).json({ error: "filePath, metadata ve type gerekli" }); } const safePath = sanitizeRelative(filePath); if (!safePath) { return res.status(400).json({ error: "Geçersiz dosya yolu" }); } const fullPath = path.join(DOWNLOAD_DIR, safePath); if (!fs.existsSync(fullPath)) { return res.status(404).json({ error: "Dosya bulunamadı" }); } const rootFolder = rootFromRelPath(safePath); if (!rootFolder) { return res.status(400).json({ error: "Kök klasör belirlenemedi" }); } const rootDir = path.join(DOWNLOAD_DIR, rootFolder); const relativeVideoPath = safePath .split(/[\\/]/) .slice(1) .join("/"); const infoPath = infoFilePath(rootDir); // Mevcut info.json dosyasını oku let infoData = {}; if (fs.existsSync(infoPath)) { try { infoData = JSON.parse(fs.readFileSync(infoPath, "utf-8")); } catch (err) { console.warn(`⚠️ info.json okunamadı (${infoPath}): ${err.message}`); } } // Media info'yu çıkar let mediaInfo = null; try { mediaInfo = await extractMediaInfo(fullPath); } catch (err) { console.warn(`⚠️ Media info alınamadı (${fullPath}): ${err.message}`); } // Önce mevcut verileri temizle if (type === "movie") { // Film işlemleri const movieId = metadata.id; if (!movieId) { return res.status(400).json({ error: "Film ID bulunamadı" }); } // Mevcut movie_data ve TV verilerini temizle removeMovieData(rootFolder, relativeVideoPath); removeSeriesData(rootFolder); // TMDB'den detaylı bilgi al const movieDetails = await tmdbFetch(`/movie/${movieId}`, { language: "en-US", append_to_response: "release_dates,credits,translations" }); if (!movieDetails) { return res.status(400).json({ error: "Film detayları alınamadı" }); } // Türkçe çevirileri ekle if (movieDetails.translations?.translations?.length) { const translations = movieDetails.translations.translations; const turkish = translations.find( (t) => t.iso_639_1 === "tr" && t.data ); if (turkish?.data) { const data = turkish.data; if (data.overview) movieDetails.overview = data.overview; if (data.title) movieDetails.title = data.title; if (data.tagline) movieDetails.tagline = data.tagline; } } // Movie data'yı kaydet const movieDataResult = await ensureMovieData( rootFolder, metadata.title, relativeVideoPath, mediaInfo ); if (movieDataResult?.mediaInfo) { // info.json'u güncelle - eski verileri temizle infoData.primaryVideoPath = relativeVideoPath; infoData.primaryMediaInfo = movieDataResult.mediaInfo; infoData.movieMatch = { id: movieDetails.id, title: movieDetails.title, year: movieDetails.release_date ? movieDetails.release_date.slice(0, 4) : null, poster: movieDetails.poster_path, backdrop: movieDetails.backdrop_path, matchedAt: Date.now() }; if (!infoData.files || typeof infoData.files !== "object") { infoData.files = {}; } infoData.files[relativeVideoPath] = { ...(infoData.files[relativeVideoPath] || {}), mediaInfo: movieDataResult.mediaInfo, movieMatch: { id: movieDetails.id, title: movieDetails.title, year: movieDetails.release_date ? movieDetails.release_date.slice(0, 4) : null, poster: movieDetails.poster_path, backdrop: movieDetails.backdrop_path, cacheKey: movieDataResult.cacheKey, matchedAt: Date.now() } }; // Eski dizi verilerini temizle delete infoData.seriesEpisodes; upsertInfoFile(rootDir, infoData); } } else if (type === "series") { // Dizi işlemleri if (season === null || episode === null) { return res.status(400).json({ error: "Dizi için sezon ve bölüm bilgileri gerekli" }); } const seriesId = metadata.id; if (!seriesId) { return res.status(400).json({ error: "Dizi ID bulunamadı" }); } // Mevcut movie_data ve TV verilerini temizle removeMovieData(rootFolder); removeSeriesData(rootFolder); // TVDB'den dizi bilgilerini al const extended = await fetchTvdbSeriesExtended(seriesId); if (!extended) { return res.status(400).json({ error: "Dizi detayları alınamadı" }); } // Dizi bilgilerini oluştur const seriesInfo = { title: metadata.title, searchTitle: metadata.title, season, episode, key: `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}` }; // TV data'yı kaydet const tvDataResult = await ensureSeriesData( rootFolder, safePath.split('/').slice(1).join('/'), seriesInfo, mediaInfo ); if (tvDataResult) { // info.json'u güncelle - eski verileri temizle if (!infoData.seriesEpisodes) infoData.seriesEpisodes = {}; const relPath = safePath.split('/').slice(1).join('/'); infoData.seriesEpisodes[relPath] = { season, episode, key: seriesInfo.key, title: tvDataResult.episode.title || seriesInfo.title, showId: tvDataResult.show.id || null, showTitle: tvDataResult.show.title || seriesInfo.title, seasonName: tvDataResult.season?.name || `Season ${season}`, seasonId: tvDataResult.season?.tvdbSeasonId || null, seasonPoster: tvDataResult.season?.poster || null, overview: tvDataResult.episode.overview || "", aired: tvDataResult.episode.aired || null, runtime: tvDataResult.episode.runtime || null, still: tvDataResult.episode.still || null, episodeId: tvDataResult.episode.tvdbEpisodeId || null, slug: tvDataResult.episode.slug || null, matchedAt: Date.now() }; if (!infoData.files || typeof infoData.files !== "object") { infoData.files = {}; } const currentFileEntry = infoData.files[relPath] || {}; infoData.files[relPath] = { ...currentFileEntry, mediaInfo: mediaInfo || currentFileEntry.mediaInfo || null, seriesMatch: { id: tvDataResult.show.id || null, title: tvDataResult.show.title || seriesInfo.title, season, episode, code: tvDataResult.episode.code || seriesInfo.key, poster: tvDataResult.show.poster || null, backdrop: tvDataResult.show.backdrop || null, seasonPoster: tvDataResult.season?.poster || null, aired: tvDataResult.episode.aired || null, runtime: tvDataResult.episode.runtime || null, tvdbEpisodeId: tvDataResult.episode.tvdbEpisodeId || null, matchedAt: Date.now() } }; // Eski film verilerini temizle delete infoData.movieMatch; delete infoData.primaryMediaInfo; upsertInfoFile(rootDir, infoData); } } // Thumbnail'ı yeniden oluştur if (mediaInfo?.format?.mimeType?.startsWith("video/")) { queueVideoThumbnail(fullPath, safePath); } // Değişiklikleri bildir broadcastFileUpdate(rootFolder); // Elle eşleştirme için özel bildirim gönder if (wss) { const data = JSON.stringify({ type: "manualMatch", filePath: safePath, rootFolder, matchType: type }); wss.clients.forEach((c) => c.readyState === 1 && c.send(data)); } res.json({ success: true, message: "Eşleştirme başarıyla tamamlandı", type, rootFolder }); } catch (err) { console.error("❌ Manual match error:", err); res.status(500).json({ error: err.message }); } }); // --- Klasör oluşturma endpoint'i --- app.post("/api/folder", requireAuth, async (req, res) => { try { const { name, path: targetPath } = req.body; if (!name || !targetPath) { return res.status(400).json({ error: "Klasör adı ve yol gerekli" }); } // Güvenli yol kontrolü const safePath = sanitizeRelative(targetPath); if (!safePath) { return res.status(400).json({ error: "Geçersiz klasör yolu" }); } const fullPath = path.join(DOWNLOAD_DIR, safePath); // Klasörü oluştur try { fs.mkdirSync(fullPath, { recursive: true }); console.log(`📁 Klasör oluşturuldu: ${fullPath}`); // İlişkili info.json dosyasını güncelle const rootFolder = rootFromRelPath(safePath); if (rootFolder) { const rootDir = path.join(DOWNLOAD_DIR, rootFolder); upsertInfoFile(rootDir, { folder: rootFolder, updatedAt: Date.now() }); broadcastFileUpdate(rootFolder); } // /downloads klasörüne de aynı yapıda oluştur try { // Mevcut yolun yapısını analiz et const pathSegments = safePath.split('/').filter(Boolean); // Eğer mevcut dizin Home ise doğrudan downloads içine oluştur if (pathSegments.length === 1) { const downloadsPath = path.join(DOWNLOAD_DIR, name); fs.mkdirSync(downloadsPath, { recursive: true }); console.log(`📁 Downloads klasörü oluşturuldu: ${downloadsPath}`); } else { // İç içe klasör yapısını koru // Örn: Home/IT.Welcome.to.Derry.S01E01.1080p.x265-ELiTE ise // downloads/1761836594224/IT.Welcome.to.Derry.S01E01.1080p.x265-ELiTE oluştur const rootSegment = pathSegments[0]; // İlk segment (örn: 1761836594224) const remainingPath = pathSegments.slice(1).join('/'); // Kalan path const downloadsRootPath = path.join(DOWNLOAD_DIR, rootSegment); const downloadsFullPath = path.join(downloadsRootPath, remainingPath); fs.mkdirSync(downloadsFullPath, { recursive: true }); console.log(`📁 Downloads iç klasör oluşturuldu: ${downloadsFullPath}`); } } catch (downloadsErr) { console.warn("⚠️ Downloads klasörü oluşturulamadı:", downloadsErr.message); // Ana klasör oluşturulduysa hata döndürme } res.json({ success: true, message: "Klasör başarıyla oluşturuldu", path: safePath }); } catch (mkdirErr) { console.error("❌ Klasör oluşturma hatası:", mkdirErr); const friendlyMessage = mkdirErr?.code === "EACCES" ? "Sunucu bu dizine yazma iznine sahip değil. Lütfen downloads klasörünün izinlerini güncelle." : "Klasör oluşturulamadı: " + (mkdirErr?.message || "Bilinmeyen hata"); res.status(500).json({ error: friendlyMessage }); } } catch (err) { console.error("❌ Folder API error:", err); res.status(500).json({ error: err.message }); } }); app.patch("/api/folder", requireAuth, (req, res) => { try { const { path: targetPath, newName } = req.body || {}; if (!targetPath || !newName) { return res.status(400).json({ error: "path ve newName gerekli" }); } const safePath = sanitizeRelative(targetPath); if (!safePath) { return res.status(400).json({ error: "Geçersiz hedef yol" }); } const segments = safePath.split(/[\\/]/).filter(Boolean); if (!segments.length) { return res.status(400).json({ error: "Geçersiz hedef yol" }); } const trimmedName = String(newName).trim(); if (!trimmedName || /[\\/]/.test(trimmedName)) { return res.status(400).json({ error: "Geçersiz yeni isim" }); } const parentSegments = segments.slice(0, -1); const newSegments = [...parentSegments, trimmedName]; const newRelativePath = newSegments.join("/"); const safeNewRelativePath = sanitizeRelative(newRelativePath); if (!safeNewRelativePath) { return res.status(400).json({ error: "Geçersiz yeni yol" }); } const oldFullPath = path.join(DOWNLOAD_DIR, safePath); const newFullPath = path.join(DOWNLOAD_DIR, safeNewRelativePath); if (!fs.existsSync(oldFullPath)) { return res.status(404).json({ error: "Yeniden adlandırılacak klasör bulunamadı" }); } if (fs.existsSync(newFullPath)) { return res.status(409).json({ error: "Yeni isimde bir klasör zaten var" }); } // Yeniden adlandırmayı gerçekleştir fs.renameSync(oldFullPath, newFullPath); const rootFolder = segments[0]; const newRootFolder = newSegments[0]; const oldRelWithinRoot = segments.slice(1).join("/"); const newRelWithinRoot = newSegments.slice(1).join("/"); if (!oldRelWithinRoot) { // Kök klasör yeniden adlandırıldı renameRootCaches(rootFolder, newRootFolder); trashStateCache.delete(rootFolder); trashStateCache.delete(newRootFolder); // Torrent kayıtlarını güncelle for (const entry of torrents.values()) { if (!entry?.savePath) continue; const baseName = path.basename(entry.savePath); if (baseName === rootFolder) { entry.savePath = path.join(path.dirname(entry.savePath), newRootFolder); } } console.log(`📁 Kök klasör yeniden adlandırıldı: ${rootFolder} -> ${newRootFolder}`); broadcastFileUpdate(rootFolder); broadcastFileUpdate(newRootFolder); broadcastSnapshot(); return res.json({ success: true, message: "Klasör yeniden adlandırıldı", oldPath: safePath, newPath: safeNewRelativePath }); } // Alt klasör yeniden adlandırıldı renameInfoPaths(rootFolder, oldRelWithinRoot, newRelWithinRoot); renameSeriesDataPaths(rootFolder, oldRelWithinRoot, newRelWithinRoot); renameTrashEntries(rootFolder, oldRelWithinRoot, newRelWithinRoot); removeThumbnailsForDirectory(rootFolder, oldRelWithinRoot); trashStateCache.delete(rootFolder); console.log( `📁 Klasör yeniden adlandırıldı: ${safePath} -> ${safeNewRelativePath}` ); broadcastFileUpdate(rootFolder); res.json({ success: true, message: "Klasör yeniden adlandırıldı", oldPath: safePath, newPath: safeNewRelativePath }); } catch (err) { console.error("❌ Folder rename error:", err); res.status(500).json({ error: err.message }); } }); // --- 📋 Dosya/klasör kopyalama endpoint'i --- app.post("/api/file/copy", requireAuth, async (req, res) => { try { const { sourcePath, targetDirectory } = req.body || {}; if (!sourcePath) { return res.status(400).json({ error: "sourcePath gerekli" }); } const normalizedSource = normalizeTrashPath(sourcePath); if (!normalizedSource) { return res.status(400).json({ error: "Geçersiz sourcePath" }); } const sourceFullPath = path.join(DOWNLOAD_DIR, normalizedSource); if (!fs.existsSync(sourceFullPath)) { return res.status(404).json({ error: "Kaynak öğe bulunamadı" }); } const sourceStats = fs.statSync(sourceFullPath); const isDirectory = sourceStats.isDirectory(); const normalizedTargetDir = targetDirectory ? normalizeTrashPath(targetDirectory) : ""; if (normalizedTargetDir) { const targetDirFullPath = path.join(DOWNLOAD_DIR, normalizedTargetDir); if (!fs.existsSync(targetDirFullPath)) { return res.status(404).json({ error: "Hedef klasör bulunamadı" }); } } const posixPath = path.posix; const sourceName = posixPath.basename(normalizedSource); const newRelativePath = normalizedTargetDir ? posixPath.join(normalizedTargetDir, sourceName) : sourceName; if (newRelativePath === normalizedSource) { return res.json({ success: true, unchanged: true }); } const newFullPath = path.join(DOWNLOAD_DIR, newRelativePath); if (fs.existsSync(newFullPath)) { return res .status(409) .json({ error: "Hedef konumda aynı isimde bir öğe zaten var" }); } const destinationParent = path.dirname(newFullPath); if (!fs.existsSync(destinationParent)) { fs.mkdirSync(destinationParent, { recursive: true }); } // Kopyalama işlemi try { copyFolderRecursiveSync(sourceFullPath, newFullPath); } catch (copyErr) { console.error("❌ Copy error:", copyErr); return res.status(500).json({ error: "Kopyalama işlemi başarısız: " + copyErr.message }); } const sourceRoot = rootFromRelPath(normalizedSource); const destRoot = rootFromRelPath(newRelativePath); // Kopyalanan öğe için yeni thumbnails oluştur if (!isDirectory) { const mimeType = mime.lookup(newFullPath) || ""; if (mimeType.startsWith("video/")) { queueVideoThumbnail(newFullPath, newRelativePath); } else if (mimeType.startsWith("image/")) { queueImageThumbnail(newFullPath, newRelativePath); } } // Hedef root için file update bildirimi gönder if (destRoot) { broadcastFileUpdate(destRoot); } if (sourceRoot && sourceRoot !== destRoot) { broadcastFileUpdate(sourceRoot); } console.log( `📋 Öğe kopyalandı: ${normalizedSource} -> ${newRelativePath}` ); res.json({ success: true, newPath: newRelativePath, rootFolder: destRoot || null, isDirectory, copied: true }); } catch (err) { console.error("❌ File copy error:", err); res.status(500).json({ error: err.message }); } }); // Recursive klasör kopyalama fonksiyonu function copyFolderRecursiveSync(source, target) { // Kaynak öğenin istatistiklerini al const stats = fs.statSync(source); // Eğer kaynak bir dosyaysa, direkt kopyala if (stats.isFile()) { // Hedef dizinin var olduğundan emin ol const targetDir = path.dirname(target); if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, { recursive: true }); } fs.copyFileSync(source, target); return; } // Kaynak bir klasörse if (!stats.isDirectory()) { throw new Error(`Kaynak ne dosya ne de klasör: ${source}`); } // Hedef klasörü oluştur if (!fs.existsSync(target)) { fs.mkdirSync(target, { recursive: true }); } // Kaynak klasördeki tüm öğeleri oku const files = fs.readdirSync(source); // Her öğeyi işle files.forEach(file => { const sourcePath = path.join(source, file); const targetPath = path.join(target, file); // Dosya istatistiklerini al const itemStats = fs.statSync(sourcePath); if (itemStats.isDirectory()) { // Alt klasörse recursive olarak kopyala copyFolderRecursiveSync(sourcePath, targetPath); } else { // Dosyaysa kopyala fs.copyFileSync(sourcePath, targetPath); } }); } client.on("error", (err) => { if (!String(err).includes("uTP")) console.error("WebTorrent error:", err.message); });