Video/resim thumbnail cache’ini kalıcı klasöre taşı ve Docker volume ile paylaştır.

This commit is contained in:
2025-10-26 19:18:36 +03:00
parent b95f864207
commit 97e7404e81
3 changed files with 242 additions and 125 deletions

1
server/cache/.gitkeep vendored Normal file
View File

@@ -0,0 +1 @@

View File

@@ -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);