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"); for (const dir of [ THUMBNAIL_DIR, VIDEO_THUMB_ROOT, IMAGE_THUMB_ROOT, MOVIE_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 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 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 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 }; } 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); } } const resp = await fetch(url); if (!resp.ok) { console.warn(`⚠️ TMDB isteği başarısız (${url}): ${resp.status}`); return null; } return resp.json(); } 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; } } async function ensureMovieData( rootFolder, displayName, bestVideoPath, precomputedMediaInfo = null ) { if (!TMDB_API_KEY) return precomputedMediaInfo || null; console.log("🎬 ensureMovieData çağrıldı:", { rootFolder, displayName }); 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}`); } } } 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); 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.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 = {}; 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 }; } // 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; 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); }); // --- 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}` ) ); } } } 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; 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 }); } } 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 }); } }); // --- 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); });