Files
dupe/server/server.js
2025-12-01 23:35:09 +03:00

7362 lines
225 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { fileURLToPath } from "url";
import { exec, spawn } from "child_process";
import crypto from "crypto"; // 🔒 basit token üretimi için
import { getSystemDiskInfo } from "./utils/diskSpace.js";
import { createAuth } from "./modules/auth.js";
import { buildHealthReport, healthRouter } from "./modules/health.js";
import { restoreTorrentsFromDisk } from "./modules/state.js";
import { createWebsocketServer, broadcastJson } from "./modules/websocket.js";
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();
const youtubeJobs = 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 });
// --- Çöp klasörü oluştur ---
const TRASH_DIR = path.join(__dirname, "trash");
if (!fs.existsSync(TRASH_DIR))
fs.mkdirSync(TRASH_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");
const TV_DATA_ROOT = path.join(CACHE_DIR, "tv_data");
const YT_DATA_ROOT = path.join(CACHE_DIR, "yt_data");
const MUSIC_EXTENSIONS = new Set([
".mp3",
".m4a",
".aac",
".flac",
".wav",
".ogg",
".oga",
".opus",
".mka"
]);
for (const dir of [
THUMBNAIL_DIR,
VIDEO_THUMB_ROOT,
IMAGE_THUMB_ROOT,
MOVIE_DATA_ROOT,
TV_DATA_ROOT,
YT_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 YT_ID_REGEX = /^[A-Za-z0-9_-]{11}$/;
const YT_DLP_BIN = process.env.YT_DLP_BIN || null;
let resolvedYtDlpBinary = null;
const COOKIES_FILE = path.join(__dirname, "cookies.txt");
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 TVDB_API_KEY =
process.env.TTVDB_API_KEY || process.env.TVDB_API_KEY || null;
const TVDB_USER_TOKEN = "mock_api_key"
const TVDB_BASE_URL = "https://api4.thetvdb.com/v4";
const TVDB_IMAGE_BASE =
process.env.TVDB_IMAGE_BASE || "https://artworks.thetvdb.com";
const FANART_TV_API_KEY = process.env.FANART_TV_API_KEY || null;
const FANART_TV_BASE_URL = "https://webservice.fanart.tv/v3";
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 enumerateVideoFiles(rootFolder) {
const safe = sanitizeRelative(rootFolder);
if (!safe) return [];
const baseDir = path.join(DOWNLOAD_DIR, safe);
if (!fs.existsSync(baseDir)) return [];
const videos = [];
const stack = [""];
while (stack.length) {
const currentRel = stack.pop();
const currentDir = path.join(baseDir, currentRel);
let dirEntries = [];
try {
dirEntries = fs.readdirSync(currentDir, { withFileTypes: true });
} catch (err) {
console.warn(`⚠️ Klasör okunamadı (${currentDir}): ${err.message}`);
continue;
}
for (const entry of dirEntries) {
const name = entry.name;
if (name.startsWith(".")) continue;
if (name === INFO_FILENAME) continue;
const relPath = path.join(currentRel, name);
const absPath = path.join(currentDir, name);
if (entry.isDirectory()) {
if (isPathTrashed(safe, relPath, true)) continue;
stack.push(relPath);
continue;
}
if (!entry.isFile()) continue;
if (isPathTrashed(safe, relPath, false)) continue;
const ext = path.extname(name).toLowerCase();
if (!VIDEO_EXTS.includes(ext)) continue;
let size = 0;
try {
size = fs.statSync(absPath).size;
} catch (err) {
console.warn(`⚠️ Dosya boyutu alınamadı (${absPath}): ${err.message}`);
}
videos.push({
relPath: relPath.replace(/\\/g, "/"),
size
});
}
}
videos.sort((a, b) => b.size - a.size);
return videos;
}
function ensureDirForFile(filePath) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
const AUTH_DATA_DIR = path.join(__dirname, "data");
const USERS_FILE = path.join(AUTH_DATA_DIR, "users.json");
const JWT_SECRET_FILE = path.join(CACHE_DIR, "jwt-secret");
let healthSnapshot = null;
const auth = createAuth({ usersPath: USERS_FILE, secretPath: JWT_SECRET_FILE });
const { router: authRouter, requireAuth, requireRole, issueMediaToken, verifyToken } = auth;
app.use(authRouter);
buildHealthReport({
ffmpegPath: "ffmpeg",
ffprobePath: FFPROBE_PATH,
tmdbKey: TMDB_API_KEY,
tvdbKey: TVDB_API_KEY,
fanartKey: FANART_TV_API_KEY
})
.then((report) => {
healthSnapshot = report;
const missing = report.binaries.filter((b) => !b.ok);
if (missing.length) {
console.warn("⚠️ Eksik bağımlılıklar:", missing.map((m) => m.name).join(", "));
}
if (!TMDB_API_KEY || !TVDB_API_KEY) {
console.warn("⚠️ TMDB/TVDB anahtarları eksik, metadata özellikleri sınırlı olacak.");
}
})
.catch((err) => console.warn("⚠️ Sağlık kontrolü çalıştırılamadı:", err.message));
app.get("/api/health", requireAuth, healthRouter(() => healthSnapshot));
function tvdbImageUrl(pathSegment) {
if (!pathSegment) return null;
if (pathSegment.startsWith("http")) return pathSegment;
if (pathSegment.startsWith("/"))
return `${TVDB_IMAGE_BASE}${pathSegment}`;
return `${TVDB_IMAGE_BASE}/${pathSegment}`;
}
async function downloadTvdbImage(imagePath, targetPath) {
const url = tvdbImageUrl(imagePath);
if (!url) return false;
try {
const resp = await fetch(url);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
ensureDirForFile(targetPath);
const arr = await resp.arrayBuffer();
fs.writeFileSync(targetPath, Buffer.from(arr));
return true;
} catch (err) {
console.warn(`⚠️ TVDB görsel indirilemedi (${url}): ${err.message}`);
return false;
}
}
async function fetchFanartTvImages(thetvdbId) {
if (!FANART_TV_API_KEY || !thetvdbId) return null;
const url = `${FANART_TV_BASE_URL}/tv/${thetvdbId}?api_key=${FANART_TV_API_KEY}`;
try {
const resp = await fetch(url);
if (!resp.ok) {
console.warn(`⚠️ Fanart.tv isteği başarısız (${url}): ${resp.status}`);
return null;
}
const data = await resp.json();
console.log("🖼️ Fanart.tv backdrop araması:", { thetvdbId, hasShowbackground: Boolean(data.showbackground) });
return data;
} catch (err) {
console.warn(`⚠️ Fanart.tv isteği hatası (${url}): ${err.message}`);
return null;
}
}
async function downloadFanartTvImage(imageUrl, targetPath) {
if (!imageUrl) return false;
try {
const resp = await fetch(imageUrl);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
ensureDirForFile(targetPath);
const arr = await resp.arrayBuffer();
fs.writeFileSync(targetPath, Buffer.from(arr));
return true;
} catch (err) {
console.warn(`⚠️ Fanart.tv görsel indirilemedi (${imageUrl}): ${err.message}`);
return false;
}
}
function titleCase(value) {
if (!value) return "";
return value
.toLowerCase()
.split(/\s+/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function normalizeTvdbId(value) {
if (value === null || value === undefined) return null;
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const match = value.match(/\d+/);
if (match) {
const num = Number(match[0]);
if (Number.isFinite(num)) return num;
}
}
return null;
}
function normalizeTvdbEpisode(raw) {
if (!raw || typeof raw !== "object") return null;
const seasonNumber = toFiniteNumber(
raw.seasonNumber ??
raw.season ??
raw.airedSeason ??
raw.season_number ??
raw.seasonNum
);
const episodeNumber = toFiniteNumber(
raw.number ??
raw.episodeNumber ??
raw.airedEpisodeNumber ??
raw.episode_number ??
raw.episodeNum
);
return {
id: normalizeTvdbId(
raw.id ?? raw.tvdb_id ?? raw.episodeId ?? raw.episode_id
),
seasonId: normalizeTvdbId(raw.seasonId ?? raw.season_id ?? raw.parentId),
seriesId: normalizeTvdbId(raw.seriesId ?? raw.series_id),
seasonNumber: Number.isFinite(seasonNumber) ? seasonNumber : null,
episodeNumber: Number.isFinite(episodeNumber) ? episodeNumber : null,
name:
raw.name ??
raw.episodeName ??
raw.title ??
raw.episodeTitle ??
null,
overview:
raw.overview ?? raw.description ?? raw.synopsis ?? raw.plot ?? "",
image:
raw.image ??
raw.filename ??
raw.fileName ??
raw.thumb ??
raw.thumbnail ??
raw.imageUrl ??
raw.image_url ??
null,
aired:
raw.aired ??
raw.firstAired ??
raw.airDate ??
raw.air_date ??
raw.released ??
null,
runtime: toFiniteNumber(
raw.runtime ??
raw.length ??
raw.duration ??
raw.runTime ??
raw.runtimeMinutes ??
raw.runtime_minutes
),
slug: raw.slug ?? null,
translations: raw.translations || null,
raw
};
}
function normalizeTvdbSeason(raw) {
if (!raw || typeof raw !== "object") return null;
const seasonNumber = toFiniteNumber(
raw.number ??
raw.seasonNumber ??
raw.season ??
raw.airedSeason ??
raw.seasonNum
);
return {
id: normalizeTvdbId(raw.id ?? raw.tvdb_id ?? raw.seasonId ?? raw.season_id),
number: Number.isFinite(seasonNumber) ? seasonNumber : null,
name: raw.name ?? raw.title ?? raw.translation ?? null,
overview: raw.overview ?? raw.description ?? "",
image:
raw.image ??
raw.poster ??
raw.filename ??
raw.fileName ??
raw.thumb ??
raw.thumbnail ??
null,
translations: raw.translations || null,
raw
};
}
function toDimension(value) {
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
function artworkKind(value) {
return String(value || "").toLowerCase();
}
function isBackgroundArtwork(entry) {
const candidates = [
entry?.type,
entry?.artworkType,
entry?.artworkTypeSlug,
entry?.type2,
entry?.name,
entry?.artwork
];
return candidates
.map(artworkKind)
.some((kind) =>
kind.includes("background") ||
kind.includes("fanart") ||
kind.includes("landscape") ||
kind === "1"
);
}
function selectBackgroundArtwork(entries) {
if (!Array.isArray(entries) || !entries.length) return null;
const candidates = entries.filter(isBackgroundArtwork);
if (!candidates.length) return null;
const normalized = candidates.map((entry) => {
const width = toDimension(entry.width);
const height = toDimension(entry.height);
const area = width && height ? width * height : null;
return { entry, width, height, area };
});
const landscape = normalized
.filter((item) => item.width && item.height && item.width >= item.height)
.sort((a, b) => (b.area || 0) - (a.area || 0));
if (landscape.length) return landscape[0].entry;
normalized.sort((a, b) => (b.area || 0) - (a.area || 0));
return normalized[0].entry;
}
async function fetchTvdbArtworks(seriesId, typeSlug) {
if (!seriesId || !typeSlug) return [];
const resp = await tvdbFetch(`/series/${seriesId}/artworks/${typeSlug}`);
if (!resp) return [];
if (Array.isArray(resp.data)) return resp.data;
if (Array.isArray(resp.artworks)) return resp.artworks;
return [];
}
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 determineMediaType({
tracker,
movieMatch,
seriesEpisode,
categories,
relPath
}) {
if (seriesEpisode) return "tv";
if (movieMatch) return "movie";
if (
Array.isArray(categories) &&
categories.some((cat) => String(cat).toLowerCase() === "music")
) {
return "music";
}
if (relPath) {
const ext = path.extname(relPath).toLowerCase();
if (MUSIC_EXTENSIONS.has(ext)) return "music";
}
return "video";
}
function getYtDlpBinary() {
if (resolvedYtDlpBinary) return resolvedYtDlpBinary;
const candidates = [
YT_DLP_BIN,
"/usr/local/bin/yt-dlp",
path.join(__dirname, "..", ".pipx", "bin", "yt-dlp"),
"yt-dlp"
].filter(Boolean);
for (const candidate of candidates) {
if (candidate.includes(path.sep) || candidate.startsWith("/")) {
if (fs.existsSync(candidate)) {
resolvedYtDlpBinary = candidate;
return resolvedYtDlpBinary;
}
continue;
}
// bare command name, trust PATH
resolvedYtDlpBinary = candidate;
return resolvedYtDlpBinary;
}
resolvedYtDlpBinary = "yt-dlp";
return resolvedYtDlpBinary;
}
function normalizeYoutubeWatchUrl(value) {
if (!value || typeof value !== "string") return null;
try {
const urlObj = new URL(value.trim());
if (urlObj.protocol !== "https:") return null;
const host = urlObj.hostname.toLowerCase();
if (host !== "youtube.com" && host !== "www.youtube.com") return null;
if (urlObj.pathname !== "/watch") return null;
const videoId = urlObj.searchParams.get("v");
if (!videoId || !YT_ID_REGEX.test(videoId)) return null;
return `https://www.youtube.com/watch?v=${videoId}`;
} catch (err) {
return null;
}
}
function startYoutubeDownload(url) {
const normalized = normalizeYoutubeWatchUrl(url);
if (!normalized) return null;
const videoId = new URL(normalized).searchParams.get("v");
const folderId = `yt_${videoId}_${Date.now().toString(36)}`;
const savePath = path.join(DOWNLOAD_DIR, folderId);
fs.mkdirSync(savePath, { recursive: true });
const job = {
id: folderId,
infoHash: folderId,
type: "youtube",
url: normalized,
videoId,
folderId,
savePath,
added: Date.now(),
title: null,
state: "downloading",
progress: 0,
downloaded: 0,
totalBytes: 0,
downloadSpeed: 0,
stages: [],
currentStage: null,
completedBytes: 0,
files: [],
selectedIndex: 0,
thumbnail: null,
process: null,
error: null
};
youtubeJobs.set(job.id, job);
launchYoutubeJob(job);
console.log(`▶️ YouTube indirmesi başlatıldı: ${job.url}`);
broadcastSnapshot();
return job;
}
function launchYoutubeJob(job) {
const binary = getYtDlpBinary();
const args = [
"-f",
"bv+ba/b",
"--write-thumbnail",
"--convert-thumbnails",
"jpg",
"--write-info-json",
job.url
];
if (fs.existsSync(COOKIES_FILE)) {
args.splice(-1, 0, "--cookies", COOKIES_FILE);
}
const child = spawn(binary, args, {
cwd: job.savePath,
env: process.env
});
job.process = child;
const handleChunk = (chunk) => {
const text = chunk.toString();
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
processYoutubeOutput(job, line);
}
};
child.stdout.on("data", handleChunk);
child.stderr.on("data", handleChunk);
child.on("close", (code) => finalizeYoutubeJob(job, code));
child.on("error", (err) => {
job.state = "error";
job.downloadSpeed = 0;
job.error = err?.message || "yt-dlp çalıştırılamadı";
broadcastSnapshot();
});
}
function processYoutubeOutput(job, line) {
const destMatch = line.match(/^\[download\]\s+Destination:\s+(.+)$/i);
if (destMatch) {
startYoutubeStage(job, destMatch[1].trim());
return;
}
const progressMatch = line.match(
/^\[download\]\s+([\d.]+)%\s+of\s+([\d.]+)\s*([KMGTP]?i?B)(?:\s+at\s+([\d.]+)\s*([KMGTP]?i?B)\/s)?/i
);
if (progressMatch) {
updateYoutubeProgress(job, progressMatch);
}
}
function startYoutubeStage(job, fileName) {
if (job.currentStage && !job.currentStage.done) {
if (job.currentStage.totalBytes) {
job.completedBytes += job.currentStage.totalBytes;
}
job.currentStage.done = true;
}
const stage = {
name: fileName,
totalBytes: null,
downloadedBytes: 0,
done: false
};
job.currentStage = stage;
job.stages.push(stage);
broadcastSnapshot();
}
function updateYoutubeProgress(job, match) {
const percent = Number(match[1]) || 0;
const totalValue = Number(match[2]) || 0;
const totalUnit = (match[3] || "B").trim();
const totalBytes = bytesFromHuman(totalValue, totalUnit);
const downloadedBytes = Math.min(
totalBytes,
Math.round((percent / 100) * totalBytes)
);
const speedValue = Number(match[4]);
const speedUnit = match[5];
if (job.currentStage) {
job.currentStage.totalBytes = totalBytes;
job.currentStage.downloadedBytes = downloadedBytes;
if (percent >= 100 && !job.currentStage.done) {
job.currentStage.done = true;
job.completedBytes += totalBytes;
}
}
job.totalBytes = job.stages.reduce(
(sum, stage) => sum + (stage.totalBytes || 0),
0
);
const activeBytes = job.currentStage && !job.currentStage.done
? job.currentStage.downloadedBytes
: 0;
const denominator = job.totalBytes || totalBytes || 0;
job.downloaded = job.completedBytes + activeBytes;
job.progress = denominator > 0 ? job.downloaded / denominator : 0;
if (Number.isFinite(speedValue) && speedUnit) {
job.downloadSpeed = bytesFromHuman(speedValue, speedUnit);
}
broadcastSnapshot();
}
async function finalizeYoutubeJob(job, exitCode) {
job.downloadSpeed = 0;
if (exitCode !== 0) {
job.state = "error";
job.error = `yt-dlp ${exitCode} kodu ile sonlandı`;
broadcastSnapshot();
return;
}
try {
if (job.currentStage && !job.currentStage.done && job.currentStage.totalBytes) {
job.completedBytes += job.currentStage.totalBytes;
job.currentStage.done = true;
}
const infoJson = findYoutubeInfoJson(job.savePath);
const videoFile = findYoutubeVideoFile(job.savePath);
if (!videoFile) {
job.state = "error";
job.error = "Video dosyası bulunamadı";
broadcastSnapshot();
return;
}
const absVideo = path.join(job.savePath, videoFile);
const stats = fs.statSync(absVideo);
const mediaInfo = await extractMediaInfo(absVideo).catch(() => null);
const relativeName = videoFile.replace(/\\/g, "/");
job.files = [
{
index: 0,
name: relativeName,
length: stats.size
}
];
job.selectedIndex = 0;
job.title = deriveYoutubeTitle(videoFile, job.videoId);
job.downloaded = stats.size;
job.totalBytes = stats.size;
job.progress = 1;
job.state = "completed";
const metadataPayload = await writeYoutubeMetadata(
job,
absVideo,
mediaInfo,
infoJson
);
const payloadWithThumb =
updateYoutubeThumbnail(job, metadataPayload) || metadataPayload;
const mediaType = payloadWithThumb?.type || "video";
const categories = payloadWithThumb?.categories || null;
upsertInfoFile(job.savePath, {
infoHash: job.id,
name: job.title,
tracker: "youtube",
added: job.added,
folder: job.folderId,
type: mediaType,
categories,
files: {
[relativeName]: {
size: stats.size,
extension: path.extname(relativeName).replace(/^\./, ""),
mediaInfo,
youtube: {
url: job.url,
videoId: job.videoId
},
categories,
type: mediaType
}
},
primaryVideoPath: relativeName,
primaryMediaInfo: mediaInfo
});
broadcastFileUpdate(job.folderId);
broadcastSnapshot();
broadcastDiskSpace();
console.log(`✅ YouTube indirmesi tamamlandı: ${job.title}`);
} catch (err) {
job.state = "error";
job.error = err?.message || "YouTube indirimi tamamlanamadı";
broadcastSnapshot();
}
}
function findYoutubeVideoFile(savePath) {
const entries = fs.readdirSync(savePath, { withFileTypes: true });
const videos = entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((name) => VIDEO_EXTS.includes(path.extname(name).toLowerCase()));
if (!videos.length) return null;
videos.sort((a, b) => {
const aSize = fs.statSync(path.join(savePath, a)).size;
const bSize = fs.statSync(path.join(savePath, b)).size;
return bSize - aSize;
});
return videos[0];
}
function findYoutubeInfoJson(savePath) {
const entries = fs.readdirSync(savePath, { withFileTypes: true });
const jsons = entries
.filter((entry) =>
entry.isFile() && entry.name.toLowerCase().endsWith(".info.json")
)
.map((entry) => entry.name)
.sort();
return jsons[0] || null;
}
function deriveYoutubeTitle(fileName, videoId) {
const base = fileName.replace(path.extname(fileName), "");
const pattern = videoId ? new RegExp(`\\[${videoId}\\]`, "i") : null;
const cleaned = pattern ? base.replace(pattern, "") : base;
return cleaned.replace(/[-_.]+$/g, "").trim() || base;
}
async function writeYoutubeMetadata(job, videoPath, mediaInfo, infoJsonFile) {
const targetDir = path.join(YT_DATA_ROOT, job.folderId);
fs.mkdirSync(targetDir, { recursive: true });
let infoJson = null;
if (infoJsonFile) {
try {
infoJson = JSON.parse(
fs.readFileSync(path.join(job.savePath, infoJsonFile), "utf-8")
);
} catch (err) {
console.warn("YouTube info.json okunamadı:", err.message);
}
}
const categories = Array.isArray(infoJson?.categories)
? infoJson.categories
: null;
const derivedType = determineMediaType({
tracker: "youtube",
movieMatch: null,
seriesEpisode: null,
categories,
relPath: job.files?.[0]?.name || null
});
const payload = {
id: job.id,
title: job.title,
url: job.url,
videoId: job.videoId,
added: job.added,
folderId: job.folderId,
file: job.files?.[0]?.name || null,
mediaInfo,
type: derivedType,
categories,
ytMeta: infoJson || null
};
fs.writeFileSync(
path.join(targetDir, "metadata.json"),
JSON.stringify(payload, null, 2),
"utf-8"
);
return payload;
}
function updateYoutubeThumbnail(job, metadataPayload = null) {
const thumbs = fs
.readdirSync(job.savePath, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".jpg"));
if (!thumbs.length) return;
const source = path.join(job.savePath, thumbs[0].name);
const targetDir = path.join(YT_DATA_ROOT, job.folderId);
fs.mkdirSync(targetDir, { recursive: true });
const target = path.join(targetDir, "thumbnail.jpg");
try {
fs.copyFileSync(source, target);
job.thumbnail = `/yt-data/${job.folderId}/thumbnail.jpg?t=${Date.now()}`;
} catch (err) {
console.warn("Thumbnail kopyalanamadı:", err.message);
}
try {
const metaPath = path.join(YT_DATA_ROOT, job.folderId, "metadata.json");
let payload = metadataPayload;
if (!payload && fs.existsSync(metaPath)) {
payload = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
}
if (payload) {
payload.thumbnail = job.thumbnail;
fs.writeFileSync(metaPath, JSON.stringify(payload, null, 2), "utf-8");
return payload;
}
} catch (err) {
console.warn("YT metadata güncellenemedi:", err.message);
}
return metadataPayload || null;
}
function removeYoutubeJob(jobId, { removeFiles = true } = {}) {
const job = youtubeJobs.get(jobId);
if (!job) return false;
if (job.process && !job.process.killed) {
try {
job.process.kill("SIGTERM");
} catch (err) {
console.warn("YT job kill error:", err.message);
}
}
youtubeJobs.delete(jobId);
let filesRemoved = false;
if (removeFiles && job.savePath && fs.existsSync(job.savePath)) {
try {
fs.rmSync(job.savePath, { recursive: true, force: true });
filesRemoved = true;
} catch (err) {
console.warn("YT dosyası silinemedi:", err.message);
}
}
const cacheDir = path.join(YT_DATA_ROOT, job.folderId);
if (removeFiles && fs.existsSync(cacheDir)) {
try {
fs.rmSync(cacheDir, { recursive: true, force: true });
} catch (err) {
console.warn("YT cache silinemedi:", err.message);
}
}
broadcastSnapshot();
if (filesRemoved) {
broadcastFileUpdate(job.folderId);
broadcastDiskSpace();
}
return true;
}
function youtubeSnapshot(job) {
const files = (job.files || []).map((file, index) => ({
index,
name: file.name,
length: file.length
}));
return {
infoHash: job.id,
type: "youtube",
name: job.title || job.url,
progress: Math.min(1, job.progress || 0),
downloaded: job.downloaded || 0,
downloadSpeed: job.state === "downloading" ? job.downloadSpeed || 0 : 0,
uploadSpeed: 0,
numPeers: 0,
tracker: null,
added: job.added,
savePath: job.savePath,
paused: false,
files,
selectedIndex: job.selectedIndex || 0,
thumbnail: job.thumbnail,
status: job.state
};
}
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;
}
const HUMAN_SIZE_UNITS = {
B: 1,
KB: 1000,
MB: 1000 ** 2,
GB: 1000 ** 3,
TB: 1000 ** 4,
KIB: 1024,
MIB: 1024 ** 2,
GIB: 1024 ** 3,
TIB: 1024 ** 4
};
function bytesFromHuman(value, unit = "B") {
if (!Number.isFinite(value)) return 0;
if (!unit) return value;
const normalized = unit.replace(/\s+/g, "").toUpperCase();
const scale = HUMAN_SIZE_UNITS[normalized] || 1;
return value * scale;
}
async function extractMediaInfo(filePath, retryCount = 0) {
if (!filePath || !fs.existsSync(filePath)) return null;
// Farklı ffprobe stratejileri
const strategies = [
// Standart yöntem
`${FFPROBE_PATH} -v quiet -print_format json -show_format -show_streams "${filePath}"`,
// Daha toleranslı yöntem
`${FFPROBE_PATH} -v error -print_format json -show_format -show_streams "${filePath}"`,
// Sadece format bilgisi
`${FFPROBE_PATH} -v error -print_format json -show_format "${filePath}"`,
// En basit yöntem
`${FFPROBE_PATH} -v fatal -print_format json -show_format -show_streams "${filePath}"`
];
const strategyIndex = Math.min(retryCount, strategies.length - 1);
const cmd = strategies[strategyIndex];
return new Promise((resolve) => {
exec(
cmd,
{ maxBuffer: FFPROBE_MAX_BUFFER },
(err, stdout, stderr) => {
if (err) {
// Hata durumunda yeniden dene
if (retryCount < strategies.length - 1) {
console.warn(
`⚠️ ffprobe çalıştırılamadı (${filePath}): ${err.message}. Yeniden deneme (${retryCount + 1}/${strategies.length - 1})`
);
setTimeout(() => extractMediaInfo(filePath, retryCount + 1).then(resolve), 1000 * (retryCount + 1));
return;
}
// Tüm denemeler başarısız oldu
console.error(`❌ ffprobe çalıştırılamadı (${filePath}): ${err.message}`);
if (stderr) {
console.error(`🔍 ffprobe stderr: ${stderr}`);
}
// Dosya boyutu kontrolü
try {
const stats = fs.statSync(filePath);
const fileSizeMB = stats.size / (1024 * 1024);
if (fileSizeMB < 1) {
console.warn(`⚠️ Dosya çok küçük olabilir (${fileSizeMB.toFixed(2)}MB): ${filePath}`);
}
} catch (statErr) {
console.warn(`⚠️ Dosya bilgileri alınamadı: ${statErr.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) {
// Parse hatası durumunda yeniden dene
if (retryCount < strategies.length - 1) {
console.warn(
`⚠️ ffprobe çıktısı parse edilemedi (${filePath}): ${parseErr.message}. Yeniden deneme (${retryCount + 1}/${strategies.length - 1})`
);
setTimeout(() => extractMediaInfo(filePath, retryCount + 1).then(resolve), 1000 * (retryCount + 1));
return;
}
console.error(
`❌ ffprobe çıktısı parse edilemedi (${filePath}): ${parseErr.message}`
);
resolve(null);
}
}
);
});
}
function queueVideoThumbnail(fullPath, relPath, retryCount = 0) {
const { relThumb, absThumb } = getVideoThumbnailPaths(relPath);
if (fs.existsSync(absThumb) || generatingThumbnails.has(absThumb)) return;
ensureDirForFile(absThumb);
markGenerating(absThumb, true);
// Farklı ffmpeg stratejileri deneme
const strategies = [
// Standart yöntem
`ffmpeg -y -ss ${VIDEO_THUMBNAIL_TIME} -i "${fullPath}" -frames:v 1 -vf "scale=320:-1" -q:v 2 "${absThumb}"`,
// Daha toleranslı yöntem - hata ayıklama modu
`ffmpeg -y -ss ${VIDEO_THUMBNAIL_TIME} -i "${fullPath}" -frames:v 1 -vf "scale=320:-1" -q:v 2 -err_detect ignore_err "${absThumb}"`,
// Dosya sonundan başlayarak deneme
`ffmpeg -y -ss 5 -i "${fullPath}" -frames:v 1 -vf "scale=320:-1" -q:v 2 -avoid_negative_ts make_zero "${absThumb}"`,
// En basit yöntem - sadece kare yakalama
`ffmpeg -y -i "${fullPath}" -frames:v 1 -vf "scale=320:-1" -q:v 3 "${absThumb}"`
];
const strategyIndex = Math.min(retryCount, strategies.length - 1);
const cmd = strategies[strategyIndex];
exec(cmd, (err, stdout, stderr) => {
markGenerating(absThumb, false);
if (err) {
// Hata durumunda yeniden dene
if (retryCount < strategies.length - 1) {
console.warn(`⚠️ Video thumbnail oluşturulamadı (${fullPath}): ${err.message}. Yeniden deneme (${retryCount + 1}/${strategies.length - 1})`);
setTimeout(() => queueVideoThumbnail(fullPath, relPath, retryCount + 1), 1000 * (retryCount + 1));
return;
}
// Tüm denemeler başarısız oldu
console.error(`❌ Video thumbnail oluşturulamadı (${fullPath}): ${err.message}`);
if (stderr) {
console.error(`🔍 ffmpeg stderr: ${stderr}`);
}
// Bozuk dosya kontrolü
try {
const stats = fs.statSync(fullPath);
const fileSizeMB = stats.size / (1024 * 1024);
if (fileSizeMB < 1) {
console.warn(`⚠️ Dosya çok küçük olabilir (${fileSizeMB.toFixed(2)}MB): ${fullPath}`);
}
} catch (statErr) {
console.warn(`⚠️ Dosya bilgileri alınamadı: ${statErr.message}`);
}
return;
}
console.log(`🎞️ Video thumbnail oluşturuldu: ${absThumb}`);
const root = rootFromRelPath(relPath);
if (root) broadcastFileUpdate(root);
});
}
function queueImageThumbnail(fullPath, relPath, retryCount = 0) {
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";
// Farklı ffmpeg stratejileri deneme
const strategies = [
// Standart yöntem
`ffmpeg -y -i "${fullPath}" -vf "scale=320:-1"${needsQuality ? ' -q:v 5' : ''} "${absThumb}"`,
// Daha toleranslı yöntem
`ffmpeg -y -i "${fullPath}" -vf "scale=320:-1"${needsQuality ? ' -q:v 7' : ''} -err_detect ignore_err "${absThumb}"`,
// En basit yöntem
`ffmpeg -y -i "${fullPath}" -vf "scale=320:-1" "${absThumb}"`
];
const strategyIndex = Math.min(retryCount, strategies.length - 1);
const cmd = strategies[strategyIndex];
exec(cmd, (err, stdout, stderr) => {
markGenerating(absThumb, false);
if (err) {
// Hata durumunda yeniden dene
if (retryCount < strategies.length - 1) {
console.warn(`⚠️ Resim thumbnail oluşturulamadı (${fullPath}): ${err.message}. Yeniden deneme (${retryCount + 1}/${strategies.length - 1})`);
setTimeout(() => queueImageThumbnail(fullPath, relPath, retryCount + 1), 1000 * (retryCount + 1));
return;
}
// Tüm denemeler başarısız oldu
console.error(`❌ Resim thumbnail oluşturulamadı (${fullPath}): ${err.message}`);
if (stderr) {
console.error(`🔍 ffmpeg stderr: ${stderr}`);
}
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;
}
}
// --- 🗑️ .trash yardımcı fonksiyonları ---
const trashStateCache = new Map();
function normalizeTrashPath(value) {
if (value === null || value === undefined) return "";
return String(value).replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
}
function trashFlagPathFor(rootFolder) {
const safeRoot = sanitizeRelative(rootFolder);
if (!safeRoot) return null;
return path.join(DOWNLOAD_DIR, safeRoot, ".trash");
}
function readTrashRegistry(rootFolder) {
const flagPath = trashFlagPathFor(rootFolder);
if (!flagPath || !fs.existsSync(flagPath)) return null;
try {
const raw = JSON.parse(fs.readFileSync(flagPath, "utf-8"));
if (!raw || typeof raw !== "object") return null;
if (!Array.isArray(raw.items)) raw.items = [];
raw.items = raw.items
.map((item) => {
if (!item || typeof item !== "object") return null;
const normalizedPath = normalizeTrashPath(item.path);
return {
...item,
path: normalizedPath,
originalPath: item.originalPath || normalizedPath,
deletedAt: Number(item.deletedAt) || Date.now(),
isDirectory: Boolean(item.isDirectory),
type: item.type || (item.isDirectory ? "inode/directory" : null)
};
})
.filter(Boolean);
return raw;
} catch (err) {
console.warn(`⚠️ .trash dosyası okunamadı (${flagPath}): ${err.message}`);
return null;
}
}
function writeTrashRegistry(rootFolder, registry) {
const flagPath = trashFlagPathFor(rootFolder);
if (!flagPath) return;
const items = Array.isArray(registry?.items)
? registry.items.filter(Boolean)
: [];
if (!items.length) {
try {
if (fs.existsSync(flagPath)) fs.rmSync(flagPath, { force: true });
} catch (err) {
console.warn(`⚠️ .trash kaldırılırken hata (${flagPath}): ${err.message}`);
}
trashStateCache.delete(rootFolder);
return;
}
const payload = {
updatedAt: Date.now(),
items: items.map((item) => ({
...item,
path: normalizeTrashPath(item.path),
originalPath: item.originalPath || normalizeTrashPath(item.path),
deletedAt: Number(item.deletedAt) || Date.now(),
isDirectory: Boolean(item.isDirectory),
type: item.type || (item.isDirectory ? "inode/directory" : null)
}))
};
try {
fs.writeFileSync(flagPath, JSON.stringify(payload, null, 2), "utf-8");
} catch (err) {
console.warn(`⚠️ .trash yazılamadı (${flagPath}): ${err.message}`);
}
trashStateCache.delete(rootFolder);
}
function addTrashEntry(rootFolder, entry) {
if (!rootFolder || !entry) return null;
const safeRoot = sanitizeRelative(rootFolder);
if (!safeRoot) return null;
const registry = readTrashRegistry(safeRoot) || { items: [] };
const normalizedPath = normalizeTrashPath(entry.path);
const isDirectory = Boolean(entry.isDirectory);
const timestamp = Number(entry.deletedAt) || Date.now();
const type =
entry.type ||
(isDirectory
? "inode/directory"
: mime.lookup(entry.originalPath || normalizedPath) ||
"application/octet-stream");
let items = registry.items.filter((item) => {
const itemPath = normalizeTrashPath(item.path);
if (isDirectory) {
if (!itemPath) return false;
if (itemPath === normalizedPath) return false;
if (itemPath.startsWith(`${normalizedPath}/`)) return false;
return true;
}
// üst klasör çöpteyse tekrar eklemeye gerek yok
if (item.isDirectory) {
const normalizedItemPath = itemPath;
if (
!normalizedPath ||
normalizedPath === normalizedItemPath ||
normalizedPath.startsWith(`${normalizedItemPath}/`)
) {
return true;
}
}
return itemPath !== normalizedPath;
});
if (!isDirectory) {
const ancestor = items.find(
(item) =>
item.isDirectory &&
(normalizeTrashPath(item.path) === "" ||
normalizedPath === normalizeTrashPath(item.path) ||
normalizedPath.startsWith(`${normalizeTrashPath(item.path)}/`))
);
if (ancestor) {
trashStateCache.delete(safeRoot);
return ancestor;
}
}
const newEntry = {
...entry,
path: normalizedPath,
originalPath:
entry.originalPath ||
(normalizedPath ? `${safeRoot}/${normalizedPath}` : safeRoot),
deletedAt: timestamp,
isDirectory,
type
};
items.push(newEntry);
writeTrashRegistry(safeRoot, { ...registry, items });
return newEntry;
}
function removeTrashEntry(rootFolder, relPath) {
if (!rootFolder) return null;
const safeRoot = sanitizeRelative(rootFolder);
if (!safeRoot) return null;
const registry = readTrashRegistry(safeRoot);
if (!registry || !Array.isArray(registry.items)) return null;
const normalized = normalizeTrashPath(relPath);
const removed = [];
const kept = [];
for (const item of registry.items) {
const itemPath = normalizeTrashPath(item.path);
if (
itemPath === normalized ||
(item.isDirectory &&
normalized &&
normalized.startsWith(`${itemPath}/`))
) {
removed.push(item);
continue;
}
kept.push(item);
}
if (!removed.length) return null;
writeTrashRegistry(safeRoot, { ...registry, items: kept });
return removed[0];
}
function getTrashStateForRoot(rootFolder) {
if (!rootFolder) return null;
if (trashStateCache.has(rootFolder)) return trashStateCache.get(rootFolder);
const registry = readTrashRegistry(rootFolder);
if (!registry || !Array.isArray(registry.items) || !registry.items.length) {
trashStateCache.set(rootFolder, null);
return null;
}
const directories = [];
const files = new Set();
for (const item of registry.items) {
const normalizedPath = normalizeTrashPath(item.path);
if (item.isDirectory) {
directories.push(normalizedPath);
} else {
files.add(normalizedPath);
}
}
directories.sort((a, b) => a.length - b.length);
const result = { registry, directories, files };
trashStateCache.set(rootFolder, result);
return result;
}
function isPathTrashed(rootFolder, relPath, isDirectory = false) {
if (!rootFolder) return false;
const state = getTrashStateForRoot(rootFolder);
if (!state) return false;
const normalized = normalizeTrashPath(relPath);
for (const dirPath of state.directories) {
if (!dirPath) return true;
if (normalized === dirPath) return true;
if (normalized.startsWith(`${dirPath}/`)) return true;
}
if (!isDirectory && state.files.has(normalized)) {
return true;
}
return false;
}
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 resolveTvDataAbsolute(relPath) {
const normalized = sanitizeRelative(relPath);
const resolved = path.resolve(TV_DATA_ROOT, normalized);
if (
resolved !== TV_DATA_ROOT &&
!resolved.startsWith(TV_DATA_ROOT + path.sep)
) {
return null;
}
return resolved;
}
function resolveYoutubeDataAbsolute(relPath) {
const normalized = sanitizeRelative(relPath);
const resolved = path.resolve(YT_DATA_ROOT, normalized);
if (
resolved !== YT_DATA_ROOT &&
!resolved.startsWith(YT_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 movieDataKey(rootFolder, videoRelPath = null) {
const normalizedRoot = rootFolder ? sanitizeRelative(rootFolder) : null;
if (!normalizedRoot) return null;
if (!videoRelPath) return normalizedRoot;
const normalizedVideo = normalizeTrashPath(videoRelPath);
if (!normalizedVideo) return normalizedRoot;
const hash = crypto
.createHash("sha1")
.update(normalizedVideo)
.digest("hex")
.slice(0, 12);
const baseSegment =
normalizedVideo
.split("/")
.filter(Boolean)
.pop() || "video";
const safeSegment = baseSegment
.replace(/[^a-z0-9]+/gi, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase()
.slice(0, 60);
const suffix = safeSegment ? `${safeSegment}-${hash}` : hash;
return `${normalizedRoot}__${suffix}`;
}
function movieDataLegacyDir(rootFolder) {
const normalizedRoot = sanitizeRelative(rootFolder);
if (!normalizedRoot) return null;
return path.join(MOVIE_DATA_ROOT, normalizedRoot);
}
function movieDataDir(rootFolder, videoRelPath = null) {
const key = movieDataKey(rootFolder, videoRelPath);
if (!key) return MOVIE_DATA_ROOT;
return path.join(MOVIE_DATA_ROOT, key);
}
function movieDataPaths(rootFolder, videoRelPath = null) {
const dir = movieDataDir(rootFolder, videoRelPath);
return {
dir,
metadata: path.join(dir, "metadata.json"),
poster: path.join(dir, "poster.jpg"),
backdrop: path.join(dir, "backdrop.jpg"),
key: movieDataKey(rootFolder, videoRelPath)
};
}
function movieDataPathsByKey(key) {
const dir = path.join(MOVIE_DATA_ROOT, key);
return {
dir,
metadata: path.join(dir, "metadata.json"),
poster: path.join(dir, "poster.jpg"),
backdrop: path.join(dir, "backdrop.jpg"),
key
};
}
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 };
}
function parseSeriesInfo(rawName) {
if (!rawName) return null;
const withoutExt = rawName.replace(/\.[^/.]+$/, "");
const match = withoutExt.match(/(.+?)[\s._-]*S(\d{1,2})[\s._-]*E(\d{1,2})/i);
if (!match) return null;
const rawTitle = match[1]
.replace(/[._]+/g, " ")
.replace(/\s+-\s+/g, " - ")
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!rawTitle) return null;
const season = Number(match[2]);
const episode = Number(match[3]);
if (!Number.isFinite(season) || !Number.isFinite(episode)) return null;
return {
title: titleCase(rawTitle),
searchTitle: rawTitle,
season,
episode,
key: `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`
};
}
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);
}
}
try {
const resp = await fetch(url);
if (!resp.ok) {
console.warn(`⚠️ TMDB isteği başarısız (${url}): ${resp.status}`);
return null;
}
return await resp.json();
} catch (err) {
console.warn(`⚠️ TMDB isteği başarısız (${url}): ${err.message}`);
return null;
}
}
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;
}
}
let tvdbAuthState = { token: null, expires: 0 };
const TVDB_TOKEN_TTL = 1000 * 60 * 60 * 20; // 20 saat
async function getTvdbToken(force = false) {
if (!TVDB_API_KEY) return null;
const trimmedUserToken = (TVDB_USER_TOKEN || "").trim();
const now = Date.now();
if (
!force &&
tvdbAuthState.token &&
now < tvdbAuthState.expires - 60 * 1000
) {
return tvdbAuthState.token;
}
if (trimmedUserToken) {
try {
const resp = await fetch(`${TVDB_BASE_URL}/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json"
},
body: JSON.stringify({
apikey: TVDB_API_KEY,
userkey: trimmedUserToken
})
});
if (resp.ok) {
const json = await resp.json();
const token = json?.data?.token;
if (token) {
console.log("📺 TVDB token alındı (login).");
tvdbAuthState = {
token,
expires: Date.now() + TVDB_TOKEN_TTL
};
return token;
}
console.warn("⚠️ TVDB login yanıtında token bulunamadı, API key'e düşülüyor.");
} else {
console.warn(
`⚠️ TVDB login başarısız (${resp.status}); API key ile devam edilecek.`
);
}
} catch (err) {
console.warn(
`⚠️ TVDB login hatası (${err.message}); API key ile devam edilecek.`
);
}
}
tvdbAuthState = {
token: TVDB_API_KEY,
expires: Date.now() + TVDB_TOKEN_TTL
};
console.log("📺 TVDB token (API key) kullanılıyor.");
return tvdbAuthState.token;
}
async function tvdbFetch(pathname, options = {}, retry = true) {
if (!TVDB_API_KEY) return null;
const token = await getTvdbToken();
if (!token) return null;
const url = pathname.startsWith("http")
? pathname
: `${TVDB_BASE_URL}${pathname}`;
const headers = {
Accept: "application/json",
...(options.headers || {}),
Authorization: `Bearer ${token}`
};
let body = options.body;
if (body && typeof body === "object" && !(body instanceof Buffer)) {
body = JSON.stringify(body);
headers["Content-Type"] = "application/json";
}
try {
const resp = await fetch(url, {
...options,
headers,
body
});
const rawText = await resp.text();
let json = null;
if (rawText) {
try {
json = JSON.parse(rawText);
} catch (err) {
console.warn(
`⚠️ TVDB yanıtı JSON parse edilemedi (${url}): ${err.message}`
);
}
}
console.log("📺 TVDB fetch:", {
url: String(url),
status: resp.status,
hasData: Boolean(json?.data),
message: json?.status?.message ?? null
});
if (resp.status === 401 && retry) {
await getTvdbToken(true);
return tvdbFetch(pathname, options, false);
}
if (!resp.ok) {
console.warn(`⚠️ TVDB isteği başarısız (${url}): ${resp.status}`);
return null;
}
return json;
} catch (err) {
console.warn(`⚠️ TVDB isteği hatası (${url}): ${err.message}`);
return null;
}
}
function guessPrimaryVideo(rootFolder) {
const videos = enumerateVideoFiles(rootFolder);
return videos.length ? videos[0].relPath : null;
}
async function ensureMovieData(
rootFolder,
displayName,
bestVideoPath,
precomputedMediaInfo = null
) {
const normalizedRoot = sanitizeRelative(rootFolder);
if (!TMDB_API_KEY) {
return {
mediaInfo: precomputedMediaInfo || null,
metadata: null,
cacheKey: null,
videoPath: bestVideoPath ? normalizeTrashPath(bestVideoPath) : null
};
}
console.log("🎬 ensureMovieData çağrıldı:", {
rootFolder,
displayName,
bestVideoPath
});
const isSeriesPattern =
/S\d{1,2}E\d{1,2}/i.test(displayName || "") ||
(bestVideoPath && /S\d{1,2}E\d{1,2}/i.test(bestVideoPath));
if (isSeriesPattern) {
console.log(
"🎬 TMDB atlandı (TV bölümü tespit edildi, movie_data oluşturulmadı):",
{ rootFolder, displayName }
);
removeMovieData(normalizedRoot || rootFolder);
return {
mediaInfo: precomputedMediaInfo || null,
metadata: null,
cacheKey: null,
videoPath: bestVideoPath ? normalizeTrashPath(bestVideoPath) : null
};
}
let normalizedVideoPath = bestVideoPath
? normalizeTrashPath(bestVideoPath)
: null;
if (!normalizedVideoPath) {
normalizedVideoPath = guessPrimaryVideo(normalizedRoot || rootFolder);
}
if (!normalizedVideoPath) {
console.log(
"🎬 TMDB jeçildi (uygun video dosyası bulunamadı):",
normalizedRoot || rootFolder
);
removeMovieData(normalizedRoot || rootFolder);
return {
mediaInfo: precomputedMediaInfo || null,
metadata: null,
cacheKey: null,
videoPath: null
};
}
const rootForPaths = normalizedRoot || rootFolder;
const paths = movieDataPaths(rootForPaths, normalizedVideoPath);
const legacyPaths = movieDataPaths(rootForPaths);
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}`
);
}
} else if (
legacyPaths.metadata !== paths.metadata &&
fs.existsSync(legacyPaths.metadata)
) {
try {
metadata = JSON.parse(fs.readFileSync(legacyPaths.metadata, "utf-8"));
console.log("🎬 Legacy metadata bulundu:", legacyPaths.metadata);
} catch (err) {
console.warn(
`⚠️ metadata.json okunamadı (${legacyPaths.metadata}): ${err.message}`
);
}
}
let fetchedMetadata = false;
const hasTmdbMetadata = isTmdbMetadata(metadata);
if (!hasTmdbMetadata) {
const searchCandidates = [];
if (normalizedVideoPath) {
const videoFileName = path.basename(normalizedVideoPath);
const { title: videoTitle, year: videoYear } =
parseTitleAndYear(videoFileName);
if (videoTitle) {
searchCandidates.push({
title: videoTitle,
year: videoYear,
source: "video"
});
}
}
if (displayName) {
const { title: folderTitle, year: folderYear } =
parseTitleAndYear(displayName);
if (folderTitle) {
searchCandidates.push({
title: folderTitle,
year: folderYear,
source: "folder"
});
} else {
searchCandidates.push({
title: displayName,
year: null,
source: "folderRaw"
});
}
}
const tried = new Set();
for (const candidate of searchCandidates) {
const key = `${candidate.title}::${candidate.year ?? ""}`;
if (tried.has(key)) continue;
tried.add(key);
console.log("🎬 TMDB araması için analiz:", candidate);
const fetched = await fetchMovieMetadata(candidate.title, candidate.year);
if (fetched) {
metadata = fetched;
fetchedMetadata = true;
break;
}
}
}
if (!isTmdbMetadata(metadata)) {
console.log(
"🎬 TMDB verisi bulunamadı, movie_data oluşturulmadı:",
rootFolder
);
removeMovieData(normalizedRoot, normalizedVideoPath);
return {
mediaInfo: precomputedMediaInfo || null,
metadata: null,
cacheKey: null,
videoPath: normalizedVideoPath
};
}
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: normalizedRoot,
videoPath,
mediaInfo,
cacheKey: paths.key,
displayName: displayName || null,
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}`, {
videoPath,
cacheKey: paths.key
});
if (
legacyPaths.metadata !== paths.metadata &&
fs.existsSync(legacyPaths.metadata)
) {
try {
fs.rmSync(legacyPaths.dir, { recursive: true, force: true });
console.log(`🧹 Legacy movie metadata kaldırıldı: ${legacyPaths.dir}`);
} catch (err) {
console.warn(
`⚠️ Legacy movie metadata kaldırılamadı (${legacyPaths.dir}): ${err.message}`
);
}
}
return {
mediaInfo,
metadata: enriched,
cacheKey: paths.key,
videoPath
};
}
function removeMovieData(rootFolder, videoRelPath = null) {
const normalizedRoot = rootFolder ? sanitizeRelative(rootFolder) : null;
if (!normalizedRoot) return;
const targetDirs = new Set();
if (videoRelPath) {
targetDirs.add(movieDataDir(normalizedRoot, videoRelPath));
} else {
targetDirs.add(movieDataLegacyDir(normalizedRoot));
try {
const entries = fs.readdirSync(MOVIE_DATA_ROOT, {
withFileTypes: true
});
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (
entry.name === normalizedRoot ||
entry.name.startsWith(`${normalizedRoot}__`)
) {
targetDirs.add(path.join(MOVIE_DATA_ROOT, entry.name));
}
}
} catch (err) {
console.warn(
`⚠️ Movie metadata dizini listelenemedi (${MOVIE_DATA_ROOT}): ${err.message}`
);
}
}
for (const dir of targetDirs) {
if (!dir) continue;
if (!fs.existsSync(dir)) continue;
try {
fs.rmSync(dir, { recursive: true, force: true });
console.log(`🧹 Movie metadata silindi: ${dir}`);
} catch (err) {
console.warn(`⚠️ Movie metadata temizlenemedi (${dir}): ${err.message}`);
}
}
}
const tvdbSeriesCache = new Map();
const tvdbEpisodeCache = new Map();
const tvdbEpisodeDetailCache = new Map();
async function searchTvdbSeries(title) {
if (!title) return null;
const key = title.toLowerCase();
if (tvdbSeriesCache.has(key)) return tvdbSeriesCache.get(key);
console.log("📺 TVDB seri araması yapılıyor:", title);
const params = new URLSearchParams({ type: "series", query: title });
const resp = await tvdbFetch(`/search?${params.toString()}`);
const series = resp?.data?.[0] || null;
tvdbSeriesCache.set(key, series);
return series;
}
async function fetchTvdbSeriesExtended(seriesId) {
if (!seriesId) return null;
const cacheKey = `series-${seriesId}`;
if (tvdbSeriesCache.has(cacheKey)) return tvdbSeriesCache.get(cacheKey);
console.log("📺 TVDB extended isteniyor:", seriesId);
const resp = await tvdbFetch(
`/series/${seriesId}/extended?meta=episodes,artworks,translations&short=false`
);
const data = resp?.data || null;
tvdbSeriesCache.set(cacheKey, data);
return data;
}
async function fetchTvdbEpisodesForSeason(seriesId, seasonNumber) {
if (!seriesId || !Number.isFinite(seasonNumber)) return new Map();
const cacheKey = `${seriesId}-S${seasonNumber}`;
if (tvdbEpisodeCache.has(cacheKey)) return tvdbEpisodeCache.get(cacheKey);
console.log("📺 TVDB sezon bölümleri çekiliyor:", {
seriesId,
seasonNumber
});
let page = 0;
const seasonMap = new Map();
while (page < 50) {
const resp = await tvdbFetch(
`/series/${seriesId}/episodes/default?page=${page}&lang=eng`
);
const payload = resp?.data;
const items = Array.isArray(payload)
? payload
: Array.isArray(payload?.episodes)
? payload.episodes
: [];
if (!Array.isArray(items) || !items.length) break;
for (const item of items) {
const normalized = normalizeTvdbEpisode(item);
if (!normalized) continue;
const season = normalized.seasonNumber;
const episode = normalized.episodeNumber;
if (!Number.isFinite(season) || !Number.isFinite(episode)) continue;
const key = `${seriesId}-S${season}`;
let seasonEntry = tvdbEpisodeCache.get(key);
if (!seasonEntry) {
seasonEntry = new Map();
tvdbEpisodeCache.set(key, seasonEntry);
}
if (!seasonEntry.has(episode)) seasonEntry.set(episode, normalized);
if (!seasonMap.has(episode) && season === seasonNumber) {
seasonMap.set(episode, normalized);
}
}
const links = resp?.links || payload?.links || {};
const nextRaw =
links?.next !== undefined ? links.next : links?.nextPage ?? null;
const nextPage = toFiniteNumber(nextRaw);
if (!Number.isFinite(nextPage) || nextPage <= page) break;
page = nextPage;
}
tvdbEpisodeCache.set(cacheKey, seasonMap);
return seasonMap;
}
async function fetchTvdbEpisode(seriesId, season, episode) {
const cacheKey = `${seriesId}-S${season}`;
const cache = tvdbEpisodeCache.get(cacheKey);
if (cache?.has(episode)) return cache.get(episode);
console.log("📺 TVDB tekil bölüm çekiliyor:", {
seriesId,
season,
episode
});
const seasonEpisodes = await fetchTvdbEpisodesForSeason(seriesId, season);
return seasonEpisodes.get(episode) || null;
}
async function fetchTvdbEpisodeExtended(episodeId) {
if (!episodeId) return null;
const cacheKey = `episode-${episodeId}-extended`;
if (tvdbEpisodeDetailCache.has(cacheKey))
return tvdbEpisodeDetailCache.get(cacheKey);
const resp = await tvdbFetch(
`/episodes/${episodeId}/extended?meta=artworks,translations&short=false`
);
const payload = resp?.data || null;
if (!payload) {
tvdbEpisodeDetailCache.set(cacheKey, null);
return null;
}
const base =
normalizeTvdbEpisode(payload.episode || payload) ||
normalizeTvdbEpisode(payload);
if (!base) {
tvdbEpisodeDetailCache.set(cacheKey, null);
return null;
}
const artworks = Array.isArray(payload.artworks)
? payload.artworks
: Array.isArray(payload.episode?.artworks)
? payload.episode.artworks
: [];
if (!base.image) {
const stillArtwork = artworks.find((a) => {
const type = String(
a?.type || a?.artworkType || a?.name || ""
).toLowerCase();
return (
type.includes("still") ||
type.includes("screencap") ||
type.includes("episode") ||
type.includes("thumb")
);
});
if (stillArtwork?.image) base.image = stillArtwork.image;
}
const translations =
payload.translations ||
payload.episode?.translations ||
{};
const overviewTranslations =
translations.overviewTranslations ||
translations.overviews ||
[];
const nameTranslations =
translations.nameTranslations || translations.names || [];
const pickTranslation = (list, field) => {
if (!Array.isArray(list)) return null;
const preferred = ["tr", "turkish", "tr-tr", "tr_tur"];
const fallback = ["en", "english", "en-us", "eng"];
const pickByLang = (langs) =>
langs
.map((lng) => lng.toLowerCase())
.map((lng) =>
list.find((item) => {
const code = String(
item?.language ||
item?.iso6391 ||
item?.iso_639_1 ||
item?.locale ||
item?.languageCode ||
""
).toLowerCase();
return code === lng;
})
)
.find(Boolean);
const preferredMatch = pickByLang(preferred) || pickByLang(fallback);
if (!preferredMatch) return null;
return (
preferredMatch[field] ??
preferredMatch.value ??
preferredMatch.translation?.[field] ??
null
);
};
if (!base.overview) {
const localizedOverview = pickTranslation(overviewTranslations, "overview");
if (localizedOverview) base.overview = localizedOverview;
}
if (!base.name) {
const localizedName = pickTranslation(nameTranslations, "name");
if (localizedName) base.name = localizedName;
}
tvdbEpisodeDetailCache.set(cacheKey, base);
return base;
}
function ensureSeasonContainer(seriesData, seasonNumber) {
if (!seriesData.seasons) seriesData.seasons = {};
const key = String(seasonNumber);
if (!seriesData.seasons[key]) {
seriesData.seasons[key] = {
seasonNumber,
name: `Season ${seasonNumber}`,
episodes: {}
};
}
const container = seriesData.seasons[key];
if (!container.episodes) container.episodes = {};
if (container.seasonNumber === undefined) {
container.seasonNumber = seasonNumber;
}
if (!container.name) {
container.name = `Season ${seasonNumber}`;
}
if (!("poster" in container)) container.poster = null;
if (!("overview" in container)) container.overview = "";
if (!("slug" in container)) container.slug = null;
if (!("tvdbId" in container)) container.tvdbId = null;
if (!("episodeCount" in container)) container.episodeCount = null;
return container;
}
function encodeTvDataPath(rootOrKey, relativePath) {
if (!rootOrKey) return null;
const base = String(rootOrKey);
const normalizedKey = base.includes("__")
? sanitizeRelative(base)
: tvSeriesKey(rootOrKey);
if (!normalizedKey) return null;
const encodedBase = normalizedKey
.split(path.sep)
.map(encodeURIComponent)
.join("/");
if (!relativePath) return `/tv-data/${encodedBase}`;
const encodedRel = String(relativePath)
.split(path.sep)
.map(encodeURIComponent)
.join("/");
return `/tv-data/${encodedBase}/${encodedRel}`;
}
async function ensureSeriesData(
rootFolder,
relativeFilePath,
seriesInfo,
mediaInfo
) {
if (!TVDB_API_KEY || !seriesInfo) {
console.log("📺 TVDB atlandı (eksik anahtar ya da seriesInfo yok):", {
rootFolder,
relativeFilePath
});
return null;
}
console.log("📺 ensureSeriesData başladı:", {
rootFolder,
relativeFilePath,
seriesInfo
});
const normalizedRoot = sanitizeRelative(rootFolder);
const normalizedFile = normalizeTrashPath(relativeFilePath);
const candidateKeys = listTvSeriesKeysForRoot(normalizedRoot);
let seriesData = null;
let existingPaths = null;
for (const key of candidateKeys) {
const candidatePaths = tvSeriesPathsByKey(key);
if (!fs.existsSync(candidatePaths.metadata)) continue;
try {
const data = JSON.parse(fs.readFileSync(candidatePaths.metadata, "utf-8")) || {};
const seasons = data?.seasons || {};
const matchesEpisode = Object.values(seasons).some((season) =>
season?.episodes &&
Object.values(season.episodes).some((episode) => episode?.file === normalizedFile)
);
if (matchesEpisode) {
seriesData = data;
existingPaths = candidatePaths;
break;
}
} catch (err) {
console.warn(
`⚠️ series.json okunamadı (${candidatePaths.metadata}): ${err.message}`
);
}
}
const legacyPaths = tvSeriesPaths(normalizedRoot);
if (!seriesData && fs.existsSync(legacyPaths.metadata)) {
try {
seriesData = JSON.parse(fs.readFileSync(legacyPaths.metadata, "utf-8")) || {};
existingPaths = legacyPaths;
} catch (err) {
console.warn(
`⚠️ series.json okunamadı (${legacyPaths.metadata}): ${err.message}`
);
}
}
if (!seriesData) {
seriesData = {};
}
let seriesId = seriesData.id ?? seriesData.tvdbId ?? null;
if (!seriesId) {
const searchResult = await searchTvdbSeries(seriesInfo.searchTitle);
console.log("📺 TVDB arama sonucu:", {
search: seriesInfo.searchTitle,
resultId: searchResult?.id ?? searchResult?.tvdb_id ?? null
});
const normalizedId = normalizeTvdbId(
searchResult?.tvdb_id ?? searchResult?.id ?? searchResult?.seriesId
);
if (!normalizedId) {
console.warn("⚠️ TVDB seri kimliği çözülmedi:", searchResult);
return null;
}
seriesId = normalizedId;
seriesData.id = seriesId;
seriesData.tvdbId = seriesId;
}
let targetPaths =
existingPaths && existingPaths.key?.includes("__") ? existingPaths : null;
if (!targetPaths) {
targetPaths = buildTvSeriesPaths(
normalizedRoot,
seriesId,
seriesInfo.title
);
if (!targetPaths && existingPaths) {
targetPaths = existingPaths;
}
}
if (!targetPaths) return null;
const showDir = targetPaths.dir;
const seriesMetaPath = targetPaths.metadata;
const extended = await fetchTvdbSeriesExtended(seriesId);
const container = extended || {};
console.log("📺 TVDB extended yükleme:", {
seriesId,
keys: Object.keys(container || {})
});
const info =
(container.series && typeof container.series === "object"
? container.series
: container) || {};
const translations =
container.translations ||
info.translations ||
{};
const nameTranslations =
translations.nameTranslations ||
translations.names ||
[];
const overviewTranslations =
translations.overviewTranslations ||
translations.overviews ||
[];
const localizedName =
nameTranslations.find((t) =>
["tr", "tur", "turkish"].includes(String(t?.language || t?.iso6391).toLowerCase())
)?.value ||
nameTranslations.find((t) =>
["en", "eng", "english"].includes(String(t?.language || t?.iso6391).toLowerCase())
)?.value ||
null;
const localizedOverview =
overviewTranslations.find((t) =>
["tr", "tur", "turkish"].includes(String(t?.language || t?.iso6391).toLowerCase())
)?.overview ||
overviewTranslations.find((t) =>
["en", "eng", "english"].includes(String(t?.language || t?.iso6391).toLowerCase())
)?.overview ||
null;
seriesData.name =
seriesData.name ||
info.name ||
info.seriesName ||
localizedName ||
seriesInfo.title;
seriesData.slug = seriesData.slug || info.slug || info.slugged || null;
seriesData.overview =
seriesData.overview || info.overview || localizedOverview || "";
const firstAired =
info.firstAired ||
info.firstAirDate ||
info.first_air_date ||
info.premiere ||
null;
if (!seriesData.firstAired) seriesData.firstAired = firstAired || null;
if (!seriesData.year && firstAired) {
const yearMatch = String(firstAired).match(/(\d{4})/);
if (yearMatch) seriesData.year = Number(yearMatch[1]);
}
if (!seriesData.year) {
for (const candidate of [info.year, info.startYear, info.start_year]) {
const numeric = Number(candidate);
if (Number.isFinite(numeric) && numeric > 0) {
seriesData.year = numeric;
break;
}
}
}
if (
!seriesData.genres ||
!Array.isArray(seriesData.genres) ||
!seriesData.genres.length
) {
seriesData.genres = Array.isArray(info.genres)
? info.genres
.map((g) =>
typeof g === "string"
? g
: g?.name || g?.genre || g?.slug || null
)
.filter(Boolean)
: [];
}
if (!seriesData.status) {
const status =
info.status?.name ||
info.status ||
container.status ||
null;
seriesData.status =
typeof status === "string" ? status : null;
}
seriesData._dupe = {
...(seriesData._dupe || {}),
folder: normalizedRoot,
seriesId,
key: targetPaths.key
};
seriesData.updatedAt = Date.now();
const artworksRaw = Array.isArray(container.artworks)
? container.artworks
: Array.isArray(info.artworks)
? info.artworks
: [];
const posterArtwork =
artworksRaw.find((a) => {
const type = String(
a?.type ||
a?.artworkType ||
a?.type2 ||
a?.name ||
a?.artwork
).toLowerCase();
return type.includes("poster") || type === "series" || type === "2";
}) || artworksRaw[0];
// Fanart.tv'den backdrop al
let backdropImage = null;
const fanartData = await fetchFanartTvImages(seriesData.id);
if (fanartData?.showbackground && Array.isArray(fanartData.showbackground) && fanartData.showbackground.length > 0) {
// İlk showbackground resmini al
const firstBackdrop = fanartData.showbackground[0];
backdropImage = firstBackdrop.url;
}
// Eğer Fanart.tv'de backdrop yoksa, TVDB'i fallback olarak kullan
if (!backdropImage) {
let backdropArtwork = selectBackgroundArtwork(artworksRaw);
if (!backdropArtwork) {
const backgroundArtworks = await fetchTvdbArtworks(seriesData.id, "background");
backdropArtwork = selectBackgroundArtwork(backgroundArtworks);
}
if (!backdropArtwork) {
const fanartArtworks = await fetchTvdbArtworks(seriesData.id, "fanart");
backdropArtwork = selectBackgroundArtwork(fanartArtworks);
}
if (backdropArtwork) {
backdropImage = backdropArtwork?.image ||
backdropArtwork?.file ||
backdropArtwork?.fileName ||
backdropArtwork?.thumbnail ||
backdropArtwork?.url ||
null;
}
}
const posterImage =
posterArtwork?.image ||
posterArtwork?.file ||
posterArtwork?.fileName ||
posterArtwork?.thumbnail ||
posterArtwork?.url ||
null;
const posterPath = path.join(showDir, "poster.jpg");
const backdropPath = path.join(showDir, "backdrop.jpg");
if (posterImage && !fs.existsSync(posterPath)) {
await downloadTvdbImage(posterImage, posterPath);
}
if (backdropImage && !fs.existsSync(backdropPath)) {
// Eğer backdropImage Fanart.tv'den geldiyse, onun indirme fonksiyonunu kullan
if (fanartData?.showbackground && backdropImage.startsWith('http')) {
await downloadFanartTvImage(backdropImage, backdropPath);
} else {
await downloadTvdbImage(backdropImage, backdropPath);
}
}
const seasonPaths = seasonAssetPaths(targetPaths, seriesInfo.season);
const seasonsRaw = Array.isArray(container.seasons)
? container.seasons
: Array.isArray(container.series?.seasons)
? container.series.seasons
: [];
const normalizedSeasons = seasonsRaw
.map(normalizeTvdbSeason)
.filter(Boolean);
const seasonMeta = normalizedSeasons.find(
(season) => season.number === seriesInfo.season
);
const seasonKey = String(seriesInfo.season);
const seasonContainer = ensureSeasonContainer(seriesData, seriesInfo.season);
if (seasonMeta) {
if (seasonMeta.name) seasonContainer.name = seasonMeta.name;
if (seasonMeta.overview) seasonContainer.overview = seasonMeta.overview;
if (seasonMeta.id && !seasonContainer.tvdbId) {
seasonContainer.tvdbId = seasonMeta.id;
}
if (seasonMeta.slug && !seasonContainer.slug) {
seasonContainer.slug = seasonMeta.slug;
}
if (
seasonMeta.raw?.episodeCount !== undefined &&
seasonMeta.raw?.episodeCount !== null
) {
const count = Number(seasonMeta.raw.episodeCount);
if (Number.isFinite(count)) seasonContainer.episodeCount = count;
}
const seasonImage =
seasonMeta.image ||
seasonMeta.raw?.image ||
seasonMeta.raw?.poster ||
seasonMeta.raw?.filename ||
seasonMeta.raw?.fileName ||
null;
if (seasonImage && !fs.existsSync(seasonPaths.poster)) {
await downloadTvdbImage(seasonImage, seasonPaths.poster);
}
}
if (fs.existsSync(seasonPaths.poster)) {
const relPoster = path.relative(showDir, seasonPaths.poster);
if (!relPoster.startsWith("..")) {
seasonContainer.poster = encodeTvDataPath(targetPaths.key, relPoster);
}
}
if (!seasonContainer.overview) seasonContainer.overview = "";
const episodeData = await fetchTvdbEpisode(
seriesData.id,
seriesInfo.season,
seriesInfo.episode
);
let detailedEpisode = episodeData;
if (
detailedEpisode?.id &&
(!detailedEpisode.overview ||
!detailedEpisode.image ||
!detailedEpisode.name)
) {
const extendedEpisode = await fetchTvdbEpisodeExtended(
detailedEpisode.id
);
if (extendedEpisode) {
detailedEpisode = {
...detailedEpisode,
...extendedEpisode,
image: extendedEpisode.image || detailedEpisode.image,
overview: extendedEpisode.overview || detailedEpisode.overview,
name: extendedEpisode.name || detailedEpisode.name,
aired: extendedEpisode.aired || detailedEpisode.aired,
runtime: extendedEpisode.runtime || detailedEpisode.runtime
};
}
}
const episodeDetails = detailedEpisode || episodeData || {};
if (
episodeDetails &&
episodeData &&
episodeDetails !== episodeData
) {
const seasonCacheKey = `${seriesData.id}-S${seriesInfo.season}`;
const seasonCache = tvdbEpisodeCache.get(seasonCacheKey);
if (seasonCache) {
seasonCache.set(seriesInfo.episode, episodeDetails);
}
}
const episodeKey = String(seriesInfo.episode);
const stillDir = path.join(
showDir,
"episodes",
`season-${String(seriesInfo.season).padStart(2, "0")}`
);
const stillFileName = `episode-${String(seriesInfo.episode).padStart(2, "0")}.jpg`;
const stillPath = path.join(stillDir, stillFileName);
const episodeImage =
episodeDetails.image ||
episodeDetails.filename ||
episodeDetails.thumb ||
episodeDetails.thumbnail ||
episodeDetails.imageUrl ||
(episodeDetails.raw && (
episodeDetails.raw.image ||
episodeDetails.raw.filename ||
episodeDetails.raw.thumb ||
episodeDetails.raw.thumbnail
)) ||
null;
if (episodeImage && !fs.existsSync(stillPath)) {
await downloadTvdbImage(episodeImage, stillPath);
}
const episodeCode =
seriesInfo.key ||
`S${String(seriesInfo.season).padStart(2, "0")}E${String(
seriesInfo.episode
).padStart(2, "0")}`;
const episodeTitle =
episodeDetails.name ||
episodeDetails.raw?.name ||
episodeDetails.raw?.episodeName ||
`Episode ${seriesInfo.episode}`;
const episodeOverview =
episodeDetails.overview ||
episodeDetails.raw?.overview ||
"";
const episodeRuntime =
toFiniteNumber(episodeDetails.runtime) ||
toFiniteNumber(episodeDetails.raw?.runtime) ||
toFiniteNumber(episodeDetails.raw?.length) ||
null;
const episodeAirDate =
episodeDetails.aired ||
episodeDetails.raw?.aired ||
episodeDetails.raw?.firstAired ||
null;
const episodeSlug =
episodeDetails.slug || episodeDetails.raw?.slug || null;
const episodeTvdbId = normalizeTvdbId(
episodeDetails.id ?? episodeDetails.raw?.id
);
const episodeSeasonId = normalizeTvdbId(
episodeDetails.seasonId ?? episodeDetails.raw?.seasonId
);
seasonContainer.episodes[episodeKey] = {
episodeNumber: seriesInfo.episode,
seasonNumber: seriesInfo.season,
code: episodeCode,
title: episodeTitle,
overview: episodeOverview,
runtime: episodeRuntime,
aired: episodeAirDate,
still: fs.existsSync(stillPath)
? encodeTvDataPath(targetPaths.key, path.relative(showDir, stillPath))
: null,
file: normalizedFile,
mediaInfo: mediaInfo || null,
tvdbEpisodeId: episodeTvdbId,
slug: episodeSlug,
seasonId:
seasonContainer.tvdbId ||
episodeSeasonId ||
null
};
seasonContainer.episodeCount = Object.keys(seasonContainer.episodes).length;
seasonContainer.updatedAt = Date.now();
ensureDirForFile(seriesMetaPath);
fs.writeFileSync(seriesMetaPath, JSON.stringify(seriesData, null, 2), "utf-8");
if (
existingPaths &&
existingPaths.key !== targetPaths.key &&
fs.existsSync(existingPaths.dir)
) {
try {
fs.rmSync(existingPaths.dir, { recursive: true, force: true });
console.log(
`🧹 Legacy TV metadata kaldırıldı: ${existingPaths.dir}`
);
} catch (err) {
console.warn(
`⚠️ Legacy TV metadata kaldırılamadı (${existingPaths.dir}): ${err.message}`
);
}
}
return {
show: {
id: seriesData.id || null,
title: seriesData.name || seriesInfo.title,
year: seriesData.year || null,
overview: seriesData.overview || "",
poster: fs.existsSync(path.join(showDir, "poster.jpg"))
? encodeTvDataPath(targetPaths.key, "poster.jpg")
: null,
backdrop: fs.existsSync(path.join(showDir, "backdrop.jpg"))
? encodeTvDataPath(targetPaths.key, "backdrop.jpg")
: null
},
season: {
seasonNumber: seasonContainer.seasonNumber,
name: seasonContainer.name || `Season ${seriesInfo.season}`,
overview: seasonContainer.overview || "",
poster: seasonContainer.poster || null,
tvdbSeasonId: seasonContainer.tvdbId || null,
slug: seasonContainer.slug || null
},
episode: seasonContainer.episodes[episodeKey],
cacheKey: targetPaths.key
};
}
function tvSeriesKey(rootFolder, seriesId = null, fallbackTitle = null) {
const normalizedRoot = rootFolder ? sanitizeRelative(rootFolder) : null;
if (!normalizedRoot) return null;
let suffix = null;
if (seriesId) {
suffix = String(seriesId).toLowerCase();
} else if (fallbackTitle) {
const slug = String(fallbackTitle)
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
if (slug) {
const hash = crypto.createHash("sha1").update(slug).digest("hex");
suffix = `${slug}-${hash.slice(0, 8)}`;
}
}
return suffix ? `${normalizedRoot}__${suffix}` : normalizedRoot;
}
function parseTvSeriesKey(key) {
const normalized = sanitizeRelative(String(key || ""));
if (!normalized.includes("__")) {
return { rootFolder: normalized, seriesId: null, key: normalized };
}
const [rootFolder, suffix] = normalized.split("__", 2);
return { rootFolder, seriesId: suffix || null, key: normalized };
}
function tvSeriesPathsByKey(key) {
const normalizedKey = sanitizeRelative(key);
const dir = path.join(TV_DATA_ROOT, normalizedKey);
return {
key: normalizedKey,
dir,
metadata: path.join(dir, "series.json"),
poster: path.join(dir, "poster.jpg"),
backdrop: path.join(dir, "backdrop.jpg"),
episodesDir: path.join(dir, "episodes"),
seasonsDir: path.join(dir, "seasons"),
rootFolder: parseTvSeriesKey(normalizedKey).rootFolder
};
}
function tvSeriesPaths(rootFolderOrKey, seriesId = null, fallbackTitle = null) {
if (
seriesId === null &&
fallbackTitle === null &&
String(rootFolderOrKey || "").includes("__")
) {
return tvSeriesPathsByKey(rootFolderOrKey);
}
const key = tvSeriesKey(rootFolderOrKey, seriesId, fallbackTitle);
if (!key) return null;
return tvSeriesPathsByKey(key);
}
function tvSeriesDir(rootFolderOrKey, seriesId = null, fallbackTitle = null) {
const paths = tvSeriesPaths(rootFolderOrKey, seriesId, fallbackTitle);
return paths?.dir || null;
}
function buildTvSeriesPaths(rootFolder, seriesId = null, fallbackTitle = null) {
const paths = tvSeriesPaths(rootFolder, seriesId, fallbackTitle);
if (!paths) return null;
if (!fs.existsSync(paths.dir)) fs.mkdirSync(paths.dir, { recursive: true });
if (!fs.existsSync(paths.episodesDir))
fs.mkdirSync(paths.episodesDir, { recursive: true });
if (!fs.existsSync(paths.seasonsDir))
fs.mkdirSync(paths.seasonsDir, { recursive: true });
return paths;
}
function seasonAssetPaths(paths, seasonNumber) {
const padded = String(seasonNumber).padStart(2, "0");
const dir = path.join(paths.dir, "seasons", `season-${padded}`);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
return {
dir,
poster: path.join(dir, "poster.jpg")
};
}
function listTvSeriesKeysForRoot(rootFolder) {
const normalizedRoot = rootFolder ? sanitizeRelative(rootFolder) : null;
if (!normalizedRoot) return [];
if (!fs.existsSync(TV_DATA_ROOT)) return [];
const keys = [];
try {
const entries = fs.readdirSync(TV_DATA_ROOT, { withFileTypes: true });
for (const dirent of entries) {
if (!dirent.isDirectory()) continue;
const name = dirent.name;
if (
name === normalizedRoot ||
name.startsWith(`${normalizedRoot}__`)
) {
keys.push(name);
}
}
} catch (err) {
console.warn(
`⚠️ TV metadata dizini listelenemedi (${TV_DATA_ROOT}): ${err.message}`
);
}
return keys;
}
function removeSeriesData(rootFolder, seriesId = null) {
const keys = seriesId
? [tvSeriesKey(rootFolder, seriesId)].filter(Boolean)
: listTvSeriesKeysForRoot(rootFolder);
for (const key of keys) {
const dir = tvSeriesDir(key);
if (dir && fs.existsSync(dir)) {
try {
fs.rmSync(dir, { recursive: true, force: true });
console.log(`🧹 TV metadata silindi: ${dir}`);
} catch (err) {
console.warn(`⚠️ TV metadata temizlenemedi (${dir}): ${err.message}`);
}
}
}
}
function removeSeriesEpisode(rootFolder, relativeFilePath) {
if (!rootFolder || !relativeFilePath) return;
const keys = listTvSeriesKeysForRoot(rootFolder);
if (!keys.length) return;
for (const key of keys) {
const paths = tvSeriesPathsByKey(key);
if (!fs.existsSync(paths.metadata)) continue;
let seriesData;
try {
seriesData = JSON.parse(fs.readFileSync(paths.metadata, "utf-8"));
} catch (err) {
console.warn(
`⚠️ series.json okunamadı (${paths.metadata}): ${err.message}`
);
continue;
}
const seasons = seriesData?.seasons || {};
let removed = false;
for (const [seasonKey, season] of Object.entries(seasons)) {
if (!season?.episodes) continue;
let seasonChanged = false;
for (const episodeKey of Object.keys(season.episodes)) {
const episode = season.episodes[episodeKey];
if (episode?.file === relativeFilePath) {
delete season.episodes[episodeKey];
seasonChanged = true;
removed = true;
}
}
if (
seasonChanged &&
(!season.episodes || Object.keys(season.episodes).length === 0)
) {
delete seasons[seasonKey];
}
}
if (!removed) continue;
if (!Object.keys(seasons).length) {
removeSeriesData(seriesData._dupe?.folder || rootFolder, seriesData.id);
continue;
}
seriesData.seasons = seasons;
seriesData.updatedAt = Date.now();
try {
fs.writeFileSync(paths.metadata, JSON.stringify(seriesData, null, 2), "utf-8");
} catch (err) {
console.warn(
`⚠️ series.json güncellenemedi (${paths.metadata}): ${err.message}`
);
}
try {
const seasonDirs = [paths.episodesDir, paths.seasonsDir];
for (const dir of seasonDirs) {
if (!dir || !fs.existsSync(dir)) continue;
cleanupEmptyDirs(dir);
}
} catch (err) {
console.warn(
`⚠️ TV metadata klasörü temizlenemedi (${paths.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);
removeSeriesData(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.seriesEpisodes &&
info.seriesEpisodes[relativePath]
) {
delete info.seriesEpisodes[relativePath];
if (Object.keys(info.seriesEpisodes).length === 0) {
delete info.seriesEpisodes;
}
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 pruneInfoForDirectory(rootFolder, relativeDir) {
if (!rootFolder) return;
const info = readInfoForRoot(rootFolder);
if (!info) return;
const normalizedDir = normalizeTrashPath(relativeDir);
const prefix = normalizedDir ? `${normalizedDir}/` : "";
const removedEpisodePaths = [];
let changed = false;
if (info.files && typeof info.files === "object") {
for (const key of Object.keys(info.files)) {
if (key === normalizedDir || (prefix && key.startsWith(prefix))) {
delete info.files[key];
changed = true;
}
}
if (Object.keys(info.files).length === 0) delete info.files;
}
if (info.seriesEpisodes && typeof info.seriesEpisodes === "object") {
for (const key of Object.keys(info.seriesEpisodes)) {
if (key === normalizedDir || (prefix && key.startsWith(prefix))) {
removedEpisodePaths.push(key);
delete info.seriesEpisodes[key];
changed = true;
}
}
if (Object.keys(info.seriesEpisodes).length === 0) {
delete info.seriesEpisodes;
}
}
if (info.primaryVideoPath) {
if (
info.primaryVideoPath === normalizedDir ||
(prefix && info.primaryVideoPath.startsWith(prefix))
) {
delete info.primaryVideoPath;
delete info.primaryMediaInfo;
delete info.movieMatch;
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}`);
}
}
// TV metadata dosyalarından da temizle
for (const relPath of removedEpisodePaths) {
try {
removeSeriesEpisode(rootFolder, relPath);
} catch (err) {
console.warn(
`⚠️ Serie metadata temizlenemedi (${rootFolder}/${relPath}): ${err.message}`
);
}
}
}
function writeInfoForRoot(rootFolder, info) {
if (!rootFolder || !info) return;
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 moveInfoDataBetweenRoots(oldRoot, newRoot, oldRel, newRel, isDirectory) {
if (!oldRoot || !newRoot) return false;
const oldInfo = readInfoForRoot(oldRoot);
if (!oldInfo) return false;
let newInfo = readInfoForRoot(newRoot);
if (!newInfo) {
const rootDir = path.join(DOWNLOAD_DIR, sanitizeRelative(newRoot));
newInfo =
upsertInfoFile(rootDir, {
folder: newRoot,
createdAt: Date.now(),
updatedAt: Date.now()
}) || readInfoForRoot(newRoot) || { folder: newRoot };
}
const normalizedOldRel = normalizeTrashPath(oldRel);
const normalizedNewRel = normalizeTrashPath(newRel);
const shouldMove = (normalizedKey) => {
if (!normalizedOldRel) return true;
if (normalizedKey === normalizedOldRel) return true;
return (
isDirectory &&
normalizedOldRel &&
normalizedKey.startsWith(`${normalizedOldRel}/`)
);
};
const mapKey = (normalizedKey) => {
const suffix = normalizedOldRel
? normalizedKey.slice(normalizedOldRel.length).replace(/^\/+/, "")
: normalizedKey;
if (!normalizedNewRel) return suffix;
return `${normalizedNewRel}${suffix ? `/${suffix}` : ""}`;
};
let moved = false;
if (oldInfo.files && typeof oldInfo.files === "object") {
const remaining = {};
for (const [key, value] of Object.entries(oldInfo.files)) {
const normalizedKey = normalizeTrashPath(key);
if (!shouldMove(normalizedKey)) {
remaining[key] = value;
continue;
}
const nextKey = mapKey(normalizedKey);
if (!newInfo.files || typeof newInfo.files !== "object") {
newInfo.files = {};
}
newInfo.files[nextKey] = value;
moved = true;
}
if (Object.keys(remaining).length > 0) oldInfo.files = remaining;
else delete oldInfo.files;
}
if (oldInfo.seriesEpisodes && typeof oldInfo.seriesEpisodes === "object") {
const remainingEpisodes = {};
for (const [key, value] of Object.entries(oldInfo.seriesEpisodes)) {
const normalizedKey = normalizeTrashPath(key);
if (!shouldMove(normalizedKey)) {
remainingEpisodes[key] = value;
continue;
}
const nextKey = mapKey(normalizedKey);
if (
!newInfo.seriesEpisodes ||
typeof newInfo.seriesEpisodes !== "object"
) {
newInfo.seriesEpisodes = {};
}
newInfo.seriesEpisodes[nextKey] = value;
moved = true;
}
if (Object.keys(remainingEpisodes).length > 0) {
oldInfo.seriesEpisodes = remainingEpisodes;
} else {
delete oldInfo.seriesEpisodes;
}
}
if (oldInfo.primaryVideoPath) {
const normalizedPrimary = normalizeTrashPath(oldInfo.primaryVideoPath);
if (shouldMove(normalizedPrimary)) {
const nextPrimary = mapKey(normalizedPrimary);
newInfo.primaryVideoPath = nextPrimary;
if (oldInfo.primaryMediaInfo !== undefined) {
newInfo.primaryMediaInfo = oldInfo.primaryMediaInfo;
delete oldInfo.primaryMediaInfo;
}
if (oldInfo.movieMatch !== undefined) {
newInfo.movieMatch = oldInfo.movieMatch;
delete oldInfo.movieMatch;
}
delete oldInfo.primaryVideoPath;
moved = true;
}
}
if (!moved) return false;
writeInfoForRoot(oldRoot, oldInfo);
writeInfoForRoot(newRoot, newInfo);
return true;
}
function renameInfoPaths(rootFolder, oldRel, newRel) {
if (!rootFolder) return;
const info = readInfoForRoot(rootFolder);
if (!info) return;
const oldPrefix = normalizeTrashPath(oldRel);
const newPrefix = normalizeTrashPath(newRel);
if (!oldPrefix || oldPrefix === newPrefix) return;
const transformKey = (key) => {
const normalizedKey = normalizeTrashPath(key);
if (
normalizedKey === oldPrefix ||
normalizedKey.startsWith(`${oldPrefix}/`)
) {
const suffix = normalizedKey.slice(oldPrefix.length).replace(/^\/+/, "");
return newPrefix
? `${newPrefix}${suffix ? `/${suffix}` : ""}`
: suffix;
}
return normalizedKey;
};
let changed = false;
if (info.files && typeof info.files === "object") {
const nextFiles = {};
for (const [key, value] of Object.entries(info.files)) {
const nextKey = transformKey(key);
if (nextKey !== key) changed = true;
nextFiles[nextKey] = value;
}
info.files = nextFiles;
}
if (info.seriesEpisodes && typeof info.seriesEpisodes === "object") {
const nextEpisodes = {};
for (const [key, value] of Object.entries(info.seriesEpisodes)) {
const nextKey = transformKey(key);
if (nextKey !== key) changed = true;
nextEpisodes[nextKey] = value;
}
info.seriesEpisodes = nextEpisodes;
}
if (
info.primaryVideoPath &&
(info.primaryVideoPath === oldPrefix ||
info.primaryVideoPath.startsWith(`${oldPrefix}/`))
) {
const suffix = info.primaryVideoPath.slice(oldPrefix.length).replace(/^\/+/, "");
info.primaryVideoPath = newPrefix
? `${newPrefix}${suffix ? `/${suffix}` : ""}`
: suffix;
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 renameSeriesDataPaths(rootFolder, oldRel, newRel) {
if (!rootFolder) return;
const oldPrefix = normalizeTrashPath(oldRel);
const newPrefix = normalizeTrashPath(newRel);
if (!oldPrefix || oldPrefix === newPrefix) return;
const keys = listTvSeriesKeysForRoot(rootFolder);
for (const key of keys) {
const metadataPath = tvSeriesPathsByKey(key).metadata;
if (!fs.existsSync(metadataPath)) continue;
let seriesData;
try {
seriesData = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
} catch (err) {
console.warn(`⚠️ series.json okunamadı (${metadataPath}): ${err.message}`);
continue;
}
const transform = (value) => {
const normalized = normalizeTrashPath(value);
if (
normalized === oldPrefix ||
normalized.startsWith(`${oldPrefix}/`)
) {
const suffix = normalized.slice(oldPrefix.length).replace(/^\/+/, "");
return newPrefix
? `${newPrefix}${suffix ? `/${suffix}` : ""}`
: suffix;
}
return value;
};
let changed = false;
const seasons = seriesData?.seasons || {};
for (const season of Object.values(seasons)) {
if (!season?.episodes) continue;
for (const episode of Object.values(season.episodes)) {
if (!episode || typeof episode !== "object") continue;
if (episode.file) {
const nextFile = transform(episode.file);
if (nextFile !== episode.file) {
episode.file = nextFile;
changed = true;
}
}
if (episode.videoPath) {
const nextVideo = transform(episode.videoPath);
if (nextVideo !== episode.videoPath) {
episode.videoPath = nextVideo;
changed = true;
}
}
}
}
if (changed) {
try {
fs.writeFileSync(metadataPath, JSON.stringify(seriesData, null, 2), "utf-8");
} catch (err) {
console.warn(
`⚠️ series.json güncellenemedi (${metadataPath}): ${err.message}`
);
}
}
}
}
function removeThumbnailsForDirectory(rootFolder, relativeDir) {
const normalizedRoot = sanitizeRelative(rootFolder);
if (!normalizedRoot) return;
const normalizedDir = normalizeTrashPath(relativeDir);
const segments = normalizedDir ? normalizedDir.split("/") : [];
const videoThumbDir = path.join(
VIDEO_THUMB_ROOT,
normalizedRoot,
...segments
);
const imageThumbDir = path.join(
IMAGE_THUMB_ROOT,
normalizedRoot,
...segments
);
for (const dir of [videoThumbDir, imageThumbDir]) {
if (!dir.startsWith(THUMBNAIL_DIR)) continue;
if (fs.existsSync(dir)) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch (err) {
console.warn(`⚠️ Thumbnail klasörü silinemedi (${dir}): ${err.message}`);
}
}
cleanupEmptyDirs(path.dirname(dir));
}
}
function renameTrashEntries(rootFolder, oldRel, newRel) {
if (!rootFolder) return;
const registry = readTrashRegistry(rootFolder);
if (!registry || !Array.isArray(registry.items)) return;
const oldPrefix = normalizeTrashPath(oldRel);
const newPrefix = normalizeTrashPath(newRel);
if (!oldPrefix || oldPrefix === newPrefix) return;
let changed = false;
const updatedItems = registry.items.map((item) => {
const itemPath = normalizeTrashPath(item.path);
if (
itemPath === oldPrefix ||
itemPath.startsWith(`${oldPrefix}/`)
) {
const suffix = itemPath.slice(oldPrefix.length).replace(/^\/+/, "");
const nextPath = newPrefix
? `${newPrefix}${suffix ? `/${suffix}` : ""}`
: suffix;
changed = true;
return {
...item,
path: nextPath,
originalPath: nextPath
? `${rootFolder}/${nextPath}`
: rootFolder
};
}
return item;
});
if (changed) {
writeTrashRegistry(rootFolder, { ...registry, items: updatedItems });
}
}
function renameRootCaches(oldRoot, newRoot) {
const pairs = [
VIDEO_THUMB_ROOT,
IMAGE_THUMB_ROOT,
MOVIE_DATA_ROOT,
TV_DATA_ROOT
];
for (const base of pairs) {
const from = path.join(base, oldRoot);
if (!fs.existsSync(from)) continue;
const to = path.join(base, newRoot);
try {
fs.mkdirSync(path.dirname(to), { recursive: true });
fs.renameSync(from, to);
} catch (err) {
console.warn(
`⚠️ Kök cache klasörü yeniden adlandırılamadı (${from} -> ${to}): ${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 broadcastDiskSpace() {
if (!wss) return;
getSystemDiskInfo(DOWNLOAD_DIR).then(diskInfo => {
const data = JSON.stringify({
type: "diskSpace",
data: diskInfo
});
wss.clients.forEach((c) => c.readyState === 1 && c.send(data));
}).catch(err => {
console.error("❌ Disk space broadcast error:", err.message);
});
}
function broadcastSnapshot() {
if (!wss) return;
const data = JSON.stringify({ type: "progress", torrents: snapshot() });
wss.clients.forEach((c) => c.readyState === 1 && c.send(data));
}
let mediaRescanTask = null;
let pendingMediaRescan = { movies: false, tv: false };
let lastMediaRescanReason = "manual";
function queueMediaRescan({ movies = false, tv = false, reason = "manual" } = {}) {
if (!movies && !tv) return;
pendingMediaRescan.movies = pendingMediaRescan.movies || movies;
pendingMediaRescan.tv = pendingMediaRescan.tv || tv;
lastMediaRescanReason = reason;
if (!mediaRescanTask) {
mediaRescanTask = runQueuedMediaRescan().finally(() => {
mediaRescanTask = null;
});
}
}
async function runQueuedMediaRescan() {
while (pendingMediaRescan.movies || pendingMediaRescan.tv) {
const targets = { ...pendingMediaRescan };
pendingMediaRescan = { movies: false, tv: false };
const reason = lastMediaRescanReason;
console.log(
`🔁 Medya taraması tetiklendi (${reason}) -> movies:${targets.movies} tv:${targets.tv}`
);
try {
if (targets.movies) {
if (TMDB_API_KEY) {
await rebuildMovieMetadata({ clearCache: true });
} else {
console.warn("⚠️ TMDB anahtarı tanımsız olduğu için film taraması atlandı.");
}
}
if (targets.tv) {
if (TVDB_API_KEY) {
await rebuildTvMetadata({ clearCache: true });
} else {
console.warn("⚠️ TVDB anahtarı tanımsız olduğu için dizi taraması atlandı.");
}
}
if (targets.movies || targets.tv) {
broadcastFileUpdate("media-library");
}
} catch (err) {
console.error("❌ Medya kütüphanesi taraması başarısız:", err?.message || err);
}
}
}
function detectMediaFlagsForPath(info, relWithinRoot, isDirectory) {
const flags = { movies: false, tv: false };
if (!info || typeof info !== "object") return flags;
const normalized = normalizeTrashPath(relWithinRoot);
const matchesPath = (candidate) => {
const normalizedCandidate = normalizeTrashPath(candidate);
if (!normalized) return true;
if (isDirectory) {
return (
normalizedCandidate === normalized ||
normalizedCandidate.startsWith(`${normalized}/`)
);
}
return normalizedCandidate === normalized;
};
const files = info.files || {};
for (const [key, meta] of Object.entries(files)) {
if (!meta) continue;
if (!matchesPath(key)) continue;
if (meta.movieMatch) flags.movies = true;
if (meta.seriesMatch) flags.tv = true;
}
const episodes = info.seriesEpisodes || {};
for (const [key] of Object.entries(episodes)) {
if (!matchesPath(key)) continue;
flags.tv = true;
break;
}
if (!normalized || isDirectory) {
if (info.movieMatch || info.primaryVideoPath) {
flags.movies = true;
}
if (
info.seriesEpisodes &&
Object.keys(info.seriesEpisodes).length &&
!flags.tv
) {
flags.tv = true;
}
}
return flags;
}
function inferMediaFlagsFromTrashEntry(entry) {
if (!entry) return { movies: false, tv: false };
if (
entry.mediaFlags &&
typeof entry.mediaFlags === "object" &&
("movies" in entry.mediaFlags || "tv" in entry.mediaFlags)
) {
return {
movies: Boolean(entry.mediaFlags.movies),
tv: Boolean(entry.mediaFlags.tv)
};
}
const normalized = normalizeTrashPath(entry.path || entry.originalPath || "");
if (!normalized || entry.isDirectory) {
return { movies: true, tv: true };
}
const ext = path.extname(normalized).toLowerCase();
if (!VIDEO_EXTS.includes(ext)) {
return { movies: false, tv: false };
}
const base = path.basename(normalized);
const seriesCandidate = parseSeriesInfo(base);
if (seriesCandidate) {
return { movies: false, tv: true };
}
return { movies: true, tv: false };
}
// --- Snapshot (thumbnail dahil, tracker + tarih eklendi) ---
function snapshot() {
const torrentEntries = Array.from(torrents.values()).map(
({ torrent, selectedIndex, savePath, added, paused }) => {
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,
type: "torrent",
name: torrent.name,
progress: torrent.progress,
downloaded: torrent.downloaded,
downloadSpeed: paused ? 0 : torrent.downloadSpeed, // Pause durumunda hız 0
uploadSpeed: paused ? 0 : torrent.uploadSpeed, // Pause durumunda hız 0
numPeers: paused ? 0 : torrent.numPeers, // Pause durumunda peer sayısı 0
tracker: torrent.announce?.[0] || null,
added,
savePath, // 🆕 BURASI!
paused: paused || false, // Pause durumunu ekle
files: torrent.files.map((f, i) => ({
index: i,
name: f.name,
length: f.length
})),
selectedIndex,
thumbnail
};
}
);
const youtubeEntries = Array.from(youtubeJobs.values()).map((job) =>
youtubeSnapshot(job)
);
const combined = [...torrentEntries, ...youtubeEntries];
combined.sort((a, b) => (b.added || 0) - (a.added || 0));
return combined;
}
function wireTorrent(torrent, { savePath, added, respond, restored = false }) {
torrents.set(torrent.infoHash, {
torrent,
selectedIndex: 0,
savePath,
added,
paused: false
});
torrent.on("ready", () => {
onTorrentReady({ torrent, savePath, added, respond, restored });
});
torrent.on("done", () => {
onTorrentDone({ torrent });
});
}
function onTorrentReady({ torrent, savePath, added, respond }) {
const selectedIndex = pickBestVideoFile(torrent);
torrents.set(torrent.infoHash, {
torrent,
selectedIndex,
savePath,
added,
paused: false
});
const rootFolder = path.basename(savePath);
upsertInfoFile(savePath, {
infoHash: torrent.infoHash,
name: torrent.name,
tracker: torrent.announce?.[0] || null,
added,
magnetURI: torrent.magnetURI,
createdAt: added,
folder: rootFolder
});
broadcastFileUpdate(rootFolder);
const payload = {
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
}))
};
if (typeof respond === "function") respond(payload);
broadcastSnapshot();
}
async function onTorrentDone({ torrent }) {
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 = {};
const seriesEpisodes = {};
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,
type: determineMediaType({
tracker: torrent.announce?.[0] || null,
movieMatch: null,
seriesEpisode: null,
categories: null,
relPath: normalizedRelPath
})
};
const seriesInfo = parseSeriesInfo(file.name);
if (seriesInfo) {
try {
const ensured = await ensureSeriesData(
rootFolder,
normalizedRelPath,
seriesInfo,
metaInfo
);
if (ensured?.show && ensured?.episode) {
seriesEpisodes[normalizedRelPath] = {
season: seriesInfo.season,
episode: seriesInfo.episode,
key: seriesInfo.key,
title: ensured.episode.title || seriesInfo.title,
showId: ensured.show.id || null,
showTitle: ensured.show.title || seriesInfo.title,
seasonName:
ensured.season?.name || `Season ${seriesInfo.season}`,
seasonId: ensured.season?.tvdbSeasonId || null,
seasonPoster: ensured.season?.poster || null,
overview: ensured.episode.overview || "",
aired: ensured.episode.aired || null,
runtime: ensured.episode.runtime || null,
still: ensured.episode.still || null,
episodeId: ensured.episode.tvdbEpisodeId || null,
slug: ensured.episode.slug || null
};
const fileEntry = perFileMetadata[normalizedRelPath] || {};
perFileMetadata[normalizedRelPath] = {
...fileEntry,
seriesMatch: {
id: ensured.show.id || null,
title: ensured.show.title || seriesInfo.title,
season: ensured.season?.seasonNumber ?? seriesInfo.season,
episode: ensured.episode.episodeNumber ?? seriesInfo.episode,
code: ensured.episode.code || seriesInfo.key,
poster: ensured.show.poster || null,
backdrop: ensured.show.backdrop || null,
seasonPoster: ensured.season?.poster || null,
aired: ensured.episode.aired || null,
runtime: ensured.episode.runtime || null,
tvdbEpisodeId: ensured.episode.tvdbEpisodeId || null,
matchedAt: Date.now()
}
};
perFileMetadata[normalizedRelPath].type = determineMediaType({
tracker: torrent.announce?.[0] || null,
movieMatch: null,
seriesEpisode: seriesEpisodes[normalizedRelPath],
categories: null,
relPath: normalizedRelPath
});
}
} catch (err) {
console.warn(
`⚠️ TV metadata oluşturulamadı (${rootFolder} - ${file.name}): ${
err?.message || err
}`
);
}
}
}
// 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,
magnetURI: torrent.magnetURI
};
if (bestVideoPath) infoUpdate.primaryVideoPath = bestVideoPath;
if (Object.keys(seriesEpisodes).length) {
infoUpdate.seriesEpisodes = seriesEpisodes;
}
const ensuredMedia = await ensureMovieData(
rootFolder,
displayName,
bestVideoPath,
primaryMediaInfo
);
if (ensuredMedia?.mediaInfo) {
infoUpdate.primaryMediaInfo = ensuredMedia.mediaInfo;
if (!infoUpdate.files) infoUpdate.files = perFileMetadata;
if (bestVideoPath) {
const entry = infoUpdate.files[bestVideoPath] || {};
infoUpdate.files[bestVideoPath] = {
...entry,
movieMatch: ensuredMedia.metadata
? {
id: ensuredMedia.metadata.id ?? null,
title:
ensuredMedia.metadata.title ||
ensuredMedia.metadata.matched_title ||
displayName,
year: ensuredMedia.metadata.release_date
? Number(
ensuredMedia.metadata.release_date.slice(0, 4)
)
: ensuredMedia.metadata.matched_year || null,
poster: ensuredMedia.metadata.poster_path || null,
backdrop: ensuredMedia.metadata.backdrop_path || null,
cacheKey: ensuredMedia.cacheKey || null,
matchedAt: Date.now()
}
: entry.movieMatch
};
const movieType = determineMediaType({
tracker: torrent.announce?.[0] || null,
movieMatch: ensuredMedia.metadata,
seriesEpisode: seriesEpisodes[bestVideoPath] || null,
categories: null,
relPath: bestVideoPath
});
perFileMetadata[bestVideoPath] = {
...(perFileMetadata[bestVideoPath] || {}),
type: movieType
};
infoUpdate.files[bestVideoPath].type = movieType;
}
}
upsertInfoFile(entry.savePath, infoUpdate);
broadcastFileUpdate(rootFolder);
// Torrent tamamlandığında disk space bilgisini güncelle
broadcastDiskSpace();
// Medya tespiti tamamlandığında özel bildirim gönder
if (Object.keys(seriesEpisodes).length > 0 || infoUpdate.primaryMediaInfo) {
if (wss) {
const data = JSON.stringify({
type: "mediaDetected",
rootFolder,
hasSeriesEpisodes: Object.keys(seriesEpisodes).length > 0,
hasMovieMatch: !!infoUpdate.primaryMediaInfo
});
wss.clients.forEach((c) => c.readyState === 1 && c.send(data));
}
}
if (bestVideoPath && infoUpdate.files && infoUpdate.files[bestVideoPath]) {
const rootType = determineMediaType({
tracker: torrent.announce?.[0] || null,
movieMatch: infoUpdate.files[bestVideoPath].movieMatch || null,
seriesEpisode: seriesEpisodes[bestVideoPath] || null,
categories: null,
relPath: bestVideoPath
});
infoUpdate.type = rootType;
infoUpdate.files[bestVideoPath].type =
infoUpdate.files[bestVideoPath].type || rootType;
}
broadcastSnapshot();
}
// Auth router ve middleware createAuth ile yüklendi
// --- Güvenli medya URL'i (TV için) ---
// Dönen URL segmentleri ayrı ayrı encode eder, slash'ları korur ve tam hostlu URL döner
app.get("/api/media-url", requireAuth, (req, res) => {
const filePath = req.query.path;
if (!filePath) return res.status(400).json({ error: "path parametresi gerekli" });
// TTL saniye olarak (default 3600 = 1 saat). Min 60s, max 72h
const ttl = Math.min(Math.max(Number(req.query.ttl) || 3600, 60), 72 * 3600);
// Medya token oluştur
const mediaToken = issueMediaToken(filePath, ttl);
// Her path segmentini ayrı encode et (slash korunur)
const encodedPath = String(filePath)
.split(/[\\/]/)
.filter(Boolean)
.map((s) => encodeURIComponent(s))
.join("/");
const host = req.get("host") || "localhost";
const protocol = req.protocol || (req.secure ? "https" : "http");
const absoluteUrl = `${protocol}://${host}/media/${encodedPath}?token=${mediaToken}`;
console.log("Generated media URL:", { original: filePath, url: absoluteUrl, ttl });
res.json({ url: absoluteUrl, token: mediaToken, expiresIn: ttl });
});
// --- 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();
wireTorrent(torrent, {
savePath,
added,
respond: (payload) => res.json(payload)
});
} 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ı");
// Cache kontrolü için dosya değişim zamanını ekle
const stats = fs.statSync(fullPath);
const lastModified = stats.mtime.getTime();
// Eğer client If-Modified-Since header gönderdiyse kontrol et
const ifModifiedSince = req.headers['if-modified-since'];
if (ifModifiedSince) {
const clientTime = new Date(ifModifiedSince).getTime();
if (clientTime >= lastModified) {
return res.status(304).end(); // Not Modified
}
}
// Cache-Control header'larını ayarla
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.setHeader('Last-Modified', new Date(lastModified).toUTCString());
res.sendFile(fullPath);
});
app.get("/yt-data/:path(*)", requireAuth, (req, res) => {
const relPath = req.params.path || "";
const fullPath = resolveYoutubeDataAbsolute(relPath);
if (!fullPath) return res.status(400).send("Geçersiz yt data yolu");
if (!fs.existsSync(fullPath)) return res.status(404).send("Dosya bulunamadı");
res.sendFile(fullPath);
});
app.get("/tv-data/:path(*)", requireAuth, (req, res) => {
const relPath = req.params.path || "";
const fullPath = resolveTvDataAbsolute(relPath);
if (!fullPath) return res.status(400).send("Geçersiz tv data yolu");
if (!fs.existsSync(fullPath)) return res.status(404).send("Dosya bulunamadı");
// Cache kontrolü için dosya değişim zamanını ekle
const stats = fs.statSync(fullPath);
const lastModified = stats.mtime.getTime();
// Eğer client If-Modified-Since header gönderdiyse kontrol et
const ifModifiedSince = req.headers['if-modified-since'];
if (ifModifiedSince) {
const clientTime = new Date(ifModifiedSince).getTime();
if (clientTime >= lastModified) {
return res.status(304).end(); // Not Modified
}
}
// Cache-Control header'larını ayarla
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.setHeader('Last-Modified', new Date(lastModified).toUTCString());
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) {
const job = youtubeJobs.get(req.params.hash);
if (!job) return res.status(404).json({ error: "torrent bulunamadı" });
const targetIndex = Number(req.params.index) || 0;
if (!job.files?.[targetIndex]) {
return res
.status(400)
.json({ error: "Geçerli bir video dosyası bulunamadı" });
}
job.selectedIndex = targetIndex;
return res.json({ ok: true, selectedIndex: targetIndex });
}
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) {
const ytRemoved = removeYoutubeJob(req.params.hash, { removeFiles: true });
if (ytRemoved) {
return res.json({ ok: true, filesRemoved: true });
}
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
});
});
});
function getPieceCount(torrent) {
if (!torrent) return 0;
if (Array.isArray(torrent.pieces)) return torrent.pieces.length;
const pieces = torrent.pieces;
if (pieces && typeof pieces.length === "number") return pieces.length;
return 0;
}
function pauseTorrentEntry(entry) {
const torrent = entry?.torrent;
if (!torrent || torrent._destroyed || entry.paused) return false;
entry.previousSelection = entry.selectedIndex;
const pieceCount = getPieceCount(torrent);
if (pieceCount > 0 && typeof torrent.deselect === "function") {
try {
torrent.deselect(0, pieceCount - 1, 0);
} catch (err) {
console.warn("Torrent deselect failed during pause:", err.message);
}
}
if (Array.isArray(torrent.files)) {
for (const file of torrent.files) {
if (file && typeof file.deselect === "function") {
try {
file.deselect();
} catch (err) {
console.warn(
`File deselect failed during pause (${torrent.infoHash}):`,
err.message
);
}
}
}
}
if (typeof torrent.pause === "function") {
try {
torrent.pause();
} catch (err) {
console.warn("Torrent pause method failed:", err.message);
}
}
entry.paused = true;
entry.pausedAt = Date.now();
return true;
}
function resumeTorrentEntry(entry) {
const torrent = entry?.torrent;
if (!torrent || torrent._destroyed || !entry.paused) return false;
const pieceCount = getPieceCount(torrent);
if (pieceCount > 0 && typeof torrent.select === "function") {
try {
torrent.select(0, pieceCount - 1, 0);
} catch (err) {
console.warn("Torrent select failed during resume:", err.message);
}
}
if (Array.isArray(torrent.files)) {
const preferredIndex =
entry.previousSelection !== undefined
? entry.previousSelection
: entry.selectedIndex ?? 0;
const targetFile =
torrent.files[preferredIndex] || torrent.files[0] || null;
if (targetFile && typeof targetFile.select === "function") {
try {
targetFile.select();
} catch (err) {
console.warn(
`File select failed during resume (${torrent.infoHash}):`,
err.message
);
}
}
}
if (typeof torrent.resume === "function") {
try {
torrent.resume();
} catch (err) {
console.warn("Torrent resume method failed:", err.message);
}
}
entry.paused = false;
delete entry.pausedAt;
return true;
}
// --- Tüm torrentleri durdur/devam ettir ---
app.post("/api/torrents/toggle-all", requireAuth, (req, res) => {
try {
const { action } = req.body; // 'pause' veya 'resume'
if (!action || (action !== 'pause' && action !== 'resume')) {
return res.status(400).json({ error: "action 'pause' veya 'resume' olmalı" });
}
let updatedCount = 0;
const pausedTorrents = new Set();
for (const [infoHash, entry] of torrents.entries()) {
if (!entry?.torrent || entry.torrent._destroyed) continue;
try {
const changed =
action === "pause"
? pauseTorrentEntry(entry)
: resumeTorrentEntry(entry);
if (changed) updatedCount++;
if (entry.paused) pausedTorrents.add(infoHash);
} catch (err) {
console.warn(
`⚠️ Torrent ${infoHash} ${action} işleminde hata:`,
err.message
);
}
}
global.pausedTorrents = pausedTorrents;
broadcastSnapshot();
res.json({
ok: true,
action,
updatedCount,
totalCount: torrents.size
});
} catch (err) {
console.error("❌ Toggle all torrents error:", err.message);
res.status(500).json({ error: err.message });
}
});
// --- Tek torrent'i durdur/devam ettir ---
app.post("/api/torrents/:hash/toggle", requireAuth, (req, res) => {
try {
const { action } = req.body; // 'pause' veya 'resume'
const infoHash = req.params.hash;
if (!action || (action !== 'pause' && action !== 'resume')) {
return res.status(400).json({ error: "action 'pause' veya 'resume' olmalı" });
}
const entry = torrents.get(infoHash);
if (!entry || !entry.torrent || entry.torrent._destroyed) {
if (youtubeJobs.has(infoHash)) {
return res
.status(400)
.json({ error: "YouTube indirmeleri duraklatılamaz" });
}
return res.status(404).json({ error: "torrent bulunamadı" });
}
const changed =
action === "pause"
? pauseTorrentEntry(entry)
: resumeTorrentEntry(entry);
if (!changed) {
const message =
action === "pause"
? "Torrent zaten durdurulmuş"
: "Torrent zaten devam ediyor";
return res.json({
ok: true,
action,
infoHash,
paused: entry.paused,
message
});
}
const pausedTorrents = new Set();
for (const [hash, item] of torrents.entries()) {
if (item?.paused) pausedTorrents.add(hash);
}
global.pausedTorrents = pausedTorrents;
broadcastSnapshot();
res.json({
ok: true,
action,
infoHash,
paused: entry.paused
});
} catch (err) {
console.error("❌ Toggle single torrent error:", err.message);
res.status(500).json({ error: err.message });
}
});
// --- GENEL MEDYA SUNUMU (🆕 resimler + videolar) ---
app.get("/media/:path(*)", requireAuth, (req, res) => {
// URL'deki encode edilmiş karakterleri decode et
let relPath = req.params.path || "";
try {
relPath = decodeURIComponent(relPath);
} catch (err) {
console.warn("Failed to decode media path:", relPath, err.message);
}
// sanitizeRelative sadece baştaki slash'ları temizler; buradan sonra ekstra kontrol yapıyoruz
const safeRel = sanitizeRelative(relPath);
if (!safeRel) {
console.error("Invalid media path after sanitize:", relPath);
return res.status(400).send("Invalid path");
}
const fullPath = path.join(DOWNLOAD_DIR, safeRel);
if (!fs.existsSync(fullPath)) {
console.error("File not found:", 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;
// CORS headers ekle
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Range, Accept-Ranges, Content-Type");
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üne .trash flag'i ekleme ---
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 mediaFlags = { movies: false, tv: false };
let stats = null;
try {
stats = fs.statSync(fullPath);
} catch (err) {
const message = err?.message || String(err);
console.warn(`⚠️ Silme işlemi 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 {
const isDirectory = stats.isDirectory();
const relWithinRoot = safePath.split(/[\\/]/).slice(1).join("/");
let trashEntry = null;
if (folderId && rootDir) {
const infoBeforeDelete = readInfoForRoot(folderId);
mediaFlags = detectMediaFlagsForPath(
infoBeforeDelete,
relWithinRoot,
isDirectory
);
} else {
mediaFlags = { movies: false, tv: false };
}
if (folderId && rootDir) {
trashEntry = addTrashEntry(folderId, {
path: relWithinRoot,
originalPath: safePath,
isDirectory,
deletedAt: Date.now(),
type: isDirectory
? "inode/directory"
: mime.lookup(fullPath) || "application/octet-stream",
mediaFlags: { ...mediaFlags }
});
if (isDirectory) {
pruneInfoForDirectory(folderId, relWithinRoot);
} else {
pruneInfoEntry(folderId, relWithinRoot);
removeSeriesEpisode(folderId, relWithinRoot);
}
}
if (isDirectory) {
console.log(`🗑️ Klasör çöpe taşındı (işaretlendi): ${safePath}`);
} else {
console.log(`🗑️ Dosya çöpe taşındı (işaretlendi): ${fullPath}`);
removeThumbnailsForPath(safePath);
}
if (!folderId) {
// Kök klasöre ait olmayan dosyaları doğrudan sil
if (fs.existsSync(fullPath)) {
fs.rmSync(fullPath, { recursive: true, force: true });
}
removeThumbnailsForPath(safePath);
}
if (folderId) {
broadcastFileUpdate(folderId);
trashStateCache.delete(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();
// Torrent silindiğinde disk space bilgisini güncelle
broadcastDiskSpace();
});
} else {
broadcastSnapshot();
}
} else {
broadcastSnapshot();
}
if (
folderId &&
(mediaFlags.movies || mediaFlags.tv)
) {
queueMediaRescan({
movies: mediaFlags.movies,
tv: mediaFlags.tv,
reason: "trash-add"
});
}
res.json({ ok: true, filesRemoved: true });
} catch (err) {
console.error("❌ Dosya silinemedi:", err.message);
res.status(500).json({ error: err.message });
}
});
// --- 🚚 Dosya veya klasörü hedef klasöre taşıma ---
app.post("/api/file/move", requireAuth, (req, res) => {
try {
const { sourcePath, targetDirectory } = req.body || {};
if (!sourcePath) {
return res.status(400).json({ error: "sourcePath gerekli" });
}
const normalizedSource = normalizeTrashPath(sourcePath);
if (!normalizedSource) {
return res.status(400).json({ error: "Geçersiz sourcePath" });
}
const sourceFullPath = path.join(DOWNLOAD_DIR, normalizedSource);
if (!fs.existsSync(sourceFullPath)) {
return res.status(404).json({ error: "Kaynak öğe bulunamadı" });
}
const sourceStats = fs.statSync(sourceFullPath);
const isDirectory = sourceStats.isDirectory();
const normalizedTargetDir = targetDirectory
? normalizeTrashPath(targetDirectory)
: "";
if (normalizedTargetDir) {
const targetDirFullPath = path.join(DOWNLOAD_DIR, normalizedTargetDir);
if (!fs.existsSync(targetDirFullPath)) {
return res.status(404).json({ error: "Hedef klasör bulunamadı" });
}
}
const posixPath = path.posix;
const sourceName = posixPath.basename(normalizedSource);
const newRelativePath = normalizedTargetDir
? posixPath.join(normalizedTargetDir, sourceName)
: sourceName;
if (newRelativePath === normalizedSource) {
return res.json({ success: true, unchanged: true });
}
if (
isDirectory &&
(newRelativePath === normalizedSource ||
newRelativePath.startsWith(`${normalizedSource}/`))
) {
return res
.status(400)
.json({ error: "Bir klasörü kendi içine taşıyamazsın." });
}
const newFullPath = path.join(DOWNLOAD_DIR, newRelativePath);
if (fs.existsSync(newFullPath)) {
return res
.status(409)
.json({ error: "Hedef konumda aynı isimde bir öğe zaten var" });
}
const destinationParent = path.dirname(newFullPath);
if (!fs.existsSync(destinationParent)) {
fs.mkdirSync(destinationParent, { recursive: true });
}
const sourceRoot = rootFromRelPath(normalizedSource);
const destRoot = rootFromRelPath(newRelativePath);
const sourceSegments = relPathToSegments(normalizedSource);
const destSegments = relPathToSegments(newRelativePath);
const sourceRelWithinRoot = sourceSegments.slice(1).join("/");
const destRelWithinRoot = destSegments.slice(1).join("/");
if (sourceRoot && !sourceRelWithinRoot) {
return res
.status(400)
.json({ error: "Kök klasör bu yöntemle taşınamaz" });
}
fs.renameSync(sourceFullPath, newFullPath);
const sameRoot =
sourceRoot && destRoot && sourceRoot === destRoot && sourceRoot !== null;
const movedAcrossRoots =
sourceRoot && destRoot && sourceRoot !== destRoot && sourceRoot !== null;
if (sameRoot) {
renameInfoPaths(sourceRoot, sourceRelWithinRoot, destRelWithinRoot);
renameSeriesDataPaths(sourceRoot, sourceRelWithinRoot, destRelWithinRoot);
renameTrashEntries(sourceRoot, sourceRelWithinRoot, destRelWithinRoot);
if (isDirectory) {
removeThumbnailsForDirectory(sourceRoot, sourceRelWithinRoot);
} else {
removeThumbnailsForPath(normalizedSource);
}
trashStateCache.delete(sourceRoot);
} else {
if (movedAcrossRoots) {
moveInfoDataBetweenRoots(
sourceRoot,
destRoot,
sourceRelWithinRoot,
destRelWithinRoot,
isDirectory
);
if (isDirectory) {
removeThumbnailsForDirectory(sourceRoot, sourceRelWithinRoot);
} else {
removeThumbnailsForPath(normalizedSource);
}
if (sourceRoot) trashStateCache.delete(sourceRoot);
if (destRoot) trashStateCache.delete(destRoot);
} else if (sourceRoot) {
if (isDirectory) {
pruneInfoForDirectory(sourceRoot, sourceRelWithinRoot);
removeThumbnailsForDirectory(sourceRoot, sourceRelWithinRoot);
} else {
pruneInfoEntry(sourceRoot, sourceRelWithinRoot);
removeThumbnailsForPath(normalizedSource);
}
trashStateCache.delete(sourceRoot);
}
}
if (sourceRoot) {
broadcastFileUpdate(sourceRoot);
}
if (destRoot && destRoot !== sourceRoot) {
broadcastFileUpdate(destRoot);
}
res.json({
success: true,
newPath: newRelativePath,
rootFolder: destRoot || null,
isDirectory,
movedAcrossRoots: Boolean(movedAcrossRoots)
});
} catch (err) {
console.error("❌ File move error:", err);
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()) {
const safeRel = sanitizeRelative(rel);
if (!safeRel) continue;
const rootFolder = rootFromRelPath(safeRel);
const relWithinRoot = safeRel.split(/[\\/]/).slice(1).join("/");
// 🗑️ Çöpte işaretli klasörleri atla
if (isPathTrashed(rootFolder, relWithinRoot, true)) continue;
const dirInfo = getInfo(safeRel) || {};
const added = dirInfo.added ?? dirInfo.createdAt ?? null;
const completedAt = dirInfo.completedAt ?? null;
const tracker = dirInfo.tracker ?? null;
const torrentName = dirInfo.name ?? null;
const infoHash = dirInfo.infoHash ?? null;
const baseName = safeRel.split(/[\\/]/).pop();
const isRoot = !relWithinRoot;
const displayName =
isRoot && tracker === "youtube" && torrentName
? torrentName
: baseName;
result.push({
name: safeRel,
displayName,
size: 0,
type: "inode/directory",
isDirectory: true,
rootFolder,
added,
completedAt,
tracker,
torrentName,
infoHash,
mediaCategory: dirInfo.type || null,
extension: null,
mediaInfo: null,
primaryVideoPath: null,
primaryMediaInfo: null,
movieMatch: null,
seriesEpisode: null,
thumbnail: null,
});
result = result.concat(walk(full));
} else {
if (entry.name.toLowerCase() === INFO_FILENAME) continue;
const safeRel = sanitizeRelative(rel);
const rootFolder = rootFromRelPath(safeRel);
const relWithinRoot = safeRel.split(/[\\/]/).slice(1).join("/");
// 🗑️ Çöpte işaretli dosyaları atla
if (isPathTrashed(rootFolder, relWithinRoot, false)) continue;
const size = fs.statSync(full).size;
const type = mime.lookup(full) || "application/octet-stream";
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 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 fileMeta = relWithinRoot
? info.files?.[relWithinRoot] || null
: null;
const extensionForFile = fileMeta?.extension || path.extname(entry.name).replace(/^\./, "").toLowerCase() || null;
const mediaInfoForFile = fileMeta?.mediaInfo || null;
const seriesEpisodeInfo = relWithinRoot
? info.seriesEpisodes?.[relWithinRoot] || null
: null;
let mediaCategory = fileMeta?.type || null;
if (!mediaCategory) {
const canInheritFromInfo = !relWithinRoot || isVideo;
if (canInheritFromInfo && info.type) {
mediaCategory = info.type;
}
}
const isPrimaryVideo =
!!info.primaryVideoPath && info.primaryVideoPath === relWithinRoot;
const displayName = entry.name;
result.push({
name: safeRel,
displayName,
size,
type,
url,
thumbnail: thumb,
rootFolder,
added,
completedAt,
tracker,
torrentName,
infoHash,
mediaCategory,
extension: extensionForFile,
mediaInfo: mediaInfoForFile,
primaryVideoPath: info.primaryVideoPath || null,
primaryMediaInfo: info.primaryMediaInfo || null,
movieMatch: isPrimaryVideo ? info.movieMatch || null : null,
seriesEpisode: seriesEpisodeInfo
});
}
}
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 });
}
});
// --- 🗑️ Çöp listesi API (.trash flag sistemi) ---
app.get("/api/trash", requireAuth, (req, res) => {
try {
const result = [];
const roots = fs
.readdirSync(DOWNLOAD_DIR, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory());
for (const dirent of roots) {
const rootFolder = sanitizeRelative(dirent.name);
if (!rootFolder) continue;
const state = getTrashStateForRoot(rootFolder);
if (!state || !Array.isArray(state.registry?.items)) continue;
const info = readInfoForRoot(rootFolder) || {};
for (const item of state.registry.items) {
const relWithinRoot = normalizeTrashPath(item.path);
const displayPath = relWithinRoot
? `${rootFolder}/${relWithinRoot}`
: rootFolder;
const fullPath = path.join(DOWNLOAD_DIR, displayPath);
if (!fs.existsSync(fullPath)) {
removeTrashEntry(rootFolder, relWithinRoot);
continue;
}
let stat = null;
try {
stat = fs.statSync(fullPath);
} catch (err) {
console.warn(
`⚠️ Çöp öğesi stat okunamadı (${fullPath}): ${err.message}`
);
}
const isDirectory = item.isDirectory || stat?.isDirectory() || false;
const type = isDirectory
? "inode/directory"
: mime.lookup(fullPath) || item.type || "application/octet-stream";
const size = stat?.size ?? 0;
let thumbnail = null;
let mediaInfo = null;
if (!isDirectory) {
const isVideo = String(type).startsWith("video/");
const isImage = String(type).startsWith("image/");
if (isVideo) {
const { relThumb, absThumb } = getVideoThumbnailPaths(displayPath);
if (fs.existsSync(absThumb)) {
thumbnail = thumbnailUrl(relThumb);
}
} else if (isImage) {
const { relThumb, absThumb } = getImageThumbnailPaths(displayPath);
if (fs.existsSync(absThumb)) {
thumbnail = thumbnailUrl(relThumb);
}
}
const metaKey = relWithinRoot || null;
if (metaKey && info.files && info.files[metaKey]) {
mediaInfo = info.files[metaKey].mediaInfo || null;
}
}
result.push({
name: displayPath,
trashName: displayPath,
size,
type,
isDirectory,
thumbnail,
mediaInfo,
movedAt: Number(item.deletedAt) || Date.now(),
originalPath: displayPath,
folderId: rootFolder
});
}
}
result.sort((a, b) => (b.movedAt || 0) - (a.movedAt || 0));
res.json(result);
} catch (err) {
console.error("🗑️ Trash API error:", err);
res.status(500).json({ error: err.message });
}
});
// --- 🗑️ Çöpten geri yükleme API (.trash flag sistemi) ---
app.post("/api/trash/restore", requireAuth, (req, res) => {
try {
const { trashName } = req.body;
if (!trashName) {
return res.status(400).json({ error: "trashName gerekli" });
}
const safeName = sanitizeRelative(trashName);
const segments = safeName.split(/[\\/]/).filter(Boolean);
if (!segments.length) {
return res.status(400).json({ error: "Geçersiz trashName" });
}
const rootFolder = segments[0];
const relWithinRoot = segments.slice(1).join("/");
const removed = removeTrashEntry(rootFolder, relWithinRoot);
if (!removed) {
return res.status(404).json({ error: "Çöp öğesi bulunamadı" });
}
const mediaFlags = inferMediaFlagsFromTrashEntry(removed);
console.log(`♻️ Öğe geri yüklendi: ${safeName}`);
broadcastFileUpdate(rootFolder);
if (mediaFlags.movies || mediaFlags.tv) {
queueMediaRescan({
movies: mediaFlags.movies,
tv: mediaFlags.tv,
reason: "trash-restore"
});
}
res.json({
success: true,
message: "Öğe başarıyla geri yüklendi",
folderId: rootFolder
});
} catch (err) {
console.error("❌ Restore error:", err);
res.status(500).json({ error: err.message });
}
});
// --- 🗑️ Çöpü tamamen silme API (.trash flag sistemi) ---
app.delete("/api/trash", requireAuth, (req, res) => {
try {
const trashName =
req.body?.trashName || req.query?.trashName || req.params?.trashName;
if (!trashName) {
return res.status(400).json({ error: "trashName gerekli" });
}
const safeName = sanitizeRelative(trashName);
const segments = safeName.split(/[\\/]/).filter(Boolean);
if (!segments.length) {
return res.status(400).json({ error: "Geçersiz trashName" });
}
const rootFolder = segments[0];
const relWithinRoot = segments.slice(1).join("/");
const removed = removeTrashEntry(rootFolder, relWithinRoot);
if (!removed) {
return res.status(404).json({ error: "Çöp öğesi bulunamadı" });
}
const fullPath = path.join(DOWNLOAD_DIR, safeName);
if (fs.existsSync(fullPath)) {
try {
fs.rmSync(fullPath, { recursive: true, force: true });
} catch (err) {
console.warn(`⚠️ Çöp öğesi silinemedi (${fullPath}): ${err.message}`);
}
}
if (!relWithinRoot) {
purgeRootFolder(rootFolder);
} else if (removed.isDirectory) {
pruneInfoForDirectory(rootFolder, relWithinRoot);
} else {
pruneInfoEntry(rootFolder, relWithinRoot);
removeThumbnailsForPath(safeName);
removeSeriesEpisode(rootFolder, relWithinRoot);
}
console.log(`🗑️ Öğe kalıcı olarak silindi: ${safeName}`);
broadcastFileUpdate(rootFolder);
broadcastDiskSpace();
res.json({
success: true,
message: "Öğe tamamen silindi"
});
} catch (err) {
console.error("❌ Delete trash 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 key = dirent.name;
const paths = movieDataPathsByKey(key);
if (!fs.existsSync(paths.metadata)) return null;
try {
const metadata = JSON.parse(
fs.readFileSync(paths.metadata, "utf-8")
);
if (!isTmdbMetadata(metadata)) {
try {
fs.rmSync(paths.dir, { recursive: true, force: true });
} catch (err) {
console.warn(
`⚠️ Movie metadata temizlenemedi (${paths.dir}): ${err.message}`
);
}
return null;
}
const dupe = metadata._dupe || {};
const rootFolder = dupe.folder || key;
const videoPath = dupe.videoPath || metadata.videoPath || null;
const encodedKey = key
.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 cacheKey = paths.key;
return {
folder: rootFolder,
cacheKey,
id:
metadata.id ??
`${rootFolder}:${videoPath || "unknown"}:${cacheKey || "cache"}`,
title: metadata.title || metadata.matched_title || rootFolder,
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/${encodedKey}/poster.jpg`
: null,
backdrop: backdropExists
? `/movie-data/${encodedKey}/backdrop.jpg`
: null,
videoPath,
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 });
}
});
async function rebuildMovieMetadata({ clearCache = false, resetSeriesData = false } = {}) {
if (!TMDB_API_KEY) {
throw new Error("TMDB API key tanımlı değil.");
}
if (clearCache && fs.existsSync(MOVIE_DATA_ROOT)) {
try {
fs.rmSync(MOVIE_DATA_ROOT, { recursive: true, force: true });
console.log("🧹 Movie cache temizlendi.");
} catch (err) {
console.warn(
`⚠️ Movie cache temizlenemedi (${MOVIE_DATA_ROOT}): ${err.message}`
);
}
}
fs.mkdirSync(MOVIE_DATA_ROOT, { recursive: true });
const dirEntries = fs
.readdirSync(DOWNLOAD_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory());
const processed = [];
for (const dirent of dirEntries) {
const folder = sanitizeRelative(dirent.name);
if (!folder) continue;
const rootDir = path.join(DOWNLOAD_DIR, folder);
if (!fs.existsSync(rootDir)) continue;
try {
const info = readInfoForRoot(folder) || {};
const infoFiles = info.files || {};
const filesUpdate = { ...infoFiles };
const videoEntries = enumerateVideoFiles(folder);
const videoSet = new Set(videoEntries.map((item) => item.relPath));
if (!videoEntries.length) {
removeMovieData(folder);
if (resetSeriesData) {
removeSeriesData(folder);
}
const update = {
primaryVideoPath: null,
primaryMediaInfo: null,
movieMatch: null,
files: { ...filesUpdate }
};
for (const value of Object.values(update.files)) {
if (value?.seriesMatch) delete value.seriesMatch;
}
// Clear movieMatch for legacy entries
for (const key of Object.keys(update.files)) {
if (update.files[key]?.movieMatch) {
delete update.files[key].movieMatch;
}
}
upsertInfoFile(rootDir, update);
console.log(
` Movie taraması atlandı (video bulunamadı): ${folder}`
);
processed.push(folder);
continue;
}
removeMovieData(folder);
if (resetSeriesData) {
removeSeriesData(folder);
}
const matches = [];
for (const { relPath, size } of videoEntries) {
const absVideo = path.join(rootDir, relPath);
let mediaInfo = filesUpdate[relPath]?.mediaInfo || null;
if (!mediaInfo && fs.existsSync(absVideo)) {
try {
mediaInfo = await extractMediaInfo(absVideo);
} catch (err) {
console.warn(
`⚠️ Media info alınamadı (${absVideo}): ${err?.message || err}`
);
}
}
const ensured = await ensureMovieData(
folder,
path.basename(relPath),
relPath,
mediaInfo
);
if (ensured?.mediaInfo) {
mediaInfo = ensured.mediaInfo;
}
const fileEntry = {
...(filesUpdate[relPath] || {}),
size: size ?? filesUpdate[relPath]?.size ?? null,
mediaInfo: mediaInfo || null
};
if (ensured?.metadata) {
const meta = ensured.metadata;
const releaseYear = meta.release_date
? Number(meta.release_date.slice(0, 4))
: meta.matched_year || null;
fileEntry.movieMatch = {
id: meta.id ?? null,
title: meta.title || meta.matched_title || path.basename(relPath),
year: releaseYear,
poster: meta.poster_path || null,
backdrop: meta.backdrop_path || null,
cacheKey: ensured.cacheKey,
matchedAt: Date.now()
};
matches.push({ relPath, match: fileEntry.movieMatch });
} else if (fileEntry.movieMatch) {
delete fileEntry.movieMatch;
}
if (ensured?.show && ensured?.episode) {
const episode = ensured.episode;
const show = ensured.show;
const season = ensured.season;
fileEntry.seriesMatch = {
id: show?.id ?? null,
title: show?.title || seriesInfo.title,
season: season?.seasonNumber ?? seriesInfo.season,
episode: episode?.episodeNumber ?? seriesInfo.episode,
code: episode?.code || seriesInfo.key,
poster: show?.poster || null,
backdrop: show?.backdrop || null,
seasonPoster: season?.poster || null,
aired: episode?.aired || null,
runtime: episode?.runtime || null,
tvdbEpisodeId: episode?.tvdbEpisodeId || null,
cacheKey: ensured.cacheKey || null,
matchedAt: Date.now()
};
} else if (fileEntry.seriesMatch) {
delete fileEntry.seriesMatch;
}
filesUpdate[relPath] = fileEntry;
}
// Temizlenmiş video listesinde yer almayanların film eşleşmesini temizle
for (const key of Object.keys(filesUpdate)) {
if (videoSet.has(key)) continue;
if (filesUpdate[key]?.movieMatch) {
delete filesUpdate[key].movieMatch;
}
if (filesUpdate[key]?.seriesMatch) {
delete filesUpdate[key].seriesMatch;
}
}
const update = {
files: filesUpdate
};
if (videoEntries.length) {
const primaryRel = videoEntries[0].relPath;
update.primaryVideoPath = primaryRel;
update.primaryMediaInfo = filesUpdate[primaryRel]?.mediaInfo || null;
const primaryMatch =
matches.find((item) => item.relPath === primaryRel)?.match ||
matches[0]?.match ||
null;
update.movieMatch = primaryMatch || null;
} else {
update.primaryVideoPath = null;
update.primaryMediaInfo = null;
update.movieMatch = null;
}
upsertInfoFile(rootDir, update);
processed.push(folder);
} catch (err) {
console.error(
`❌ Movie metadata yeniden oluşturulamadı (${folder}):`,
err?.message || err
);
}
}
return processed;
}
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 processed = await rebuildMovieMetadata();
res.json({ ok: true, processed });
} catch (err) {
console.error("🎬 Movies refresh error:", err);
res.status(500).json({ error: err.message });
}
});
app.post("/api/movies/rescan", requireAuth, async (req, res) => {
if (!TMDB_API_KEY) {
return res.status(400).json({ error: "TMDB API key tanımlı değil." });
}
try {
const processed = await rebuildMovieMetadata({ clearCache: true });
res.json({ ok: true, processed });
} catch (err) {
console.error("🎬 Movies rescan error:", err);
res.status(500).json({ error: err.message });
}
});
app.post("/api/youtube/download", requireAuth, async (req, res) => {
try {
const rawUrl = req.body?.url;
const job = startYoutubeDownload(rawUrl);
if (!job) {
return res
.status(400)
.json({ ok: false, error: "Geçerli bir YouTube URL'si gerekli." });
}
res.json({ ok: true, jobId: job.id });
} catch (err) {
res.status(500).json({
ok: false,
error: err?.message || "YouTube indirimi başarısız oldu."
});
}
});
// --- Cookie yükleme (YouTube için) ---
app.post(
"/api/cookies/upload",
requireAuth,
upload.single("cookies"),
async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: "cookies dosyası gerekli." });
}
const target = COOKIES_FILE;
try {
fs.renameSync(req.file.path, target);
} catch (err) {
// fallback: kopyala, sonra geçici dosyayı sil
fs.copyFileSync(req.file.path, target);
fs.rmSync(req.file.path, { force: true });
}
res.json({ ok: true });
} catch (err) {
console.error("🍪 Cookie yükleme hatası:", err?.message || err);
res.status(500).json({ error: "Cookie yüklenemedi." });
}
}
);
// --- 📺 TV dizileri listesi ---
app.get("/api/tvshows", requireAuth, (req, res) => {
try {
if (!fs.existsSync(TV_DATA_ROOT)) {
return res.json([]);
}
const dirEntries = fs
.readdirSync(TV_DATA_ROOT, { withFileTypes: true })
.filter((d) => d.isDirectory());
const aggregated = new Map();
const mergeEpisode = (existing, incoming) => {
if (!existing) return incoming;
const merged = {
...existing,
...incoming
};
if (existing.still && !incoming.still) merged.still = existing.still;
if (!existing.still && incoming.still) merged.still = incoming.still;
if (existing.mediaInfo && !incoming.mediaInfo)
merged.mediaInfo = existing.mediaInfo;
if (!existing.mediaInfo && incoming.mediaInfo)
merged.mediaInfo = incoming.mediaInfo;
if (existing.overview && !incoming.overview)
merged.overview = existing.overview;
return merged;
};
for (const dirent of dirEntries) {
const key = sanitizeRelative(dirent.name);
if (!key) continue;
const paths = tvSeriesPathsByKey(key);
if (!paths || !fs.existsSync(paths.metadata)) continue;
const { rootFolder } = parseTvSeriesKey(key);
if (!rootFolder) continue;
const infoForFolder = readInfoForRoot(rootFolder) || {};
const infoFiles = infoForFolder.files || {};
const infoEpisodes = infoForFolder.seriesEpisodes || {};
const infoEpisodeIndex = new Map();
for (const [relPath, meta] of Object.entries(infoEpisodes)) {
if (!meta) continue;
const seasonNumber = toFiniteNumber(
meta.season ?? meta.seasonNumber ?? meta.seasonNum
);
const episodeNumber = toFiniteNumber(
meta.episode ?? meta.episodeNumber ?? meta.episodeNum
);
if (!Number.isFinite(seasonNumber) || !Number.isFinite(episodeNumber)) continue;
const normalizedRel = normalizeTrashPath(relPath);
const ext = path.extname(normalizedRel).toLowerCase();
if (!VIDEO_EXTS.includes(ext)) continue;
const absVideo = normalizedRel
? path.join(DOWNLOAD_DIR, rootFolder, normalizedRel)
: null;
if (!absVideo || !fs.existsSync(absVideo)) continue;
infoEpisodeIndex.set(`${seasonNumber}-${episodeNumber}`, {
relPath: normalizedRel,
meta
});
}
let seriesData;
try {
seriesData = JSON.parse(fs.readFileSync(paths.metadata, "utf-8"));
} catch (err) {
console.warn(
`⚠️ series.json okunamadı (${paths.metadata}): ${err.message}`
);
continue;
}
const seasonsObj = seriesData?.seasons || {};
if (!Object.keys(seasonsObj).length) {
removeSeriesData(rootFolder, seriesData.id ?? seriesData.tvdbId ?? null);
continue;
}
let dataChanged = false;
const showId =
seriesData.id ?? seriesData.tvdbId ?? seriesData.slug ?? seriesData.name ?? rootFolder;
const showKey = String(showId).toLowerCase();
const record =
aggregated.get(showKey) ||
(() => {
const base = {
id: seriesData.id ?? seriesData.tvdbId ?? rootFolder,
title: seriesData.name || rootFolder,
overview: seriesData.overview || "",
year: seriesData.year || null,
status: seriesData.status || null,
poster: fs.existsSync(paths.poster)
? encodeTvDataPath(paths.key, "poster.jpg")
: null,
backdrop: fs.existsSync(paths.backdrop)
? encodeTvDataPath(paths.key, "backdrop.jpg")
: null,
genres: new Set(
Array.isArray(seriesData.genres)
? seriesData.genres
.map((g) =>
typeof g === "string" ? g : g?.name || null
)
.filter(Boolean)
: []
),
seasons: new Map(),
primaryFolder: rootFolder,
folders: new Set([rootFolder])
};
aggregated.set(showKey, base);
return base;
})();
record.folders.add(rootFolder);
if (
seriesData.overview &&
seriesData.overview.length > (record.overview?.length || 0)
) {
record.overview = seriesData.overview;
}
if (!record.status && seriesData.status) record.status = seriesData.status;
if (
!record.year ||
(seriesData.year && Number(seriesData.year) < Number(record.year))
) {
record.year = seriesData.year || record.year;
}
if (!record.poster && fs.existsSync(paths.poster)) {
record.poster = encodeTvDataPath(paths.key, "poster.jpg");
}
if (!record.backdrop && fs.existsSync(paths.backdrop)) {
record.backdrop = encodeTvDataPath(paths.key, "backdrop.jpg");
}
if (Array.isArray(seriesData.genres)) {
seriesData.genres
.map((g) => (typeof g === "string" ? g : g?.name || null))
.filter(Boolean)
.forEach((genre) => record.genres.add(genre));
}
for (const [seasonKey, rawSeason] of Object.entries(seasonsObj)) {
if (!rawSeason?.episodes) continue;
const seasonNumber = toFiniteNumber(
rawSeason.seasonNumber ?? rawSeason.number ?? seasonKey
);
if (!Number.isFinite(seasonNumber)) continue;
const seasonPaths = seasonAssetPaths(paths, seasonNumber);
const rawEpisodes = rawSeason.episodes || {};
for (const [episodeKey, rawEpisode] of Object.entries(rawEpisodes)) {
if (!rawEpisode || typeof rawEpisode !== "object") continue;
const relativeFile = (rawEpisode.file || "").replace(/\\/g, "/");
if (relativeFile) {
const absEpisodePath = path.join(
DOWNLOAD_DIR,
rootFolder,
relativeFile
);
if (!fs.existsSync(absEpisodePath)) {
delete rawEpisodes[episodeKey];
dataChanged = true;
}
}
}
if (!Object.keys(rawSeason.episodes || {}).length) {
delete seasonsObj[seasonKey];
dataChanged = true;
continue;
}
let seasonRecord = record.seasons.get(seasonNumber);
if (!seasonRecord) {
seasonRecord = {
seasonNumber,
name: rawSeason.name || `Season ${seasonNumber}`,
overview: rawSeason.overview || "",
poster: rawSeason.poster || null,
tvdbId: rawSeason.tvdbId || null,
slug: rawSeason.slug || null,
episodeCount: rawSeason.episodeCount || null,
episodes: new Map()
};
record.seasons.set(seasonNumber, seasonRecord);
} else {
if (!seasonRecord.name && rawSeason.name)
seasonRecord.name = rawSeason.name;
if (!seasonRecord.overview && rawSeason.overview)
seasonRecord.overview = rawSeason.overview;
if (!seasonRecord.poster && rawSeason.poster)
seasonRecord.poster = rawSeason.poster;
if (!seasonRecord.tvdbId && rawSeason.tvdbId)
seasonRecord.tvdbId = rawSeason.tvdbId;
if (!seasonRecord.slug && rawSeason.slug)
seasonRecord.slug = rawSeason.slug;
if (!seasonRecord.episodeCount && rawSeason.episodeCount)
seasonRecord.episodeCount = rawSeason.episodeCount;
}
if (!seasonRecord.poster && fs.existsSync(seasonPaths.poster)) {
const relPoster = path.relative(paths.dir, seasonPaths.poster);
seasonRecord.poster = encodeTvDataPath(paths.key, relPoster);
}
for (const [episodeKey, rawEpisode] of Object.entries(
rawSeason.episodes
)) {
if (!rawEpisode || typeof rawEpisode !== "object") continue;
const episodeNumber = toFiniteNumber(
rawEpisode.episodeNumber ?? rawEpisode.number ?? episodeKey
);
if (!Number.isFinite(episodeNumber)) continue;
const normalizedEpisode = {
...rawEpisode
};
normalizedEpisode.seasonNumber = seasonNumber;
normalizedEpisode.episodeNumber = episodeNumber;
if (!normalizedEpisode.code) {
normalizedEpisode.code = `S${String(seasonNumber).padStart(
2,
"0"
)}E${String(episodeNumber).padStart(2, "0")}`;
}
const infoEpisode = infoEpisodeIndex.get(`${seasonNumber}-${episodeNumber}`);
if (infoEpisode?.relPath) {
const normalizedRel = infoEpisode.relPath.replace(/^\/+/, "");
const withRoot = `${rootFolder}/${normalizedRel}`.replace(/^\/+/, "");
normalizedEpisode.file = normalizedRel;
normalizedEpisode.videoPath = withRoot;
const fileMeta = infoFiles[normalizedRel];
if (fileMeta?.mediaInfo && !normalizedEpisode.mediaInfo) {
normalizedEpisode.mediaInfo = fileMeta.mediaInfo;
}
if (fileMeta?.size) {
normalizedEpisode.fileSize = Number(fileMeta.size);
}
}
const relativeFile =
normalizedEpisode.file || normalizedEpisode.videoPath || "";
const rawVideoPath = normalizedEpisode.videoPath || relativeFile || "";
let videoPath = rawVideoPath.replace(/\\/g, "/").replace(/^\.\//, "");
if (videoPath) {
const isExternal = /^https?:\/\//i.test(videoPath);
const needsFolderPrefix =
!isExternal &&
!videoPath.startsWith(`${rootFolder}/`) &&
!videoPath.startsWith(`/${rootFolder}/`);
if (needsFolderPrefix) {
videoPath = `${rootFolder}/${videoPath}`.replace(/\\/g, "/");
}
const finalPath = videoPath.replace(/^\/+/, "");
if (finalPath !== rawVideoPath) {
dataChanged = true;
}
normalizedEpisode.videoPath = finalPath;
} else if (relativeFile) {
normalizedEpisode.videoPath = `${rootFolder}/${relativeFile}`
.replace(/\\/g, "/")
.replace(/^\/+/, "");
if (normalizedEpisode.videoPath !== rawVideoPath) {
dataChanged = true;
}
}
if (normalizedEpisode.videoPath && !/^https?:\/\//i.test(normalizedEpisode.videoPath)) {
const ext = path.extname(normalizedEpisode.videoPath).toLowerCase();
if (!VIDEO_EXTS.includes(ext)) {
const absVideo = path.join(DOWNLOAD_DIR, normalizedEpisode.videoPath);
if (
!fs.existsSync(absVideo) ||
!VIDEO_EXTS.includes(path.extname(absVideo).toLowerCase())
) {
normalizedEpisode.videoPath = null;
}
}
if (normalizedEpisode.videoPath) {
const absVideo = path.join(DOWNLOAD_DIR, normalizedEpisode.videoPath);
if (!fs.existsSync(absVideo)) {
normalizedEpisode.videoPath = null;
}
if (fs.existsSync(absVideo)) {
const stats = fs.statSync(absVideo);
normalizedEpisode.fileSize = Number(stats.size);
}
}
}
normalizedEpisode.folder = rootFolder;
const existingEpisode = seasonRecord.episodes.get(episodeNumber);
seasonRecord.episodes.set(
episodeNumber,
mergeEpisode(existingEpisode, normalizedEpisode)
);
}
if (!seasonRecord.episodeCount && seasonRecord.episodes.size) {
seasonRecord.episodeCount = seasonRecord.episodes.size;
}
}
if (dataChanged) {
try {
seriesData.seasons = seasonsObj;
seriesData.updatedAt = Date.now();
fs.writeFileSync(
paths.metadata,
JSON.stringify(seriesData, null, 2),
"utf-8"
);
} catch (err) {
console.warn(
`⚠️ series.json güncellenemedi (${paths.metadata}): ${err.message}`
);
}
}
}
const shows = Array.from(aggregated.values())
.map((record) => {
const seasons = Array.from(record.seasons.values())
.map((season) => {
const episodes = Array.from(season.episodes.values())
.filter((episode) => {
if (!episode?.videoPath) return false;
if (/^https?:\/\//i.test(episode.videoPath)) return true;
const ext = path.extname(episode.videoPath).toLowerCase();
if (!VIDEO_EXTS.includes(ext)) return false;
const absVideo = path.join(DOWNLOAD_DIR, episode.videoPath);
return fs.existsSync(absVideo);
})
.sort((a, b) => a.episodeNumber - b.episodeNumber);
return {
seasonNumber: season.seasonNumber,
name: season.name || `Season ${season.seasonNumber}`,
overview: season.overview || "",
poster: season.poster || null,
tvdbSeasonId: season.tvdbId || null,
slug: season.slug || null,
episodeCount: season.episodeCount || episodes.length,
episodes
};
})
.sort((a, b) => a.seasonNumber - b.seasonNumber);
return {
folder: record.primaryFolder,
id: record.id || record.title,
title: record.title,
overview: record.overview || "",
year: record.year || null,
genres: Array.from(record.genres).filter(Boolean),
status: record.status || null,
poster: record.poster || null,
backdrop: record.backdrop || null,
seasons,
folders: Array.from(record.folders)
};
})
.filter((show) => show.seasons.length > 0);
shows.sort((a, b) => a.title.localeCompare(b.title, "en"));
res.json(shows);
} catch (err) {
console.error("📺 TvShows API error:", err);
res.status(500).json({ error: err.message });
}
});
function collectMusicEntries() {
const entries = [];
const dirEntries = fs
.readdirSync(DOWNLOAD_DIR, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory());
for (const dirent of dirEntries) {
const folder = sanitizeRelative(dirent.name);
if (!folder) continue;
// Klasörün tamamı çöpe taşınmışsa atla
if (isPathTrashed(folder, "", true)) continue;
const info = readInfoForRoot(folder) || {};
const files = info.files || {};
const fileKeys = Object.keys(files);
if (!fileKeys.length) continue;
let targetPath = info.primaryVideoPath;
if (targetPath && files[targetPath]?.type !== "music") {
targetPath = null;
}
if (!targetPath) {
targetPath =
fileKeys.find((key) => files[key]?.type === "music") || fileKeys[0];
}
if (!targetPath) continue;
const fileMeta = files[targetPath];
// Hedef dosya çöpteyse atla
if (isPathTrashed(folder, targetPath, false)) continue;
const mediaType = fileMeta?.type || info.type || null;
if (mediaType !== "music") continue;
const metadataPath = path.join(YT_DATA_ROOT, folder, "metadata.json");
let metadata = null;
if (fs.existsSync(metadataPath)) {
try {
metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
} catch (err) {
console.warn(`⚠️ YT metadata okunamadı (${metadataPath}): ${err.message}`);
}
}
const keysArray = fileKeys;
const fileIndex = Math.max(keysArray.indexOf(targetPath), 0);
const infoHash = info.infoHash || folder;
const title =
info.name || metadata?.title || path.basename(targetPath) || folder;
const thumbnail =
metadata?.thumbnail ||
(metadata ? `/yt-data/${folder}/thumbnail.jpg` : null);
entries.push({
id: `${folder}:${targetPath}`,
folder,
infoHash,
fileIndex,
filePath: targetPath,
title,
added: info.added || info.createdAt || null,
size: fileMeta?.size || 0,
url:
metadata?.url ||
fileMeta?.youtube?.url ||
fileMeta?.youtube?.videoId
? `https://www.youtube.com/watch?v=${fileMeta.youtube.videoId}`
: null,
thumbnail,
categories: metadata?.categories || fileMeta?.categories || null
});
}
entries.sort((a, b) => (b.added || 0) - (a.added || 0));
return entries;
}
app.get("/api/music", requireAuth, (req, res) => {
try {
const entries = collectMusicEntries();
res.json(entries);
} catch (err) {
console.error("🎵 Music API error:", err);
res.status(500).json({ error: err.message });
}
});
async function rebuildTvMetadata({ clearCache = false } = {}) {
if (!TVDB_API_KEY) {
throw new Error("TVDB API erişimi için gerekli anahtar tanımlı değil.");
}
if (clearCache && fs.existsSync(TV_DATA_ROOT)) {
try {
fs.rmSync(TV_DATA_ROOT, { recursive: true, force: true });
console.log("🧹 TV cache temizlendi.");
} catch (err) {
console.warn(
`⚠️ TV cache temizlenemedi (${TV_DATA_ROOT}): ${err.message}`
);
}
}
fs.mkdirSync(TV_DATA_ROOT, { recursive: true });
if (clearCache) {
tvdbSeriesCache.clear();
tvdbEpisodeCache.clear();
tvdbEpisodeDetailCache.clear();
}
const dirEntries = fs
.readdirSync(DOWNLOAD_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory());
const processed = [];
for (const dirent of dirEntries) {
const folder = sanitizeRelative(dirent.name);
if (!folder) continue;
const rootDir = path.join(DOWNLOAD_DIR, folder);
if (!fs.existsSync(rootDir)) continue;
try {
const info = readInfoForRoot(folder) || {};
const infoFiles = info.files || {};
const filesUpdate = { ...infoFiles };
const detected = {};
const walkDir = async (currentDir, relativeBase = "") => {
let entries = [];
try {
entries = fs.readdirSync(currentDir, { withFileTypes: true });
} catch (err) {
console.warn(
`⚠️ Klasör okunamadı (${currentDir}): ${err.message}`
);
return;
}
for (const entry of entries) {
const relPath = relativeBase
? `${relativeBase}/${entry.name}`
: entry.name;
const absPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
if (isPathTrashed(folder, relPath, true)) continue;
await walkDir(absPath, relPath);
continue;
}
if (!entry.isFile()) continue;
if (isPathTrashed(folder, relPath, false)) continue;
if (entry.name.toLowerCase() === INFO_FILENAME) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!VIDEO_EXTS.includes(ext)) continue;
const seriesInfo = parseSeriesInfo(entry.name);
if (!seriesInfo) continue;
const normalizedRel = relPath.replace(/\\/g, "/");
let mediaInfo =
filesUpdate?.[normalizedRel]?.mediaInfo || null;
if (!mediaInfo) {
try {
mediaInfo = await extractMediaInfo(absPath);
} catch (err) {
console.warn(
`⚠️ Media info alınamadı (${absPath}): ${err?.message || err}`
);
}
}
try {
const ensured = await ensureSeriesData(
folder,
normalizedRel,
seriesInfo,
mediaInfo
);
if (ensured?.show && ensured?.episode) {
detected[normalizedRel] = {
season: seriesInfo.season,
episode: seriesInfo.episode,
key: seriesInfo.key,
title: ensured.episode.title || seriesInfo.title,
showId: ensured.show.id || null,
showTitle: ensured.show.title || seriesInfo.title,
seasonName:
ensured.season?.name || `Season ${seriesInfo.season}`,
seasonId: ensured.season?.tvdbSeasonId || null,
seasonPoster: ensured.season?.poster || null,
overview: ensured.episode.overview || "",
aired: ensured.episode.aired || null,
runtime: ensured.episode.runtime || null,
still: ensured.episode.still || null,
episodeId: ensured.episode.tvdbEpisodeId || null,
slug: ensured.episode.slug || null
};
const statSize = (() => {
try {
return fs.statSync(absPath).size;
} catch (err) {
return filesUpdate?.[normalizedRel]?.size ?? null;
}
})();
const fileEntry = {
...(filesUpdate[normalizedRel] || {}),
size: statSize,
mediaInfo: mediaInfo || null,
seriesMatch: {
id: ensured.show.id || null,
title: ensured.show.title || seriesInfo.title,
season: ensured.season?.seasonNumber ?? seriesInfo.season,
episode: ensured.episode.episodeNumber ?? seriesInfo.episode,
code: ensured.episode.code || seriesInfo.key,
poster: ensured.show.poster || null,
backdrop: ensured.show.backdrop || null,
seasonPoster: ensured.season?.poster || null,
aired: ensured.episode.aired || null,
runtime: ensured.episode.runtime || null,
tvdbEpisodeId: ensured.episode.tvdbEpisodeId || null,
matchedAt: Date.now()
}
};
filesUpdate[normalizedRel] = fileEntry;
} else {
const entry = {
...(filesUpdate[normalizedRel] || {}),
mediaInfo: mediaInfo || null
};
if (entry.seriesMatch) delete entry.seriesMatch;
filesUpdate[normalizedRel] = entry;
}
} catch (err) {
console.warn(
`⚠️ TV metadata yenilenemedi (${folder} - ${entry.name}): ${
err?.message || err
}`
);
}
}
};
await walkDir(rootDir);
for (const key of Object.keys(filesUpdate)) {
if (detected[key]) continue;
if (filesUpdate[key]?.seriesMatch) {
delete filesUpdate[key].seriesMatch;
}
}
const episodeCount = Object.keys(detected).length;
if (episodeCount > 0) {
const update = {
seriesEpisodes: detected,
files: filesUpdate
};
upsertInfoFile(rootDir, update);
} else if (clearCache) {
for (const key of Object.keys(filesUpdate)) {
if (filesUpdate[key]?.seriesMatch) {
delete filesUpdate[key].seriesMatch;
}
}
upsertInfoFile(rootDir, {
seriesEpisodes: {},
files: filesUpdate
});
}
processed.push({
folder,
episodes: episodeCount
});
} catch (err) {
console.error(
`❌ TV metadata yeniden oluşturulamadı (${folder}):`,
err?.message || err
);
}
}
return processed;
}
app.post("/api/tvshows/refresh", requireAuth, async (req, res) => {
if (!TVDB_API_KEY) {
return res
.status(400)
.json({ error: "TVDB API erişimi için gerekli anahtar tanımlı değil." });
}
try {
const processed = await rebuildTvMetadata();
res.json({ ok: true, processed });
} catch (err) {
console.error("📺 TvShows refresh error:", err);
res.status(500).json({ error: err.message });
}
});
app.post("/api/tvshows/rescan", requireAuth, async (req, res) => {
if (!TVDB_API_KEY) {
return res
.status(400)
.json({ error: "TVDB API erişimi için gerekli anahtar tanımlı değil." });
}
try {
const processed = await rebuildTvMetadata({ clearCache: true });
res.json({ ok: true, processed });
} catch (err) {
console.error("📺 TvShows rescan error:", err);
res.status(500).json({ error: err.message });
}
});
// --- Stream endpoint (torrent içinden) ---
app.get("/stream/:hash", requireAuth, (req, res) => {
const range = req.headers.range;
const entry = torrents.get(req.params.hash);
if (entry) {
const selected =
entry.torrent.files[entry.selectedIndex] || entry.torrent.files[0];
return streamTorrentFile(selected, range, res);
}
const job = youtubeJobs.get(req.params.hash);
if (job && job.files?.length) {
const index = job.selectedIndex || 0;
const fileEntry = job.files[index] || job.files[0];
if (!fileEntry) return res.status(404).end();
const absPath = path.join(job.savePath, fileEntry.name);
return streamLocalFile(absPath, range, res);
}
const info = readInfoForRoot(req.params.hash);
if (info && info.files) {
const fileKeys = Object.keys(info.files);
if (fileKeys.length) {
const idx = Number(req.query.index) || 0;
const targetKey = fileKeys[idx] || fileKeys[0];
const absPath = path.join(
DOWNLOAD_DIR,
req.params.hash,
targetKey.replace(/\\/g, "/")
);
if (fs.existsSync(absPath)) {
return streamLocalFile(absPath, range, res);
}
}
}
return res.status(404).end();
});
function streamTorrentFile(file, range, res) {
const total = file.length;
const type = mime.lookup(file.name) || "video/mp4";
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);
}
function streamLocalFile(filePath, range, res) {
if (!fs.existsSync(filePath)) return res.status(404).end();
const total = fs.statSync(filePath).size;
const type = mime.lookup(filePath) || "video/mp4";
if (!range) {
res.writeHead(200, {
"Content-Length": total,
"Content-Type": type,
"Accept-Ranges": "bytes"
});
return fs.createReadStream(filePath).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 = fs.createReadStream(filePath, { 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);
// Sunucu açılışında mevcut torrentleri yeniden ekle
const restored = restoreTorrentsFromDisk({
downloadDir: DOWNLOAD_DIR,
client,
register: (torrent, ctx) => wireTorrent(torrent, ctx)
});
if (restored.length) {
console.log(`♻️ ${restored.length} torrent yeniden eklendi.`);
}
// --- ✅ 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(`🐔 du.pe server ${PORT} portunda çalışıyor`)
);
wss = createWebsocketServer(server, { verifyToken });
wss.on("connection", (ws) => {
ws.send(JSON.stringify({ type: "progress", torrents: snapshot() }));
// Bağlantı kurulduğunda disk space bilgisi gönder
broadcastDiskSpace();
});
// --- ⏱️ Her 2 saniyede bir aktif torrent durumu yayınla ---
setInterval(() => {
if (torrents.size > 0) {
broadcastSnapshot();
}
}, 2000);
// --- ⏱️ Her 30 saniyede bir disk space bilgisi yayınla ---
setInterval(() => {
broadcastDiskSpace();
}, 30000);
// --- Disk space bilgisi ---
app.get("/api/disk-space", requireAuth, async (req, res) => {
try {
// Downloads klasörü yoksa oluştur
if (!fs.existsSync(DOWNLOAD_DIR)) {
fs.mkdirSync(DOWNLOAD_DIR, { recursive: true });
}
const diskInfo = await getSystemDiskInfo(DOWNLOAD_DIR);
res.json(diskInfo);
} catch (err) {
console.error("❌ Disk space error:", err.message);
res.status(500).json({ error: err.message });
}
});
// --- 🔍 TMDB/TVDB Arama Endpoint'i ---
app.get("/api/search/metadata", requireAuth, async (req, res) => {
try {
const { query, year, type } = req.query;
if (!query) {
return res.status(400).json({ error: "query parametresi gerekli" });
}
if (type === "movie") {
// TMDB Film Araması
if (!TMDB_API_KEY) {
return res.status(400).json({ error: "TMDB API key tanımlı değil" });
}
const params = new URLSearchParams({
api_key: TMDB_API_KEY,
query: query,
language: "en-US",
include_adult: false
});
if (year) {
params.set("year", year);
}
const response = await fetch(`${TMDB_BASE_URL}/search/movie?${params}`);
if (!response.ok) {
throw new Error(`TMDB API error: ${response.status}`);
}
const data = await response.json();
// Her film için detaylı bilgi çek
const resultsWithDetails = await Promise.all(
(data.results || []).slice(0, 10).map(async (item) => {
try {
const detailResponse = await fetch(
`${TMDB_BASE_URL}/movie/${item.id}?api_key=${TMDB_API_KEY}&append_to_response=credits&language=en-US`
);
if (detailResponse.ok) {
const details = await detailResponse.json();
const cast = (details.credits?.cast || []).slice(0, 3).map(c => c.name);
const genres = (details.genres || []).map(g => g.name);
return {
id: item.id,
title: item.title,
year: item.release_date ? item.release_date.slice(0, 4) : null,
overview: item.overview || "",
poster: item.poster_path ? `${TMDB_IMG_BASE}${item.poster_path}` : null,
runtime: details.runtime || null,
genres: genres,
cast: cast,
type: "movie"
};
}
} catch (err) {
console.warn(`⚠️ Film detayı alınamadı (${item.id}):`, err.message);
}
return {
id: item.id,
title: item.title,
year: item.release_date ? item.release_date.slice(0, 4) : null,
overview: item.overview || "",
poster: item.poster_path ? `${TMDB_IMG_BASE}${item.poster_path}` : null,
type: "movie"
};
})
);
res.json({ results: resultsWithDetails });
} else if (type === "series") {
// TVDB Dizi Araması
if (!TVDB_API_KEY) {
return res.status(400).json({ error: "TVDB API key tanımlı değil" });
}
const params = new URLSearchParams({ type: "series", query: query });
const resp = await tvdbFetch(`/search?${params.toString()}`);
if (!resp || !resp.data) {
return res.json({ results: [] });
}
const allData = Array.isArray(resp.data) ? resp.data : [];
const resultsWithDetails = await Promise.all(
allData.slice(0, 20).map(async (item) => {
try {
const seriesId = item.tvdb_id || item.id;
const extended = await fetchTvdbSeriesExtended(seriesId);
if (extended) {
const info = extended.series || extended;
const artworks = Array.isArray(extended.artworks) ? extended.artworks : [];
const posterArtwork = artworks.find(a => {
const type = String(a?.type || a?.artworkType || "").toLowerCase();
return type.includes("poster") || type === "series" || type === "2";
});
const genres = Array.isArray(info.genres)
? info.genres.map(g => typeof g === "string" ? g : g?.name || g?.genre).filter(Boolean)
: [];
// Yıl bilgisini çeşitli yerlerden al
let seriesYear = null;
if (info.year) {
seriesYear = Number(info.year);
} else if (item.year) {
seriesYear = Number(item.year);
} else if (info.first_air_date || info.firstAired) {
const dateStr = String(info.first_air_date || info.firstAired);
const yearMatch = dateStr.match(/(\d{4})/);
if (yearMatch) seriesYear = Number(yearMatch[1]);
}
return {
id: seriesId,
title: info.name || item.name,
year: seriesYear,
overview: info.overview || item.overview || "",
poster: posterArtwork?.image ? tvdbImageUrl(posterArtwork.image) : (item.image ? tvdbImageUrl(item.image) : null),
genres: genres,
status: info.status?.name || info.status || null,
type: "series"
};
}
} catch (err) {
console.warn(`⚠️ Dizi detayı alınamadı:`, err.message);
}
// Fallback için yıl bilgisini al
let itemYear = null;
if (item.year) {
itemYear = Number(item.year);
} else if (item.first_air_date || item.firstAired) {
const dateStr = String(item.first_air_date || item.firstAired);
const yearMatch = dateStr.match(/(\d{4})/);
if (yearMatch) itemYear = Number(yearMatch[1]);
}
return {
id: item.tvdb_id || item.id,
title: item.name || item.seriesName,
year: itemYear,
overview: item.overview || "",
poster: item.image ? tvdbImageUrl(item.image) : null,
type: "series"
};
})
);
// Yıl filtresi detaylı bilgiler alındıktan SONRA uygula
let filtered = resultsWithDetails.filter(Boolean);
if (year && year.trim()) {
const targetYear = Number(year);
console.log(`🔍 TVDB Yıl filtresi uygulanıyor: ${targetYear}`);
filtered = filtered.filter(item => {
const itemYear = item.year ? Number(item.year) : null;
const matches = itemYear && itemYear === targetYear;
console.log(` - ${item.title}: yıl=${itemYear}, eşleşme=${matches}`);
return matches;
});
console.log(`🔍 Yıl filtresinden sonra: ${filtered.length} sonuç`);
}
res.json({ results: filtered.slice(0, 10) });
} else {
res.status(400).json({ error: "type parametresi 'movie' veya 'series' olmalı" });
}
} catch (err) {
console.error("❌ Metadata search error:", err);
res.status(500).json({ error: err.message });
}
});
// --- 🔗 Manuel Eşleştirme Endpoint'i ---
app.post("/api/match/manual", requireAuth, async (req, res) => {
try {
const { filePath, metadata, type, season, episode } = req.body;
if (!filePath || !metadata || !type) {
return res.status(400).json({ error: "filePath, metadata ve type gerekli" });
}
const safePath = sanitizeRelative(filePath);
if (!safePath) {
return res.status(400).json({ error: "Geçersiz dosya yolu" });
}
const fullPath = path.join(DOWNLOAD_DIR, safePath);
if (!fs.existsSync(fullPath)) {
return res.status(404).json({ error: "Dosya bulunamadı" });
}
const rootFolder = rootFromRelPath(safePath);
if (!rootFolder) {
return res.status(400).json({ error: "Kök klasör belirlenemedi" });
}
const rootDir = path.join(DOWNLOAD_DIR, rootFolder);
const relativeVideoPath = safePath
.split(/[\\/]/)
.slice(1)
.join("/");
const infoPath = infoFilePath(rootDir);
// Mevcut info.json dosyasını oku
let infoData = {};
if (fs.existsSync(infoPath)) {
try {
infoData = JSON.parse(fs.readFileSync(infoPath, "utf-8"));
} catch (err) {
console.warn(`⚠️ info.json okunamadı (${infoPath}): ${err.message}`);
}
}
// Media info'yu çıkar
let mediaInfo = null;
try {
mediaInfo = await extractMediaInfo(fullPath);
} catch (err) {
console.warn(`⚠️ Media info alınamadı (${fullPath}): ${err.message}`);
}
// Önce mevcut verileri temizle
if (type === "movie") {
// Film işlemleri
const movieId = metadata.id;
if (!movieId) {
return res.status(400).json({ error: "Film ID bulunamadı" });
}
// Mevcut movie_data ve TV verilerini temizle
removeMovieData(rootFolder, relativeVideoPath);
removeSeriesData(rootFolder);
// TMDB'den detaylı bilgi al
const movieDetails = await tmdbFetch(`/movie/${movieId}`, {
language: "en-US",
append_to_response: "release_dates,credits,translations"
});
if (!movieDetails) {
return res.status(400).json({ error: "Film detayları alınamadı" });
}
// Türkçe çevirileri ekle
if (movieDetails.translations?.translations?.length) {
const translations = movieDetails.translations.translations;
const turkish = translations.find(
(t) => t.iso_639_1 === "tr" && t.data
);
if (turkish?.data) {
const data = turkish.data;
if (data.overview) movieDetails.overview = data.overview;
if (data.title) movieDetails.title = data.title;
if (data.tagline) movieDetails.tagline = data.tagline;
}
}
// Movie data'yı kaydet
const movieDataResult = await ensureMovieData(
rootFolder,
metadata.title,
relativeVideoPath,
mediaInfo
);
if (movieDataResult?.mediaInfo) {
// info.json'u güncelle - eski verileri temizle
infoData.primaryVideoPath = relativeVideoPath;
infoData.primaryMediaInfo = movieDataResult.mediaInfo;
infoData.movieMatch = {
id: movieDetails.id,
title: movieDetails.title,
year: movieDetails.release_date ? movieDetails.release_date.slice(0, 4) : null,
poster: movieDetails.poster_path,
backdrop: movieDetails.backdrop_path,
matchedAt: Date.now()
};
if (!infoData.files || typeof infoData.files !== "object") {
infoData.files = {};
}
infoData.files[relativeVideoPath] = {
...(infoData.files[relativeVideoPath] || {}),
mediaInfo: movieDataResult.mediaInfo,
movieMatch: {
id: movieDetails.id,
title: movieDetails.title,
year: movieDetails.release_date ? movieDetails.release_date.slice(0, 4) : null,
poster: movieDetails.poster_path,
backdrop: movieDetails.backdrop_path,
cacheKey: movieDataResult.cacheKey,
matchedAt: Date.now()
}
};
// Eski dizi verilerini temizle
delete infoData.seriesEpisodes;
upsertInfoFile(rootDir, infoData);
}
} else if (type === "series") {
// Dizi işlemleri
if (season === null || episode === null) {
return res.status(400).json({ error: "Dizi için sezon ve bölüm bilgileri gerekli" });
}
const seriesId = metadata.id;
if (!seriesId) {
return res.status(400).json({ error: "Dizi ID bulunamadı" });
}
// Mevcut movie_data ve TV verilerini temizle
removeMovieData(rootFolder);
removeSeriesData(rootFolder);
// TVDB'den dizi bilgilerini al
const extended = await fetchTvdbSeriesExtended(seriesId);
if (!extended) {
return res.status(400).json({ error: "Dizi detayları alınamadı" });
}
// Dizi bilgilerini oluştur
const seriesInfo = {
title: metadata.title,
searchTitle: metadata.title,
season,
episode,
key: `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`
};
// TV data'yı kaydet
const tvDataResult = await ensureSeriesData(
rootFolder,
safePath.split('/').slice(1).join('/'),
seriesInfo,
mediaInfo
);
if (tvDataResult) {
// info.json'u güncelle - eski verileri temizle
if (!infoData.seriesEpisodes) infoData.seriesEpisodes = {};
const relPath = safePath.split('/').slice(1).join('/');
infoData.seriesEpisodes[relPath] = {
season,
episode,
key: seriesInfo.key,
title: tvDataResult.episode.title || seriesInfo.title,
showId: tvDataResult.show.id || null,
showTitle: tvDataResult.show.title || seriesInfo.title,
seasonName: tvDataResult.season?.name || `Season ${season}`,
seasonId: tvDataResult.season?.tvdbSeasonId || null,
seasonPoster: tvDataResult.season?.poster || null,
overview: tvDataResult.episode.overview || "",
aired: tvDataResult.episode.aired || null,
runtime: tvDataResult.episode.runtime || null,
still: tvDataResult.episode.still || null,
episodeId: tvDataResult.episode.tvdbEpisodeId || null,
slug: tvDataResult.episode.slug || null,
matchedAt: Date.now()
};
if (!infoData.files || typeof infoData.files !== "object") {
infoData.files = {};
}
const currentFileEntry = infoData.files[relPath] || {};
infoData.files[relPath] = {
...currentFileEntry,
mediaInfo: mediaInfo || currentFileEntry.mediaInfo || null,
seriesMatch: {
id: tvDataResult.show.id || null,
title: tvDataResult.show.title || seriesInfo.title,
season,
episode,
code: tvDataResult.episode.code || seriesInfo.key,
poster: tvDataResult.show.poster || null,
backdrop: tvDataResult.show.backdrop || null,
seasonPoster: tvDataResult.season?.poster || null,
aired: tvDataResult.episode.aired || null,
runtime: tvDataResult.episode.runtime || null,
tvdbEpisodeId: tvDataResult.episode.tvdbEpisodeId || null,
matchedAt: Date.now()
}
};
// Eski film verilerini temizle
delete infoData.movieMatch;
delete infoData.primaryMediaInfo;
upsertInfoFile(rootDir, infoData);
}
}
// Thumbnail'ı yeniden oluştur
if (mediaInfo?.format?.mimeType?.startsWith("video/")) {
queueVideoThumbnail(fullPath, safePath);
}
// Değişiklikleri bildir
broadcastFileUpdate(rootFolder);
// Elle eşleştirme için özel bildirim gönder
if (wss) {
const data = JSON.stringify({
type: "manualMatch",
filePath: safePath,
rootFolder,
matchType: type
});
wss.clients.forEach((c) => c.readyState === 1 && c.send(data));
}
res.json({
success: true,
message: "Eşleştirme başarıyla tamamlandı",
type,
rootFolder
});
} catch (err) {
console.error("❌ Manual match error:", err);
res.status(500).json({ error: err.message });
}
});
// --- Klasör oluşturma endpoint'i ---
app.post("/api/folder", requireAuth, async (req, res) => {
try {
const { name, path: targetPath } = req.body;
if (!name || !targetPath) {
return res.status(400).json({ error: "Klasör adı ve yol gerekli" });
}
// Güvenli yol kontrolü
const safePath = sanitizeRelative(targetPath);
if (!safePath) {
return res.status(400).json({ error: "Geçersiz klasör yolu" });
}
const fullPath = path.join(DOWNLOAD_DIR, safePath);
// Klasörü oluştur
try {
fs.mkdirSync(fullPath, { recursive: true });
console.log(`📁 Klasör oluşturuldu: ${fullPath}`);
// İlişkili info.json dosyasını güncelle
const rootFolder = rootFromRelPath(safePath);
if (rootFolder) {
const rootDir = path.join(DOWNLOAD_DIR, rootFolder);
upsertInfoFile(rootDir, {
folder: rootFolder,
updatedAt: Date.now()
});
broadcastFileUpdate(rootFolder);
}
// /downloads klasörüne de aynı yapıda oluştur
try {
// Mevcut yolun yapısını analiz et
const pathSegments = safePath.split('/').filter(Boolean);
// Eğer mevcut dizin Home ise doğrudan downloads içine oluştur
if (pathSegments.length === 1) {
const downloadsPath = path.join(DOWNLOAD_DIR, name);
fs.mkdirSync(downloadsPath, { recursive: true });
console.log(`📁 Downloads klasörü oluşturuldu: ${downloadsPath}`);
} else {
// İç içe klasör yapısını koru
// Örn: Home/IT.Welcome.to.Derry.S01E01.1080p.x265-ELiTE ise
// downloads/1761836594224/IT.Welcome.to.Derry.S01E01.1080p.x265-ELiTE oluştur
const rootSegment = pathSegments[0]; // İlk segment (örn: 1761836594224)
const remainingPath = pathSegments.slice(1).join('/'); // Kalan path
const downloadsRootPath = path.join(DOWNLOAD_DIR, rootSegment);
const downloadsFullPath = path.join(downloadsRootPath, remainingPath);
fs.mkdirSync(downloadsFullPath, { recursive: true });
console.log(`📁 Downloads iç klasör oluşturuldu: ${downloadsFullPath}`);
}
} catch (downloadsErr) {
console.warn("⚠️ Downloads klasörü oluşturulamadı:", downloadsErr.message);
// Ana klasör oluşturulduysa hata döndürme
}
res.json({
success: true,
message: "Klasör başarıyla oluşturuldu",
path: safePath
});
} catch (mkdirErr) {
console.error("❌ Klasör oluşturma hatası:", mkdirErr);
const friendlyMessage =
mkdirErr?.code === "EACCES"
? "Sunucu bu dizine yazma iznine sahip değil. Lütfen downloads klasörünün izinlerini güncelle."
: "Klasör oluşturulamadı: " + (mkdirErr?.message || "Bilinmeyen hata");
res.status(500).json({
error: friendlyMessage
});
}
} catch (err) {
console.error("❌ Folder API error:", err);
res.status(500).json({ error: err.message });
}
});
app.patch("/api/folder", requireAuth, (req, res) => {
try {
const { path: targetPath, newName } = req.body || {};
if (!targetPath || !newName) {
return res.status(400).json({ error: "path ve newName gerekli" });
}
const safePath = sanitizeRelative(targetPath);
if (!safePath) {
return res.status(400).json({ error: "Geçersiz hedef yol" });
}
const segments = safePath.split(/[\\/]/).filter(Boolean);
if (!segments.length) {
return res.status(400).json({ error: "Geçersiz hedef yol" });
}
const trimmedName = String(newName).trim();
if (!trimmedName || /[\\/]/.test(trimmedName)) {
return res.status(400).json({ error: "Geçersiz yeni isim" });
}
const parentSegments = segments.slice(0, -1);
const newSegments = [...parentSegments, trimmedName];
const newRelativePath = newSegments.join("/");
const safeNewRelativePath = sanitizeRelative(newRelativePath);
if (!safeNewRelativePath) {
return res.status(400).json({ error: "Geçersiz yeni yol" });
}
const oldFullPath = path.join(DOWNLOAD_DIR, safePath);
const newFullPath = path.join(DOWNLOAD_DIR, safeNewRelativePath);
if (!fs.existsSync(oldFullPath)) {
return res.status(404).json({ error: "Yeniden adlandırılacak klasör bulunamadı" });
}
if (fs.existsSync(newFullPath)) {
return res.status(409).json({ error: "Yeni isimde bir klasör zaten var" });
}
// Yeniden adlandırmayı gerçekleştir
fs.renameSync(oldFullPath, newFullPath);
const rootFolder = segments[0];
const newRootFolder = newSegments[0];
const oldRelWithinRoot = segments.slice(1).join("/");
const newRelWithinRoot = newSegments.slice(1).join("/");
if (!oldRelWithinRoot) {
// Kök klasör yeniden adlandırıldı
renameRootCaches(rootFolder, newRootFolder);
trashStateCache.delete(rootFolder);
trashStateCache.delete(newRootFolder);
// Torrent kayıtlarını güncelle
for (const entry of torrents.values()) {
if (!entry?.savePath) continue;
const baseName = path.basename(entry.savePath);
if (baseName === rootFolder) {
entry.savePath = path.join(path.dirname(entry.savePath), newRootFolder);
}
}
console.log(`📁 Kök klasör yeniden adlandırıldı: ${rootFolder} -> ${newRootFolder}`);
broadcastFileUpdate(rootFolder);
broadcastFileUpdate(newRootFolder);
broadcastSnapshot();
return res.json({
success: true,
message: "Klasör yeniden adlandırıldı",
oldPath: safePath,
newPath: safeNewRelativePath
});
}
// Alt klasör yeniden adlandırıldı
renameInfoPaths(rootFolder, oldRelWithinRoot, newRelWithinRoot);
renameSeriesDataPaths(rootFolder, oldRelWithinRoot, newRelWithinRoot);
renameTrashEntries(rootFolder, oldRelWithinRoot, newRelWithinRoot);
removeThumbnailsForDirectory(rootFolder, oldRelWithinRoot);
trashStateCache.delete(rootFolder);
console.log(
`📁 Klasör yeniden adlandırıldı: ${safePath} -> ${safeNewRelativePath}`
);
broadcastFileUpdate(rootFolder);
res.json({
success: true,
message: "Klasör yeniden adlandırıldı",
oldPath: safePath,
newPath: safeNewRelativePath
});
} catch (err) {
console.error("❌ Folder rename error:", err);
res.status(500).json({ error: err.message });
}
});
// --- 📋 Dosya/klasör kopyalama endpoint'i ---
app.post("/api/file/copy", requireAuth, async (req, res) => {
try {
const { sourcePath, targetDirectory } = req.body || {};
if (!sourcePath) {
return res.status(400).json({ error: "sourcePath gerekli" });
}
const normalizedSource = normalizeTrashPath(sourcePath);
if (!normalizedSource) {
return res.status(400).json({ error: "Geçersiz sourcePath" });
}
const sourceFullPath = path.join(DOWNLOAD_DIR, normalizedSource);
if (!fs.existsSync(sourceFullPath)) {
return res.status(404).json({ error: "Kaynak öğe bulunamadı" });
}
const sourceStats = fs.statSync(sourceFullPath);
const isDirectory = sourceStats.isDirectory();
const normalizedTargetDir = targetDirectory
? normalizeTrashPath(targetDirectory)
: "";
if (normalizedTargetDir) {
const targetDirFullPath = path.join(DOWNLOAD_DIR, normalizedTargetDir);
if (!fs.existsSync(targetDirFullPath)) {
return res.status(404).json({ error: "Hedef klasör bulunamadı" });
}
}
const posixPath = path.posix;
const sourceName = posixPath.basename(normalizedSource);
const newRelativePath = normalizedTargetDir
? posixPath.join(normalizedTargetDir, sourceName)
: sourceName;
if (newRelativePath === normalizedSource) {
return res.json({ success: true, unchanged: true });
}
const newFullPath = path.join(DOWNLOAD_DIR, newRelativePath);
if (fs.existsSync(newFullPath)) {
return res
.status(409)
.json({ error: "Hedef konumda aynı isimde bir öğe zaten var" });
}
const destinationParent = path.dirname(newFullPath);
if (!fs.existsSync(destinationParent)) {
fs.mkdirSync(destinationParent, { recursive: true });
}
// Kopyalama işlemi
try {
copyFolderRecursiveSync(sourceFullPath, newFullPath);
} catch (copyErr) {
console.error("❌ Copy error:", copyErr);
return res.status(500).json({
error: "Kopyalama işlemi başarısız: " + copyErr.message
});
}
const sourceRoot = rootFromRelPath(normalizedSource);
const destRoot = rootFromRelPath(newRelativePath);
// Kopyalanan öğe için yeni thumbnails oluştur
if (!isDirectory) {
const mimeType = mime.lookup(newFullPath) || "";
if (mimeType.startsWith("video/")) {
queueVideoThumbnail(newFullPath, newRelativePath);
} else if (mimeType.startsWith("image/")) {
queueImageThumbnail(newFullPath, newRelativePath);
}
}
// Hedef root için file update bildirimi gönder
if (destRoot) {
broadcastFileUpdate(destRoot);
}
if (sourceRoot && sourceRoot !== destRoot) {
broadcastFileUpdate(sourceRoot);
}
console.log(
`📋 Öğe kopyalandı: ${normalizedSource} -> ${newRelativePath}`
);
res.json({
success: true,
newPath: newRelativePath,
rootFolder: destRoot || null,
isDirectory,
copied: true
});
} catch (err) {
console.error("❌ File copy error:", err);
res.status(500).json({ error: err.message });
}
});
// Recursive klasör kopyalama fonksiyonu
function copyFolderRecursiveSync(source, target) {
// Kaynak öğenin istatistiklerini al
const stats = fs.statSync(source);
// Eğer kaynak bir dosyaysa, direkt kopyala
if (stats.isFile()) {
// Hedef dizinin var olduğundan emin ol
const targetDir = path.dirname(target);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
fs.copyFileSync(source, target);
return;
}
// Kaynak bir klasörse
if (!stats.isDirectory()) {
throw new Error(`Kaynak ne dosya ne de klasör: ${source}`);
}
// Hedef klasörü oluştur
if (!fs.existsSync(target)) {
fs.mkdirSync(target, { recursive: true });
}
// Kaynak klasördeki tüm öğeleri oku
const files = fs.readdirSync(source);
// Her öğeyi işle
files.forEach(file => {
const sourcePath = path.join(source, file);
const targetPath = path.join(target, file);
// Dosya istatistiklerini al
const itemStats = fs.statSync(sourcePath);
if (itemStats.isDirectory()) {
// Alt klasörse recursive olarak kopyala
copyFolderRecursiveSync(sourcePath, targetPath);
} else {
// Dosyaysa kopyala
fs.copyFileSync(sourcePath, targetPath);
}
});
}
client.on("error", (err) => {
if (!String(err).includes("uTP"))
console.error("WebTorrent error:", err.message);
});