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"); for (const dir of [THUMBNAIL_DIR, VIDEO_THUMB_ROOT, IMAGE_THUMB_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"; 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 (!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 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 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", () => { const entry = torrents.get(torrent.infoHash); if (!entry) return; console.log(`✅ Torrent tamamlandı: ${torrent.name}`); const rootFolder = path.basename(entry.savePath); torrent.files.forEach((file) => { const fullPath = path.join(entry.savePath, file.path); const relPath = path.join(rootFolder, file.path); const mimeType = mime.lookup(fullPath) || ""; if (mimeType.startsWith("video/")) { queueVideoThumbnail(fullPath, relPath); } else if (mimeType.startsWith("image/")) { queueImageThumbnail(fullPath, relPath); } }); // 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); } upsertInfoFile(entry.savePath, { completedAt: Date.now(), totalBytes: torrent.downloaded, fileCount: torrent.files.length }); 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); }); // --- 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) { removeThumbnailsForPath(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 fullPath = path.join(DOWNLOAD_DIR, filePath); if (!fs.existsSync(fullPath)) return res.status(404).json({ error: "Dosya bulunamadı" }); try { // 1) Dosya/klasörü sil fs.rmSync(fullPath, { recursive: true, force: true }); console.log(`🗑️ Dosya/klasör silindi: ${fullPath}`); removeThumbnailsForPath(filePath); // 2) İlk segment (klasör adı) => folderId (örn: "1730048432921") const folderId = (filePath.split(/[\\/]/)[0] || "").trim(); // 3) torrents Map’inde, savePath'in son klasörü folderId olan entry’yi bul let matchedInfoHash = null; for (const [infoHash, entry] of torrents.entries()) { const lastDir = path.basename(entry.savePath); if (lastDir === folderId) { matchedInfoHash = infoHash; break; } } // 4) Eşleşen torrent varsa destroy + Map’ten sil + snapshot yayınla if (matchedInfoHash) { const entry = torrents.get(matchedInfoHash); entry?.torrent?.destroy(() => { torrents.delete(matchedInfoHash); console.log(`🧹 Torrent kaydı da temizlendi: ${matchedInfoHash}`); broadcastSnapshot(); }); } else { // Torrent eşleşmediyse de listeyi tazele (ör. sade dosya silinmiştir) broadcastSnapshot(); } if (folderId) broadcastFileUpdate(folderId); res.json({ ok: 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; result.push({ name: safeRel, size, type, url, thumbnail: thumb, rootFolder, added, completedAt, tracker, torrentName, infoHash }); } } 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 }); } }); // --- 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); // --- WebSocket: anlık durum yayını --- const server = app.listen(PORT, () => console.log(`✅ WebTorrent server ${PORT} portunda çalışıyor`) ); // --- ✅ 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")); }); } 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); });