1616 lines
48 KiB
JavaScript
1616 lines
48 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");
|
||
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);
|
||
});
|