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 { WebSocketServer } from "ws"; import { fileURLToPath } from "url"; import { exec } from "child_process"; import crypto from "crypto"; // 🔒 basit token üretimi için const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const upload = multer({ dest: path.join(__dirname, "uploads") }); const client = new WebTorrent(); const torrents = new Map(); let wss; const PORT = process.env.PORT || 3001; // --- İndirilen dosyalar için klasör oluştur --- const DOWNLOAD_DIR = path.join(__dirname, "downloads"); if (!fs.existsSync(DOWNLOAD_DIR)) fs.mkdirSync(DOWNLOAD_DIR, { recursive: true }); // --- 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"); for (const dir of [ THUMBNAIL_DIR, VIDEO_THUMB_ROOT, IMAGE_THUMB_ROOT, MOVIE_DATA_ROOT, TV_DATA_ROOT ]) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } const VIDEO_THUMBNAIL_TIME = process.env.VIDEO_THUMBNAIL_TIME || "00:00:05"; const VIDEO_EXTS = [".mp4", ".webm", ".mkv", ".mov", ".m4v"]; const generatingThumbnails = new Set(); const INFO_FILENAME = "info.json"; const 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 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; app.use(cors()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use("/downloads", express.static(DOWNLOAD_DIR)); // --- En uygun video dosyasını seç --- function pickBestVideoFile(torrent) { const videos = torrent.files .map((f, i) => ({ i, f })) .filter(({ f }) => VIDEO_EXTS.includes(path.extname(f.name).toLowerCase())); if (!videos.length) return 0; videos.sort((a, b) => b.f.length - a.f.length); return videos[0].i; } function ensureDirForFile(filePath) { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } 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; } } 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 infoFilePath(savePath) { return path.join(savePath, INFO_FILENAME); } function readInfoFile(savePath) { const target = infoFilePath(savePath); if (!fs.existsSync(target)) return null; try { return JSON.parse(fs.readFileSync(target, "utf-8")); } catch (err) { console.warn(`⚠️ info.json okunamadı (${target}): ${err.message}`); return null; } } function upsertInfoFile(savePath, partial) { const target = infoFilePath(savePath); try { ensureDirForFile(target); let current = {}; if (fs.existsSync(target)) { try { current = JSON.parse(fs.readFileSync(target, "utf-8")) || {}; } catch (err) { console.warn(`⚠️ info.json parse edilemedi (${target}): ${err.message}`); } } const timestamp = Date.now(); const next = { ...current, ...partial, updatedAt: timestamp }; if (partial && Object.prototype.hasOwnProperty.call(partial, "files")) { if (partial.files && typeof partial.files === "object") { next.files = partial.files; } else { delete next.files; } } else if (current.files && next.files === undefined) { next.files = current.files; } if (!next.createdAt) { next.createdAt = current.createdAt ?? partial?.createdAt ?? timestamp; } if (!next.added && partial?.added) { next.added = partial.added; } if (!next.folder) { next.folder = path.basename(savePath); } fs.writeFileSync(target, JSON.stringify(next, null, 2), "utf-8"); return next; } catch (err) { console.warn(`⚠️ info.json yazılamadı (${target}): ${err.message}`); return null; } } function readInfoForRoot(rootFolder) { const safe = sanitizeRelative(rootFolder); if (!safe) return null; const target = path.join(DOWNLOAD_DIR, safe, INFO_FILENAME); if (!fs.existsSync(target)) return null; try { return JSON.parse(fs.readFileSync(target, "utf-8")); } catch (err) { console.warn(`⚠️ info.json okunamadı (${target}): ${err.message}`); return null; } } function sanitizeRelative(relPath) { return relPath.replace(/^[\\/]+/, ""); } function 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; } async function extractMediaInfo(filePath) { if (!filePath || !fs.existsSync(filePath)) return null; return new Promise((resolve) => { exec( `${FFPROBE_PATH} -v quiet -print_format json -show_format -show_streams "${filePath}"`, { maxBuffer: FFPROBE_MAX_BUFFER }, (err, stdout) => { if (err) { console.warn( `⚠️ ffprobe çalıştırılamadı (${filePath}): ${err.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) { console.warn( `⚠️ ffprobe çıktısı parse edilemedi (${filePath}): ${parseErr.message}` ); resolve(null); } } ); }); } function queueVideoThumbnail(fullPath, relPath) { const { relThumb, absThumb } = getVideoThumbnailPaths(relPath); if (fs.existsSync(absThumb) || generatingThumbnails.has(absThumb)) return; ensureDirForFile(absThumb); markGenerating(absThumb, true); const cmd = `ffmpeg -y -ss ${VIDEO_THUMBNAIL_TIME} -i "${fullPath}" -frames:v 1 -vf "scale=320:-1" -q:v 2 "${absThumb}"`; exec(cmd, (err) => { markGenerating(absThumb, false); if (err) { console.warn(`⚠️ Video thumbnail oluşturulamadı (${fullPath}): ${err.message}`); return; } console.log(`🎞️ Video thumbnail oluşturuldu: ${absThumb}`); const root = rootFromRelPath(relPath); if (root) broadcastFileUpdate(root); }); } function queueImageThumbnail(fullPath, relPath) { 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"; const qualityArgs = needsQuality ? ' -q:v 5' : ""; const cmd = `ffmpeg -y -i "${fullPath}" -vf "scale=320:-1"${qualityArgs} "${absThumb}"`; exec(cmd, (err) => { markGenerating(absThumb, false); if (err) { console.warn(`⚠️ Resim thumbnail oluşturulamadı (${fullPath}): ${err.message}`); 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; } } function resolveThumbnailAbsolute(relThumbPath) { const normalized = sanitizeRelative(relThumbPath); const resolved = path.resolve(THUMBNAIL_DIR, normalized); if ( resolved !== THUMBNAIL_DIR && !resolved.startsWith(THUMBNAIL_DIR + path.sep) ) { return null; } return resolved; } function resolveMovieDataAbsolute(relPath) { const normalized = sanitizeRelative(relPath); const resolved = path.resolve(MOVIE_DATA_ROOT, normalized); if ( resolved !== MOVIE_DATA_ROOT && !resolved.startsWith(MOVIE_DATA_ROOT + path.sep) ) { return null; } return resolved; } function resolveTvDataAbsolute(relPath) { const normalized = sanitizeRelative(relPath); const resolved = path.resolve(TV_DATA_ROOT, normalized); if ( resolved !== TV_DATA_ROOT && !resolved.startsWith(TV_DATA_ROOT + path.sep) ) { return null; } return resolved; } function 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 movieDataDir(rootFolder) { return path.join(MOVIE_DATA_ROOT, sanitizeRelative(rootFolder)); } function movieDataPaths(rootFolder) { const dir = movieDataDir(rootFolder); return { dir, metadata: path.join(dir, "metadata.json"), poster: path.join(dir, "poster.jpg"), backdrop: path.join(dir, "backdrop.jpg") }; } function isTmdbMetadata(metadata) { if (!metadata) return false; if (typeof metadata.id === "number") return true; if (metadata._dupe?.source === "tmdb") return true; return false; } function parseTitleAndYear(rawName) { if (!rawName) return { title: null, year: null }; const withoutExt = rawName.replace(/\.[^/.]+$/, ""); const cleaned = withoutExt.replace(/[\[\]\(\)\-]/g, " ").replace(/[._]/g, " "); const tokens = cleaned .split(/\s+/) .map((t) => t.trim()) .filter(Boolean); if (!tokens.length) { return { title: withoutExt.trim(), year: null }; } const ignoredExact = new Set( [ "hdrip", "hdr", "webrip", "webdl", "web", "dl", "bluray", "bdrip", "dvdrip", "remux", "multi", "audio", "aac", "ddp", "dts", "xvid", "x264", "x265", "x266", "h264", "h265", "hevc", "hdr10", "hdr10plus", "amzn", "nf", "netflix", "disney", "imax", "atmos", "dubbed", "dublado", "ita", "eng", "turkce", "multi-audio", "eazy", "tbmovies", "tbm", "bone" ].map((t) => t.toLowerCase()) ); const yearIndex = tokens.findIndex((token) => /^(19|20)\d{2}$/.test(token)); if (yearIndex > 0) { const yearToken = tokens[yearIndex]; const candidateTokens = tokens.slice(0, yearIndex); const filteredTitleTokens = candidateTokens.filter((token) => { const lower = token.toLowerCase(); if (ignoredExact.has(lower)) return false; if (/^\d{3,4}p$/.test(lower)) return false; if (/^(ac3|aac\d*|ddp\d*|dts|dubbed|dual|multi|hc)$/.test(lower)) return false; if (/^(x|h)?26[45]$/.test(lower)) return false; if (lower.includes("hdrip") || lower.includes("web-dl")) return false; if (lower.includes("multi-audio")) return false; return true; }); const titleTokens = filteredTitleTokens.length ? filteredTitleTokens : candidateTokens; const title = titleTokens.join(" ").replace(/\s+/g, " ").trim(); return { title: title || withoutExt.trim(), year: Number(yearToken) }; } let year = null; const filtered = []; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; const lower = token.toLowerCase(); if (!year && /^(19|20)\d{2}$/.test(lower)) { year = Number(lower); continue; } if (/^\d{3,4}p$/.test(lower)) continue; if (lower === "web" && tokens[i + 1]?.toLowerCase() === "dl") { i += 1; continue; } if (/^(ac3|aac\d*|ddp\d*|dts|dubbed|dual|multi|hc)$/.test(lower)) continue; if (/^(x|h)?26[45]$/.test(lower)) continue; if (ignoredExact.has(lower)) continue; if (lower.includes("hdrip") || lower.includes("web-dl")) continue; if (lower.includes("multi-audio")) continue; filtered.push(token); } const title = filtered.join(" ").replace(/\s+/g, " ").trim(); return { title: title || withoutExt.trim(), year }; } function parseSeriesInfo(rawName) { if (!rawName) return null; const withoutExt = rawName.replace(/\.[^/.]+$/, ""); const match = withoutExt.match(/(.+?)[\s._-]*S(\d{1,2})[\s._-]*E(\d{1,2})/i); if (!match) return null; const rawTitle = match[1] .replace(/[._]+/g, " ") .replace(/\s+-\s+/g, " - ") .replace(/[-_]+/g, " ") .replace(/\s+/g, " ") .trim(); if (!rawTitle) return null; const season = Number(match[2]); const episode = Number(match[3]); if (!Number.isFinite(season) || !Number.isFinite(episode)) return null; return { title: titleCase(rawTitle), searchTitle: rawTitle, season, episode, key: `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}` }; } async function tmdbFetch(endpoint, params = {}) { if (!TMDB_API_KEY) return null; const url = new URL(`${TMDB_BASE_URL}${endpoint}`); url.searchParams.set("api_key", TMDB_API_KEY); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null && value !== "") { url.searchParams.set(key, value); } } try { const resp = await fetch(url); if (!resp.ok) { console.warn(`⚠️ TMDB isteği başarısız (${url}): ${resp.status}`); return null; } return await resp.json(); } catch (err) { console.warn(`⚠️ TMDB isteği başarısız (${url}): ${err.message}`); return null; } } async function fetchMovieMetadata(title, year) { if (!TMDB_API_KEY || !title) return null; console.log("🎬 TMDB araması yapılıyor:", { title, year }); const search = await tmdbFetch("/search/movie", { query: title, year: year || undefined, include_adult: false, language: "en-US" }); if (!search?.results?.length) { console.log("🎬 TMDB sonucu bulunamadı:", { title, year }); return null; } const match = search.results[0]; const details = await tmdbFetch(`/movie/${match.id}`, { language: "en-US", append_to_response: "release_dates,credits,translations" }); if (!details) return null; if (details.translations?.translations?.length) { const translations = details.translations.translations; const turkish = translations.find( (t) => t.iso_639_1 === "tr" && t.data ); if (turkish?.data) { const data = turkish.data; if (data.overview) details.overview = data.overview; if (data.title) details.title = data.title; if (data.tagline) details.tagline = data.tagline; } } console.log("🎬 TMDB sonucu bulundu:", { title: match.title || match.name, year: match.release_date ? match.release_date.slice(0, 4) : null, id: match.id }); return { ...details, poster_path: match.poster_path || details.poster_path, backdrop_path: match.backdrop_path || details.backdrop_path, matched_title: match.title || match.name || title, matched_year: match.release_date ? Number(match.release_date.slice(0, 4)) : year || null }; } async function downloadImage(url, targetPath) { if (!url) return false; try { const resp = await fetch(url); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const arrayBuffer = await resp.arrayBuffer(); ensureDirForFile(targetPath); fs.writeFileSync(targetPath, Buffer.from(arrayBuffer)); return true; } catch (err) { console.warn(`⚠️ Görsel indirilemedi (${url}): ${err.message}`); return false; } } let tvdbAuthState = { token: null, expires: 0 }; const TVDB_TOKEN_TTL = 1000 * 60 * 60 * 20; // 20 saat async function getTvdbToken(force = false) { if (!TVDB_API_KEY) return null; const trimmedUserToken = (TVDB_USER_TOKEN || "").trim(); const now = Date.now(); if ( !force && tvdbAuthState.token && now < tvdbAuthState.expires - 60 * 1000 ) { return tvdbAuthState.token; } if (trimmedUserToken) { try { const resp = await fetch(`${TVDB_BASE_URL}/login`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ apikey: TVDB_API_KEY, userkey: trimmedUserToken }) }); if (resp.ok) { const json = await resp.json(); const token = json?.data?.token; if (token) { console.log("📺 TVDB token alındı (login)."); tvdbAuthState = { token, expires: Date.now() + TVDB_TOKEN_TTL }; return token; } console.warn("⚠️ TVDB login yanıtında token bulunamadı, API key'e düşülüyor."); } else { console.warn( `⚠️ TVDB login başarısız (${resp.status}); API key ile devam edilecek.` ); } } catch (err) { console.warn( `⚠️ TVDB login hatası (${err.message}); API key ile devam edilecek.` ); } } tvdbAuthState = { token: TVDB_API_KEY, expires: Date.now() + TVDB_TOKEN_TTL }; console.log("📺 TVDB token (API key) kullanılıyor."); return tvdbAuthState.token; } async function tvdbFetch(pathname, options = {}, retry = true) { if (!TVDB_API_KEY) return null; const token = await getTvdbToken(); if (!token) return null; const url = pathname.startsWith("http") ? pathname : `${TVDB_BASE_URL}${pathname}`; const headers = { Accept: "application/json", ...(options.headers || {}), Authorization: `Bearer ${token}` }; let body = options.body; if (body && typeof body === "object" && !(body instanceof Buffer)) { body = JSON.stringify(body); headers["Content-Type"] = "application/json"; } try { const resp = await fetch(url, { ...options, headers, body }); const rawText = await resp.text(); let json = null; if (rawText) { try { json = JSON.parse(rawText); } catch (err) { console.warn( `⚠️ TVDB yanıtı JSON parse edilemedi (${url}): ${err.message}` ); } } console.log("📺 TVDB fetch:", { url: String(url), status: resp.status, hasData: Boolean(json?.data), message: json?.status?.message ?? null }); if (resp.status === 401 && retry) { await getTvdbToken(true); return tvdbFetch(pathname, options, false); } if (!resp.ok) { console.warn(`⚠️ TVDB isteği başarısız (${url}): ${resp.status}`); return null; } return json; } catch (err) { console.warn(`⚠️ TVDB isteği hatası (${url}): ${err.message}`); return null; } } function guessPrimaryVideo(rootFolder) { const safe = sanitizeRelative(rootFolder); if (!safe) return null; const baseDir = path.join(DOWNLOAD_DIR, safe); if (!fs.existsSync(baseDir)) return null; let bestRelPath = null; let bestSize = 0; 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()) { stack.push(relPath); continue; } if (!entry.isFile()) 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}`); continue; } if (size >= bestSize) { bestSize = size; bestRelPath = relPath; } } } return bestRelPath ? bestRelPath.replace(/\\/g, "/") : null; } async function ensureMovieData( rootFolder, displayName, bestVideoPath, precomputedMediaInfo = null ) { if (!TMDB_API_KEY) return precomputedMediaInfo || null; console.log("🎬 ensureMovieData çağrıldı:", { rootFolder, displayName }); 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(rootFolder); return precomputedMediaInfo || null; } const paths = movieDataPaths(rootFolder); const normalizedRoot = sanitizeRelative(rootFolder); const normalizedVideoPath = bestVideoPath ? bestVideoPath.replace(/\\/g, "/") : null; 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}` ); } } let fetchedMetadata = false; const hasTmdbMetadata = isTmdbMetadata(metadata); if (!hasTmdbMetadata) { const { title, year } = parseTitleAndYear(displayName); console.log("🎬 TMDB araması için analiz:", { displayName, title, year }); if (title) { const fetched = await fetchMovieMetadata(title, year); if (fetched) { metadata = fetched; fetchedMetadata = true; } } } if (!isTmdbMetadata(metadata)) { console.log( "🎬 TMDB verisi bulunamadı, movie_data oluşturulmadı:", rootFolder ); removeMovieData(rootFolder); return precomputedMediaInfo || null; } 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: rootFolder, videoPath, mediaInfo, 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}`); return mediaInfo; } function removeMovieData(rootFolder) { const dir = movieDataDir(rootFolder); if (fs.existsSync(dir)) { try { fs.rmSync(dir, { recursive: true, force: true }); console.log(`🧹 Movie metadata silindi: ${dir}`); } catch (err) { console.warn(`⚠️ Movie metadata temizlenemedi (${dir}): ${err.message}`); } } } const tvdbSeriesCache = new Map(); const tvdbEpisodeCache = new Map(); const tvdbEpisodeDetailCache = new Map(); async function searchTvdbSeries(title) { if (!title) return null; const key = title.toLowerCase(); if (tvdbSeriesCache.has(key)) return tvdbSeriesCache.get(key); console.log("📺 TVDB seri araması yapılıyor:", title); const params = new URLSearchParams({ type: "series", query: title }); const resp = await tvdbFetch(`/search?${params.toString()}`); const series = resp?.data?.[0] || null; tvdbSeriesCache.set(key, series); return series; } async function fetchTvdbSeriesExtended(seriesId) { if (!seriesId) return null; const cacheKey = `series-${seriesId}`; if (tvdbSeriesCache.has(cacheKey)) return tvdbSeriesCache.get(cacheKey); console.log("📺 TVDB extended isteniyor:", seriesId); const resp = await tvdbFetch( `/series/${seriesId}/extended?meta=episodes,artworks,translations&short=false` ); const data = resp?.data || null; tvdbSeriesCache.set(cacheKey, data); return data; } async function fetchTvdbEpisodesForSeason(seriesId, seasonNumber) { if (!seriesId || !Number.isFinite(seasonNumber)) return new Map(); const cacheKey = `${seriesId}-S${seasonNumber}`; if (tvdbEpisodeCache.has(cacheKey)) return tvdbEpisodeCache.get(cacheKey); console.log("📺 TVDB sezon bölümleri çekiliyor:", { seriesId, seasonNumber }); let page = 0; const seasonMap = new Map(); while (page < 50) { const resp = await tvdbFetch( `/series/${seriesId}/episodes/default?page=${page}&lang=eng` ); const payload = resp?.data; const items = Array.isArray(payload) ? payload : Array.isArray(payload?.episodes) ? payload.episodes : []; if (!Array.isArray(items) || !items.length) break; for (const item of items) { const normalized = normalizeTvdbEpisode(item); if (!normalized) continue; const season = normalized.seasonNumber; const episode = normalized.episodeNumber; if (!Number.isFinite(season) || !Number.isFinite(episode)) continue; const key = `${seriesId}-S${season}`; let seasonEntry = tvdbEpisodeCache.get(key); if (!seasonEntry) { seasonEntry = new Map(); tvdbEpisodeCache.set(key, seasonEntry); } if (!seasonEntry.has(episode)) seasonEntry.set(episode, normalized); if (!seasonMap.has(episode) && season === seasonNumber) { seasonMap.set(episode, normalized); } } const links = resp?.links || payload?.links || {}; const nextRaw = links?.next !== undefined ? links.next : links?.nextPage ?? null; const nextPage = toFiniteNumber(nextRaw); if (!Number.isFinite(nextPage) || nextPage <= page) break; page = nextPage; } tvdbEpisodeCache.set(cacheKey, seasonMap); return seasonMap; } async function fetchTvdbEpisode(seriesId, season, episode) { const cacheKey = `${seriesId}-S${season}`; const cache = tvdbEpisodeCache.get(cacheKey); if (cache?.has(episode)) return cache.get(episode); console.log("📺 TVDB tekil bölüm çekiliyor:", { seriesId, season, episode }); const seasonEpisodes = await fetchTvdbEpisodesForSeason(seriesId, season); return seasonEpisodes.get(episode) || null; } async function fetchTvdbEpisodeExtended(episodeId) { if (!episodeId) return null; const cacheKey = `episode-${episodeId}-extended`; if (tvdbEpisodeDetailCache.has(cacheKey)) return tvdbEpisodeDetailCache.get(cacheKey); const resp = await tvdbFetch( `/episodes/${episodeId}/extended?meta=artworks,translations&short=false` ); const payload = resp?.data || null; if (!payload) { tvdbEpisodeDetailCache.set(cacheKey, null); return null; } const base = normalizeTvdbEpisode(payload.episode || payload) || normalizeTvdbEpisode(payload); if (!base) { tvdbEpisodeDetailCache.set(cacheKey, null); return null; } const artworks = Array.isArray(payload.artworks) ? payload.artworks : Array.isArray(payload.episode?.artworks) ? payload.episode.artworks : []; if (!base.image) { const stillArtwork = artworks.find((a) => { const type = String( a?.type || a?.artworkType || a?.name || "" ).toLowerCase(); return ( type.includes("still") || type.includes("screencap") || type.includes("episode") || type.includes("thumb") ); }); if (stillArtwork?.image) base.image = stillArtwork.image; } const translations = payload.translations || payload.episode?.translations || {}; const overviewTranslations = translations.overviewTranslations || translations.overviews || []; const nameTranslations = translations.nameTranslations || translations.names || []; const pickTranslation = (list, field) => { if (!Array.isArray(list)) return null; const preferred = ["tr", "turkish", "tr-tr", "tr_tur"]; const fallback = ["en", "english", "en-us", "eng"]; const pickByLang = (langs) => langs .map((lng) => lng.toLowerCase()) .map((lng) => list.find((item) => { const code = String( item?.language || item?.iso6391 || item?.iso_639_1 || item?.locale || item?.languageCode || "" ).toLowerCase(); return code === lng; }) ) .find(Boolean); const preferredMatch = pickByLang(preferred) || pickByLang(fallback); if (!preferredMatch) return null; return ( preferredMatch[field] ?? preferredMatch.value ?? preferredMatch.translation?.[field] ?? null ); }; if (!base.overview) { const localizedOverview = pickTranslation(overviewTranslations, "overview"); if (localizedOverview) base.overview = localizedOverview; } if (!base.name) { const localizedName = pickTranslation(nameTranslations, "name"); if (localizedName) base.name = localizedName; } tvdbEpisodeDetailCache.set(cacheKey, base); return base; } function buildTvShowDir(rootFolder) { const dir = tvSeriesDir(rootFolder); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); return dir; } 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(rootFolder, relativePath) { const encoded = sanitizeRelative(rootFolder) .split(path.sep) .map(encodeURIComponent) .join("/"); return relativePath ? `/tv-data/${encoded}/${relativePath.split(path.sep).map(encodeURIComponent).join("/")}` : `/tv-data/${encoded}`; } async function ensureSeriesData( rootFolder, relativeFilePath, seriesInfo, mediaInfo ) { if (!TVDB_API_KEY || !seriesInfo) { console.log("📺 TVDB atlandı (eksik anahtar ya da seriesInfo yok):", { rootFolder, relativeFilePath }); return null; } console.log("📺 ensureSeriesData başladı:", { rootFolder, relativeFilePath, seriesInfo }); const showDir = buildTvShowDir(rootFolder); const seriesMetaPath = path.join(showDir, "series.json"); let seriesData = {}; if (fs.existsSync(seriesMetaPath)) { try { seriesData = JSON.parse(fs.readFileSync(seriesMetaPath, "utf-8")) || {}; } catch (err) { console.warn(`⚠️ series.json okunamadı (${seriesMetaPath}): ${err.message}`); } } 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; } const extended = await fetchTvdbSeriesExtended(seriesId); const container = extended || {}; console.log("📺 TVDB extended yükleme:", { seriesId, keys: Object.keys(container || {}) }); const info = (container.series && typeof container.series === "object" ? container.series : container) || {}; const translations = container.translations || info.translations || {}; const nameTranslations = translations.nameTranslations || translations.names || []; const overviewTranslations = translations.overviewTranslations || translations.overviews || []; const localizedName = nameTranslations.find((t) => ["tr", "tur", "turkish"].includes(String(t?.language || t?.iso6391).toLowerCase()) )?.value || nameTranslations.find((t) => ["en", "eng", "english"].includes(String(t?.language || t?.iso6391).toLowerCase()) )?.value || null; const localizedOverview = overviewTranslations.find((t) => ["tr", "tur", "turkish"].includes(String(t?.language || t?.iso6391).toLowerCase()) )?.overview || overviewTranslations.find((t) => ["en", "eng", "english"].includes(String(t?.language || t?.iso6391).toLowerCase()) )?.overview || null; seriesData.name = seriesData.name || info.name || info.seriesName || localizedName || seriesInfo.title; seriesData.slug = seriesData.slug || info.slug || info.slugged || null; seriesData.overview = seriesData.overview || info.overview || localizedOverview || ""; const firstAired = info.firstAired || info.firstAirDate || info.first_air_date || info.premiere || null; if (!seriesData.firstAired) seriesData.firstAired = firstAired || null; if (!seriesData.year && firstAired) { const yearMatch = String(firstAired).match(/(\d{4})/); if (yearMatch) seriesData.year = Number(yearMatch[1]); } if (!seriesData.year) { for (const candidate of [info.year, info.startYear, info.start_year]) { const numeric = Number(candidate); if (Number.isFinite(numeric) && numeric > 0) { seriesData.year = numeric; break; } } } if ( !seriesData.genres || !Array.isArray(seriesData.genres) || !seriesData.genres.length ) { seriesData.genres = Array.isArray(info.genres) ? info.genres .map((g) => typeof g === "string" ? g : g?.name || g?.genre || g?.slug || null ) .filter(Boolean) : []; } if (!seriesData.status) { const status = info.status?.name || info.status || container.status || null; seriesData.status = typeof status === "string" ? status : null; } seriesData._dupe = { ...(seriesData._dupe || {}), folder: rootFolder }; 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]; const backdropArtwork = artworksRaw.find((a) => { const type = String( a?.type || a?.artworkType || a?.type2 || a?.name || a?.artwork ).toLowerCase(); return ( type.includes("fanart") || type.includes("background") || type === "1" ); }); const posterImage = posterArtwork?.image || posterArtwork?.file || posterArtwork?.fileName || posterArtwork?.thumbnail || posterArtwork?.url || null; const backdropImage = backdropArtwork?.image || backdropArtwork?.file || backdropArtwork?.fileName || backdropArtwork?.thumbnail || backdropArtwork?.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)) { await downloadTvdbImage(backdropImage, backdropPath); } const seasonPaths = seasonAssetPaths(rootFolder, 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(rootFolder, relPoster); } } if (!seasonContainer.overview) seasonContainer.overview = ""; const episodeData = await fetchTvdbEpisode( seriesData.id, seriesInfo.season, seriesInfo.episode ); let detailedEpisode = episodeData; if ( detailedEpisode?.id && (!detailedEpisode.overview || !detailedEpisode.image || !detailedEpisode.name) ) { const extendedEpisode = await fetchTvdbEpisodeExtended( detailedEpisode.id ); if (extendedEpisode) { detailedEpisode = { ...detailedEpisode, ...extendedEpisode, image: extendedEpisode.image || detailedEpisode.image, overview: extendedEpisode.overview || detailedEpisode.overview, name: extendedEpisode.name || detailedEpisode.name, aired: extendedEpisode.aired || detailedEpisode.aired, runtime: extendedEpisode.runtime || detailedEpisode.runtime }; } } const episodeDetails = detailedEpisode || episodeData || {}; if ( episodeDetails && episodeData && episodeDetails !== episodeData ) { const seasonCacheKey = `${seriesData.id}-S${seriesInfo.season}`; const seasonCache = tvdbEpisodeCache.get(seasonCacheKey); if (seasonCache) { seasonCache.set(seriesInfo.episode, episodeDetails); } } const episodeKey = String(seriesInfo.episode); const stillDir = path.join( showDir, "episodes", `season-${String(seriesInfo.season).padStart(2, "0")}` ); const stillFileName = `episode-${String(seriesInfo.episode).padStart(2, "0")}.jpg`; const stillPath = path.join(stillDir, stillFileName); const episodeImage = episodeDetails.image || episodeDetails.filename || episodeDetails.thumb || episodeDetails.thumbnail || episodeDetails.imageUrl || (episodeDetails.raw && ( episodeDetails.raw.image || episodeDetails.raw.filename || episodeDetails.raw.thumb || episodeDetails.raw.thumbnail )) || null; if (episodeImage && !fs.existsSync(stillPath)) { await downloadTvdbImage(episodeImage, stillPath); } const episodeCode = seriesInfo.key || `S${String(seriesInfo.season).padStart(2, "0")}E${String( seriesInfo.episode ).padStart(2, "0")}`; const episodeTitle = episodeDetails.name || episodeDetails.raw?.name || episodeDetails.raw?.episodeName || `Episode ${seriesInfo.episode}`; const episodeOverview = episodeDetails.overview || episodeDetails.raw?.overview || ""; const episodeRuntime = toFiniteNumber(episodeDetails.runtime) || toFiniteNumber(episodeDetails.raw?.runtime) || toFiniteNumber(episodeDetails.raw?.length) || null; const episodeAirDate = episodeDetails.aired || episodeDetails.raw?.aired || episodeDetails.raw?.firstAired || null; const episodeSlug = episodeDetails.slug || episodeDetails.raw?.slug || null; const episodeTvdbId = normalizeTvdbId( episodeDetails.id ?? episodeDetails.raw?.id ); const episodeSeasonId = normalizeTvdbId( episodeDetails.seasonId ?? episodeDetails.raw?.seasonId ); seasonContainer.episodes[episodeKey] = { episodeNumber: seriesInfo.episode, seasonNumber: seriesInfo.season, code: episodeCode, title: episodeTitle, overview: episodeOverview, runtime: episodeRuntime, aired: episodeAirDate, still: fs.existsSync(stillPath) ? encodeTvDataPath(rootFolder, path.relative(showDir, stillPath)) : null, file: relativeFilePath, 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"); 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(rootFolder, "poster.jpg") : null, backdrop: fs.existsSync(path.join(showDir, "backdrop.jpg")) ? encodeTvDataPath(rootFolder, "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] }; } function tvSeriesDir(rootFolder) { return path.join(TV_DATA_ROOT, sanitizeRelative(rootFolder)); } function tvSeriesPaths(rootFolder) { const dir = tvSeriesDir(rootFolder); return { 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") }; } function seasonAssetPaths(rootFolder, seasonNumber) { const baseDir = tvSeriesDir(rootFolder); const padded = String(seasonNumber).padStart(2, "0"); const dir = path.join(baseDir, "seasons", `season-${padded}`); return { dir, poster: path.join(dir, "poster.jpg") }; } function removeSeriesData(rootFolder) { const dir = tvSeriesDir(rootFolder); if (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 safeRoot = sanitizeRelative(rootFolder); if (!safeRoot) return; const seriesMetaPath = tvSeriesPaths(safeRoot).metadata; if (!fs.existsSync(seriesMetaPath)) return; let seriesData; try { seriesData = JSON.parse(fs.readFileSync(seriesMetaPath, "utf-8")); } catch (err) { console.warn( `⚠️ series.json okunamadı (${seriesMetaPath}): ${err.message}` ); return; } 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) return; if (!Object.keys(seasons).length) { removeSeriesData(safeRoot); return; } seriesData.seasons = seasons; seriesData.updatedAt = Date.now(); try { fs.writeFileSync(seriesMetaPath, JSON.stringify(seriesData, null, 2), "utf-8"); } catch (err) { console.warn( `⚠️ series.json güncellenemedi (${seriesMetaPath}): ${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 broadcastFileUpdate(rootFolder) { if (!wss) return; const data = JSON.stringify({ type: "fileUpdate", path: rootFolder }); wss.clients.forEach((c) => c.readyState === 1 && c.send(data)); } function broadcastSnapshot() { if (!wss) return; const data = JSON.stringify({ type: "progress", torrents: snapshot() }); wss.clients.forEach((c) => c.readyState === 1 && c.send(data)); } // --- Snapshot (thumbnail dahil, tracker + tarih eklendi) --- function snapshot() { return Array.from(torrents.values()).map( ({ torrent, selectedIndex, savePath, added }) => { const rootFolder = path.basename(savePath); const bestVideoIndex = pickBestVideoFile(torrent); const bestVideo = torrent.files[bestVideoIndex]; let thumbnail = null; if (bestVideo) { const relPath = path.join(rootFolder, bestVideo.path); const { relThumb, absThumb } = getVideoThumbnailPaths(relPath); if (fs.existsSync(absThumb)) thumbnail = thumbnailUrl(relThumb); else if (torrent.progress === 1) queueVideoThumbnail(path.join(savePath, bestVideo.path), relPath); } return { infoHash: torrent.infoHash, name: torrent.name, progress: torrent.progress, downloaded: torrent.downloaded, downloadSpeed: torrent.downloadSpeed, uploadSpeed: torrent.uploadSpeed, numPeers: torrent.numPeers, tracker: torrent.announce?.[0] || null, added, savePath, // 🆕 BURASI! files: torrent.files.map((f, i) => ({ index: i, name: f.name, length: f.length })), selectedIndex, thumbnail }; } ); } // --- Basit kimlik doğrulama sistemi --- const USERNAME = process.env.USERNAME; const PASSWORD = process.env.PASSWORD; let activeTokens = new Set(); app.post("/api/login", (req, res) => { const { username, password } = req.body; if (username === USERNAME && password === PASSWORD) { const token = crypto.randomBytes(24).toString("hex"); activeTokens.add(token); return res.json({ token }); } res.status(401).json({ error: "Invalid credentials" }); }); function requireAuth(req, res, next) { const token = req.headers.authorization?.split(" ")[1] || req.query.token; if (!token || !activeTokens.has(token)) return res.status(401).json({ error: "Unauthorized" }); next(); } // --- Torrent veya magnet ekleme --- app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => { try { let source = req.body.magnet; if (req.file) source = fs.readFileSync(req.file.path); if (!source) return res.status(400).json({ error: "magnet veya .torrent gerekli" }); // Her torrent için ayrı klasör const savePath = path.join(DOWNLOAD_DIR, Date.now().toString()); fs.mkdirSync(savePath, { recursive: true }); const torrent = client.add(source, { announce: [], path: savePath }); // 🆕 Torrent eklendiği anda tarih kaydedelim const added = Date.now(); torrents.set(torrent.infoHash, { torrent, selectedIndex: 0, savePath, added }); // --- Metadata geldiğinde --- torrent.on("ready", () => { const selectedIndex = pickBestVideoFile(torrent); torrents.set(torrent.infoHash, { torrent, selectedIndex, savePath, added }); const rootFolder = path.basename(savePath); upsertInfoFile(savePath, { infoHash: torrent.infoHash, name: torrent.name, tracker: torrent.announce?.[0] || null, added, createdAt: added, folder: rootFolder }); broadcastFileUpdate(rootFolder); res.json({ ok: true, infoHash: torrent.infoHash, name: torrent.name, selectedIndex, tracker: torrent.announce?.[0] || null, added, files: torrent.files.map((f, i) => ({ index: i, name: f.name, length: f.length })) }); broadcastSnapshot(); }); // --- İndirme tamamlandığında thumbnail oluştur --- torrent.on("done", async () => { const entry = torrents.get(torrent.infoHash); if (!entry) return; console.log(`✅ Torrent tamamlandı: ${torrent.name}`); const rootFolder = path.basename(entry.savePath); const bestVideoIndex = pickBestVideoFile(torrent); const bestVideo = torrent.files[bestVideoIndex] || torrent.files[0] || null; const displayName = bestVideo?.name || torrent.name || rootFolder; const bestVideoPath = bestVideo?.path ? bestVideo.path.replace(/\\/g, "/") : null; const perFileMetadata = {}; const seriesEpisodes = {}; let primaryMediaInfo = null; for (const file of torrent.files) { const fullPath = path.join(entry.savePath, file.path); const relPathWithRoot = path.join(rootFolder, file.path); const normalizedRelPath = file.path.replace(/\\/g, "/"); const mimeType = mime.lookup(fullPath) || ""; const ext = path.extname(file.name).replace(/^\./, "").toLowerCase(); if (mimeType.startsWith("video/")) { queueVideoThumbnail(fullPath, relPathWithRoot); } else if (mimeType.startsWith("image/")) { queueImageThumbnail(fullPath, relPathWithRoot); } let metaInfo = null; if ( mimeType.startsWith("video/") || mimeType.startsWith("audio/") || mimeType.startsWith("image/") ) { metaInfo = await extractMediaInfo(fullPath); } if ( !primaryMediaInfo && bestVideoPath && normalizedRelPath === bestVideoPath && metaInfo ) { primaryMediaInfo = metaInfo; } perFileMetadata[normalizedRelPath] = { size: file.length, extension: ext || null, mimeType, mediaInfo: metaInfo }; const seriesInfo = parseSeriesInfo(file.name); if (seriesInfo) { try { const ensured = await ensureSeriesData( rootFolder, normalizedRelPath, seriesInfo, metaInfo ); if (ensured?.show && ensured?.episode) { seriesEpisodes[normalizedRelPath] = { season: seriesInfo.season, episode: seriesInfo.episode, key: seriesInfo.key, title: ensured.episode.title || seriesInfo.title, showId: ensured.show.id || null, showTitle: ensured.show.title || seriesInfo.title, seasonName: ensured.season?.name || `Season ${seriesInfo.season}`, seasonId: ensured.season?.tvdbSeasonId || null, seasonPoster: ensured.season?.poster || null, overview: ensured.episode.overview || "", aired: ensured.episode.aired || null, runtime: ensured.episode.runtime || null, still: ensured.episode.still || null, episodeId: ensured.episode.tvdbEpisodeId || null, slug: ensured.episode.slug || null }; } } catch (err) { console.warn( `⚠️ TV metadata oluşturulamadı (${rootFolder} - ${file.name}): ${ err?.message || err }` ); } } } // Eski thumbnail yapısını temizle try { const legacyThumb = path.join(entry.savePath, "thumbnail.jpg"); if (fs.existsSync(legacyThumb)) fs.rmSync(legacyThumb, { force: true }); const legacyDir = path.join(entry.savePath, "thumbnail"); if (fs.existsSync(legacyDir)) fs.rmSync(legacyDir, { recursive: true, force: true }); } catch (err) { console.warn("⚠️ Eski thumbnail klasörü temizlenemedi:", err.message); } const infoUpdate = { completedAt: Date.now(), totalBytes: torrent.downloaded, fileCount: torrent.files.length, files: perFileMetadata }; if (bestVideoPath) infoUpdate.primaryVideoPath = bestVideoPath; if (Object.keys(seriesEpisodes).length) { infoUpdate.seriesEpisodes = seriesEpisodes; } const ensuredMedia = await ensureMovieData( rootFolder, displayName, bestVideoPath, primaryMediaInfo ); if (ensuredMedia) infoUpdate.primaryMediaInfo = ensuredMedia; upsertInfoFile(entry.savePath, infoUpdate); broadcastFileUpdate(rootFolder); broadcastSnapshot(); }); } catch (err) { res.status(500).json({ error: err.message }); } }); // --- Thumbnail endpoint --- app.get("/thumbnails/:path(*)", requireAuth, (req, res) => { const relThumb = req.params.path || ""; const fullPath = resolveThumbnailAbsolute(relThumb); if (!fullPath) return res.status(400).send("Geçersiz thumbnail yolu"); if (!fs.existsSync(fullPath)) return res.status(404).send("Thumbnail yok"); res.sendFile(fullPath); }); 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"); if (!fs.existsSync(fullPath)) return res.status(404).send("Dosya bulunamadı"); res.sendFile(fullPath); }); 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"); if (!fs.existsSync(fullPath)) return res.status(404).send("Dosya bulunamadı"); res.sendFile(fullPath); }); // --- 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) return res.status(404).json({ error: "torrent bulunamadı" }); 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) return res.status(404).json({ error: "torrent bulunamadı" }); const { torrent, savePath } = entry; const isComplete = torrent?.done || (torrent?.progress ?? 0) >= 1; const rootFolder = savePath ? path.basename(savePath) : null; torrent.destroy(() => { torrents.delete(req.params.hash); if (!isComplete) { if (savePath && fs.existsSync(savePath)) { try { fs.rmSync(savePath, { recursive: true, force: true }); console.log(`🗑️ ${savePath} klasörü silindi`); } catch (err) { console.warn(`⚠️ ${savePath} silinemedi:`, err.message); } } if (rootFolder) { purgeRootFolder(rootFolder); broadcastFileUpdate(rootFolder); } } else { console.log( `ℹ️ ${req.params.hash} torrent'i tamamlandığı için yalnızca Transfers listesinden kaldırıldı; dosyalar tutuldu.` ); } broadcastSnapshot(); res.json({ ok: true, filesRemoved: !isComplete }); }); }); // --- GENEL MEDYA SUNUMU (🆕 resimler + videolar) --- app.get("/media/:path(*)", requireAuth, (req, res) => { const relPath = req.params.path; const fullPath = path.join(DOWNLOAD_DIR, relPath); if (!fs.existsSync(fullPath)) return res.status(404).send("File not found"); const stat = fs.statSync(fullPath); const fileSize = stat.size; const type = mime.lookup(fullPath) || "application/octet-stream"; const isVideo = String(type).startsWith("video/"); const range = req.headers.range; 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ü silme --- app.delete("/api/file", requireAuth, (req, res) => { const filePath = req.query.path; if (!filePath) return res.status(400).json({ error: "path gerekli" }); const safePath = sanitizeRelative(filePath); const fullPath = path.join(DOWNLOAD_DIR, safePath); const folderId = (safePath.split(/[\/]/)[0] || "").trim(); const rootDir = folderId ? path.join(DOWNLOAD_DIR, folderId) : null; let stats = null; try { stats = fs.statSync(fullPath); } catch (err) { const message = err?.message || String(err); console.warn(`⚠️ Silme sırasında stat alınamadı (${fullPath}): ${message}`); } if (!stats || !fs.existsSync(fullPath)) { if (folderId && (!rootDir || !fs.existsSync(rootDir))) { purgeRootFolder(folderId); broadcastFileUpdate(folderId); return res.json({ ok: true, alreadyRemoved: true }); } return res.status(404).json({ error: "Dosya bulunamadı" }); } try { fs.rmSync(fullPath, { recursive: true, force: true }); console.log(`🗑️ Dosya/klasör silindi: ${fullPath}`); removeThumbnailsForPath(safePath); if (folderId) { const relWithinRoot = safePath.split(/[\/]/).slice(1).join("/"); const rootExists = rootDir && fs.existsSync(rootDir); if (!relWithinRoot || !rootExists) { purgeRootFolder(folderId); } else { const remaining = fs.readdirSync(rootDir); const meaningful = remaining.filter((name) => { if (!name) return false; if (name === INFO_FILENAME) return false; if (name.startsWith(".")) return false; const full = path.join(rootDir, name); try { const stat = fs.statSync(full); if (stat.isDirectory()) { const subItems = fs.readdirSync(full); return subItems.some((entry) => !entry.startsWith(".")); } } catch (err) { return false; } return true; }); if (meaningful.length === 0 || stats?.isDirectory?.()) { purgeRootFolder(folderId); } else { pruneInfoEntry(folderId, relWithinRoot); const infoAfter = readInfoForRoot(folderId); const displayName = infoAfter?.name || folderId; const primaryVideo = infoAfter?.primaryVideoPath || guessPrimaryVideo(folderId); if (primaryVideo) { const candidateMedia = infoAfter?.files?.[primaryVideo]?.mediaInfo || infoAfter?.primaryMediaInfo || null; ensureMovieData(folderId, displayName, primaryVideo, candidateMedia).catch( (err) => console.warn( `⚠️ Movie metadata yenilenemedi (${folderId}): ${err?.message || err}` ) ); } removeSeriesEpisode(folderId, relWithinRoot); } } broadcastFileUpdate(folderId); } if (folderId) { let matchedInfoHash = null; for (const [infoHash, entry] of torrents.entries()) { const lastDir = path.basename(entry.savePath); if (lastDir === folderId) { matchedInfoHash = infoHash; break; } } if (matchedInfoHash) { const entry = torrents.get(matchedInfoHash); entry?.torrent?.destroy(() => { torrents.delete(matchedInfoHash); console.log(`🧹 Torrent kaydı da temizlendi: ${matchedInfoHash}`); broadcastSnapshot(); }); } else { broadcastSnapshot(); } } else { broadcastSnapshot(); } res.json({ ok: true, filesRemoved: true }); } catch (err) { console.error("❌ Dosya silinemedi:", err.message); res.status(500).json({ error: err.message }); } }); // --- 📁 Dosya gezgini (🆕 type ve url alanları eklendi; resim thumb'ı) --- app.get("/api/files", requireAuth, (req, res) => { // --- 🧩 .ignoreFiles içeriğini oku --- let ignoreList = []; const ignorePath = path.join(__dirname, ".ignoreFiles"); if (fs.existsSync(ignorePath)) { try { const raw = fs.readFileSync(ignorePath, "utf-8"); ignoreList = raw .split("\n") .map((l) => l.trim().toLowerCase()) .filter((l) => l && !l.startsWith("#")); } catch (err) { console.warn("⚠️ .ignoreFiles okunamadı:", err.message); } } // --- 🔍 Yardımcı fonksiyon: dosya ignoreList’te mi? --- const isIgnored = (name) => { const lower = name.toLowerCase(); const ext = path.extname(lower).replace(".", ""); return ignoreList.some( (ignored) => lower === ignored || lower.endsWith(ignored) || lower.endsWith(`.${ignored}`) || ext === ignored.replace(/^\./, "") ); }; const infoCache = new Map(); const getInfo = (relPath) => { const root = rootFromRelPath(relPath); if (!root) return null; if (!infoCache.has(root)) { infoCache.set(root, readInfoForRoot(root)); } return infoCache.get(root); }; // --- 📁 Klasörleri dolaş --- const walk = (dir) => { let result = []; const list = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of list) { const full = path.join(dir, entry.name); const rel = path.relative(DOWNLOAD_DIR, full); // 🔥 Ignore kontrolü (hem dosya hem klasör için) if (isIgnored(entry.name) || isIgnored(rel)) continue; if (entry.isDirectory()) { result = result.concat(walk(full)); } else { if (entry.name.toLowerCase() === INFO_FILENAME) continue; const size = fs.statSync(full).size; const type = mime.lookup(full) || "application/octet-stream"; const safeRel = sanitizeRelative(rel); 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 rootFolder = rootFromRelPath(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 relWithinRoot = safeRel.split(/[\\/]/).slice(1).join("/"); 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; result.push({ name: safeRel, size, type, url, thumbnail: thumb, rootFolder, added, completedAt, tracker, torrentName, infoHash, extension: extensionForFile, mediaInfo: mediaInfoForFile, primaryVideoPath: info.primaryVideoPath || null, primaryMediaInfo: info.primaryMediaInfo || null, seriesEpisode: seriesEpisodeInfo }); } } return result; }; try { const files = walk(DOWNLOAD_DIR); res.json(files); } catch (err) { console.error("📁 Files API error:", err); res.status(500).json({ error: err.message }); } }); // --- 🎬 Film listesi --- app.get("/api/movies", requireAuth, (req, res) => { try { if (!fs.existsSync(MOVIE_DATA_ROOT)) { return res.json([]); } const entries = fs .readdirSync(MOVIE_DATA_ROOT, { withFileTypes: true }) .filter((d) => d.isDirectory()); const movies = entries .map((dirent) => { const folder = dirent.name; const paths = movieDataPaths(folder); if (!fs.existsSync(paths.metadata)) return null; try { const metadata = JSON.parse( fs.readFileSync(paths.metadata, "utf-8") ); if (!isTmdbMetadata(metadata)) { removeMovieData(folder); return null; } const encodedFolder = folder .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 dupe = metadata._dupe || {}; return { folder, id: metadata.id ?? folder, title: metadata.title || metadata.matched_title || folder, 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/${encodedFolder}/poster.jpg` : null, backdrop: backdropExists ? `/movie-data/${encodedFolder}/backdrop.jpg` : null, videoPath: dupe.videoPath || null, mediaInfo: dupe.mediaInfo || null, metadata }; } catch (err) { console.warn( `⚠️ metadata.json okunamadı (${paths.metadata}): ${err.message}` ); return null; } }) .filter(Boolean); movies.sort((a, b) => { const yearA = a.year || 0; const yearB = b.year || 0; if (yearA !== yearB) return yearB - yearA; return a.title.localeCompare(b.title); }); res.json(movies); } catch (err) { console.error("🎬 Movies API error:", err); res.status(500).json({ error: err.message }); } }); 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 folders = fs .readdirSync(DOWNLOAD_DIR, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name); const processed = []; for (const folder of folders) { const info = readInfoForRoot(folder); const displayName = info?.name || folder; const primaryVideo = info?.primaryVideoPath || guessPrimaryVideo(folder); const candidateMedia = info?.files?.[primaryVideo]?.mediaInfo || info?.primaryMediaInfo || null; const ensured = await ensureMovieData( folder, displayName, primaryVideo, candidateMedia ); if (primaryVideo || ensured) { const update = {}; if (primaryVideo) update.primaryVideoPath = primaryVideo; if (ensured) update.primaryMediaInfo = ensured; if (Object.keys(update).length) { upsertInfoFile(path.join(DOWNLOAD_DIR, folder), update); } } processed.push(folder); } res.json({ ok: true, processed }); } catch (err) { console.error("🎬 Movies refresh error:", err); res.status(500).json({ error: err.message }); } }); // --- 📺 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 folder = sanitizeRelative(dirent.name); if (!folder) continue; const paths = tvSeriesPaths(folder); 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 seasonsObj = seriesData?.seasons || {}; if (!Object.keys(seasonsObj).length) { removeSeriesData(folder); continue; } let dataChanged = false; const encodedFolder = folder .split(path.sep) .map(encodeURIComponent) .join("/"); const showId = seriesData.id ?? seriesData.tvdbId ?? seriesData.slug ?? seriesData.name ?? folder; const showKey = String(showId).toLowerCase(); const record = aggregated.get(showKey) || (() => { const base = { id: seriesData.id ?? seriesData.tvdbId ?? folder, title: seriesData.name || folder, overview: seriesData.overview || "", year: seriesData.year || null, status: seriesData.status || null, poster: fs.existsSync(paths.poster) ? `/tv-data/${encodedFolder}/poster.jpg` : null, backdrop: fs.existsSync(paths.backdrop) ? `/tv-data/${encodedFolder}/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: folder, folders: new Set([folder]) }; aggregated.set(showKey, base); return base; })(); record.folders.add(folder); 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 = `/tv-data/${encodedFolder}/poster.jpg`; } if (!record.backdrop && fs.existsSync(paths.backdrop)) { record.backdrop = `/tv-data/${encodedFolder}/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(folder, seasonNumber); const rawEpisodes = rawSeason.episodes || {}; for (const [episodeKey, rawEpisode] of Object.entries(rawEpisodes)) { if (!rawEpisode || typeof rawEpisode !== "object") continue; const relativeFile = (rawEpisode.file || "").replace(/\\/g, "/"); if (relativeFile) { const absEpisodePath = path.join( DOWNLOAD_DIR, folder, relativeFile ); if (!fs.existsSync(absEpisodePath)) { delete rawEpisodes[episodeKey]; dataChanged = true; } } } if (!Object.keys(rawSeason.episodes || {}).length) { delete seasonsObj[seasonKey]; dataChanged = true; continue; } let seasonRecord = record.seasons.get(seasonNumber); if (!seasonRecord) { seasonRecord = { seasonNumber, name: rawSeason.name || `Season ${seasonNumber}`, overview: rawSeason.overview || "", poster: rawSeason.poster || null, tvdbId: rawSeason.tvdbId || null, slug: rawSeason.slug || null, episodeCount: rawSeason.episodeCount || null, episodes: new Map() }; record.seasons.set(seasonNumber, seasonRecord); } else { if (!seasonRecord.name && rawSeason.name) seasonRecord.name = rawSeason.name; if (!seasonRecord.overview && rawSeason.overview) seasonRecord.overview = rawSeason.overview; if (!seasonRecord.poster && rawSeason.poster) seasonRecord.poster = rawSeason.poster; if (!seasonRecord.tvdbId && rawSeason.tvdbId) seasonRecord.tvdbId = rawSeason.tvdbId; if (!seasonRecord.slug && rawSeason.slug) seasonRecord.slug = rawSeason.slug; if (!seasonRecord.episodeCount && rawSeason.episodeCount) seasonRecord.episodeCount = rawSeason.episodeCount; } if (!seasonRecord.poster && fs.existsSync(seasonPaths.poster)) { const relPoster = path.relative( tvSeriesDir(folder), seasonPaths.poster ); seasonRecord.poster = encodeTvDataPath(folder, 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 || normalizedEpisode.videoPath || ""; if (!normalizedEpisode.videoPath && relativeFile) { const joined = relativeFile.includes("/") ? relativeFile : `${folder}/${relativeFile}`; normalizedEpisode.videoPath = joined.replace(/\\/g, "/"); } else if (normalizedEpisode.videoPath) { normalizedEpisode.videoPath = normalizedEpisode.videoPath.replace( /\\/g, "/" ); } normalizedEpisode.folder = 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( `⚠️ 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()).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 }; }) .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 }); } }); 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 folders = fs .readdirSync(DOWNLOAD_DIR, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name); const processed = []; for (const folder of folders) { const safeFolder = sanitizeRelative(folder); if (!safeFolder) continue; const rootDir = path.join(DOWNLOAD_DIR, safeFolder); if (!fs.existsSync(rootDir)) continue; const info = readInfoForRoot(safeFolder) || {}; const infoFiles = info.files || {}; const detected = {}; const walkDir = async (currentDir, relativeBase = "") => { const entries = fs.readdirSync(currentDir, { withFileTypes: true }); for (const entry of entries) { const relPath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name; const absPath = path.join(currentDir, entry.name); if (entry.isDirectory()) { await walkDir(absPath, relPath); 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 = infoFiles?.[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( safeFolder, 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 }; } } catch (err) { console.warn( `⚠️ TV metadata yenilenemedi (${safeFolder} - ${entry.name}): ${ err?.message || err }` ); } } }; await walkDir(rootDir); if (Object.keys(detected).length) { upsertInfoFile(rootDir, { seriesEpisodes: detected }); } processed.push({ folder: safeFolder, episodes: Object.keys(detected).length }); } res.json({ ok: true, processed }); } catch (err) { console.error("📺 TvShows refresh error:", err); res.status(500).json({ error: err.message }); } }); // --- Stream endpoint (torrent içinden) --- app.get("/stream/:hash", requireAuth, (req, res) => { const entry = torrents.get(req.params.hash); if (!entry) return res.status(404).end(); const file = entry.torrent.files[entry.selectedIndex] || entry.torrent.files[0]; const total = file.length; const type = mime.lookup(file.name) || "video/mp4"; const range = req.headers.range; 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); }); console.log("📂 Download path:", DOWNLOAD_DIR); // --- ✅ 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(`✅ WebTorrent server ${PORT} portunda çalışıyor`) ); wss = new WebSocketServer({ server }); wss.on("connection", (ws) => { ws.send(JSON.stringify({ type: "progress", torrents: snapshot() })); }); // --- ⏱️ Her 2 saniyede bir aktif torrent durumu yayınla --- setInterval(() => { if (torrents.size > 0) { broadcastSnapshot(); } }, 2000); client.on("error", (err) => { if (!String(err).includes("uTP")) console.error("WebTorrent error:", err.message); });