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 { v2 as webdav } from "webdav-server"; import { fileURLToPath } from "url"; import { exec, execSync, spawn } from "child_process"; import crypto from "crypto"; // 🔒 basit token üretimi için import { getDiskSpace, getDownloadsSize } 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(); app.set("trust proxy", true); const upload = multer({ dest: path.join(__dirname, "uploads") }); const client = new WebTorrent(); const torrents = new Map(); const youtubeJobs = new Map(); const mailruJobs = new Map(); let wss; const PORT = process.env.PORT || 3001; const DEBUG_CPU = process.env.DEBUG_CPU === "1"; const DISABLE_MEDIA_PROCESSING = process.env.DISABLE_MEDIA_PROCESSING === "1"; const AUTO_PAUSE_ON_COMPLETE = process.env.AUTO_PAUSE_ON_COMPLETE === "1"; const WEBDAV_ENABLED = ["1", "true", "yes", "on"].includes( String(process.env.WEBDAV_ENABLED || "").toLowerCase() ); const WEBDAV_USERNAME = process.env.WEBDAV_USERNAME || ""; const WEBDAV_PASSWORD = process.env.WEBDAV_PASSWORD || ""; const WEBDAV_PATH = process.env.WEBDAV_PATH || "/webdav"; const WEBDAV_READONLY = !["0", "false", "no", "off"].includes( String(process.env.WEBDAV_READONLY || "1").toLowerCase() ); const WEBDAV_INDEX_TTL = Number(process.env.WEBDAV_INDEX_TTL || 60000); const RCLONE_ENABLED = ["1", "true", "yes", "on"].includes( String(process.env.RCLONE_ENABLED || "").toLowerCase() ); const RCLONE_CONFIG_PATH = process.env.RCLONE_CONFIG_PATH || "/config/rclone/rclone.conf"; const RCLONE_MOUNT_DIR = process.env.RCLONE_MOUNT_DIR || path.join(__dirname, "gdrive"); const RCLONE_REMOTE_NAME = process.env.RCLONE_REMOTE_NAME || "dupe"; const RCLONE_REMOTE_PATH = process.env.RCLONE_REMOTE_PATH || "Dupe"; const RCLONE_POLL_INTERVAL = process.env.RCLONE_POLL_INTERVAL || "1m"; const RCLONE_DIR_CACHE_TIME = process.env.RCLONE_DIR_CACHE_TIME || "1m"; const RCLONE_VFS_CACHE_MODE = process.env.RCLONE_VFS_CACHE_MODE || "full"; const RCLONE_DEBUG_MODE_LOG = ["1", "true", "yes", "on"].includes( String(process.env.RCLONE_DEBUG_MODE_LOG || "").toLowerCase() ); const RCLONE_RC_ENABLED = ["1", "true", "yes", "on"].includes( String(process.env.RCLONE_RC_ENABLED || "1").toLowerCase() ); const RCLONE_RC_ADDR = process.env.RCLONE_RC_ADDR || "127.0.0.1:5572"; const RCLONE_VFS_CACHE_MAX_SIZE = process.env.RCLONE_VFS_CACHE_MAX_SIZE || "20G"; const RCLONE_VFS_CACHE_MAX_AGE = process.env.RCLONE_VFS_CACHE_MAX_AGE || "24h"; // --- Streaming performans ayarları --- const RCLONE_BUFFER_SIZE = process.env.RCLONE_BUFFER_SIZE || "8M"; const RCLONE_VFS_READ_AHEAD = process.env.RCLONE_VFS_READ_AHEAD || "128M"; const RCLONE_VFS_READ_CHUNK_SIZE = process.env.RCLONE_VFS_READ_CHUNK_SIZE || "32M"; const RCLONE_VFS_READ_CHUNK_SIZE_LIMIT = process.env.RCLONE_VFS_READ_CHUNK_SIZE_LIMIT || "64M"; // Disk doluluk oranı eşik değeri (百分比) - Bu oran aşıldığında cache temizlenir const RCLONE_CACHE_CLEAN_THRESHOLD = Number(process.env.RCLONE_CACHE_CLEAN_THRESHOLD) || 85; // Cache temizleme sırasında korunacak minimum boş alan (GB) const RCLONE_MIN_FREE_SPACE_GB = Number(process.env.RCLONE_MIN_FREE_SPACE_GB) || 5; // Auto-restart enable/disable const RCLONE_AUTO_RESTART = ["1", "true", "yes", "on"].includes( String(process.env.RCLONE_AUTO_RESTART || "1").toLowerCase() ); // Auto-restart için retry sayısı ve delay const RCLONE_AUTO_RESTART_MAX_RETRIES = Number(process.env.RCLONE_AUTO_RESTART_MAX_RETRIES) || 5; const RCLONE_AUTO_RESTART_DELAY_MS = Number(process.env.RCLONE_AUTO_RESTART_DELAY_MS) || 5000; const MEDIA_DEBUG_LOG = ["1", "true", "yes", "on"].includes( String(process.env.MEDIA_DEBUG_LOG || "").toLowerCase() ); // --- İ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 }); const ROOT_TRASH_PREFIX = "__root__"; const ROOT_TRASH_DIR = path.join(TRASH_DIR, "root"); if (!fs.existsSync(ROOT_TRASH_DIR)) fs.mkdirSync(ROOT_TRASH_DIR, { recursive: true }); // --- Thumbnail cache klasörü --- const CACHE_DIR = path.join(__dirname, "cache"); 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 ANIME_DATA_ROOT = path.join(CACHE_DIR, "anime_data"); const YT_DATA_ROOT = path.join(CACHE_DIR, "yt_data"); const WEBDAV_ROOT = path.join(CACHE_DIR, "webdav"); const RCLONE_VFS_CACHE_DIR = process.env.RCLONE_VFS_CACHE_DIR || path.join(CACHE_DIR, "rclone-vfs"); const GDRIVE_ROOT = RCLONE_MOUNT_DIR; const RCLONE_SETTINGS_PATH = path.join(CACHE_DIR, "rclone.json"); const ANIME_ROOT_FOLDER = "_anime"; const ROOT_TRASH_REGISTRY = path.join(CACHE_DIR, "root-trash.json"); 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, ANIME_DATA_ROOT, YT_DATA_ROOT, WEBDAV_ROOT, RCLONE_VFS_CACHE_DIR ]) { 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; const YT_COOKIES_PATH = process.env.YT_DLP_COOKIES || process.env.YT_DLP_COOKIE_FILE || path.join(CACHE_DIR, "yt_cookies.txt"); const YT_SETTINGS_PATH = path.join(CACHE_DIR, "yt_settings.json"); const YT_DEFAULT_RESOLUTION = "1080p"; const YT_ALLOWED_RESOLUTIONS = new Set([ "1080p", "720p", "480p", "360p", "240p", "144p" ]); const YT_EXTRACTOR_ARGS = process.env.YT_DLP_EXTRACTOR_ARGS || null; let resolvedYtDlpBinary = null; const ARIA2C_BIN = process.env.ARIA2C_BIN || null; let resolvedAria2cBinary = null; const TMDB_API_KEY = process.env.TMDB_API_KEY; const TMDB_BASE_URL = "https://api.themoviedb.org/3"; const TMDB_IMG_BASE = 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"); function getWsClientCount() { if (!wss) return 0; let count = 0; wss.clients.forEach((c) => { if (c.readyState === 1) count += 1; }); return count; } function startCpuProfiler() { if (!DEBUG_CPU) return; const intervalMs = 5000; let lastUsage = process.cpuUsage(); let lastTime = process.hrtime.bigint(); setInterval(() => { const usage = process.cpuUsage(); const now = process.hrtime.bigint(); const deltaUser = usage.user - lastUsage.user; const deltaSystem = usage.system - lastUsage.system; const elapsedUs = Number(now - lastTime) / 1000; const cpuPct = elapsedUs > 0 ? ((deltaUser + deltaSystem) / elapsedUs) * 100 : 0; lastUsage = usage; lastTime = now; console.log( `📈 CPU ${(cpuPct || 0).toFixed(1)}% | torrents:${torrents.size} yt:${youtubeJobs.size} ws:${getWsClientCount()}` ); }, intervalMs); } app.use(cors()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use("/downloads", express.static(DOWNLOAD_DIR)); startCpuProfiler(); // --- 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 = resolveRootDir(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 candidates = [ path.join(DOWNLOAD_DIR, safe, INFO_FILENAME), path.join(GDRIVE_ROOT, safe, INFO_FILENAME) ]; for (const target of candidates) { if (!fs.existsSync(target)) continue; try { return JSON.parse(fs.readFileSync(target, "utf-8")); } catch (err) { console.warn(`⚠️ info.json okunamadı (${target}): ${err.message}`); return null; } } return null; } function sanitizeRelative(relPath) { return relPath.replace(/^[\\/]+/, ""); } function getStorageRoots() { const roots = [DOWNLOAD_DIR]; if (RCLONE_ENABLED && fs.existsSync(GDRIVE_ROOT)) { roots.push(GDRIVE_ROOT); } return roots; } function listStorageRootFolders() { const roots = getStorageRoots(); const seen = new Set(); const entries = []; for (const base of roots) { if (!fs.existsSync(base)) continue; let dirs = []; try { dirs = fs.readdirSync(base, { withFileTypes: true }); } catch (err) { continue; } for (const dirent of dirs) { if (!dirent.isDirectory()) continue; const name = sanitizeRelative(dirent.name); if (!name || seen.has(name)) continue; seen.add(name); entries.push({ rootFolder: name, baseDir: base }); } } return entries; } function resolveStoragePath(relPath) { const safe = sanitizeRelative(relPath); if (!safe) return null; for (const base of getStorageRoots()) { const full = path.join(base, safe); if (fs.existsSync(full)) return full; } return null; } function resolveRootDir(rootFolder) { const safe = sanitizeRelative(rootFolder); if (!safe) return null; const local = path.join(DOWNLOAD_DIR, safe); if (fs.existsSync(local)) return local; const gdrive = path.join(GDRIVE_ROOT, safe); if (fs.existsSync(gdrive)) return gdrive; return null; } let rcloneProcess = null; let rcloneLastError = null; let rcloneLastLogMessage = null; // Tüm log mesajları için (NOTICE dahil) const rcloneAuthSessions = new Map(); let rcloneCacheCleanTimer = null; // Auto-restart sayaçları let rcloneRestartCount = 0; let rcloneRestartInProgress = false; function logRcloneMoveError(context, error) { if (!error) return; console.warn(`☁️ Rclone taşıma hatası (${context}): ${error}`); if (RCLONE_DEBUG_MODE_LOG) { console.warn("☁️ Rclone taşıma hata detayı:", { context, error }); } } const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function waitForFileStable(filePath, attempts = 8, intervalMs = 3000) { if (!filePath || !fs.existsSync(filePath)) return false; let prevSize = null; let prevMtime = null; let stableCount = 0; for (let i = 0; i < attempts; i += 1) { let stat; try { stat = fs.statSync(filePath); } catch { return false; } const size = stat.size; const mtime = stat.mtimeMs; if (prevSize !== null && prevMtime !== null) { if (size === prevSize && mtime === prevMtime) { stableCount += 1; if (stableCount >= 2) return true; } else { stableCount = 0; } } prevSize = size; prevMtime = mtime; await sleep(intervalMs); } return false; } function computeRootVideoBytes(rootFolder) { try { const entries = enumerateVideoFiles(rootFolder) || []; const total = entries.reduce((sum, item) => sum + (Number(item.size) || 0), 0); return total || null; } catch { return null; } } let rcloneStatsTimer = null; async function fetchRcloneStats() { if (!RCLONE_RC_ENABLED) return null; try { let resp = await fetch(`http://${RCLONE_RC_ADDR}/core/stats`, { method: "POST" }); if (resp.status === 404) { resp = await fetch(`http://${RCLONE_RC_ADDR}/rc/core/stats`, { method: "POST" }); } if (!resp.ok) return null; return await resp.json(); } catch { return null; } } function updateMoveProgressFromStats(stats) { if (!stats) return false; const transfers = Array.isArray(stats.transferring) ? stats.transferring : []; let updated = false; const applyProgress = (entry, prefixes, relRoot) => { if (!entry) return; const safePrefixes = Array.isArray(prefixes) ? prefixes.filter(Boolean) : [prefixes].filter(Boolean); const matched = transfers.filter((t) => safePrefixes.some((p) => String(t.name || "").includes(p)) ); if (matched.length) { const bytes = matched.reduce((sum, t) => sum + (Number(t.bytes) || 0), 0); const pct = matched.reduce((sum, t) => sum + (Number(t.percentage) || 0), 0) / matched.length; if (!entry.moveTotalBytes) { const totalFromStats = matched.reduce((sum, t) => sum + (Number(t.size) || 0), 0); entry.moveTotalBytes = totalFromStats || entry.moveTotalBytes || null; } const progress = Number.isFinite(pct) ? Math.min(Math.max(pct / 100, 0), 0.99) : entry.moveTotalBytes ? Math.min(Math.max(bytes / entry.moveTotalBytes, 0), 0.99) : 0; if (Number.isFinite(progress) && progress !== entry.moveProgress) { entry.moveProgress = progress; updated = true; } if (entry.moveStatus !== "uploading") { entry.moveStatus = "uploading"; updated = true; } } else { // Transfer listesinde eşleşme yok // Done kararı için aşağıdaki !hasTransfers kontrolü beklenmeli // Burada sadece "uploading" durumunu "queued"ye düşürüyoruz ki polling devam etsin if (entry.moveStatus === "uploading") { entry.moveStatus = "queued"; updated = true; } } }; for (const entry of torrents.values()) { const relRoot = entry.rootFolder || ""; const prefixes = [ relRoot, RCLONE_REMOTE_PATH ? `${RCLONE_REMOTE_PATH}/${relRoot}` : null ]; applyProgress(entry, prefixes, relRoot); } for (const job of youtubeJobs.values()) { const relRoot = job.folderId || ""; const prefixes = [ relRoot, RCLONE_REMOTE_PATH ? `${RCLONE_REMOTE_PATH}/${relRoot}` : null ]; applyProgress(job, prefixes, relRoot); } for (const job of mailruJobs.values()) { const relRoot = job.folderId || ""; const prefixes = [ relRoot, RCLONE_REMOTE_PATH ? `${RCLONE_REMOTE_PATH}/${relRoot}` : null ]; if (relRoot) { applyProgress(job, prefixes, relRoot); } } const hasTransfers = transfers.length > 0; if (!hasTransfers) { const markDoneIfReady = (entry) => { if (!entry || !entry.moveTotalBytes) return; if (entry.moveStatus === "queued" || entry.moveStatus === "uploading") { if ((entry.moveProgress || 0) >= 0.99) { entry.moveStatus = "done"; entry.moveProgress = 1; updated = true; } } }; for (const entry of torrents.values()) markDoneIfReady(entry); for (const job of youtubeJobs.values()) markDoneIfReady(job); for (const job of mailruJobs.values()) markDoneIfReady(job); } return updated; } function startRcloneStatsPolling() { if (rcloneStatsTimer) return; rcloneStatsTimer = setInterval(async () => { const hasActive = Array.from(torrents.values()).some((e) => ["queued", "uploading"].includes(e.moveStatus) ) || Array.from(youtubeJobs.values()).some((e) => ["queued", "uploading"].includes(e.moveStatus) ) || Array.from(mailruJobs.values()).some((e) => ["queued", "uploading"].includes(e.moveStatus) ); if (!hasActive) { clearInterval(rcloneStatsTimer); rcloneStatsTimer = null; return; } const stats = await fetchRcloneStats(); if (updateMoveProgressFromStats(stats)) { scheduleSnapshotBroadcast(); } }, 2000); } function parseRcloneTokenFromText(text) { if (!text || !text.includes("access_token")) return null; const start = text.indexOf("{"); const end = text.lastIndexOf("}"); if (start < 0 || end <= start) return null; const snippet = text.slice(start, end + 1); try { return JSON.parse(snippet); } catch (err) { return null; } } function cleanupRcloneAuthSession(sessionId) { const session = rcloneAuthSessions.get(sessionId); if (!session) return; if (session.child && !session.child.killed) { try { session.child.kill("SIGTERM"); } catch {} } rcloneAuthSessions.delete(sessionId); } function loadRcloneSettings() { if (!fs.existsSync(RCLONE_SETTINGS_PATH)) { return { autoMove: false, autoMount: false, cacheCleanMinutes: 0, remoteName: RCLONE_REMOTE_NAME, remotePath: RCLONE_REMOTE_PATH, mountDir: RCLONE_MOUNT_DIR, configPath: RCLONE_CONFIG_PATH }; } try { const data = JSON.parse(fs.readFileSync(RCLONE_SETTINGS_PATH, "utf-8")); return { autoMove: Boolean(data.autoMove), autoMount: Boolean(data.autoMount), cacheCleanMinutes: Number(data.cacheCleanMinutes) || 0, remoteName: data.remoteName || RCLONE_REMOTE_NAME, remotePath: data.remotePath || RCLONE_REMOTE_PATH, mountDir: data.mountDir || RCLONE_MOUNT_DIR, configPath: data.configPath || RCLONE_CONFIG_PATH }; } catch (err) { console.warn(`⚠️ rclone ayarları okunamadı: ${err.message}`); return { autoMove: false, autoMount: false, cacheCleanMinutes: 0, remoteName: RCLONE_REMOTE_NAME, remotePath: RCLONE_REMOTE_PATH, mountDir: RCLONE_MOUNT_DIR, configPath: RCLONE_CONFIG_PATH }; } } function saveRcloneSettings(partial) { const current = loadRcloneSettings(); const next = { ...current, ...partial }; try { fs.writeFileSync(RCLONE_SETTINGS_PATH, JSON.stringify(next, null, 2), "utf-8"); } catch (err) { console.warn(`⚠️ rclone ayarları yazılamadı: ${err.message}`); } return next; } function startRcloneCacheCleanSchedule(minutes) { if (rcloneCacheCleanTimer) { clearInterval(rcloneCacheCleanTimer); rcloneCacheCleanTimer = null; } const interval = Number(minutes); if (!interval || interval <= 0) return; rcloneCacheCleanTimer = setInterval(() => { if (!RCLONE_RC_ENABLED) return; // Query string format kullan const params = new URLSearchParams(); params.append("recursive", "true"); fetch(`http://${RCLONE_RC_ADDR}/vfs/refresh?${params.toString()}`, { method: "POST" }) .then((resp) => { if (resp.status === 404) { return fetch(`http://${RCLONE_RC_ADDR}/rc/vfs/refresh?${params.toString()}`, { method: "POST" }); } return resp; }) .then(() => { console.log("🧹 Rclone cache temizleme tetiklendi."); }) .catch(() => { console.warn("⚠️ Rclone cache temizleme başarısız."); }); }, interval * 60 * 1000); } async function runRcloneCacheClean() { const wasRunning = Boolean(rcloneProcess && !rcloneProcess.killed); try { if (wasRunning && RCLONE_RC_ENABLED) { // Rclone RC API doğru format: Query string kullanılır // POST /vfs/refresh with form data: recursive=true const params = new URLSearchParams(); params.append("recursive", "true"); const resp = await fetch(`http://${RCLONE_RC_ADDR}/vfs/refresh?${params.toString()}`, { method: "POST" }); if (resp.status === 404) { // Fallback for older rclone versions const fallbackParams = new URLSearchParams(); fallbackParams.append("recursive", "true"); const fallback = await fetch(`http://${RCLONE_RC_ADDR}/rc/vfs/refresh?${fallbackParams.toString()}`, { method: "POST" }); if (!fallback.ok) { const body = await fallback.text(); return { ok: false, error: `Rclone RC hata: ${body || fallback.status}` }; } } else if (!resp.ok) { const body = await resp.text(); return { ok: false, error: `Rclone RC hata: ${body || resp.status}` }; } return { ok: true, method: "rc", restarted: false }; } if (wasRunning && !RCLONE_RC_ENABLED) { return { ok: false, error: "Rclone RC kapalıyken mount durdurulmadan cache temizlenemez." }; } // RC kapalıysa dosya sisteminden temizle fs.rmSync(RCLONE_VFS_CACHE_DIR, { recursive: true, force: true }); fs.mkdirSync(RCLONE_VFS_CACHE_DIR, { recursive: true }); return { ok: true, method: "fs", restarted: false }; } catch (err) { return { ok: false, error: err?.message || String(err) }; } } // --- Akıllı cache yönetimi --- /** * Disk alanını kontrol eder ve gerekirse cache temizler * @returns {Promise<{ok: boolean, cleaned: boolean, diskUsage: object, message: string}>} */ async function checkAndCleanCacheIfNeeded() { try { const diskInfo = await getDiskSpace(RCLONE_VFS_CACHE_DIR); const usedPercent = diskInfo.usedPercent || 0; const availableGB = parseFloat(diskInfo.availableGB) || 0; const shouldClean = usedPercent >= RCLONE_CACHE_CLEAN_THRESHOLD || availableGB < RCLONE_MIN_FREE_SPACE_GB; if (!shouldClean) { return { ok: true, cleaned: false, diskUsage: { usedPercent, availableGB, threshold: RCLONE_CACHE_CLEAN_THRESHOLD, minFreeGB: RCLONE_MIN_FREE_SPACE_GB }, message: `Disk durumu iyi (${usedPercent}% kullanılıyor, ${availableGB}GB boş)` }; } console.warn(`⚠️ Disk doluluk oranı yüksek (${usedPercent}%) veya boş alan az (${availableGB}GB). Cache temizleniyor...`); const result = await runRcloneCacheClean(); if (result.ok) { // Temizleme sonrası disk durumunu tekrar kontrol et const newDiskInfo = await getDiskSpace(RCLONE_VFS_CACHE_DIR); return { ok: true, cleaned: true, diskUsage: { before: { usedPercent, availableGB }, after: { usedPercent: newDiskInfo.usedPercent, availableGB: parseFloat(newDiskInfo.availableGB) } }, message: `Cache temizlendi. Öncesi: ${usedPercent}%, Sonrası: ${newDiskInfo.usedPercent}%`, method: result.method }; } else { return { ok: false, cleaned: false, error: result.error, message: `Cache temizleme başarısız: ${result.error}` }; } } catch (err) { return { ok: false, cleaned: false, error: err?.message || String(err), message: `Cache kontrolü başarısız: ${err?.message}` }; } } function isRcloneMounted(mountDir) { if (!mountDir) return false; try { const mounts = fs.readFileSync("/proc/mounts", "utf-8"); return mounts .split("\n") .some((line) => line.split(" ")[1] === mountDir); } catch (err) { return false; } } function ensureRcloneDirs(settings) { if (!settings?.mountDir) return; const mountDir = settings.mountDir; try { if (!fs.existsSync(mountDir)) { fs.mkdirSync(mountDir, { recursive: true }); } } catch (err) { if (err?.code === "ENOTCONN") { try { execSync(`fusermount -u ${JSON.stringify(mountDir)}`, { stdio: "ignore" }); } catch {} try { execSync(`umount -l ${JSON.stringify(mountDir)}`, { stdio: "ignore" }); } catch {} fs.mkdirSync(mountDir, { recursive: true }); } else { throw err; } } if (!fs.existsSync(RCLONE_VFS_CACHE_DIR)) { fs.mkdirSync(RCLONE_VFS_CACHE_DIR, { recursive: true }); } } function startRcloneMount(settings) { if (!RCLONE_ENABLED) { return { ok: false, error: "Rclone kapalı." }; } if (rcloneProcess && !rcloneProcess.killed) { return { ok: true, alreadyRunning: true }; } const configPath = settings.configPath || RCLONE_CONFIG_PATH; const mountDir = settings.mountDir || RCLONE_MOUNT_DIR; const remoteName = settings.remoteName || RCLONE_REMOTE_NAME; const remotePath = settings.remotePath || RCLONE_REMOTE_PATH; ensureRcloneDirs({ mountDir }); const args = [ "mount", `${remoteName}:${remotePath}`, mountDir, "--config", configPath, "--vfs-cache-mode", RCLONE_VFS_CACHE_MODE, "--cache-dir", RCLONE_VFS_CACHE_DIR, "--vfs-cache-max-size", RCLONE_VFS_CACHE_MAX_SIZE, "--vfs-cache-max-age", RCLONE_VFS_CACHE_MAX_AGE, "--buffer-size", RCLONE_BUFFER_SIZE, "--vfs-read-ahead", RCLONE_VFS_READ_AHEAD, "--vfs-read-chunk-size", RCLONE_VFS_READ_CHUNK_SIZE, "--vfs-read-chunk-size-limit", RCLONE_VFS_READ_CHUNK_SIZE_LIMIT, "--dir-cache-time", RCLONE_DIR_CACHE_TIME, "--poll-interval", RCLONE_POLL_INTERVAL, "--log-level", "INFO" ]; if (RCLONE_RC_ENABLED) { args.push("--rc"); args.push("--rc-addr", RCLONE_RC_ADDR); args.push("--rc-no-auth"); } try { rcloneProcess = spawn("rclone", args, { stdio: ["ignore", "pipe", "pipe"] }); } catch (err) { rcloneLastError = err?.message || String(err); return { ok: false, error: rcloneLastError }; } rcloneProcess.stdout.on("data", (data) => { const msg = data.toString().trim(); if (msg) { rcloneLastLogMessage = msg; // NOTICE mesajları için farklı ikon, diğerleri için normal if (msg.toUpperCase().includes("NOTICE")) { console.log(`📡 rclone: ${msg}`); } else { console.log(`🌀 rclone: ${msg}`); } } }); rcloneProcess.stderr.on("data", (data) => { const msg = data.toString().trim(); if (msg) { rcloneLastLogMessage = msg; // NOTICE ve INFO seviyesindeki loglar hata değil // Sadece ERROR, FATAL, CRITICAL seviyesindekileri "son hata" olarak işaretle const upperMsg = msg.toUpperCase(); if (upperMsg.includes("ERROR") || upperMsg.includes("FATAL") || upperMsg.includes("CRITICAL") || upperMsg.includes("FAILED") || upperMsg.includes("COULDN'T") || upperMsg.includes("CANNOT") || upperMsg.includes("REFUSED") || upperMsg.includes("TIMEOUT") || upperMsg.includes("CONNECTION")) { rcloneLastError = msg; } console.warn(`⚠️ rclone: ${msg}`); } }); rcloneProcess.on("exit", async (code) => { if (code !== 0) { rcloneLastError = `rclone exit: ${code}`; console.warn(`⚠️ rclone mount durdu (code ${code})`); // Auto-restart mekanizması if (RCLONE_AUTO_RESTART && !rcloneRestartInProgress) { const settings = loadRcloneSettings(); if (settings.autoMount && rcloneRestartCount < RCLONE_AUTO_RESTART_MAX_RETRIES) { rcloneRestartInProgress = true; rcloneRestartCount++; console.warn(`🔄 Rclone otomatik yeniden başlatılıyor (${rcloneRestartCount}/${RCLONE_AUTO_RESTART_MAX_RETRIES})...`); // Bekle ve yeniden başlat setTimeout(async () => { const result = startRcloneMount(settings); if (result.ok) { console.log(`✅ Rclone başarıyla yeniden başlatıldı.`); rcloneRestartCount = 0; // Başarılı olunca sayacı sıfırla } else { console.error(`❌ Rclone yeniden başlatılamadı: ${result.error}`); } rcloneRestartInProgress = false; }, RCLONE_AUTO_RESTART_DELAY_MS); } else if (rcloneRestartCount >= RCLONE_AUTO_RESTART_MAX_RETRIES) { console.error(`❌ Rclone yeniden başlatma sayısı aşıldı (${RCLONE_AUTO_RESTART_MAX_RETRIES}). Otomatik yeniden başlatma devre dışı.`); } } } else { // Normal exit (code 0) - sayacı sıfırla rcloneRestartCount = 0; } rcloneProcess = null; }); return { ok: true }; } function stopRcloneMount() { if (!rcloneProcess) return { ok: true, alreadyStopped: true }; try { rcloneProcess.kill("SIGTERM"); rcloneProcess = null; return { ok: true }; } catch (err) { return { ok: false, error: err?.message || String(err) }; } } async function moveRootFolderToGdrive(rootFolder) { const safeRoot = sanitizeRelative(rootFolder); if (!safeRoot) return { ok: false, error: "Geçersiz root" }; const sourceDir = path.join(DOWNLOAD_DIR, safeRoot); if (!fs.existsSync(sourceDir)) { return { ok: false, error: "Kaynak root bulunamadı" }; } try { ensureRcloneDirs({ mountDir: GDRIVE_ROOT }); } catch (err) { return { ok: false, error: err?.message || String(err) }; } if (!fs.existsSync(GDRIVE_ROOT)) { return { ok: false, error: `GDrive mount dizini bulunamadı: ${GDRIVE_ROOT}` }; } if (!isRcloneMounted(GDRIVE_ROOT)) { return { ok: false, error: "GDrive mount aktif değil" }; } const targetDir = path.join(GDRIVE_ROOT, safeRoot); if (fs.existsSync(targetDir)) { return { ok: false, error: "GDrive'da aynı isimde klasör var" }; } try { const videoEntries = enumerateVideoFiles(safeRoot); for (const entry of videoEntries) { const absVideo = path.join(sourceDir, entry.relPath); await waitForFileStable(absVideo, 6, 2000); } fs.renameSync(sourceDir, targetDir); } catch (err) { if (err?.code === "EXDEV") { try { fs.cpSync(sourceDir, targetDir, { recursive: true }); fs.rmSync(sourceDir, { recursive: true, force: true }); } catch (copyErr) { return { ok: false, error: copyErr?.message || String(copyErr) }; } } else { return { ok: false, error: err?.message || String(err) }; } } return { ok: true, targetDir }; } async function movePathToGdrive(relPath) { const safeRel = sanitizeRelative(relPath); if (!safeRel) return { ok: false, error: "Geçersiz yol" }; const sourceFile = path.join(DOWNLOAD_DIR, safeRel); if (fs.existsSync(sourceFile) && fs.statSync(sourceFile).isFile()) { try { ensureRcloneDirs({ mountDir: GDRIVE_ROOT }); } catch (err) { return { ok: false, error: err?.message || String(err) }; } if (!fs.existsSync(GDRIVE_ROOT)) { return { ok: false, error: `GDrive mount dizini bulunamadı: ${GDRIVE_ROOT}` }; } if (!isRcloneMounted(GDRIVE_ROOT)) { return { ok: false, error: "GDrive mount aktif değil" }; } const targetFile = path.join(GDRIVE_ROOT, safeRel); ensureDirForFile(targetFile); try { await waitForFileStable(sourceFile, 6, 2000); fs.renameSync(sourceFile, targetFile); } catch (err) { if (err?.code === "EXDEV") { try { fs.copyFileSync(sourceFile, targetFile); fs.rmSync(sourceFile, { force: true }); } catch (copyErr) { return { ok: false, error: copyErr?.message || String(copyErr) }; } } else { return { ok: false, error: err?.message || String(err) }; } } return { ok: true, targetFile }; } const rootFolder = rootFromRelPath(safeRel); if (!rootFolder) return { ok: false, error: "Kök bulunamadı" }; return await moveRootFolderToGdrive(rootFolder); } function rcloneConfigHasRemote(remoteName) { if (!fs.existsSync(RCLONE_CONFIG_PATH)) return false; try { const raw = fs.readFileSync(RCLONE_CONFIG_PATH, "utf-8"); const pattern = new RegExp(`\\[${remoteName}\\]`, "i"); return pattern.test(raw); } catch (err) { return false; } } function writeRcloneConfig({ remoteName, token, clientId, clientSecret, scope = "drive" }) { if (!remoteName || !token) { return { ok: false, error: "remoteName ve token gerekli." }; } const safeName = String(remoteName).trim(); if (!safeName) return { ok: false, error: "Geçersiz remoteName." }; const lines = []; lines.push(`[${safeName}]`); lines.push("type = drive"); if (clientId) lines.push(`client_id = ${clientId}`); if (clientSecret) lines.push(`client_secret = ${clientSecret}`); lines.push(`scope = ${scope}`); lines.push(`token = ${token}`); const content = lines.join("\n") + "\n"; try { ensureDirForFile(RCLONE_CONFIG_PATH); fs.writeFileSync(RCLONE_CONFIG_PATH, content, "utf-8"); return { ok: true }; } catch (err) { return { ok: false, error: err?.message || String(err) }; } } function getRcloneAuthUrl(baseOrigin) { return new Promise((resolve) => { if (!RCLONE_ENABLED) { resolve({ ok: false, error: "Rclone kapalı." }); return; } const now = Date.now(); for (const [id, session] of rcloneAuthSessions.entries()) { if (now - session.createdAt > 15 * 60 * 1000) { cleanupRcloneAuthSession(id); } } for (const session of rcloneAuthSessions.values()) { if (!session.done && session.url) { resolve({ ok: true, url: session.url, sessionId: session.id }); return; } } let resolved = false; const sessionId = `rclone_${Date.now()}_${Math.random() .toString(36) .slice(2, 10)}`; const child = spawn("rclone", ["authorize", "drive", "--auth-no-open-browser"]); const session = { id: sessionId, child, createdAt: Date.now(), port: null, url: null, done: false, error: null, token: null }; rcloneAuthSessions.set(sessionId, session); const timeout = setTimeout(() => { if (session.done || resolved) return; session.error = "Auth URL zaman aşımı."; cleanupRcloneAuthSession(sessionId); resolved = true; resolve({ ok: false, error: session.error }); }, 5 * 60 * 1000); let outputBuffer = ""; const handleOutput = (data) => { const text = data.toString(); outputBuffer += text; if (!session.url) { const match = outputBuffer.match(/https?:\/\/[^\s]+/i); if (match) { const rawUrl = match[0]; try { const urlObj = new URL(rawUrl); session.port = urlObj.port ? Number(urlObj.port) : null; session.authPath = urlObj.pathname || "/auth"; const redirectUri = `${baseOrigin}/api/rclone/auth/callback/${sessionId}`; urlObj.searchParams.set("redirect_uri", redirectUri); const authQuery = urlObj.searchParams.toString(); session.url = `${baseOrigin}/api/rclone/auth/forward/${sessionId}${session.authPath}?${authQuery}`; if (!resolved) { resolved = true; resolve({ ok: true, url: session.url, sessionId }); } } catch (err) { session.error = "Auth URL çözümlenemedi."; cleanupRcloneAuthSession(sessionId); if (!resolved) { resolved = true; resolve({ ok: false, error: session.error }); } } } } const token = parseRcloneTokenFromText(outputBuffer); if (token && !session.done) { session.done = true; session.token = token; const settings = loadRcloneSettings(); const result = writeRcloneConfig({ remoteName: settings.remoteName || RCLONE_REMOTE_NAME, token, clientId: settings.clientId, clientSecret: settings.clientSecret }); if (!result.ok) { session.error = result.error; } clearTimeout(timeout); cleanupRcloneAuthSession(sessionId); } }; child.stdout.on("data", handleOutput); child.stderr.on("data", handleOutput); child.on("error", (err) => { if (session.done) return; session.error = err?.message || String(err); clearTimeout(timeout); cleanupRcloneAuthSession(sessionId); if (!resolved) { resolved = true; resolve({ ok: false, error: session.error }); } }); }); } 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, audioOnly = false }) { if (audioOnly) return "music"; 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 getAria2cBinary() { if (resolvedAria2cBinary) return resolvedAria2cBinary; const candidates = [ ARIA2C_BIN, "/usr/bin/aria2c", "/usr/local/bin/aria2c", "aria2c" ].filter(Boolean); for (const candidate of candidates) { if (candidate.includes(path.sep) || candidate.startsWith("/")) { if (fs.existsSync(candidate)) { resolvedAria2cBinary = candidate; return resolvedAria2cBinary; } continue; } resolvedAria2cBinary = candidate; return resolvedAria2cBinary; } resolvedAria2cBinary = "aria2c"; return resolvedAria2cBinary; } function normalizeYoutubeWatchUrl(value) { if (!value || typeof value !== "string") return null; try { 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, { moveToGdrive = false } = {}) { const normalized = normalizeYoutubeWatchUrl(url); if (!normalized) return null; const ytSettings = loadYoutubeSettings(); 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", moveToGdrive: Boolean(moveToGdrive), moveStatus: "idle", moveError: null, moveProgress: null, moveTotalBytes: null, 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: [] } }; job.resolution = ytSettings.resolution; job.onlyAudio = ytSettings.onlyAudio; youtubeJobs.set(job.id, job); launchYoutubeJob(job); console.log(`▶️ YouTube indirmesi başlatıldı: ${job.url}`); scheduleSnapshotBroadcast(); 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 loadYoutubeSettings() { const defaults = { resolution: YT_DEFAULT_RESOLUTION, onlyAudio: false }; try { if (!fs.existsSync(YT_SETTINGS_PATH)) return defaults; const raw = fs.readFileSync(YT_SETTINGS_PATH, "utf-8"); const parsed = JSON.parse(raw); const resolution = YT_ALLOWED_RESOLUTIONS.has(parsed?.resolution) ? parsed.resolution : YT_DEFAULT_RESOLUTION; const onlyAudio = Boolean(parsed?.onlyAudio); return { resolution, onlyAudio }; } catch (err) { console.warn("⚠️ YouTube ayarları okunamadı, varsayılan kullanılacak:", err.message); return defaults; } } function saveYoutubeSettings({ resolution, onlyAudio }) { const resValue = YT_ALLOWED_RESOLUTIONS.has(resolution) ? resolution : YT_DEFAULT_RESOLUTION; const settings = { resolution: resValue, onlyAudio: Boolean(onlyAudio) }; ensureDirForFile(YT_SETTINGS_PATH); fs.writeFileSync(YT_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf-8"); return settings; } function buildYoutubeFormat({ resolution, onlyAudio }) { if (onlyAudio) { // Tarayıcıda çalınabilir bir ses dosyası (öncelik m4a/mp4/webm opus) indir return "ba[ext=m4a]/ba[ext=mp4]/ba[acodec^=opus]/bestaudio"; } const match = String(resolution || YT_DEFAULT_RESOLUTION).match(/(\d+)/); const height = match ? Number(match[1]) : 1080; const safeHeight = Number.isFinite(height) && height > 0 ? height : 1080; return `bestvideo[height<=${safeHeight}]+bestaudio/best[height<=${safeHeight}]`; } function launchYoutubeJob(job) { const binary = getYtDlpBinary(); const jsRuntimeArg = process.env.YT_DLP_JS_RUNTIME || "node"; const fallbackSettings = loadYoutubeSettings(); const ytSettings = { resolution: job?.resolution || fallbackSettings.resolution, onlyAudio: typeof job?.onlyAudio === "boolean" ? job.onlyAudio : fallbackSettings.onlyAudio }; const cookieFile = (YT_COOKIES_PATH && fs.existsSync(YT_COOKIES_PATH) && YT_COOKIES_PATH) || null; const extractorArgValue = YT_EXTRACTOR_ARGS || (ytSettings.onlyAudio ? "youtube:player-client=web_safari,web,ios,mweb" : cookieFile ? "youtube:player-client=web" : "youtube:player-client=android"); const formatSelector = buildYoutubeFormat(ytSettings); const args = [ "-f", formatSelector, "--write-thumbnail", "--convert-thumbnails", "jpg", "--write-info-json", "--js-runtime", jsRuntimeArg, "--extractor-args", extractorArgValue, ...(ytSettings.onlyAudio ? [ "--extract-audio", "--audio-format", "m4a", "--audio-quality", "0" ] : []), ...(cookieFile && fs.existsSync(cookieFile) ? ["--cookies", cookieFile] : []), job.url ]; job.debug = { binary, args, logs: [], jsRuntime: jsRuntimeArg, cookies: cookieFile, extractorArgs: extractorArgValue, resolution: ytSettings.resolution, onlyAudio: ytSettings.onlyAudio, format: formatSelector }; 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 }); scheduleSnapshotBroadcast(); }); } 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); scheduleSnapshotBroadcast(); } 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); } scheduleSnapshotBroadcast(); } async function finalizeYoutubeJob(job, exitCode) { job.downloadSpeed = 0; const fallbackMedia = findYoutubeMediaFile(job.savePath, Boolean(job.onlyAudio)); if (exitCode !== 0 && !fallbackMedia) { 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 }); scheduleSnapshotBroadcast(); return; } if (exitCode !== 0 && fallbackMedia) { console.warn( `⚠️ yt-dlp çıkış kodu ${exitCode} ancak medya bulundu, devam ediliyor: ${fallbackMedia}` ); } 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 mediaFile = fallbackMedia || findYoutubeMediaFile( job.savePath, Boolean(job.onlyAudio) ); if (!mediaFile) { 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) || [] }); scheduleSnapshotBroadcast(); return; } const absMedia = path.join(job.savePath, mediaFile); const stats = fs.statSync(absMedia); const mediaInfo = await extractMediaInfo(absMedia).catch(() => null); const relativeName = mediaFile.replace(/\\/g, "/"); job.files = [ { index: 0, name: relativeName, length: stats.size } ]; job.selectedIndex = 0; job.title = deriveYoutubeTitle(mediaFile, job.videoId); job.downloaded = stats.size; job.totalBytes = stats.size; job.progress = 1; job.state = "completed"; job.error = null; const metadataPayload = await writeYoutubeMetadata( job, absMedia, 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); scheduleSnapshotBroadcast(); broadcastDiskSpace(); console.log(`✅ YouTube indirmesi tamamlandı: ${job.title}`); if (job.moveToGdrive) { job.moveStatus = "queued"; job.moveError = null; job.moveProgress = 0; job.moveTotalBytes = job.totalBytes || computeRootVideoBytes(job.folderId) || null; scheduleSnapshotBroadcast(); startRcloneStatsPolling(); const moveResult = await moveRootFolderToGdrive(job.folderId); if (moveResult.ok) { job.moveStatus = "uploading"; scheduleSnapshotBroadcast(); } else { job.moveStatus = "error"; job.moveError = moveResult.error || "GDrive taşıma hatası"; logRcloneMoveError(`youtube:${job.id}`, job.moveError); } broadcastFileUpdate("downloads"); scheduleSnapshotBroadcast(); } } catch (err) { job.state = "error"; job.error = err?.message || "YouTube indirimi tamamlanamadı"; scheduleSnapshotBroadcast(); } } function findYoutubeMediaFile(savePath, preferAudio = false) { const entries = fs.readdirSync(savePath, { withFileTypes: true }); const files = entries .filter((entry) => entry.isFile()) .map((entry) => entry.name); const audioExts = Array.from(MUSIC_EXTENSIONS); if (preferAudio) { const audios = files.filter((name) => audioExts.includes(path.extname(name).toLowerCase()) ); if (audios.length) { audios.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 audios[0]; } } const videos = files.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 normalizeMailRuUrl(value) { if (!value || typeof value !== "string") return null; try { const urlObj = new URL(value.trim()); if (urlObj.protocol !== "https:") return null; const host = urlObj.hostname.toLowerCase(); if (!host.endsWith("mail.ru")) return null; return urlObj.toString(); } catch (err) { return null; } } function sanitizeFileName(name) { const cleaned = String(name || "").trim(); if (!cleaned) return "mailru_video.mp4"; const replaced = cleaned.replace(/[\\/:*?"<>|]+/g, "_"); return replaced || "mailru_video.mp4"; } function formatMailRuSeriesFilename(title, season, episode) { const rawTitle = String(title || "").trim(); const parts = rawTitle .replace(/[\\/:*?"<>|]+/g, "") .replace(/[\s._-]+/g, " ") .replace(/\s+/g, " ") .trim() .split(" ") .filter(Boolean); const titled = parts .map((word) => { if (!word) return ""; return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); }) .filter(Boolean) .join("."); const safeTitle = titled || "Anime"; const seasonNum = Number(season) || 1; const episodeNum = Number(episode) || 1; const code = `S${String(seasonNum).padStart(2, "0")}xE${String(episodeNum).padStart(2, "0")}`; return sanitizeFileName(`${safeTitle}.${code}.mp4`); } async function resolveMailRuDirectUrl(rawUrl) { const normalized = normalizeMailRuUrl(rawUrl); if (!normalized) return null; try { const urlObj = new URL(normalized); const lowerPath = urlObj.pathname.toLowerCase(); if (lowerPath.endsWith(".mp4")) return normalized; } catch (err) { return null; } const binary = getYtDlpBinary(); return await new Promise((resolve, reject) => { const args = ["-g", "--no-playlist", normalized]; const child = spawn(binary, args, { env: process.env }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => { stdout += chunk.toString(); }); child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); child.on("error", (err) => { reject(err?.message || "yt-dlp çalıştırılamadı"); }); child.on("close", (code) => { if (code !== 0) { return reject(stderr || `yt-dlp ${code} kodu ile sonlandı`); } const lines = stdout .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); const direct = lines.find((line) => line.startsWith("http")); if (!direct) { return reject("Mail.ru video URL'si çözümlenemedi"); } resolve(direct); }); }); } function mailruSnapshot(job) { const files = (job.files || []).map((file, index) => ({ index, name: file.name, length: file.length })); return { infoHash: job.id, type: "mailru", name: job.title || job.url, progress: Math.min(1, job.progress || 0), downloaded: job.downloaded || 0, downloadSpeed: job.state === "downloading" ? job.downloadSpeed || 0 : 0, uploadSpeed: 0, numPeers: 0, tracker: "mail.ru", added: job.added, savePath: job.savePath, paused: false, files, selectedIndex: job.selectedIndex || 0, thumbnail: job.thumbnail || null, status: job.state, moveToGdrive: job.moveToGdrive || false, moveStatus: job.moveStatus || "idle", moveError: job.moveError || null, moveProgress: job.moveProgress ?? null, moveTotalBytes: job.moveTotalBytes ?? null }; } function appendMailRuLog(job, line) { if (!job?.debug) return; const lines = Array.isArray(job.debug.logs) ? job.debug.logs : []; const split = String(line || "").split(/\r?\n/); for (const l of split) { if (!l.trim()) continue; lines.push(l.trim()); } while (lines.length > 80) { lines.shift(); } job.debug.logs = lines; } function parseAria2cProgress(job, line) { if (!line) return; const cleaned = line.replace(/\u001b\[[0-9;]*m/g, "").trim(); const primaryMatch = cleaned.match( /\[#\d+\s+([\d.]+)([KMGTP]?i?B)\/([\d.]+)([KMGTP]?i?B)\((\d+)%\)\s+CN:\d+\s+DL:([\d.]+)([KMGTP]?i?B)/i ); const sizeMatch = cleaned.match( /SIZE:([\d.]+)([KMGTP]?i?B)\/([\d.]+)([KMGTP]?i?B)\((\d+)%\).*DL:([\d.]+)([KMGTP]?i?B)/i ); const match = primaryMatch || sizeMatch; if (!match) return; const downloadedValue = Number(match[1]) || 0; const downloadedUnit = match[2]; const totalValue = Number(match[3]) || 0; const totalUnit = match[4]; const percent = Number(match[5]) || 0; const speedValue = Number(match[6]) || 0; const speedUnit = match[7]; const totalBytes = bytesFromHuman(totalValue, totalUnit); const downloadedBytes = Math.min( totalBytes || Number.MAX_SAFE_INTEGER, bytesFromHuman(downloadedValue, downloadedUnit) ); job.totalBytes = totalBytes || job.totalBytes || 0; job.downloaded = downloadedBytes || job.downloaded || 0; job.progress = totalBytes > 0 ? downloadedBytes / totalBytes : percent / 100; if (speedUnit) { job.downloadSpeed = bytesFromHuman(speedValue, speedUnit); } scheduleSnapshotBroadcast(); } function startMailRuProgressPolling(job) { if (!job || job.pollTimer) return; job.lastPollBytes = 0; job.lastPollAt = Date.now(); job.pollTimer = setInterval(() => { if (!job || job.state !== "downloading") { clearInterval(job.pollTimer); job.pollTimer = null; return; } const filePath = path.join(job.savePath, job.fileName || ""); if (!job.fileName || !fs.existsSync(filePath)) return; try { const stats = fs.statSync(filePath); const now = Date.now(); const elapsed = Math.max(1, now - (job.lastPollAt || now)); const delta = Math.max(0, stats.size - (job.lastPollBytes || 0)); job.lastPollBytes = stats.size; job.lastPollAt = now; job.downloaded = stats.size; if (job.totalBytes > 0) { job.progress = Math.min(1, job.downloaded / job.totalBytes); } job.downloadSpeed = delta / (elapsed / 1000); scheduleSnapshotBroadcast(); } catch (err) { /* no-op */ } }, 1000); } function stopMailRuProgressPolling(job) { if (job?.pollTimer) { clearInterval(job.pollTimer); job.pollTimer = null; } } function attachMailRuThumbnail(job, filePath, relPath) { const { relThumb, absThumb } = getVideoThumbnailPaths(relPath); if (fs.existsSync(absThumb)) { job.thumbnail = thumbnailUrl(relThumb); scheduleSnapshotBroadcast(); return; } queueVideoThumbnail(filePath, relPath); let attempts = 0; const maxAttempts = 12; const timer = setInterval(() => { attempts += 1; if (fs.existsSync(absThumb)) { job.thumbnail = thumbnailUrl(relThumb); scheduleSnapshotBroadcast(); clearInterval(timer); return; } if (attempts >= maxAttempts) { clearInterval(timer); } }, 1000); } async function finalizeMailRuJob(job, exitCode) { job.downloadSpeed = 0; stopMailRuProgressPolling(job); if (exitCode !== 0) { job.state = "error"; const tail = job.debug?.logs ? job.debug.logs.slice(-8) : []; job.error = `aria2c ${exitCode} kodu ile sonlandı`; if (tail.length) { job.error += ` | ${tail.join(" | ")}`; } console.warn("❌ Mail.ru indirmesi hata:", { jobId: job.id, exitCode, lastLines: tail }); scheduleSnapshotBroadcast(); return; } try { const filePath = path.join(job.savePath, job.fileName); if (!fs.existsSync(filePath)) { job.state = "error"; job.error = "Mail.ru dosyası bulunamadı"; scheduleSnapshotBroadcast(); return; } const stats = fs.statSync(filePath); job.files = [ { index: 0, name: job.fileName, length: stats.size } ]; job.selectedIndex = 0; job.title = job.title || job.fileName; job.downloaded = stats.size; job.totalBytes = stats.size; job.progress = 1; job.state = "completed"; job.error = null; const relPath = job.savePath === DOWNLOAD_DIR ? String(job.fileName || "") : path.join(job.folderId || "", job.fileName || "").replace(/\\/g, "/"); const seriesInfo = job.match?.seriesInfo || null; if (seriesInfo) { extractMediaInfo(filePath) .then((mediaInfo) => ensureSeriesData(ANIME_ROOT_FOLDER, job.fileName, seriesInfo, mediaInfo) ) .catch(() => null); } attachMailRuThumbnail(job, filePath, relPath); broadcastFileUpdate(relPath || "downloads"); scheduleSnapshotBroadcast(); broadcastDiskSpace(); console.log(`✅ Mail.ru indirmesi tamamlandı: ${job.title}`); if (job.moveToGdrive) { job.moveStatus = "queued"; job.moveError = null; job.moveProgress = 0; job.moveTotalBytes = job.totalBytes || null; scheduleSnapshotBroadcast(); startRcloneStatsPolling(); const moveResult = await movePathToGdrive(relPath); if (moveResult.ok) { job.moveStatus = "uploading"; scheduleSnapshotBroadcast(); } else { job.moveStatus = "error"; job.moveError = moveResult.error || "GDrive taşıma hatası"; logRcloneMoveError(`mailru:${job.id}`, job.moveError); } broadcastFileUpdate("downloads"); scheduleSnapshotBroadcast(); } } catch (err) { job.state = "error"; job.error = err?.message || "Mail.ru indirimi tamamlanamadı"; scheduleSnapshotBroadcast(); } } function launchMailRuJob(job) { const binary = getAria2cBinary(); const args = [ "--enable-color=false", "--summary-interval=1", "--show-console-readout=true", "--console-log-level=notice", "--file-allocation=none", "--allow-overwrite=true", "-x", "16", "-s", "16", "-k", "1M", "-d", job.savePath, "-o", job.fileName, job.directUrl ]; job.debug = { binary, args, logs: [] }; const child = spawn(binary, args, { cwd: job.savePath, env: process.env }); job.process = child; const handleChunk = (chunk) => { const text = chunk.toString(); appendMailRuLog(job, text); const parts = text.split(/[\r\n]+/); for (const raw of parts) { const line = raw.trim(); if (!line) continue; parseAria2cProgress(job, line); } }; child.stdout.on("data", handleChunk); child.stderr.on("data", handleChunk); child.on("close", (code) => { void finalizeMailRuJob(job, code); }); child.on("error", (err) => { job.state = "error"; job.downloadSpeed = 0; appendMailRuLog(job, `spawn error: ${err?.message || err}`); job.error = err?.message || "aria2c çalıştırılamadı"; console.error("❌ Mail.ru aria2c spawn error:", { jobId: job.id, message: err?.message || err, binary, args }); scheduleSnapshotBroadcast(); }); } async function startMailRuDownload(url, { moveToGdrive = false } = {}) { const normalized = normalizeMailRuUrl(url); if (!normalized) return null; const folderId = null; const savePath = DOWNLOAD_DIR; const jobId = `mailru_${Date.now().toString(36)}`; const job = { id: jobId, infoHash: jobId, type: "mailru", url: normalized, directUrl: null, folderId, savePath, added: Date.now(), title: null, fileName: null, moveToGdrive: Boolean(moveToGdrive), moveStatus: "idle", moveError: null, moveProgress: null, moveTotalBytes: null, state: "awaiting_match", progress: 0, downloaded: 0, totalBytes: 0, downloadSpeed: 0, files: [], selectedIndex: 0, thumbnail: null, process: null, error: null, debug: { binary: null, args: null, logs: [] }, match: null }; mailruJobs.set(job.id, job); scheduleSnapshotBroadcast(); console.log(`▶️ Mail.ru indirimi eşleştirme bekliyor: ${job.url}`); return job; } async function beginMailRuDownload(job) { if (!job || job.state !== "awaiting_match") return false; job.state = "resolving"; scheduleSnapshotBroadcast(); try { const directUrl = await resolveMailRuDirectUrl(job.url); if (!directUrl) { throw new Error("Mail.ru video URL'si çözümlenemedi"); } job.directUrl = directUrl; if (!job.fileName) { const urlObj = new URL(directUrl); const filename = sanitizeFileName(path.basename(urlObj.pathname)); job.fileName = filename || `mailru_${Date.now()}.mp4`; } job.title = job.fileName; job.state = "downloading"; scheduleSnapshotBroadcast(); try { const headResp = await fetch(directUrl, { method: "HEAD" }); const length = Number(headResp.headers.get("content-length")) || 0; if (length > 0) { job.totalBytes = length; } } catch (err) { /* no-op */ } if (!job.totalBytes) { try { const rangeResp = await fetch(directUrl, { method: "GET", headers: { Range: "bytes=0-0" } }); const contentRange = rangeResp.headers.get("content-range") || ""; const total = contentRange.split("/")[1]; const totalBytes = Number(total); if (totalBytes > 0) { job.totalBytes = totalBytes; } try { await rangeResp.arrayBuffer(); } catch { /* no-op */ } } catch (err) { /* no-op */ } } startMailRuProgressPolling(job); launchMailRuJob(job); return true; } catch (err) { job.state = "error"; job.error = err?.message || "Mail.ru indirimi başlatılamadı"; scheduleSnapshotBroadcast(); return false; } } 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; } function isYoutubeMusic(infoJson, mediaInfo) { const categories = Array.isArray(infoJson?.categories) ? infoJson.categories.map((c) => String(c).toLowerCase()) : []; if (categories.includes("music")) return true; const tags = Array.isArray(infoJson?.tags) ? infoJson.tags.map((t) => String(t).toLowerCase()) : []; if (tags.some((t) => t.includes("music"))) return true; // Sadece ses akışı varsa müzik kabul et if (!mediaInfo?.video && mediaInfo?.audio) return true; return false; } 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 isAudioOnly = isYoutubeMusic(infoJson, mediaInfo) || Boolean(job.onlyAudio); const derivedType = determineMediaType({ tracker: "youtube", movieMatch: null, seriesEpisode: null, categories, relPath: job.files?.[0]?.name || null, audioOnly: isAudioOnly }); 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); } } scheduleSnapshotBroadcast(); if (filesRemoved) { broadcastFileUpdate(job.folderId); broadcastDiskSpace(); } return true; } function removeMailRuJob(jobId, { removeFiles = true } = {}) { const job = mailruJobs.get(jobId); if (!job) return false; if (job.process && !job.process.killed) { try { job.process.kill("SIGTERM"); } catch (err) { console.warn("Mail.ru job kill error:", err.message); } } mailruJobs.delete(jobId); let filesRemoved = false; if (removeFiles) { try { if (job.savePath === DOWNLOAD_DIR && job.fileName) { const filePath = path.join(job.savePath, job.fileName); if (fs.existsSync(filePath)) { fs.rmSync(filePath, { force: true }); filesRemoved = true; } } else if (job.savePath && fs.existsSync(job.savePath)) { fs.rmSync(job.savePath, { recursive: true, force: true }); filesRemoved = true; } } catch (err) { console.warn("Mail.ru dosyası silinemedi:", err.message); } } scheduleSnapshotBroadcast(); if (filesRemoved) { broadcastFileUpdate(job.folderId); broadcastDiskSpace(); } return true; } function youtubeSnapshot(job) { const files = (job.files || []).map((file, index) => ({ index, 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, moveToGdrive: job.moveToGdrive || false, moveStatus: job.moveStatus || "idle", moveError: job.moveError || null, moveProgress: job.moveProgress ?? null, moveTotalBytes: job.moveTotalBytes ?? null }; } 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 (DISABLE_MEDIA_PROCESSING) return null; 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) { if (DISABLE_MEDIA_PROCESSING) return; 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) { if (DISABLE_MEDIA_PROCESSING) return; 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 isRootTrashName(value) { const normalized = normalizeTrashPath(value); return ( normalized === ROOT_TRASH_PREFIX || normalized.startsWith(`${ROOT_TRASH_PREFIX}/`) ); } function parseRootTrashName(value) { const normalized = normalizeTrashPath(value); if (normalized === ROOT_TRASH_PREFIX) return ""; if (!normalized.startsWith(`${ROOT_TRASH_PREFIX}/`)) return null; return normalized.slice(ROOT_TRASH_PREFIX.length + 1); } function readRootTrashRegistry() { if (!fs.existsSync(ROOT_TRASH_REGISTRY)) return { items: [] }; try { const raw = JSON.parse(fs.readFileSync(ROOT_TRASH_REGISTRY, "utf-8")); const items = Array.isArray(raw?.items) ? raw.items : []; return { items: items.filter(Boolean) }; } catch (err) { console.warn(`⚠️ root-trash okunamadı (${ROOT_TRASH_REGISTRY}): ${err.message}`); return { items: [] }; } } function writeRootTrashRegistry(registry) { const items = Array.isArray(registry?.items) ? registry.items : []; try { fs.writeFileSync( ROOT_TRASH_REGISTRY, JSON.stringify({ updatedAt: Date.now(), items }, null, 2), "utf-8" ); } catch (err) { console.warn(`⚠️ root-trash yazılamadı (${ROOT_TRASH_REGISTRY}): ${err.message}`); } } function addRootTrashEntry(relPath, fullPath, stats) { const safeRel = sanitizeRelative(relPath); if (!safeRel || safeRel.includes("/")) return null; if (!fullPath || !fs.existsSync(fullPath)) return null; const registry = readRootTrashRegistry(); const baseName = path.basename(safeRel); const storedName = `${Date.now()}_${baseName}`.replace(/[\\/:*?"<>|]+/g, "_"); const storedPath = path.join(ROOT_TRASH_DIR, storedName); try { fs.renameSync(fullPath, storedPath); } catch (err) { // Cross-device rename (EXDEV) durumunda kopyala+sil fallback'i uygula. if (err?.code === "EXDEV") { try { if (stats?.isDirectory?.()) { fs.cpSync(fullPath, storedPath, { recursive: true }); fs.rmSync(fullPath, { recursive: true, force: true }); } else { fs.copyFileSync(fullPath, storedPath); fs.rmSync(fullPath, { force: true }); } } catch (copyErr) { console.warn( `⚠️ root-trash EXDEV fallback hatası (${fullPath}): ${copyErr.message}` ); return null; } } else { console.warn(`⚠️ root-trash taşıma hatası (${fullPath}): ${err.message}`); return null; } } const nextItems = registry.items.filter((item) => item.originalName !== baseName); nextItems.push({ originalName: baseName, storedName, deletedAt: Date.now(), type: stats?.isDirectory?.() ? "inode/directory" : mime.lookup(fullPath) || "application/octet-stream" }); writeRootTrashRegistry({ items: nextItems }); return nextItems[nextItems.length - 1]; } function removeRootTrashEntry(originalName) { const safeName = sanitizeRelative(originalName); if (!safeName || safeName.includes("/")) return null; const registry = readRootTrashRegistry(); const kept = []; let removed = null; for (const item of registry.items) { if (item.originalName === safeName && !removed) { removed = item; continue; } kept.push(item); } if (!removed) return null; writeRootTrashRegistry({ items: kept }); return removed; } function trashFlagPathFor(rootFolder) { const safeRoot = sanitizeRelative(rootFolder); if (!safeRoot) return null; 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 firstSegment = normalized.split("/").filter(Boolean)[0] || ""; const { rootFolder } = parseTvSeriesKey(firstSegment); const dataRoot = rootFolder === ANIME_ROOT_FOLDER ? ANIME_DATA_ROOT : TV_DATA_ROOT; const resolved = path.resolve(dataRoot, normalized); if ( resolved !== dataRoot && !resolved.startsWith(dataRoot + path.sep) ) { return null; } return resolved; } function sanitizeWebdavSegment(value) { if (!value) return "Unknown"; return String(value) .replace(/[\\/:*?"<>|]+/g, "_") .replace(/\s+/g, " ") .trim(); } function makeUniqueWebdavName(base, used, suffix = "") { let name = base || "Unknown"; if (!used.has(name)) { used.add(name); return name; } const fallback = suffix ? `${name} [${suffix}]` : `${name} [${used.size}]`; if (!used.has(fallback)) { used.add(fallback); return fallback; } let counter = 2; while (used.has(`${fallback} ${counter}`)) counter += 1; const finalName = `${fallback} ${counter}`; used.add(finalName); return finalName; } function ensureDirSync(dirPath) { if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true }); } function createSymlinkSafe(targetPath, linkPath) { try { if (fs.existsSync(linkPath)) return; ensureDirSync(path.dirname(linkPath)); let stat = null; try { stat = fs.statSync(targetPath); } catch (err) { return; } if (stat?.isFile?.()) { try { fs.linkSync(targetPath, linkPath); return; } catch (err) { if (err?.code !== "EXDEV") { // Hardlink başarısızsa symlink'e düş fs.symlinkSync(targetPath, linkPath); return; } } } fs.symlinkSync(targetPath, linkPath); } catch (err) { console.warn(`⚠️ WebDAV link oluşturulamadı (${linkPath}): ${err.message}`); } } function resolveMovieVideoAbsPath(metadata) { const dupe = metadata?._dupe || {}; const rootFolder = sanitizeRelative(dupe.folder || "") || null; let videoPath = dupe.videoPath || metadata?.videoPath || null; if (!videoPath) return null; videoPath = String(videoPath).replace(/\\/g, "/").replace(/^\/+/, ""); const hasRoot = rootFolder && videoPath.startsWith(`${rootFolder}/`); let absPath = hasRoot ? path.join(DOWNLOAD_DIR, videoPath) : rootFolder ? path.join(DOWNLOAD_DIR, rootFolder, videoPath) : path.join(DOWNLOAD_DIR, videoPath); if (fs.existsSync(absPath)) return absPath; const rel = path.relative(DOWNLOAD_DIR, absPath); const alt = path.join(GDRIVE_ROOT, rel); return fs.existsSync(alt) ? alt : null; } function resolveEpisodeAbsPath(rootFolder, episode) { if (!episode) return null; let videoPath = episode.videoPath || episode.file || ""; if (!videoPath) return null; videoPath = String(videoPath).replace(/\\/g, "/").replace(/^\/+/, ""); const candidates = []; if (rootFolder === ANIME_ROOT_FOLDER) { candidates.push(path.join(DOWNLOAD_DIR, videoPath)); if (videoPath.includes("/")) { candidates.push(path.join(DOWNLOAD_DIR, path.basename(videoPath))); } } else { if (rootFolder && !videoPath.startsWith(`${rootFolder}/`)) { candidates.push(path.join(DOWNLOAD_DIR, rootFolder, videoPath)); } candidates.push(path.join(DOWNLOAD_DIR, videoPath)); } if (episode.file && episode.file !== videoPath) { const fallbackFile = String(episode.file) .replace(/\\/g, "/") .replace(/^\/+/, ""); candidates.push(path.join(DOWNLOAD_DIR, fallbackFile)); if (rootFolder && !fallbackFile.startsWith(`${rootFolder}/`)) { candidates.push(path.join(DOWNLOAD_DIR, rootFolder, fallbackFile)); } } for (const absPath of candidates) { if (fs.existsSync(absPath)) return absPath; const rel = path.relative(DOWNLOAD_DIR, absPath); const alt = path.join(GDRIVE_ROOT, rel); if (fs.existsSync(alt)) return alt; } return null; } function rebuildWebdavIndex() { try { if (fs.existsSync(WEBDAV_ROOT)) { fs.rmSync(WEBDAV_ROOT, { recursive: true, force: true }); } fs.mkdirSync(WEBDAV_ROOT, { recursive: true }); } catch (err) { console.warn(`⚠️ WebDAV kökü temizlenemedi (${WEBDAV_ROOT}): ${err.message}`); return; } const categoryDefs = [ { label: "Movies" }, { label: "TV Shows" }, { label: "Anime" } ]; for (const cat of categoryDefs) { const dir = path.join(WEBDAV_ROOT, cat.label); ensureDirSync(dir); } const isVideoExt = (value) => VIDEO_EXTS.includes(String(value || "").toLowerCase()); const shouldSkip = (relPath) => { if (!relPath) return true; const normalized = String(relPath) .replace(/\\/g, "/") .replace(/^\/+/, ""); const segments = normalized.split("/").filter(Boolean); if (!segments.length) return true; return segments.some((seg) => seg.startsWith("yt_")); }; const makeEpisodeCode = (seasonNum, episodeNum) => { const season = Number(seasonNum); const episode = Number(episodeNum); if (!Number.isFinite(season) || !Number.isFinite(episode)) return null; return `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`; }; const getEpisodeNumber = (episodeKey, episode) => { const direct = episode?.episodeNumber ?? episode?.number ?? episode?.episode ?? null; if (Number.isFinite(Number(direct))) return Number(direct); const match = String(episodeKey || "").match(/(\d{1,4})/); return match ? Number(match[1]) : null; }; // Movies try { const movieDirs = fs.readdirSync(MOVIE_DATA_ROOT, { withFileTypes: true }); const usedMovieNames = new Set(); for (const dirent of movieDirs) { if (!dirent.isDirectory()) continue; const metaPath = path.join(MOVIE_DATA_ROOT, dirent.name, "metadata.json"); if (!fs.existsSync(metaPath)) continue; let metadata = null; try { metadata = JSON.parse(fs.readFileSync(metaPath, "utf-8")); } catch (err) { continue; } const absVideo = resolveMovieVideoAbsPath(metadata); if (!absVideo) continue; if (shouldSkip(absVideo)) continue; const title = metadata?.title || metadata?.matched_title || metadata?._dupe?.displayName || dirent.name; const year = metadata?.release_date?.slice?.(0, 4) || metadata?.matched_year || metadata?.year || null; const baseName = sanitizeWebdavSegment( year ? `${title} (${year})` : title ); const uniqueName = makeUniqueWebdavName( baseName, usedMovieNames, metadata?.id || dirent.name ); const movieDir = path.join(WEBDAV_ROOT, "Movies", uniqueName); ensureDirSync(movieDir); const ext = path.extname(absVideo); const fileName = sanitizeWebdavSegment(`${uniqueName}${ext}`); const linkPath = path.join(movieDir, fileName); createSymlinkSafe(absVideo, linkPath); } } catch (err) { console.warn(`⚠️ WebDAV movie index oluşturulamadı: ${err.message}`); } const buildSeriesCategory = ( dataRoot, categoryLabel, usedShowNames, coveredRoots ) => { try { const dirs = fs.readdirSync(dataRoot, { withFileTypes: true }); for (const dirent of dirs) { if (!dirent.isDirectory()) continue; const seriesDir = path.join(dataRoot, dirent.name); const seriesPath = path.join(seriesDir, "series.json"); if (!fs.existsSync(seriesPath)) continue; let seriesData = null; try { seriesData = JSON.parse(fs.readFileSync(seriesPath, "utf-8")); } catch (err) { continue; } const { rootFolder } = parseTvSeriesKey(dirent.name); if (coveredRoots) coveredRoots.add(rootFolder); const showTitle = sanitizeWebdavSegment( seriesData?.name || seriesData?.title || dirent.name ); const uniqueShow = makeUniqueWebdavName( showTitle, usedShowNames, seriesData?.id || dirent.name ); const showDir = path.join(WEBDAV_ROOT, categoryLabel, uniqueShow); const seasons = seriesData?.seasons || {}; let createdSeasonCount = 0; for (const seasonKey of Object.keys(seasons)) { const season = seasons[seasonKey]; if (!season?.episodes) continue; const seasonNumber = season?.seasonNumber ?? Number(seasonKey) ?? null; const seasonLabel = seasonNumber ? `Season ${String(seasonNumber).padStart(2, "0")}` : "Season"; const seasonDir = path.join(showDir, seasonLabel); let createdEpisodeCount = 0; for (const episodeKey of Object.keys(season.episodes)) { const episode = season.episodes[episodeKey]; const absVideo = resolveEpisodeAbsPath(rootFolder, episode); if (!absVideo) continue; if (shouldSkip(absVideo)) continue; const ext = path.extname(absVideo); if (!isVideoExt(ext)) continue; if (createdEpisodeCount === 0) { ensureDirSync(seasonDir); ensureDirSync(showDir); createdSeasonCount += 1; } const episodeNumber = getEpisodeNumber(episodeKey, episode); const code = makeEpisodeCode(seasonNumber, episodeNumber); const safeCode = code || `S${String(seasonNumber || 0).padStart(2, "0")}E${String( Number(episodeNumber) || 0 ).padStart(2, "0")}`; const fileName = sanitizeWebdavSegment( `${uniqueShow} - ${safeCode}${ext}` ); const linkPath = path.join(seasonDir, fileName); createSymlinkSafe(absVideo, linkPath); createdEpisodeCount += 1; } } if (createdSeasonCount === 0 && fs.existsSync(showDir)) { fs.rmSync(showDir, { recursive: true, force: true }); } } } catch (err) { console.warn(`⚠️ WebDAV ${categoryLabel} index oluşturulamadı: ${err.message}`); } }; const tvUsedShowNames = new Set(); const animeUsedShowNames = new Set(); const coveredTvRoots = new Set(); buildSeriesCategory(TV_DATA_ROOT, "TV Shows", tvUsedShowNames, coveredTvRoots); buildSeriesCategory(ANIME_DATA_ROOT, "Anime", animeUsedShowNames, null); const buildSeriesFromInfoJson = (categoryLabel, usedShowNames, coveredRoots) => { try { const roots = listStorageRootFolders(); for (const dirent of roots) { const rootFolder = dirent.rootFolder || dirent.name; if (coveredRoots?.has(rootFolder)) continue; const baseDir = dirent.baseDir || DOWNLOAD_DIR; const infoPath = path.join(baseDir, rootFolder, "info.json"); if (!fs.existsSync(infoPath)) continue; let info = null; try { info = JSON.parse(fs.readFileSync(infoPath, "utf-8")); } catch (err) { continue; } const episodes = info?.seriesEpisodes; if (!episodes || typeof episodes !== "object") continue; const showBuckets = new Map(); for (const [relPath, episode] of Object.entries(episodes)) { if (!episode) continue; if (shouldSkip(relPath)) continue; const absVideo = path.join(baseDir, rootFolder, relPath); if (!fs.existsSync(absVideo)) continue; const ext = path.extname(absVideo).toLowerCase(); if (!isVideoExt(ext)) continue; const showTitleRaw = episode.showTitle || info?.name || rootFolder || "Unknown"; const showKey = `${episode.showId || ""}__${showTitleRaw}`; if (!showBuckets.has(showKey)) { showBuckets.set(showKey, { title: showTitleRaw, episodes: [] }); } showBuckets.get(showKey).episodes.push({ absVideo, relPath, season: episode.season, episode: episode.episode, key: episode.key }); } for (const bucket of showBuckets.values()) { const showTitle = sanitizeWebdavSegment(bucket.title); const uniqueShow = makeUniqueWebdavName( showTitle, usedShowNames, bucket.title ); const showDir = path.join(WEBDAV_ROOT, categoryLabel, uniqueShow); let createdSeasonCount = 0; for (const entry of bucket.episodes) { const seasonNumber = Number(entry.season); const episodeNumber = Number(entry.episode); const seasonLabel = Number.isFinite(seasonNumber) ? `Season ${String(seasonNumber).padStart(2, "0")}` : "Season"; const seasonDir = path.join(showDir, seasonLabel); if (createdSeasonCount === 0) { ensureDirSync(showDir); } if (!fs.existsSync(seasonDir)) { ensureDirSync(seasonDir); createdSeasonCount += 1; } const ext = path.extname(entry.absVideo); const code = entry.key || makeEpisodeCode(seasonNumber, episodeNumber) || `S${String(seasonNumber || 0).padStart(2, "0")}E${String( Number(episodeNumber) || 0 ).padStart(2, "0")}`; const fileName = sanitizeWebdavSegment( `${uniqueShow} - ${code}${ext}` ); const linkPath = path.join(seasonDir, fileName); createSymlinkSafe(entry.absVideo, linkPath); } } } } catch (err) { console.warn(`⚠️ WebDAV ${categoryLabel} info.json index oluşturulamadı: ${err.message}`); } }; buildSeriesFromInfoJson("TV Shows", tvUsedShowNames, coveredTvRoots); } let webdavIndexLast = 0; let webdavIndexBuilding = false; async function ensureWebdavIndexFresh() { if (!WEBDAV_ENABLED) return; const now = Date.now(); if (webdavIndexBuilding) return; if (now - webdavIndexLast < WEBDAV_INDEX_TTL) return; webdavIndexBuilding = true; try { rebuildWebdavIndex(); webdavIndexLast = Date.now(); } finally { webdavIndexBuilding = false; } } function webdavAuthMiddleware(req, res, next) { if (!WEBDAV_ENABLED) return res.status(404).end(); const authHeader = req.headers.authorization || ""; if (!authHeader.startsWith("Basic ")) { res.setHeader("WWW-Authenticate", "Basic realm=\"Dupe WebDAV\""); return res.status(401).end(); } const raw = Buffer.from(authHeader.slice(6), "base64") .toString("utf-8") .split(":"); const user = raw.shift() || ""; const pass = raw.join(":"); if (!WEBDAV_USERNAME || !WEBDAV_PASSWORD) { return res.status(500).end(); } if (user !== WEBDAV_USERNAME || pass !== WEBDAV_PASSWORD) { res.setHeader("WWW-Authenticate", "Basic realm=\"Dupe WebDAV\""); return res.status(401).end(); } return next(); } function webdavReadonlyGuard(req, res, next) { if (!WEBDAV_READONLY) return next(); const allowed = new Set(["GET", "HEAD", "OPTIONS", "PROPFIND"]); if (!allowed.has(req.method)) { return res.status(403).end(); } return next(); } 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")}` }; } function parseAnimeSeriesInfo(rawName) { if (!rawName) return null; const parsed = parseSeriesInfo(rawName); if (parsed) return parsed; const withoutExt = String(rawName || "").replace(/\.[^/.]+$/, ""); const match = withoutExt.match( /(.+?)[\s._-]*S(\d{1,2})xE(\d{1,2})/i ); if (!match) return null; const rawTitle = match[1] .replace(/[._]+/g, " ") .replace(/\s+-\s+/g, " - ") .replace(/[-_]+/g, " ") .replace(/\s+/g, " ") .trim(); if (!rawTitle) return null; const season = Number(match[2]); const episode = Number(match[3]); if (!Number.isFinite(season) || !Number.isFinite(episode)) return null; return { title: titleCase(rawTitle), searchTitle: rawTitle, season, episode, key: `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}` }; } async function tmdbFetch(endpoint, params = {}) { if (!TMDB_API_KEY) return null; const url = new URL(`${TMDB_BASE_URL}${endpoint}`); 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 ) { if (DISABLE_MEDIA_PROCESSING) { return { mediaInfo: precomputedMediaInfo || null, metadata: null, cacheKey: null, videoPath: bestVideoPath ? normalizeTrashPath(bestVideoPath) : 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; } function tvdbPickTranslation(list, field, preferEn = false) { if (!Array.isArray(list)) return null; const preferred = preferEn ? ["en", "eng", "english", "en-us"] : ["tr", "tur", "turkish", "tr-tr", "tr_tur"]; const fallback = preferEn ? ["tr", "tur", "turkish", "tr-tr", "tr_tur"] : ["en", "eng", "english", "en-us"]; const pickByLang = (langs) => langs .map((lng) => lng.toLowerCase()) .map((lng) => list.find((item) => { const code = String( item?.language || item?.iso6391 || item?.iso_639_1 || item?.locale || item?.languageCode || "" ).toLowerCase(); return code === lng; }) ) .find(Boolean); const match = pickByLang(preferred) || pickByLang(fallback); if (!match) return null; return match[field] ?? match.value ?? match.translation?.[field] ?? null; } async function fetchTvdbEpisodeExtended(episodeId, preferEn = false) { if (!episodeId) return null; const cacheKey = `episode-${episodeId}-extended-${preferEn ? "en" : "tr"}`; 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 || []; if (!base.overview) { const localizedOverview = tvdbPickTranslation( overviewTranslations, "overview", preferEn ); if (localizedOverview) base.overview = localizedOverview; } if (!base.name) { const localizedName = tvdbPickTranslation( nameTranslations, "name", preferEn ); 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 (DISABLE_MEDIA_PROCESSING) return null; 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}` ); } } if (!seriesData && candidateKeys.length && normalizedRoot !== ANIME_ROOT_FOLDER) { for (const key of candidateKeys) { const candidatePaths = tvSeriesPathsByKey(key); if (!fs.existsSync(candidatePaths.metadata)) continue; try { seriesData = JSON.parse(fs.readFileSync(candidatePaths.metadata, "utf-8")) || {}; existingPaths = candidatePaths; break; } catch (err) { console.warn( `⚠️ series.json okunamadı (${candidatePaths.metadata}): ${err.message}` ); } } } const legacyPaths = tvSeriesPaths(normalizedRoot); if ( !seriesData && normalizedRoot !== ANIME_ROOT_FOLDER && 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 preferEn = rootFolder === ANIME_ROOT_FOLDER; const localizedName = tvdbPickTranslation( nameTranslations, "name", preferEn ); const localizedOverview = tvdbPickTranslation( overviewTranslations, "overview", preferEn ); if (preferEn && localizedName) { seriesData.name = localizedName; } else { seriesData.name = seriesData.name || info.name || info.seriesName || localizedName || seriesInfo.title; } seriesData.slug = seriesData.slug || info.slug || info.slugged || null; if (preferEn && localizedOverview) { seriesData.overview = localizedOverview; } else { 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, preferEn ); 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 tvDataRootForRoot(rootFolder) { const safeRoot = sanitizeRelative(rootFolder); return safeRoot === ANIME_ROOT_FOLDER ? ANIME_DATA_ROOT : TV_DATA_ROOT; } function tvDataRootForKey(key) { const { rootFolder } = parseTvSeriesKey(key); return tvDataRootForRoot(rootFolder); } function tvSeriesPathsByKey(key) { const normalizedKey = sanitizeRelative(key); const dataRoot = tvDataRootForKey(normalizedKey); const dir = path.join(dataRoot, 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, dataRoot }; } 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 []; const dataRoot = tvDataRootForRoot(normalizedRoot); if (!fs.existsSync(dataRoot)) return []; const keys = []; try { const entries = fs.readdirSync(dataRoot, { 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 (${dataRoot}): ${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 collectSeriesIdsForPath(info, oldRel, isDirectory) { const ids = new Set(); if (!info || typeof info !== "object") return ids; const normalizedOldRel = normalizeTrashPath(oldRel); const shouldMatch = (key) => { if (!normalizedOldRel) return true; const normalizedKey = normalizeTrashPath(key); if (normalizedKey === normalizedOldRel) return true; return ( isDirectory && normalizedOldRel && normalizedKey.startsWith(`${normalizedOldRel}/`) ); }; const episodes = info.seriesEpisodes || {}; for (const [key, value] of Object.entries(episodes)) { if (!shouldMatch(key)) continue; const id = value?.showId ?? value?.id ?? null; if (id) ids.add(id); } const files = info.files || {}; for (const [key, value] of Object.entries(files)) { if (!shouldMatch(key)) continue; const id = value?.seriesMatch?.id ?? null; if (id) ids.add(id); } return ids; } function collectMovieRelPathsForMove(info, oldRel, isDirectory) { const relPaths = new Set(); if (!info || typeof info !== "object") return relPaths; const normalizedOldRel = normalizeTrashPath(oldRel); const shouldMatch = (key) => { if (!normalizedOldRel) return true; const normalizedKey = normalizeTrashPath(key); if (normalizedKey === normalizedOldRel) return true; return ( isDirectory && normalizedOldRel && normalizedKey.startsWith(`${normalizedOldRel}/`) ); }; if (info.primaryVideoPath && shouldMatch(info.primaryVideoPath)) { relPaths.add(normalizeTrashPath(info.primaryVideoPath)); } const files = info.files || {}; for (const [key, value] of Object.entries(files)) { if (!value?.movieMatch) continue; if (!shouldMatch(key)) continue; relPaths.add(normalizeTrashPath(key)); } return relPaths; } function mapRelPathForMove(oldRel, newRel, relPath, isDirectory) { const normalizedOldRel = normalizeTrashPath(oldRel); const normalizedNewRel = normalizeTrashPath(newRel); const normalizedRel = normalizeTrashPath(relPath); if (!normalizedOldRel) return normalizedRel; if (normalizedRel === normalizedOldRel) return normalizedNewRel; if ( isDirectory && normalizedRel.startsWith(`${normalizedOldRel}/`) ) { const suffix = normalizedRel.slice(normalizedOldRel.length).replace(/^\/+/, ""); return normalizedNewRel ? `${normalizedNewRel}${suffix ? `/${suffix}` : ""}` : suffix; } return normalizedRel; } function moveMovieDataDir(oldKey, newKey, oldRoot, newRoot, newRelPath) { if (!oldKey || !newKey) return false; if (oldKey === newKey) return false; const oldDir = movieDataPathsByKey(oldKey).dir; const newDir = movieDataPathsByKey(newKey).dir; if (!fs.existsSync(oldDir)) return false; if (fs.existsSync(newDir)) { try { fs.rmSync(newDir, { recursive: true, force: true }); } catch (err) { console.warn(`⚠️ Movie metadata hedefi temizlenemedi (${newDir}): ${err.message}`); } } try { fs.renameSync(oldDir, newDir); } catch (err) { try { fs.cpSync(oldDir, newDir, { recursive: true }); fs.rmSync(oldDir, { recursive: true, force: true }); } catch (copyErr) { console.warn(`⚠️ Movie metadata taşınamadı (${oldDir}): ${copyErr.message}`); return false; } } const metadataPath = path.join(newDir, "metadata.json"); if (fs.existsSync(metadataPath)) { try { const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); if (metadata?._dupe) { metadata._dupe.folder = newRoot; metadata._dupe.videoPath = newRelPath; metadata._dupe.cacheKey = newKey; } fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), "utf-8"); } catch (err) { console.warn(`⚠️ movie metadata güncellenemedi (${metadataPath}): ${err.message}`); } } return true; } function moveMovieDataBetweenRoots(oldRoot, newRoot, oldRel, newRel, relPaths, isDirectory) { if (!oldRoot || !newRoot) return false; if (!relPaths || relPaths.size === 0) return false; let movedAny = false; for (const relPath of relPaths) { const mappedRel = mapRelPathForMove(oldRel, newRel, relPath, isDirectory); const oldKey = movieDataKey(oldRoot, relPath); const newKey = movieDataKey(newRoot, mappedRel); if (moveMovieDataDir(oldKey, newKey, oldRoot, newRoot, mappedRel)) { movedAny = true; } } return movedAny; } function moveMovieDataWithinRoot(rootFolder, oldRel, newRel, relPaths, isDirectory) { if (!rootFolder) return false; if (!relPaths || relPaths.size === 0) return false; let movedAny = false; for (const relPath of relPaths) { const mappedRel = mapRelPathForMove(oldRel, newRel, relPath, isDirectory); const oldKey = movieDataKey(rootFolder, relPath); const newKey = movieDataKey(rootFolder, mappedRel); if (moveMovieDataDir(oldKey, newKey, rootFolder, rootFolder, mappedRel)) { movedAny = true; } } return movedAny; } function updateSeriesJsonAfterRootMove(seriesData, oldRoot, newRoot, oldRel, newRel) { if (!seriesData || typeof seriesData !== "object") return false; let changed = false; const oldKey = seriesData?._dupe?.key || null; if (seriesData._dupe) { seriesData._dupe.folder = newRoot; seriesData._dupe.key = tvSeriesKey(newRoot, seriesData._dupe.seriesId); changed = true; } const encodeKey = (key) => String(key || "") .split(path.sep) .map(encodeURIComponent) .join("/"); const oldKeyEncoded = oldKey ? encodeKey(oldKey) : null; const newKeyEncoded = seriesData?._dupe?.key ? encodeKey(seriesData._dupe.key) : null; const oldPrefix = oldKeyEncoded ? `/tv-data/${oldKeyEncoded}/` : null; const newPrefix = newKeyEncoded ? `/tv-data/${newKeyEncoded}/` : null; const replaceTvDataPath = (value) => { if (!value || !oldPrefix || !newPrefix || typeof value !== "string") { return value; } if (value.includes(oldPrefix)) { changed = true; return value.replace(oldPrefix, newPrefix); } return value; }; if (seriesData.poster) seriesData.poster = replaceTvDataPath(seriesData.poster); if (seriesData.backdrop) seriesData.backdrop = replaceTvDataPath(seriesData.backdrop); const oldRelNorm = normalizeTrashPath(oldRel); const newRelNorm = normalizeTrashPath(newRel); const shouldTransform = (value) => { const normalized = normalizeTrashPath(value); if (!oldRelNorm) return true; if (normalized === oldRelNorm) return true; return ( oldRelNorm && normalized.startsWith(`${oldRelNorm}/`) ); }; const transformRel = (value) => { const normalized = normalizeTrashPath(value); if (!shouldTransform(normalized)) return value; const suffix = oldRelNorm ? normalized.slice(oldRelNorm.length).replace(/^\/+/, "") : normalized; const next = newRelNorm ? `${newRelNorm}${suffix ? `/${suffix}` : ""}` : suffix; if (next !== value) changed = true; return next; }; const seasons = seriesData?.seasons || {}; for (const season of Object.values(seasons)) { if (!season) continue; if (season.poster) season.poster = replaceTvDataPath(season.poster); if (!season.episodes) continue; for (const episode of Object.values(season.episodes)) { if (!episode) continue; if (episode.still) episode.still = replaceTvDataPath(episode.still); if (episode.file) { const nextFile = transformRel(episode.file); if (nextFile !== episode.file) { episode.file = nextFile; changed = true; } } if (episode.videoPath) { const video = String(episode.videoPath).replace(/\\/g, "/"); if (video.startsWith(`${oldRoot}/`)) { episode.videoPath = `${newRoot}/${video.slice(oldRoot.length + 1)}`; changed = true; } else { const nextVideo = transformRel(video); if (nextVideo !== video) { episode.videoPath = nextVideo; changed = true; } } } } } return changed; } function moveSeriesDataBetweenRoots(oldRoot, newRoot, oldRel, newRel, seriesIds) { if (!oldRoot || !newRoot) return false; if (!seriesIds || !seriesIds.size) return false; let movedAny = false; for (const seriesId of seriesIds) { if (!seriesId) continue; const oldPaths = tvSeriesPaths(oldRoot, seriesId); if (!oldPaths || !fs.existsSync(oldPaths.dir)) continue; const newKey = tvSeriesKey(newRoot, seriesId); if (!newKey) continue; const newPaths = tvSeriesPathsByKey(newKey); if (!newPaths) continue; if (fs.existsSync(newPaths.dir)) { try { fs.rmSync(newPaths.dir, { recursive: true, force: true }); } catch (err) { console.warn(`⚠️ TV metadata hedefi temizlenemedi (${newPaths.dir}): ${err.message}`); } } try { fs.renameSync(oldPaths.dir, newPaths.dir); } catch (err) { try { fs.cpSync(oldPaths.dir, newPaths.dir, { recursive: true }); fs.rmSync(oldPaths.dir, { recursive: true, force: true }); } catch (copyErr) { console.warn(`⚠️ TV metadata taşınamadı (${oldPaths.dir}): ${copyErr.message}`); continue; } } const metadataPath = path.join(newPaths.dir, "series.json"); if (fs.existsSync(metadataPath)) { try { const seriesData = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); const changed = updateSeriesJsonAfterRootMove( seriesData, oldRoot, newRoot, oldRel, newRel ); if (changed) { fs.writeFileSync( metadataPath, JSON.stringify(seriesData, null, 2), "utf-8" ); } } catch (err) { console.warn(`⚠️ series.json güncellenemedi (${metadataPath}): ${err.message}`); } } movedAny = true; } if (movedAny) { renameSeriesDataPaths(newRoot, oldRel, newRel); } return movedAny; } 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, ANIME_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)); } const DISK_SPACE_CACHE_TTL_MS = 30000; const DOWNLOADS_SIZE_CACHE_TTL_MS = 5 * 60 * 1000; let diskSpaceCache = { value: null, fetchedAt: 0 }; let downloadsSizeCache = { value: null, fetchedAt: 0 }; let diskInfoInFlight = null; let lastDiskSpacePayload = null; async function getCachedDiskInfo({ force = false } = {}) { const now = Date.now(); const diskFresh = !force && diskSpaceCache.value && now - diskSpaceCache.fetchedAt < DISK_SPACE_CACHE_TTL_MS; const downloadsFresh = !force && downloadsSizeCache.value && now - downloadsSizeCache.fetchedAt < DOWNLOADS_SIZE_CACHE_TTL_MS; if (diskFresh && downloadsFresh) { return { ...diskSpaceCache.value, downloads: downloadsSizeCache.value, timestamp: new Date().toISOString() }; } if (diskInfoInFlight) return diskInfoInFlight; diskInfoInFlight = (async () => { const diskSpace = diskFresh ? diskSpaceCache.value : await getDiskSpace(DOWNLOAD_DIR); if (!diskFresh) { diskSpaceCache = { value: diskSpace, fetchedAt: now }; } const downloadsSize = downloadsFresh ? downloadsSizeCache.value : await getDownloadsSize(DOWNLOAD_DIR); if (!downloadsFresh) { downloadsSizeCache = { value: downloadsSize, fetchedAt: now }; } return { ...diskSpace, downloads: downloadsSize, timestamp: new Date().toISOString() }; })(); try { return await diskInfoInFlight; } finally { diskInfoInFlight = null; } } function broadcastDiskSpace() { if (!wss || !hasActiveWsClients()) return; getCachedDiskInfo() .then((diskInfo) => { const data = JSON.stringify({ type: "diskSpace", data: diskInfo }); if (data === lastDiskSpacePayload) return; lastDiskSpacePayload = data; wss.clients.forEach((c) => c.readyState === 1 && c.send(data)); }) .catch((err) => { console.error("❌ Disk space broadcast error:", err.message); }); } let lastSnapshotPayload = null; const SNAPSHOT_DEBOUNCE_MS = 1000; let snapshotTimer = null; let lastSnapshotAt = 0; function broadcastSnapshot() { if (!wss || !hasActiveWsClients()) return; const data = JSON.stringify({ type: "progress", torrents: snapshot() }); if (data === lastSnapshotPayload) return; lastSnapshotPayload = data; wss.clients.forEach((c) => c.readyState === 1 && c.send(data)); } function scheduleSnapshotBroadcast() { if (!wss || !hasActiveWsClients()) return; const now = Date.now(); const remaining = SNAPSHOT_DEBOUNCE_MS - (now - lastSnapshotAt); if (remaining <= 0) { lastSnapshotAt = now; broadcastSnapshot(); return; } if (snapshotTimer) return; snapshotTimer = setTimeout(() => { snapshotTimer = null; lastSnapshotAt = Date.now(); broadcastSnapshot(); }, remaining); } let mediaRescanTask = null; let pendingMediaRescan = { movies: false, tv: false }; let lastMediaRescanReason = "manual"; function queueMediaRescan({ movies = false, tv = false, reason = "manual" } = {}) { if (DISABLE_MEDIA_PROCESSING) return; 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 }; } const THUMBNAIL_CHECK_INTERVAL_MS = 15000; function hasActiveWsClients() { if (!wss) return false; let hasActive = false; wss.clients.forEach((c) => { if (c.readyState === 1) hasActive = true; }); return hasActive; } function ensureTorrentSnapshotCache(entry) { if (!entry) return entry; const { torrent, savePath } = entry; if (!entry.rootFolder && savePath) entry.rootFolder = path.basename(savePath); if (!entry.filesSnapshot && Array.isArray(torrent?.files)) { entry.filesSnapshot = torrent.files.map((f, i) => ({ index: i, name: f.name, length: f.length })); } if ( entry.bestVideoIndex === undefined || entry.bestVideoIndex === null ) { entry.bestVideoIndex = pickBestVideoFile(torrent); } const bestVideo = torrent?.files?.[entry.bestVideoIndex] || torrent?.files?.[0]; if (!bestVideo || !savePath || !entry.rootFolder) return entry; const now = Date.now(); const shouldCheckThumbnail = !entry.thumbnail || !entry.thumbnailCheckedAt || now - entry.thumbnailCheckedAt > THUMBNAIL_CHECK_INTERVAL_MS; if (shouldCheckThumbnail) { const relPath = path.join(entry.rootFolder, bestVideo.path); const { relThumb, absThumb } = getVideoThumbnailPaths(relPath); entry.thumbnailCheckedAt = now; entry.thumbnailRelPath = relThumb; if (fs.existsSync(absThumb)) { entry.thumbnail = thumbnailUrl(relThumb); entry.thumbnailQueued = false; } else if (torrent?.progress === 1 || torrent?.done) { if (!entry.thumbnailQueued) { queueVideoThumbnail(path.join(savePath, bestVideo.path), relPath); entry.thumbnailQueued = true; } } } return entry; } // --- Snapshot (thumbnail dahil, tracker + tarih eklendi) --- function snapshot() { const torrentEntries = Array.from(torrents.values()).map((entry) => { const { torrent, selectedIndex, savePath, added, paused } = entry; ensureTorrentSnapshotCache(entry); const rootFolder = entry?.rootFolder || path.basename(savePath); 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: entry?.filesSnapshot || [], selectedIndex, thumbnail: entry?.thumbnail || null, moveToGdrive: entry?.moveToGdrive || false, moveStatus: entry?.moveStatus || "idle", moveError: entry?.moveError || null, moveProgress: entry?.moveProgress ?? null, moveTotalBytes: entry?.moveTotalBytes ?? null }; } ); const youtubeEntries = Array.from(youtubeJobs.values()).map((job) => youtubeSnapshot(job) ); const mailruEntries = Array.from(mailruJobs.values()).map((job) => mailruSnapshot(job) ); const combined = [...torrentEntries, ...youtubeEntries, ...mailruEntries]; combined.sort((a, b) => (b.added || 0) - (a.added || 0)); return combined; } function wireTorrent( torrent, { savePath, added, respond, restored = false, moveToGdrive = false } ) { torrents.set(torrent.infoHash, { torrent, selectedIndex: 0, savePath, added, paused: false, filesSnapshot: null, thumbnail: null, thumbnailCheckedAt: 0, thumbnailQueued: false, bestVideoIndex: null, rootFolder: savePath ? path.basename(savePath) : null, moveToGdrive: Boolean(moveToGdrive), moveStatus: "idle", moveError: null, moveProgress: null, moveTotalBytes: null }); const scheduleTorrentSnapshot = () => scheduleSnapshotBroadcast(); torrent.on("download", scheduleTorrentSnapshot); torrent.on("upload", scheduleTorrentSnapshot); torrent.on("wire", scheduleTorrentSnapshot); torrent.on("noPeers", scheduleTorrentSnapshot); torrent.on("metadata", scheduleTorrentSnapshot); torrent.on("warning", scheduleTorrentSnapshot); torrent.on("error", scheduleTorrentSnapshot); torrent.on("ready", () => { onTorrentReady({ torrent, savePath, added, respond, restored }); }); torrent.on("done", () => { onTorrentDone({ torrent }); }); } function onTorrentReady({ torrent, savePath, added, respond }) { const selectedIndex = pickBestVideoFile(torrent); const existing = torrents.get(torrent.infoHash) || {}; torrents.set(torrent.infoHash, { torrent, selectedIndex, savePath, added, paused: false, filesSnapshot: existing.filesSnapshot || null, thumbnail: existing.thumbnail || null, thumbnailCheckedAt: existing.thumbnailCheckedAt || 0, thumbnailQueued: existing.thumbnailQueued || false, bestVideoIndex: selectedIndex, rootFolder: savePath ? path.basename(savePath) : existing.rootFolder || null, moveToGdrive: existing.moveToGdrive || false, moveStatus: existing.moveStatus || "idle", moveError: existing.moveError || null }); 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); scheduleSnapshotBroadcast(); } 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, audioOnly: false }) }; const seriesInfo = parseSeriesInfo(file.name); if (seriesInfo && !DISABLE_MEDIA_PROCESSING) { 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, audioOnly: false }); } } 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; } if (!DISABLE_MEDIA_PROCESSING) { const ensuredMedia = await ensureMovieData( rootFolder, displayName, bestVideoPath, primaryMediaInfo ); if (ensuredMedia?.mediaInfo) { infoUpdate.primaryMediaInfo = ensuredMedia.mediaInfo; if (!infoUpdate.files) infoUpdate.files = perFileMetadata; if (bestVideoPath) { const fileEntry = infoUpdate.files[bestVideoPath] || {}; infoUpdate.files[bestVideoPath] = { ...fileEntry, 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() } : fileEntry.movieMatch }; const movieType = determineMediaType({ tracker: torrent.announce?.[0] || null, movieMatch: ensuredMedia.metadata, seriesEpisode: seriesEpisodes[bestVideoPath] || null, categories: null, relPath: bestVideoPath, audioOnly: false }); 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(); if (AUTO_PAUSE_ON_COMPLETE) { const paused = pauseTorrentEntry(entry); if (paused) { console.log(`⏸️ Torrent otomatik durduruldu: ${torrent.infoHash}`); } } // 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, audioOnly: false }); infoUpdate.type = rootType; infoUpdate.files[bestVideoPath].type = infoUpdate.files[bestVideoPath].type || rootType; } if (entry.moveToGdrive) { const paused = pauseTorrentEntry(entry); if (paused) { console.log(`⏸️ GDrive taşıma için torrent durduruldu: ${entry.infoHash}`); } entry.moveStatus = "queued"; entry.moveError = null; entry.moveProgress = 0; entry.moveTotalBytes = entry.totalBytes || torrent?.length || computeRootVideoBytes(rootFolder) || null; scheduleSnapshotBroadcast(); startRcloneStatsPolling(); const moveResult = await moveRootFolderToGdrive(rootFolder); if (moveResult.ok) { entry.moveStatus = "uploading"; scheduleSnapshotBroadcast(); } else { entry.moveStatus = "error"; entry.moveError = moveResult.error || "GDrive taşıma hatası"; logRcloneMoveError(`torrent:${entry.infoHash}`, entry.moveError); } broadcastFileUpdate("downloads"); } scheduleSnapshotBroadcast(); } // 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" }); const rcloneSettings = loadRcloneSettings(); const moveToGdrive = ["1", "true", "yes", "on"].includes( String(req.body.moveToGdrive || "").toLowerCase() ) || Boolean(rcloneSettings.autoMove); // 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), moveToGdrive }); } catch (err) { res.status(500).json({ error: err.message }); } }); // --- ☁️ Transfer için GDrive taşıma ayarı --- app.post("/api/transfer/:id/gdrive", requireAuth, (req, res) => { const id = req.params.id; const enabled = ["1", "true", "yes", "on"].includes( String(req.body?.enabled || "").toLowerCase() ); const torrentEntry = torrents.get(id); if (torrentEntry) { torrentEntry.moveToGdrive = enabled; scheduleSnapshotBroadcast(); return res.json({ ok: true }); } const ytJob = youtubeJobs.get(id); if (ytJob) { ytJob.moveToGdrive = enabled; scheduleSnapshotBroadcast(); return res.json({ ok: true }); } const mailJob = mailruJobs.get(id); if (mailJob) { mailJob.moveToGdrive = enabled; scheduleSnapshotBroadcast(); return res.json({ ok: true }); } return res.status(404).json({ ok: false, error: "Transfer bulunamadı" }); }); // --- 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) { const mailJob = mailruJobs.get(req.params.hash); if (!mailJob) { return res.status(404).json({ error: "torrent bulunamadı" }); } const targetIndex = Number(req.params.index) || 0; if (!mailJob.files?.[targetIndex]) { return res .status(400) .json({ error: "Geçerli bir video dosyası bulunamadı" }); } mailJob.selectedIndex = targetIndex; return res.json({ ok: true, selectedIndex: targetIndex }); } const targetIndex = Number(req.params.index) || 0; if (!job.files?.[targetIndex]) { return res .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 }); } const mailRemoved = removeMailRuJob(req.params.hash, { removeFiles: true }); if (mailRemoved) { return res.json({ ok: true, filesRemoved: true }); } return res.status(404).json({ error: "torrent bulunamadı" }); } 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.` ); } scheduleSnapshotBroadcast(); 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; scheduleSnapshotBroadcast(); 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; scheduleSnapshotBroadcast(); 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 = resolveStoragePath(safeRel); if (!fullPath || !fs.existsSync(fullPath)) { console.error("File not found:", fullPath); return res.status(404).send("File not found"); } if (MEDIA_DEBUG_LOG) { const source = fullPath.startsWith(GDRIVE_ROOT) ? "gdrive" : fullPath.startsWith(DOWNLOAD_DIR) ? "local" : "unknown"; console.log("🎬 Media stream source:", { rel: safeRel, fullPath, source }); } 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); // Dosyanın nerede olduğunu bul (DOWNLOAD_DIR veya GDRIVE_ROOT) let fullPath = null; let storageBase = null; // Dosyanın bulunduğu storage base (DOWNLOAD_DIR veya GDRIVE_ROOT) for (const baseDir of getStorageRoots()) { const testPath = path.join(baseDir, safePath); if (fs.existsSync(testPath)) { fullPath = testPath; storageBase = baseDir; break; } } // Dosya bulunamadı if (!fullPath) { return res.status(404).json({ error: "Dosya bulunamadı" }); } let folderId = (safePath.split(/[\/]/)[0] || "").trim(); let rootDir = null; let folderIsDirectory = false; // GDrive'da ise special handling - GDrive'ın kendisi root olarak kabul edilir const isGDriveFile = storageBase === GDRIVE_ROOT; if (isGDriveFile) { // GDrive'da klasör yapısı farklıdır // folderId varsa ve GDRIVE_ROOT/folderId bir klasörse const testRootDir = path.join(GDRIVE_ROOT, folderId); if (folderId && fs.existsSync(testRootDir)) { try { folderIsDirectory = fs.statSync(testRootDir).isDirectory(); if (folderIsDirectory) { rootDir = GDRIVE_ROOT; // GDrive root'u } } catch (err) { folderIsDirectory = false; } } } else { // Downloads klasörü için normal mantık rootDir = folderId ? path.join(DOWNLOAD_DIR, folderId) : null; if (rootDir && fs.existsSync(rootDir)) { try { folderIsDirectory = fs.statSync(rootDir).isDirectory(); } catch (err) { folderIsDirectory = false; } } } // Kök dosyalarda ilk segment dosya adıdır; klasör değilse root davranışı uygula if (folderId && !folderIsDirectory) { folderId = ""; rootDir = null; } let mediaFlags = { movies: false, tv: false }; let stats = null; 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 && folderIsDirectory && (!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; // GDrive dosyaları için özel handling - doğrudan sil const isGDriveFile = storageBase === GDRIVE_ROOT; if (isGDriveFile) { // GDrive dosyaları için doğrudan silme (trash sistemi yok) try { if (isDirectory) { fs.rmSync(fullPath, { recursive: true, force: true }); console.log(`🗑️ GDrive klasör silindi: ${safePath}`); } else { fs.unlinkSync(fullPath); console.log(`🗑️ GDrive dosya silindi: ${safePath}`); } removeThumbnailsForPath(safePath); broadcastFileUpdate("gdrive"); broadcastDiskSpace(); return res.json({ ok: true, filesRemoved: true, deletedFrom: "gdrive" }); } catch (deleteErr) { console.error(`❌ GDrive dosya silme hatası: ${deleteErr.message}`); return res.status(500).json({ error: `Silme hatası: ${deleteErr.message}` }); } } // Downloads klasörü için normal trash sistemi if (folderId && folderIsDirectory && rootDir) { const infoBeforeDelete = readInfoForRoot(folderId); mediaFlags = detectMediaFlagsForPath( infoBeforeDelete, relWithinRoot, isDirectory ); } else { mediaFlags = { movies: false, tv: false }; } if (folderId && folderIsDirectory && 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 dosyaları root-trash sistemine taşı const rootTrashEntry = addRootTrashEntry(safePath, fullPath, stats); if (!rootTrashEntry) { return res.status(500).json({ error: "Kök dosya çöpe taşınamadı" }); } // Anime cache/metadata'dan ilgili bölümü kaldır removeSeriesEpisode(ANIME_ROOT_FOLDER, safePath); removeThumbnailsForPath(safePath); broadcastFileUpdate("downloads"); broadcastDiskSpace(); return res.json({ ok: true, filesRemoved: true, rootTrashed: true }); } if (folderId) { 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}`); scheduleSnapshotBroadcast(); // Torrent silindiğinde disk space bilgisini güncelle broadcastDiskSpace(); }); } else { scheduleSnapshotBroadcast(); } } else { scheduleSnapshotBroadcast(); } 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" }); } const preMoveInfo = sourceRoot ? readInfoForRoot(sourceRoot) : null; const affectedSeriesIds = collectSeriesIdsForPath( preMoveInfo, sourceRelWithinRoot, isDirectory ); const affectedMovieRelPaths = collectMovieRelPathsForMove( preMoveInfo, sourceRelWithinRoot, isDirectory ); 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 (affectedMovieRelPaths.size) { moveMovieDataWithinRoot( sourceRoot, sourceRelWithinRoot, destRelWithinRoot, affectedMovieRelPaths, isDirectory ); } if (isDirectory) { removeThumbnailsForDirectory(sourceRoot, sourceRelWithinRoot); } else { removeThumbnailsForPath(normalizedSource); } trashStateCache.delete(sourceRoot); } else { if (movedAcrossRoots) { moveInfoDataBetweenRoots( sourceRoot, destRoot, sourceRelWithinRoot, destRelWithinRoot, isDirectory ); if (affectedSeriesIds.size) { moveSeriesDataBetweenRoots( sourceRoot, destRoot, sourceRelWithinRoot, destRelWithinRoot, affectedSeriesIds ); } if (affectedMovieRelPaths.size) { moveMovieDataBetweenRoots( sourceRoot, destRoot, sourceRelWithinRoot, destRelWithinRoot, affectedMovieRelPaths, 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 results = new Map(); const addResult = (entry, key) => { if (results.has(key)) return; results.set(key, entry); }; const walk = (baseDir, dir) => { const list = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of list) { const full = path.join(dir, entry.name); const rel = path.relative(baseDir, 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; const record = { 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, }; addResult(record, `d:${safeRel}`); walk(baseDir, 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; const record = { 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 }; addResult(record, `f:${safeRel}`); } } return null; }; try { for (const base of getStorageRoots()) { if (!fs.existsSync(base)) continue; walk(base, base); } res.json(Array.from(results.values())); } 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 }); } } // Root trash öğelerini ekle (kök dosyalar için) const rootRegistry = readRootTrashRegistry(); for (const item of rootRegistry.items) { const originalName = sanitizeRelative(item.originalName || ""); const storedName = sanitizeRelative(item.storedName || ""); if (!originalName || !storedName) continue; const storedPath = path.join(ROOT_TRASH_DIR, storedName); if (!fs.existsSync(storedPath)) { removeRootTrashEntry(originalName); continue; } let stat = null; try { stat = fs.statSync(storedPath); } catch (err) { continue; } const isDirectory = stat?.isDirectory?.() || false; const type = isDirectory ? "inode/directory" : mime.lookup(originalName) || item.type || "application/octet-stream"; const trashName = `${ROOT_TRASH_PREFIX}/${originalName}`; result.push({ name: trashName, trashName, size: stat?.size ?? 0, type, isDirectory, thumbnail: null, mediaInfo: null, movedAt: Number(item.deletedAt) || Date.now(), originalPath: originalName, folderId: ROOT_TRASH_PREFIX }); } result.sort((a, b) => (b.movedAt || 0) - (a.movedAt || 0)); res.json(result); } catch (err) { 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, async (req, res) => { try { const { trashName } = req.body; if (!trashName) { return res.status(400).json({ error: "trashName gerekli" }); } const safeName = sanitizeRelative(trashName); if (isRootTrashName(safeName)) { const originalName = parseRootTrashName(safeName); if (!originalName) { return res.status(400).json({ error: "Geçersiz root trashName" }); } const removed = removeRootTrashEntry(originalName); if (!removed?.storedName) { return res.status(404).json({ error: "Root çöp öğesi bulunamadı" }); } const storedName = sanitizeRelative(removed.storedName); const storedPath = path.join(ROOT_TRASH_DIR, storedName); const targetPath = path.join(DOWNLOAD_DIR, originalName); if (!fs.existsSync(storedPath)) { return res .status(404) .json({ error: "Root çöp dosyası bulunamadı" }); } ensureDirForFile(targetPath); try { fs.renameSync(storedPath, targetPath); } catch (err) { if (err?.code === "EXDEV") { let stat = null; try { stat = fs.statSync(storedPath); } catch (statErr) { return res.status(500).json({ error: "Root çöp dosyası okunamadı" }); } try { if (stat?.isDirectory?.()) { fs.cpSync(storedPath, targetPath, { recursive: true }); fs.rmSync(storedPath, { recursive: true, force: true }); } else { fs.copyFileSync(storedPath, targetPath); fs.rmSync(storedPath, { force: true }); } } catch (copyErr) { console.warn( `⚠️ root-trash restore EXDEV hatası (${storedPath}): ${copyErr.message}` ); return res .status(500) .json({ error: "Root çöp dosyası taşınamadı" }); } } else { throw err; } } console.log(`♻️ Root öğe geri yüklendi: ${originalName}`); const animeSeriesInfo = parseAnimeSeriesInfo(originalName); if (animeSeriesInfo) { const mediaInfo = await extractMediaInfo(targetPath).catch(() => null); await ensureSeriesData( ANIME_ROOT_FOLDER, originalName, animeSeriesInfo, mediaInfo ); } broadcastFileUpdate("downloads"); broadcastDiskSpace(); return res.json({ success: true, message: "Öğe başarıyla geri yüklendi", folderId: "downloads" }); } const segments = safeName.split(/[\\/]/).filter(Boolean); if (!segments.length) { return res.status(400).json({ error: "Geçersiz trashName" }); } 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); if (isRootTrashName(safeName)) { const originalName = parseRootTrashName(safeName); if (!originalName) { return res.status(400).json({ error: "Geçersiz root trashName" }); } const removed = removeRootTrashEntry(originalName); if (!removed?.storedName) { return res.status(404).json({ error: "Root çöp öğesi bulunamadı" }); } const storedName = sanitizeRelative(removed.storedName); const storedPath = path.join(ROOT_TRASH_DIR, storedName); if (fs.existsSync(storedPath)) { try { fs.rmSync(storedPath, { recursive: true, force: true }); } catch (err) { console.warn( `⚠️ Root çöp öğesi silinemedi (${storedPath}): ${err.message}` ); } } console.log(`🗑️ Root öğe kalıcı olarak silindi: ${originalName}`); broadcastFileUpdate("downloads"); broadcastDiskSpace(); return res.json({ success: true, message: "Öğe tamamen silindi" }); } const segments = safeName.split(/[\\/]/).filter(Boolean); if (!segments.length) { return res.status(400).json({ error: "Geçersiz trashName" }); } 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 rawMovies = 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 absVideo = resolveMovieVideoAbsPath(metadata); if (!absVideo) { try { fs.rmSync(paths.dir, { recursive: true, force: true }); } catch (err) { console.warn( `⚠️ Movie metadata temizlenemedi (${paths.dir}): ${err.message}` ); } return 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 { _absVideo: absVideo, _cacheDir: paths.dir, _cacheKey: cacheKey, _dedupeKey: typeof metadata.id === "number" && metadata.id ? `id:${metadata.id}` : `title:${(metadata.title || metadata.matched_title || rootFolder) .toLowerCase()}-${year || "unknown"}`, 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); const dedupedMap = new Map(); for (const item of rawMovies) { const key = item._dedupeKey; if (!dedupedMap.has(key)) { dedupedMap.set(key, item); continue; } const existing = dedupedMap.get(key); const existingScore = existing?.metadata?._dupe?.fetchedAt || existing?.metadata?._dupe?.matchedAt || existing?.metadata?.matchedAt || 0; const nextScore = item?.metadata?._dupe?.fetchedAt || item?.metadata?._dupe?.matchedAt || item?.metadata?.matchedAt || 0; if (nextScore > existingScore) { if (existing?._cacheDir) { try { fs.rmSync(existing._cacheDir, { recursive: true, force: true }); } catch (err) { console.warn( `⚠️ Movie metadata temizlenemedi (${existing._cacheDir}): ${err.message}` ); } } dedupedMap.set(key, item); } else if (item?._cacheDir) { try { fs.rmSync(item._cacheDir, { recursive: true, force: true }); } catch (err) { console.warn( `⚠️ Movie metadata temizlenemedi (${item._cacheDir}): ${err.message}` ); } } } const movies = Array.from(dedupedMap.values()).map((item) => { const { _absVideo, _cacheDir, _cacheKey, _dedupeKey, ...rest } = item; return rest; }); 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 (DISABLE_MEDIA_PROCESSING) { console.log("🎬 Medya işlemleri kapalı; movie metadata taraması atlandı."); return []; } 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 = listStorageRootFolders(); const processed = []; for (const dirent of dirEntries) { const folder = sanitizeRelative(dirent.rootFolder || dirent.name); if (!folder) continue; const baseDir = dirent.baseDir || DOWNLOAD_DIR; const rootDir = path.join(baseDir, 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 rcloneSettings = loadRcloneSettings(); const moveToGdrive = ["1", "true", "yes", "on"].includes( String(req.body?.moveToGdrive || "").toLowerCase() ) || Boolean(rcloneSettings.autoMove); const job = startYoutubeDownload(rawUrl, { moveToGdrive }); 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." }); } }); app.post("/api/mailru/download", requireAuth, async (req, res) => { try { const rawUrl = req.body?.url; const rcloneSettings = loadRcloneSettings(); const moveToGdrive = ["1", "true", "yes", "on"].includes( String(req.body?.moveToGdrive || "").toLowerCase() ) || Boolean(rcloneSettings.autoMove); const job = await startMailRuDownload(rawUrl, { moveToGdrive }); if (!job) { return res .status(400) .json({ ok: false, error: "Geçerli bir Mail.ru URL'si gerekli." }); } res.json({ ok: true, jobId: job.id }); } catch (err) { res.status(500).json({ ok: false, error: err?.message || "Mail.ru indirimi başarısız oldu." }); } }); app.post("/api/mailru/match", requireAuth, async (req, res) => { try { const { jobId, metadata, season, episode } = req.body || {}; if (!jobId || !metadata) { return res.status(400).json({ ok: false, error: "jobId ve metadata gerekli." }); } const job = mailruJobs.get(jobId); if (!job) { return res.status(404).json({ ok: false, error: "Mail.ru işi bulunamadı." }); } if (job.state !== "awaiting_match") { if (job.match && job.fileName) { return res.json({ ok: true, jobId: job.id, fileName: job.fileName, alreadyMatched: true }); } return res.status(400).json({ ok: false, error: "Mail.ru işi eşleştirme beklemiyor." }); } const safeSeason = Number(season) || 1; const safeEpisode = Number(episode) || 1; const title = metadata.title || metadata.name || "Anime"; const seriesInfo = { title, searchTitle: title, season: safeSeason, episode: safeEpisode, key: `S${String(safeSeason).padStart(2, "0")}E${String(safeEpisode).padStart(2, "0")}` }; job.match = { id: metadata.id || null, title, season: safeSeason, episode: safeEpisode, matchedAt: Date.now(), rootFolder: ANIME_ROOT_FOLDER, seriesInfo }; job.fileName = formatMailRuSeriesFilename(title, safeSeason, safeEpisode); job.title = job.fileName; // Anime metadata'yı TVDB mantığıyla önceden hazırla (dosya henüz inmemiş olabilir) ensureSeriesData(ANIME_ROOT_FOLDER, job.fileName, seriesInfo, null).catch( () => null ); const started = await beginMailRuDownload(job); if (!started) { return res.status(500).json({ ok: false, error: job.error || "Mail.ru indirimi başlatılamadı." }); } console.log(`✅ Mail.ru eşleştirme tamamlandı: ${job.fileName}`); res.json({ ok: true, jobId: job.id, fileName: job.fileName }); } catch (err) { res.status(500).json({ ok: false, error: err?.message || "Mail.ru eşleştirme başarısız oldu." }); } }); // --- 🎫 YouTube cookies yönetimi --- app.get("/api/youtube/cookies", requireAuth, (req, res) => { try { if (!YT_COOKIES_PATH || !fs.existsSync(YT_COOKIES_PATH)) { return res.json({ hasCookies: false, cookies: null, updatedAt: null }); } const stat = fs.statSync(YT_COOKIES_PATH); const size = stat.size; if (size > 20000) { return res.json({ hasCookies: true, cookies: "", updatedAt: stat.mtimeMs }); } const content = fs.readFileSync(YT_COOKIES_PATH, "utf-8"); res.json({ hasCookies: true, cookies: content, updatedAt: stat.mtimeMs }); } catch (err) { console.warn("⚠️ YouTube cookies okunamadı:", err.message); res.status(500).json({ error: "Cookies okunamadı" }); } }); app.post("/api/youtube/cookies", requireAuth, (req, res) => { try { let cookies = req.body?.cookies; if (typeof cookies !== "string") { return res.status(400).json({ error: "cookies alanı metin olmalı" }); } cookies = cookies.replace(/\r\n/g, "\n"); if (cookies.length > 20000) { return res.status(400).json({ error: "Cookie içeriği çok büyük (20KB sınırı)." }); } if (/[^\x09\x0a\x0d\x20-\x7e]/.test(cookies)) { return res.status(400).json({ error: "Cookie içeriğinde desteklenmeyen karakterler var." }); } if (!YT_COOKIES_PATH) { return res.status(500).json({ error: "Cookie yolu tanımlı değil." }); } ensureDirForFile(YT_COOKIES_PATH); fs.writeFileSync(YT_COOKIES_PATH, cookies, "utf-8"); res.json({ ok: true, updatedAt: Date.now() }); } catch (err) { console.error("❌ YouTube cookies yazılamadı:", err.message); res.status(500).json({ error: "Cookies kaydedilemedi" }); } }); // --- 🎚️ YouTube kalite ayarları --- app.get("/api/youtube/settings", requireAuth, (req, res) => { try { const settings = loadYoutubeSettings(); let updatedAt = null; if (fs.existsSync(YT_SETTINGS_PATH)) { updatedAt = fs.statSync(YT_SETTINGS_PATH).mtimeMs; } res.json({ ok: true, ...settings, updatedAt }); } catch (err) { console.warn("⚠️ YouTube ayarları okunamadı:", err.message); res.status(500).json({ error: "Ayarlar okunamadı." }); } }); app.post("/api/youtube/settings", requireAuth, (req, res) => { try { const { resolution, onlyAudio } = req.body || {}; if (resolution && !YT_ALLOWED_RESOLUTIONS.has(resolution)) { return res.status(400).json({ error: "Geçersiz çözünürlük." }); } const saved = saveYoutubeSettings({ resolution: resolution || YT_DEFAULT_RESOLUTION, onlyAudio: Boolean(onlyAudio) }); const stat = fs.existsSync(YT_SETTINGS_PATH) ? fs.statSync(YT_SETTINGS_PATH).mtimeMs : Date.now(); res.json({ ok: true, ...saved, updatedAt: stat }); } catch (err) { console.error("❌ YouTube ayarları kaydedilemedi:", err.message); res.status(500).json({ error: "Ayarlar kaydedilemedi." }); } }); // --- ☁️ Rclone ayarları --- app.get("/api/rclone/status", requireAuth, async (req, res) => { const settings = loadRcloneSettings(); const mounted = isRcloneMounted(settings.mountDir); // Disk durumunu da ekle let diskUsage = null; try { const diskInfo = await getDiskSpace(RCLONE_VFS_CACHE_DIR); diskUsage = { usedPercent: diskInfo.usedPercent || 0, availableGB: parseFloat(diskInfo.availableGB) || 0, totalGB: parseFloat(diskInfo.totalGB) || 0 }; } catch (err) { // Disk bilgisi alınamazsa null kalsın } res.json({ enabled: RCLONE_ENABLED, mounted, running: Boolean(rcloneProcess), // Mount durumu hakkında daha fazla bilgi mountStatus: !rcloneProcess ? "stopped" : mounted ? "mounted" : "starting", // Process çalışıyor ama mount henüz tamamlanmadı mountDir: settings.mountDir, remoteName: settings.remoteName, remotePath: settings.remotePath, configPath: settings.configPath, autoMove: settings.autoMove, autoMount: settings.autoMount, cacheCleanMinutes: settings.cacheCleanMinutes || 0, configExists: fs.existsSync(settings.configPath), remoteConfigured: rcloneConfigHasRemote(settings.remoteName), lastError: rcloneLastError || null, // Sadece gerçek hatalar lastLog: rcloneLastLogMessage || null, // Son log mesajı (NOTICE dahil) // Performans ayarları vfsCacheMode: RCLONE_VFS_CACHE_MODE, bufferSize: RCLONE_BUFFER_SIZE, vfsReadAhead: RCLONE_VFS_READ_AHEAD, vfsReadChunkSize: RCLONE_VFS_READ_CHUNK_SIZE, vfsReadChunkSizeLimit: RCLONE_VFS_READ_CHUNK_SIZE_LIMIT, // Disk kullanımı diskUsage, // Cache temizleme threshold cacheCleanThreshold: RCLONE_CACHE_CLEAN_THRESHOLD, minFreeSpaceGB: RCLONE_MIN_FREE_SPACE_GB }); }); app.post("/api/rclone/settings", requireAuth, (req, res) => { try { const { autoMove, autoMount, remoteName, remotePath, mountDir, cacheCleanMinutes } = req.body || {}; const next = saveRcloneSettings({ autoMove: Boolean(autoMove), autoMount: Boolean(autoMount), cacheCleanMinutes: Number(cacheCleanMinutes) || 0, remoteName: remoteName || RCLONE_REMOTE_NAME, remotePath: remotePath || RCLONE_REMOTE_PATH, mountDir: mountDir || RCLONE_MOUNT_DIR, configPath: RCLONE_CONFIG_PATH }); startRcloneCacheCleanSchedule(next.cacheCleanMinutes); res.json({ ok: true, settings: next }); } catch (err) { res.status(500).json({ ok: false, error: err?.message || String(err) }); } }); app.get("/api/rclone/auth-url", requireAuth, async (req, res) => { const host = req.get("host") || "localhost"; const protocol = req.protocol || (req.secure ? "https" : "http"); const baseOrigin = `${protocol}://${host}`; const result = await getRcloneAuthUrl(baseOrigin); if (!result.ok) { return res.status(400).json({ ok: false, error: result.error }); } return res.json({ ok: true, url: result.url, sessionId: result.sessionId }); }); app.get("/api/rclone/auth/status/:session", requireAuth, (req, res) => { const session = rcloneAuthSessions.get(req.params.session); if (!session) { return res.status(404).json({ ok: false, error: "Session bulunamadı." }); } return res.json({ ok: true, done: session.done, error: session.error || null }); }); app.all("/api/rclone/auth/forward/:session/*", async (req, res) => { const session = rcloneAuthSessions.get(req.params.session); if (!session || !session.port) { return res.status(404).send("Auth session bulunamadı."); } const queryString = req.url.includes("?") ? req.url.split("?")[1] : ""; const extraPath = req.params[0] ? `/${req.params[0]}` : ""; const pathPart = extraPath || session.authPath || "/auth"; const targetUrl = `http://127.0.0.1:${session.port}${pathPart}${ queryString ? `?${queryString}` : "" }`; try { const method = req.method || "GET"; const headers = { ...req.headers }; delete headers.host; let body; if (!["GET", "HEAD"].includes(method)) { const chunks = []; for await (const chunk of req) chunks.push(chunk); body = Buffer.concat(chunks); } const resp = await fetch(targetUrl, { method, headers, body }); const respBody = await resp.text(); res.status(resp.status); res.setHeader( "Content-Type", resp.headers.get("content-type") || "text/html" ); return res.send(respBody); } catch (err) { return res.status(500).send("Auth proxy hatası."); } }); app.get("/api/rclone/auth/callback/:session", async (req, res) => { const session = rcloneAuthSessions.get(req.params.session); if (!session || !session.port) { return res.status(404).send("Auth session bulunamadı."); } const targetUrl = `http://127.0.0.1:${session.port}/?${new URLSearchParams( req.query ).toString()}`; try { const resp = await fetch(targetUrl); const body = await resp.text(); res.status(resp.status); res.setHeader( "Content-Type", resp.headers.get("content-type") || "text/html" ); return res.send(body); } catch (err) { return res.status(500).send("Auth proxy hatası."); } }); app.post("/api/rclone/token", requireAuth, (req, res) => { try { const { token, clientId, clientSecret, remoteName } = req.body || {}; const name = remoteName || loadRcloneSettings().remoteName || RCLONE_REMOTE_NAME; const result = writeRcloneConfig({ remoteName: name, token, clientId, clientSecret }); if (!result.ok) { return res.status(400).json({ ok: false, error: result.error }); } res.json({ ok: true }); } catch (err) { res.status(500).json({ ok: false, error: err?.message || String(err) }); } }); app.post("/api/rclone/mount", requireAuth, (req, res) => { const settings = loadRcloneSettings(); const result = startRcloneMount(settings); if (!result.ok) { return res.status(400).json({ ok: false, error: result.error }); } return res.json({ ok: true, ...result }); }); app.post("/api/rclone/cache/clean", requireAuth, async (req, res) => { const result = await runRcloneCacheClean(); if (!result.ok) { return res.status(500).json({ ok: false, error: result.error }); } return res.json({ ok: true, ...result }); }); // Akıllı cache kontrolü - disk durumunu kontrol eder ve gerekirse temizler app.post("/api/rclone/cache/check-and-clean", requireAuth, async (req, res) => { const result = await checkAndCleanCacheIfNeeded(); if (!result.ok) { return res.status(500).json({ ok: false, error: result.error, message: result.message }); } return res.json({ ok: true, ...result }); }); app.get("/api/rclone/conf", requireAuth, (req, res) => { try { if (!fs.existsSync(RCLONE_CONFIG_PATH)) { return res.json({ ok: true, content: "" }); } const content = fs.readFileSync(RCLONE_CONFIG_PATH, "utf-8"); res.json({ ok: true, content }); } catch (err) { res.status(500).json({ ok: false, error: err?.message || String(err) }); } }); app.post("/api/rclone/conf", requireAuth, (req, res) => { try { const content = String(req.body?.content || ""); if (!content.trim()) { return res.status(400).json({ ok: false, error: "Boş içerik gönderilemez." }); } ensureDirForFile(RCLONE_CONFIG_PATH); fs.writeFileSync(RCLONE_CONFIG_PATH, content, "utf-8"); const settings = loadRcloneSettings(); if (rcloneProcess && !rcloneProcess.killed) { stopRcloneMount(); const restart = startRcloneMount(settings); if (!restart.ok) { return res.status(500).json({ ok: false, error: restart.error || "Rclone yeniden başlatılamadı." }); } } res.json({ ok: true }); } catch (err) { res.status(500).json({ ok: false, error: err?.message || String(err) }); } }); app.post("/api/rclone/unmount", requireAuth, (req, res) => { const result = stopRcloneMount(); if (!result.ok) { return res.status(400).json({ ok: false, error: result.error }); } return res.json({ ok: true, ...result }); }); app.post("/api/rclone/move", requireAuth, async (req, res) => { const { path: relPath } = req.body || {}; if (!relPath) { return res.status(400).json({ ok: false, error: "path gerekli" }); } const result = await movePathToGdrive(relPath); if (!result.ok) { return res.status(400).json({ ok: false, error: result.error }); } broadcastFileUpdate("downloads"); return res.json({ ok: true, ...result }); }); // --- 🎫 YouTube cookies yönetimi --- app.get("/api/youtube/cookies", requireAuth, (req, res) => { try { if (!YT_COOKIES_PATH || !fs.existsSync(YT_COOKIES_PATH)) { return res.json({ hasCookies: false, cookies: null, updatedAt: null }); } const stat = fs.statSync(YT_COOKIES_PATH); const size = stat.size; if (size > 20000) { return res.json({ hasCookies: true, cookies: "", updatedAt: stat.mtimeMs }); } const content = fs.readFileSync(YT_COOKIES_PATH, "utf-8"); res.json({ hasCookies: true, cookies: content, updatedAt: stat.mtimeMs }); } catch (err) { console.warn("⚠️ YouTube cookies okunamadı:", err.message); res.status(500).json({ error: "Cookies okunamadı" }); } }); app.post("/api/youtube/cookies", requireAuth, (req, res) => { try { let cookies = req.body?.cookies; if (typeof cookies !== "string") { return res.status(400).json({ error: "cookies alanı metin olmalı" }); } cookies = cookies.replace(/\r\n/g, "\n"); if (cookies.length > 20000) { return res.status(400).json({ error: "Cookie içeriği çok büyük (20KB sınırı)." }); } if (/[^\x09\x0a\x0d\x20-\x7e]/.test(cookies)) { return res.status(400).json({ error: "Cookie içeriğinde desteklenmeyen karakterler var." }); } if (!YT_COOKIES_PATH) { return res.status(500).json({ error: "Cookie yolu tanımlı değil." }); } ensureDirForFile(YT_COOKIES_PATH); fs.writeFileSync(YT_COOKIES_PATH, cookies, "utf-8"); res.json({ ok: true, updatedAt: Date.now() }); } catch (err) { console.error("❌ YouTube cookies yazılamadı:", err.message); res.status(500).json({ error: "Cookies kaydedilemedi" }); } }); // --- 📺 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; if (rootFolder === ANIME_ROOT_FOLDER) 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 ? resolveStoragePath(`${rootFolder}/${normalizedRel}`) : null; if (!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 = resolveStoragePath( `${rootFolder}/${relativeFile}` ); if (!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 = resolveStoragePath(normalizedEpisode.videoPath); if ( !absVideo || !VIDEO_EXTS.includes(path.extname(absVideo || "").toLowerCase()) ) { normalizedEpisode.videoPath = null; } } if (normalizedEpisode.videoPath) { const absVideo = resolveStoragePath(normalizedEpisode.videoPath); if (!absVideo) { normalizedEpisode.videoPath = null; } if (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 = resolveStoragePath(episode.videoPath); return !!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 buildAnimeShows() { if (!fs.existsSync(ANIME_DATA_ROOT)) { return []; } const dirEntries = fs .readdirSync(ANIME_DATA_ROOT, { withFileTypes: true }) .filter((d) => d.isDirectory()); const aggregated = new Map(); const mergeEpisode = (existing, incoming) => { if (!existing) return incoming; const merged = { ...existing, ...incoming }; if (existing.still && !incoming.still) merged.still = existing.still; if (!existing.still && incoming.still) merged.still = incoming.still; if (existing.mediaInfo && !incoming.mediaInfo) merged.mediaInfo = existing.mediaInfo; if (!existing.mediaInfo && incoming.mediaInfo) merged.mediaInfo = incoming.mediaInfo; if (existing.overview && !incoming.overview) merged.overview = existing.overview; return merged; }; for (const dirent of dirEntries) { const key = sanitizeRelative(dirent.name); if (!key) continue; const paths = tvSeriesPathsByKey(key); if (!paths || !fs.existsSync(paths.metadata)) continue; const { rootFolder } = parseTvSeriesKey(key); if (rootFolder !== ANIME_ROOT_FOLDER) continue; let seriesData; try { seriesData = JSON.parse(fs.readFileSync(paths.metadata, "utf-8")); } catch (err) { console.warn(`⚠️ anime series.json okunamadı (${paths.metadata}): ${err.message}`); continue; } const seasonsObj = seriesData?.seasons || {}; if (!Object.keys(seasonsObj).length) continue; let dataChanged = false; const showId = seriesData.id ?? seriesData.tvdbId ?? seriesData.slug ?? seriesData.name ?? key; const showKey = String(showId).toLowerCase(); const record = aggregated.get(showKey) || (() => { const base = { id: seriesData.id ?? seriesData.tvdbId ?? key, title: seriesData.name || "Anime", overview: seriesData.overview || "", year: seriesData.year || null, status: seriesData.status || null, poster: fs.existsSync(paths.poster) ? encodeTvDataPath(paths.key, "poster.jpg") : null, backdrop: fs.existsSync(paths.backdrop) ? encodeTvDataPath(paths.key, "backdrop.jpg") : null, genres: new Set( Array.isArray(seriesData.genres) ? seriesData.genres .map((g) => (typeof g === "string" ? g : g?.name || null)) .filter(Boolean) : [] ), seasons: new Map(), primaryFolder: ANIME_ROOT_FOLDER, folders: new Set([ANIME_ROOT_FOLDER]) }; aggregated.set(showKey, base); return base; })(); if ( seriesData.overview && seriesData.overview.length > (record.overview?.length || 0) ) { record.overview = seriesData.overview; } if (!record.status && seriesData.status) record.status = seriesData.status; if (!record.year || (seriesData.year && Number(seriesData.year) < Number(record.year))) { record.year = seriesData.year || record.year; } if (!record.poster && fs.existsSync(paths.poster)) { record.poster = encodeTvDataPath(paths.key, "poster.jpg"); } if (!record.backdrop && fs.existsSync(paths.backdrop)) { record.backdrop = encodeTvDataPath(paths.key, "backdrop.jpg"); } if (Array.isArray(seriesData.genres)) { seriesData.genres .map((g) => (typeof g === "string" ? g : g?.name || null)) .filter(Boolean) .forEach((genre) => record.genres.add(genre)); } for (const [seasonKey, rawSeason] of Object.entries(seasonsObj)) { if (!rawSeason?.episodes) continue; const seasonNumber = toFiniteNumber( rawSeason.seasonNumber ?? rawSeason.number ?? seasonKey ); if (!Number.isFinite(seasonNumber)) continue; const seasonPaths = seasonAssetPaths(paths, seasonNumber); const rawEpisodes = rawSeason.episodes || {}; for (const [episodeKey, rawEpisode] of Object.entries(rawEpisodes)) { if (!rawEpisode || typeof rawEpisode !== "object") continue; const relativeFile = (rawEpisode.file || "").replace(/\\/g, "/"); if (!relativeFile) continue; const absEpisodePath = resolveStoragePath(relativeFile); const controlPath = absEpisodePath ? `${absEpisodePath}.aria2` : null; const isComplete = !!absEpisodePath && !fs.existsSync(controlPath); if (!isComplete) { delete rawEpisodes[episodeKey]; dataChanged = true; } } if (!Object.keys(rawSeason.episodes || {}).length) { delete seasonsObj[seasonKey]; dataChanged = true; continue; } let seasonRecord = record.seasons.get(seasonNumber); if (!seasonRecord) { seasonRecord = { seasonNumber, name: rawSeason.name || `Season ${seasonNumber}`, overview: rawSeason.overview || "", poster: rawSeason.poster || null, tvdbId: rawSeason.tvdbId || null, slug: rawSeason.slug || null, episodeCount: rawSeason.episodeCount || null, episodes: new Map() }; record.seasons.set(seasonNumber, seasonRecord); } if (!seasonRecord.poster && fs.existsSync(seasonPaths.poster)) { const relPoster = path.relative(paths.dir, seasonPaths.poster); seasonRecord.poster = encodeTvDataPath(paths.key, relPoster); } for (const [episodeKey, rawEpisode] of Object.entries(rawSeason.episodes)) { if (!rawEpisode || typeof rawEpisode !== "object") continue; const episodeNumber = toFiniteNumber( rawEpisode.episodeNumber ?? rawEpisode.number ?? episodeKey ); if (!Number.isFinite(episodeNumber)) continue; const normalizedEpisode = { ...rawEpisode }; normalizedEpisode.seasonNumber = seasonNumber; normalizedEpisode.episodeNumber = episodeNumber; if (!normalizedEpisode.code) { normalizedEpisode.code = `S${String(seasonNumber).padStart(2, "0")}E${String( episodeNumber ).padStart(2, "0")}`; } const relativeFile = (normalizedEpisode.file || "").replace(/\\/g, "/"); if (relativeFile) { const absVideo = resolveStoragePath(relativeFile); const ext = path.extname(relativeFile).toLowerCase(); if (absVideo && VIDEO_EXTS.includes(ext)) { normalizedEpisode.videoPath = relativeFile; const stats = fs.statSync(absVideo); normalizedEpisode.fileSize = Number(stats.size); } else { normalizedEpisode.videoPath = null; } } normalizedEpisode.folder = ANIME_ROOT_FOLDER; const existingEpisode = seasonRecord.episodes.get(episodeNumber); seasonRecord.episodes.set( episodeNumber, mergeEpisode(existingEpisode, normalizedEpisode) ); } if (!seasonRecord.episodeCount && seasonRecord.episodes.size) { seasonRecord.episodeCount = seasonRecord.episodes.size; } } if (dataChanged) { try { seriesData.seasons = seasonsObj; seriesData.updatedAt = Date.now(); fs.writeFileSync(paths.metadata, JSON.stringify(seriesData, null, 2), "utf-8"); } catch (err) { console.warn(`⚠️ anime series.json güncellenemedi (${paths.metadata}): ${err.message}`); } } } const shows = Array.from(aggregated.values()) .map((record) => { const seasons = Array.from(record.seasons.values()) .map((season) => { const episodes = Array.from(season.episodes.values()) .filter((episode) => { if (!episode?.videoPath) return false; const ext = path.extname(episode.videoPath).toLowerCase(); if (!VIDEO_EXTS.includes(ext)) return false; const absVideo = resolveStoragePath(episode.videoPath); if (!absVideo) return false; const controlPath = `${absVideo}.aria2`; return !fs.existsSync(controlPath); }) .sort((a, b) => a.episodeNumber - b.episodeNumber); return { seasonNumber: season.seasonNumber, name: season.name || `Season ${season.seasonNumber}`, overview: season.overview || "", poster: season.poster || null, tvdbSeasonId: season.tvdbId || null, slug: season.slug || null, episodeCount: season.episodeCount || episodes.length, episodes }; }) .sort((a, b) => a.seasonNumber - b.seasonNumber); return { folder: record.primaryFolder, id: record.id || record.title, title: record.title, overview: record.overview || "", year: record.year || null, genres: Array.from(record.genres).filter(Boolean), status: record.status || null, poster: record.poster || null, backdrop: record.backdrop || null, seasons, folders: Array.from(record.folders) }; }) .filter((show) => show.seasons.length > 0); shows.sort((a, b) => a.title.localeCompare(b.title, "en")); return shows; } async function rebuildAnimeMetadata({ clearCache = false } = {}) { if (clearCache && fs.existsSync(ANIME_DATA_ROOT)) { try { fs.rmSync(ANIME_DATA_ROOT, { recursive: true, force: true }); console.log("🧹 Anime cache temizlendi."); } catch (err) { console.warn( `⚠️ Anime cache temizlenemedi (${ANIME_DATA_ROOT}): ${err.message}` ); } } if (!fs.existsSync(ANIME_DATA_ROOT)) { fs.mkdirSync(ANIME_DATA_ROOT, { recursive: true }); } if (clearCache) { tvdbSeriesCache.clear(); tvdbEpisodeCache.clear(); tvdbEpisodeDetailCache.clear(); } let processed = 0; for (const baseDir of getStorageRoots()) { let rootEntries = []; try { rootEntries = fs.readdirSync(baseDir, { withFileTypes: true }); } catch (err) { console.warn(`⚠️ root dizini okunamadı (${baseDir}): ${err.message}`); continue; } for (const dirent of rootEntries) { if (!dirent.isFile()) continue; const safeName = sanitizeRelative(dirent.name); if (!safeName || safeName.startsWith(".")) continue; if (safeName.endsWith(".aria2")) continue; const absPath = path.join(baseDir, safeName); if (!fs.existsSync(absPath)) continue; const mimeType = mime.lookup(absPath) || ""; if (!String(mimeType).startsWith("video/")) continue; const seriesInfo = parseAnimeSeriesInfo(safeName); if (!seriesInfo) continue; const mediaInfo = await extractMediaInfo(absPath).catch(() => null); await ensureSeriesData( ANIME_ROOT_FOLDER, safeName, seriesInfo, mediaInfo ); processed += 1; } } const shows = buildAnimeShows(); for (const show of shows) { for (const season of show.seasons || []) { for (const episode of season.episodes || []) { if (!episode?.videoPath) continue; const absVideo = resolveStoragePath(episode.videoPath); if (!absVideo) continue; const seriesInfo = { title: show.title, searchTitle: show.title, season: season.seasonNumber, episode: episode.episodeNumber, key: episode.code || `S${String(season.seasonNumber).padStart(2, "0")}E${String( episode.episodeNumber ).padStart(2, "0")}` }; const mediaInfo = await extractMediaInfo(absVideo).catch(() => null); await ensureSeriesData(ANIME_ROOT_FOLDER, episode.videoPath, seriesInfo, mediaInfo); processed += 1; } } } return processed; } app.get("/api/anime", requireAuth, (req, res) => { try { const shows = buildAnimeShows(); res.json(shows); } catch (err) { console.error("🧿 Anime API error:", err); res.status(500).json({ error: err.message }); } }); app.post("/api/anime/refresh", requireAuth, async (req, res) => { if (!TVDB_API_KEY) { return res .status(400) .json({ error: "TVDB API erişimi için gerekli anahtar tanımlı değil." }); } try { const processed = await rebuildAnimeMetadata(); res.json({ ok: true, processed }); } catch (err) { console.error("🧿 Anime refresh error:", err); res.status(500).json({ error: err.message }); } }); app.post("/api/anime/rescan", requireAuth, async (req, res) => { if (!TVDB_API_KEY) { return res .status(400) .json({ error: "TVDB API erişimi için gerekli anahtar tanımlı değil." }); } try { const processed = await rebuildAnimeMetadata({ clearCache: true }); res.json({ ok: true, processed }); } catch (err) { console.error("🧿 Anime rescan error:", err); res.status(500).json({ error: err.message }); } }); function collectMusicEntries() { const entries = []; const dirEntries = listStorageRootFolders(); for (const dirent of dirEntries) { const folder = sanitizeRelative(dirent.rootFolder || 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 absMusic = resolveStoragePath(`${folder}/${targetPath}`); if (!absMusic) 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, mediaInfo: fileMeta?.mediaInfo || 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 (DISABLE_MEDIA_PROCESSING) { console.log("📺 Medya işlemleri kapalı; TV metadata taraması atlandı."); return []; } 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 = listStorageRootFolders(); const processed = []; for (const dirent of dirEntries) { const folder = sanitizeRelative(dirent.rootFolder || dirent.name); if (!folder) continue; const baseDir = dirent.baseDir || DOWNLOAD_DIR; const rootDir = path.join(baseDir, 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.`); } // --- 📁 WebDAV (Infuse) --- if (WEBDAV_ENABLED) { const webdavServer = new webdav.WebDAVServer({ strictMode: false }); const webdavBasePath = WEBDAV_PATH.startsWith("/") ? WEBDAV_PATH : `/${WEBDAV_PATH}`; const userManager = new webdav.SimpleUserManager(); if (WEBDAV_USERNAME && WEBDAV_PASSWORD) { userManager.addUser(WEBDAV_USERNAME, WEBDAV_PASSWORD, false); } webdavServer.httpAuthentication = new webdav.HTTPBasicAuthentication( userManager, "Dupe WebDAV" ); webdavServer.setFileSystem("/", new webdav.PhysicalFileSystem(WEBDAV_ROOT)); app.use( webdavBasePath, webdavAuthMiddleware, webdavReadonlyGuard, async (req, res) => { await ensureWebdavIndexFresh(); webdavServer.executeRequest(req, res); } ); console.log(`📁 WebDAV aktif: ${webdavBasePath}`); } // --- ☁️ Rclone auto mount --- const initialRcloneSettings = loadRcloneSettings(); // Başlangıçta disk kontrolü yap - cache temizleme gerekirse yap if (RCLONE_ENABLED) { checkAndCleanCacheIfNeeded().then(result => { if (result.cleaned) { console.log(`🧹 Başlangıç cache temizlemesi: ${result.message}`); } else { console.log(`✅ Disk durumu: ${result.message}`); } }).catch(err => { console.warn(`⚠️ Başlangıç cache kontrolü başarısız: ${err.message}`); }); } if (RCLONE_ENABLED && initialRcloneSettings.autoMount) { const result = startRcloneMount(initialRcloneSettings); if (!result.ok) { console.warn(`⚠️ Rclone mount başlatılamadı: ${result.error}`); } } startRcloneCacheCleanSchedule(initialRcloneSettings.cacheCleanMinutes || 0); // --- Disk alanı izleme - periyodik kontrol (her 5 dakikada bir) --- setInterval(async () => { if (RCLONE_ENABLED) { const result = await checkAndCleanCacheIfNeeded(); if (result.cleaned) { console.log(`🧹 Otomatik cache temizlemesi: ${result.message}`); } } }, 5 * 60 * 1000); // --- ✅ 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 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 getCachedDiskInfo({ force: req.query?.fresh === "1" }); 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, scope } = req.query; const preferEnForSeries = type === "series" && scope === "anime"; if (!query) { return res.status(400).json({ error: "query parametresi gerekli" }); } if (type === "movie") { // TMDB Film Araması if (!TMDB_API_KEY) { 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 translations = extended.translations || info.translations || {}; const nameTranslations = translations.nameTranslations || translations.names || []; const overviewTranslations = translations.overviewTranslations || translations.overviews || []; const localizedName = tvdbPickTranslation( nameTranslations, "name", preferEnForSeries ); const localizedOverview = tvdbPickTranslation( overviewTranslations, "overview", preferEnForSeries ); const genres = Array.isArray(info.genres) ? info.genres.map(g => typeof g === "string" ? g : g?.name || g?.genre).filter(Boolean) : []; // Yıl bilgisini çeşitli yerlerden al let seriesYear = null; if (info.year) { 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: localizedName || info.name || item.name, year: seriesYear, overview: localizedOverview || info.overview || item.overview || "", poster: posterArtwork?.image ? tvdbImageUrl(posterArtwork.image) : (item.image ? tvdbImageUrl(item.image) : null), genres: genres, status: info.status?.name || info.status || null, 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); scheduleSnapshotBroadcast(); 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); });