806 lines
24 KiB
JavaScript
806 lines
24 KiB
JavaScript
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);
|
||
});
|