diff --git a/docker-compose.yml b/docker-compose.yml index 04b5fca..a6da150 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: - "3001:3001" volumes: - ./downloads:/app/server/downloads + - ./server/cache:/app/server/cache restart: unless-stopped # Login credentials for basic auth environment: diff --git a/server/cache/.gitkeep b/server/cache/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/server/cache/.gitkeep @@ -0,0 +1 @@ + diff --git a/server/server.js b/server/server.js index 1cc1b73..df8f3d8 100644 --- a/server/server.js +++ b/server/server.js @@ -17,6 +17,7 @@ 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 --- @@ -24,6 +25,20 @@ 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(); + app.use(cors()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); @@ -31,21 +46,186 @@ app.use("/downloads", express.static(DOWNLOAD_DIR)); // --- En uygun video dosyasını seç --- function pickBestVideoFile(torrent) { - const videoExts = [".mp4", ".webm", ".mkv", ".mov", ".m4v"]; const videos = torrent.files .map((f, i) => ({ i, f })) - .filter(({ f }) => videoExts.includes(path.extname(f.name).toLowerCase())); + .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 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 directDirs = [ + path.join(VIDEO_THUMB_ROOT, normalized), + path.join(IMAGE_THUMB_ROOT, normalized) + ]; + + for (const target of directDirs) { + try { + if (fs.existsSync(target) && fs.lstatSync(target).isDirectory()) { + fs.rmSync(target, { recursive: true, force: true }); + } + } catch (err) { + console.warn(`⚠️ Thumbnail klasörü silinemedi (${target}): ${err.message}`); + } + } + + 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}`); + } + } +} + +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 thumbPath = path.join(savePath, "thumbnail.jpg"); - const hasThumb = fs.existsSync(thumbPath); + 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, @@ -63,31 +243,12 @@ function snapshot() { length: f.length })), selectedIndex, - thumbnail: hasThumb ? `/thumbnail/${torrent.infoHash}` : null + thumbnail }; } ); } -function createImageThumbnail(filePath, outputDir) { - const fileName = path.basename(filePath); - const thumbDir = path.join(outputDir, "thumbnail"); - const thumbPath = path.join(thumbDir, fileName); - - if (!fs.existsSync(thumbDir)) fs.mkdirSync(thumbDir, { recursive: true }); - - // 320px genişlikte orantılı thumbnail oluştur - const cmd = `ffmpeg -y -i "${filePath}" -vf "scale=320:-1" -q:v 5 "${thumbPath}"`; - - exec(cmd, (err) => { - if (err) { - console.warn(`❌ Thumbnail oluşturulamadı: ${fileName}`, err.message); - } else { - console.log(`🖼️ Thumbnail oluşturuldu: ${thumbPath}`); - } - }); -} - // --- Basit kimlik doğrulama sistemi --- const USERNAME = process.env.USERNAME; const PASSWORD = process.env.PASSWORD; @@ -156,11 +317,7 @@ app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => { length: f.length })) }); - const data = JSON.stringify({ - type: "progress", - torrents: snapshot() - }); - wss.clients.forEach((c) => c.readyState === 1 && c.send(data)); + broadcastSnapshot(); }); // --- İndirme tamamlandığında thumbnail oluştur --- @@ -170,49 +327,32 @@ app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => { console.log(`✅ Torrent tamamlandı: ${torrent.name}`); - // --- 1️⃣ Video için thumbnail oluştur --- - const videoFile = torrent.files[entry.selectedIndex]; - const videoPath = path.join(entry.savePath, videoFile.path); - const thumbnailPath = path.join(entry.savePath, "thumbnail.jpg"); - - const cmd = `ffmpeg -ss 00:00:30 -i "${videoPath}" -frames:v 1 -q:v 2 "${thumbnailPath}"`; - exec(cmd, (err) => { - if (err) - console.warn(`⚠️ Video thumbnail oluşturulamadı: ${err.message}`); - else { - console.log(`🎞️ Video thumbnail oluşturuldu: ${thumbnailPath}`); - const data = JSON.stringify({ - type: "fileUpdate", - path: path.relative(DOWNLOAD_DIR, entry.savePath) - }); - wss.clients.forEach((c) => c.readyState === 1 && c.send(data)); - } - }); - - // --- 2️⃣ Resimler için thumbnail oluştur --- - // Tüm resimleri tara, küçük hallerini kök klasör altındaki /thumbnail klasörüne oluştur - const rootThumbDir = path.join(entry.savePath, "thumbnail"); - if (!fs.existsSync(rootThumbDir)) - fs.mkdirSync(rootThumbDir, { recursive: true }); + const rootFolder = path.basename(entry.savePath); torrent.files.forEach((file) => { - const filePath = path.join(entry.savePath, file.path); - const mimeType = mime.lookup(filePath) || ""; + const fullPath = path.join(entry.savePath, file.path); + const relPath = path.join(rootFolder, file.path); + const mimeType = mime.lookup(fullPath) || ""; - if (mimeType.startsWith("image/")) { - const thumbPath = path.join(rootThumbDir, path.basename(filePath)); - - // 320px genişlikte, orantılı küçük versiyon oluştur - const imgCmd = `ffmpeg -y -i "${filePath}" -vf "scale=320:-1" -q:v 5 "${thumbPath}"`; - exec(imgCmd, (err) => { - if (err) - console.warn( - `⚠️ Resim thumbnail oluşturulamadı (${file.name}): ${err.message}` - ); - else console.log(`🖼️ Resim thumbnail oluşturuldu: ${thumbPath}`); - }); + 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); + } + + broadcastSnapshot(); }); } catch (err) { res.status(500).json({ error: err.message }); @@ -220,15 +360,12 @@ app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => { }); // --- Thumbnail endpoint --- -app.get("/thumbnail/:hash", (req, res) => { - const entry = torrents.get(req.params.hash); - if (!entry) return res.status(404).end(); - - const thumbnailPath = path.join(entry.savePath, "thumbnail.jpg"); - if (!fs.existsSync(thumbnailPath)) - return res.status(404).send("Thumbnail yok"); - - res.sendFile(thumbnailPath); +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 --- @@ -252,6 +389,7 @@ app.delete("/api/torrents/:hash", requireAuth, (req, res) => { const { torrent, savePath } = entry; torrent.destroy(() => { torrents.delete(req.params.hash); + const rootFolder = savePath ? path.basename(savePath) : null; if (savePath && fs.existsSync(savePath)) { try { fs.rmSync(savePath, { recursive: true, force: true }); @@ -260,6 +398,11 @@ app.delete("/api/torrents/:hash", requireAuth, (req, res) => { console.warn(`⚠️ ${savePath} silinemedi:`, err.message); } } + if (rootFolder) { + removeThumbnailsForPath(rootFolder); + broadcastFileUpdate(rootFolder); + } + broadcastSnapshot(); res.json({ ok: true }); }); }); @@ -314,6 +457,7 @@ app.delete("/api/file", requireAuth, (req, res) => { // 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(); @@ -334,27 +478,15 @@ app.delete("/api/file", requireAuth, (req, res) => { entry?.torrent?.destroy(() => { torrents.delete(matchedInfoHash); console.log(`🧹 Torrent kaydı da temizlendi: ${matchedInfoHash}`); - // anında WebSocket güncellemesi (broadcastSnapshot global fonksiyonunu kullanıyorsan onu çağır) - if (typeof broadcastSnapshot === "function") { - broadcastSnapshot(); - } else if (wss) { - const data = JSON.stringify({ - type: "progress", - torrents: snapshot() - }); - wss.clients.forEach((c) => c.readyState === 1 && c.send(data)); - } + broadcastSnapshot(); }); } else { // Torrent eşleşmediyse de listeyi tazele (ör. sade dosya silinmiştir) - if (typeof broadcastSnapshot === "function") { - broadcastSnapshot(); - } else if (wss) { - const data = JSON.stringify({ type: "progress", torrents: snapshot() }); - wss.clients.forEach((c) => c.readyState === 1 && c.send(data)); - } + broadcastSnapshot(); } + if (folderId) broadcastFileUpdate(folderId); + res.json({ ok: true }); } catch (err) { console.error("❌ Dosya silinemedi:", err.message); @@ -402,29 +534,20 @@ app.get("/api/files", requireAuth, (req, res) => { const full = path.join(dir, entry.name); const rel = path.relative(DOWNLOAD_DIR, full); - if (rel.toLowerCase().includes("/thumbnail")) continue; - // 🔥 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() === "thumbnail.jpg") continue; - const size = fs.statSync(full).size; const type = mime.lookup(full) || "application/octet-stream"; - const parts = rel.split(path.sep); - const rootHash = parts[0]; - const videoThumbPath = path.join( - DOWNLOAD_DIR, - rootHash, - "thumbnail.jpg" - ); - const hasVideoThumb = fs.existsSync(videoThumbPath); - - const urlPath = encodeURIComponent(rel).replace(/%2F/g, "/"); + const safeRel = sanitizeRelative(rel); + const urlPath = safeRel + .split(/[\\/]/) + .map(encodeURIComponent) + .join("/"); const url = `/media/${urlPath}`; const isImage = String(type).startsWith("image/"); @@ -432,27 +555,20 @@ app.get("/api/files", requireAuth, (req, res) => { let thumb = null; - // 🎬 Video thumbnail - if (hasVideoThumb) { - thumb = `/downloads/${rootHash}/thumbnail.jpg`; + if (isVideo) { + const { relThumb, absThumb } = getVideoThumbnailPaths(safeRel); + if (fs.existsSync(absThumb)) thumb = thumbnailUrl(relThumb); + else queueVideoThumbnail(full, safeRel); } - // 🖼️ Resim thumbnail (thumbnail klasöründe varsa) - const imageThumbPath = path.join( - DOWNLOAD_DIR, - rootHash, - "thumbnail", - path.basename(rel) - ); - - if (isImage && fs.existsSync(imageThumbPath)) { - thumb = `/downloads/${rootHash}/thumbnail/${encodeURIComponent( - path.basename(rel) - )}`; + if (isImage) { + const { relThumb, absThumb } = getImageThumbnailPaths(safeRel); + if (fs.existsSync(absThumb)) thumb = thumbnailUrl(relThumb); + else queueImageThumbnail(full, safeRel); } result.push({ - name: rel, + name: safeRel, size, type, url, @@ -527,7 +643,7 @@ if (fs.existsSync(publicDir)) { }); } -const wss = new WebSocketServer({ server }); +wss = new WebSocketServer({ server }); wss.on("connection", (ws) => { ws.send(JSON.stringify({ type: "progress", torrents: snapshot() })); }); @@ -535,8 +651,7 @@ wss.on("connection", (ws) => { // --- ⏱️ Her 2 saniyede bir aktif torrent durumu yayınla --- setInterval(() => { if (torrents.size > 0) { - const data = JSON.stringify({ type: "progress", torrents: snapshot() }); - wss.clients.forEach((c) => c.readyState === 1 && c.send(data)); + broadcastSnapshot(); } }, 2000);