Files
dupe/server/server.js
2025-11-02 00:15:06 +03:00

5098 lines
156 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 { WebSocketServer } from "ws";
import { fileURLToPath } from "url";
import { exec } from "child_process";
import crypto from "crypto"; // 🔒 basit token üretimi için
import { getSystemDiskInfo } from "./utils/diskSpace.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();
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");
for (const dir of [
THUMBNAIL_DIR,
VIDEO_THUMB_ROOT,
IMAGE_THUMB_ROOT,
MOVIE_DATA_ROOT,
TV_DATA_ROOT
]) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
const VIDEO_THUMBNAIL_TIME = process.env.VIDEO_THUMBNAIL_TIME || "00:00:05";
const VIDEO_EXTS = [".mp4", ".webm", ".mkv", ".mov", ".m4v"];
const generatingThumbnails = new Set();
const INFO_FILENAME = "info.json";
const TMDB_API_KEY = process.env.TMDB_API_KEY;
const TMDB_BASE_URL = "https://api.themoviedb.org/3";
const TMDB_IMG_BASE =
process.env.TMDB_IMAGE_BASE || "https://image.tmdb.org/t/p/original";
const 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 ensureDirForFile(filePath) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
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 relPathToSegments(relPath) {
return sanitizeRelative(relPath).split(/[\\/]/).filter(Boolean);
}
function rootFromRelPath(relPath) {
const segments = relPathToSegments(relPath);
return segments[0] || null;
}
function getVideoThumbnailPaths(relPath) {
const parsed = path.parse(relPath);
const relThumb = path.join("videos", parsed.dir, `${parsed.name}.jpg`);
const absThumb = path.join(THUMBNAIL_DIR, relThumb);
return { relThumb, absThumb };
}
function getImageThumbnailPaths(relPath) {
const parsed = path.parse(relPath);
const relThumb = path.join(
"images",
parsed.dir,
`${parsed.name}${parsed.ext || ".jpg"}`
);
const absThumb = path.join(THUMBNAIL_DIR, relThumb);
return { relThumb, absThumb };
}
function thumbnailUrl(relThumb) {
const safe = relThumb
.split(path.sep)
.filter(Boolean)
.map(encodeURIComponent)
.join("/");
return `/thumbnails/${safe}`;
}
function markGenerating(absThumb, add) {
if (add) generatingThumbnails.add(absThumb);
else generatingThumbnails.delete(absThumb);
}
function toFiniteNumber(value) {
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
function parseFrameRate(value) {
if (!value) return null;
if (typeof value === "number") return Number.isFinite(value) ? value : null;
const parts = String(value).split("/");
if (parts.length === 2) {
const numerator = Number(parts[0]);
const denominator = Number(parts[1]);
if (
Number.isFinite(numerator) &&
Number.isFinite(denominator) &&
denominator !== 0
) {
return Number((numerator / denominator).toFixed(3));
}
}
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
async function extractMediaInfo(filePath) {
if (!filePath || !fs.existsSync(filePath)) return null;
return new Promise((resolve) => {
exec(
`${FFPROBE_PATH} -v quiet -print_format json -show_format -show_streams "${filePath}"`,
{ maxBuffer: FFPROBE_MAX_BUFFER },
(err, stdout) => {
if (err) {
console.warn(
`⚠️ ffprobe çalıştırılamadı (${filePath}): ${err.message}`
);
return resolve(null);
}
try {
const parsed = JSON.parse(stdout);
const streams = Array.isArray(parsed?.streams) ? parsed.streams : [];
const format = parsed?.format || {};
const videoStream =
streams.find((s) => s.codec_type === "video") || null;
const audioStream =
streams.find((s) => s.codec_type === "audio") || null;
const mediaInfo = {
format: {
duration: toFiniteNumber(format.duration),
size: toFiniteNumber(format.size),
bitrate: toFiniteNumber(format.bit_rate)
},
video: videoStream
? {
codec: videoStream.codec_name || null,
profile: videoStream.profile || null,
width: toFiniteNumber(videoStream.width),
height: toFiniteNumber(videoStream.height),
resolution: videoStream.height
? `${videoStream.height}p`
: videoStream.width
? `${videoStream.width}px`
: null,
bitrate: toFiniteNumber(videoStream.bit_rate),
frameRate: parseFrameRate(
videoStream.avg_frame_rate || videoStream.r_frame_rate
),
pixelFormat: videoStream.pix_fmt || null
}
: null,
audio: audioStream
? {
codec: audioStream.codec_name || null,
channels: toFiniteNumber(audioStream.channels),
channelLayout: audioStream.channel_layout || null,
bitrate: toFiniteNumber(audioStream.bit_rate),
sampleRate: toFiniteNumber(audioStream.sample_rate)
}
: null
};
resolve(mediaInfo);
} catch (parseErr) {
console.warn(
`⚠️ ffprobe çıktısı parse edilemedi (${filePath}): ${parseErr.message}`
);
resolve(null);
}
}
);
});
}
function queueVideoThumbnail(fullPath, relPath) {
const { relThumb, absThumb } = getVideoThumbnailPaths(relPath);
if (fs.existsSync(absThumb) || generatingThumbnails.has(absThumb)) return;
ensureDirForFile(absThumb);
markGenerating(absThumb, true);
const cmd = `ffmpeg -y -ss ${VIDEO_THUMBNAIL_TIME} -i "${fullPath}" -frames:v 1 -vf "scale=320:-1" -q:v 2 "${absThumb}"`;
exec(cmd, (err) => {
markGenerating(absThumb, false);
if (err) {
console.warn(`⚠️ Video thumbnail oluşturulamadı (${fullPath}): ${err.message}`);
return;
}
console.log(`🎞️ Video thumbnail oluşturuldu: ${absThumb}`);
const root = rootFromRelPath(relPath);
if (root) broadcastFileUpdate(root);
});
}
function queueImageThumbnail(fullPath, relPath) {
const { relThumb, absThumb } = getImageThumbnailPaths(relPath);
if (fs.existsSync(absThumb) || generatingThumbnails.has(absThumb)) return;
ensureDirForFile(absThumb);
markGenerating(absThumb, true);
const outputExt = path.extname(absThumb).toLowerCase();
const needsQuality = outputExt === ".jpg" || outputExt === ".jpeg";
const qualityArgs = needsQuality ? ' -q:v 5' : "";
const cmd = `ffmpeg -y -i "${fullPath}" -vf "scale=320:-1"${qualityArgs} "${absThumb}"`;
exec(cmd, (err) => {
markGenerating(absThumb, false);
if (err) {
console.warn(`⚠️ Resim thumbnail oluşturulamadı (${fullPath}): ${err.message}`);
return;
}
console.log(`🖼️ Resim thumbnail oluşturuldu: ${absThumb}`);
const root = rootFromRelPath(relPath);
if (root) broadcastFileUpdate(root);
});
}
function removeThumbnailsForPath(relPath) {
const normalized = sanitizeRelative(relPath);
if (!normalized) return;
const parsed = path.parse(normalized);
const candidates = [
path.join(VIDEO_THUMB_ROOT, parsed.dir, `${parsed.name}.jpg`),
path.join(IMAGE_THUMB_ROOT, parsed.dir, `${parsed.name}${parsed.ext}`)
];
for (const candidate of candidates) {
try {
if (fs.existsSync(candidate)) fs.rmSync(candidate, { recursive: true, force: true });
} catch (err) {
console.warn(`⚠️ Thumbnail silinemedi (${candidate}): ${err.message}`);
}
}
const potentialDirs = [
path.join(VIDEO_THUMB_ROOT, parsed.dir),
path.join(IMAGE_THUMB_ROOT, parsed.dir)
];
for (const dirPath of potentialDirs) {
cleanupEmptyDirs(dirPath);
}
}
function cleanupEmptyDirs(startDir) {
let dir = startDir;
while (
dir &&
dir.startsWith(THUMBNAIL_DIR) &&
fs.existsSync(dir)
) {
try {
const stat = fs.lstatSync(dir);
if (!stat.isDirectory()) break;
const entries = fs.readdirSync(dir);
if (entries.length > 0) break;
fs.rmdirSync(dir);
} catch (err) {
console.warn(`⚠️ Thumbnail klasörü temizlenemedi (${dir}): ${err.message}`);
break;
}
const parent = path.dirname(dir);
if (
!parent ||
parent === dir ||
parent.length < THUMBNAIL_DIR.length ||
parent === THUMBNAIL_DIR
) {
break;
}
dir = parent;
}
}
// --- 🗑️ .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 removeAllThumbnailsForRoot(rootFolder) {
const safe = sanitizeRelative(rootFolder);
if (!safe) return;
const targets = [
path.join(VIDEO_THUMB_ROOT, safe),
path.join(IMAGE_THUMB_ROOT, safe)
];
for (const target of targets) {
try {
if (fs.existsSync(target)) {
fs.rmSync(target, { recursive: true, force: true });
cleanupEmptyDirs(path.dirname(target));
}
} catch (err) {
console.warn(`⚠️ Thumbnail klasörü silinemedi (${target}): ${err.message}`);
}
}
}
function movieDataDir(rootFolder) {
return path.join(MOVIE_DATA_ROOT, sanitizeRelative(rootFolder));
}
function movieDataPaths(rootFolder) {
const dir = movieDataDir(rootFolder);
return {
dir,
metadata: path.join(dir, "metadata.json"),
poster: path.join(dir, "poster.jpg"),
backdrop: path.join(dir, "backdrop.jpg")
};
}
function isTmdbMetadata(metadata) {
if (!metadata) return false;
if (typeof metadata.id === "number") return true;
if (metadata._dupe?.source === "tmdb") return true;
return false;
}
function parseTitleAndYear(rawName) {
if (!rawName) return { title: null, year: null };
const withoutExt = rawName.replace(/\.[^/.]+$/, "");
const cleaned = withoutExt.replace(/[\[\]\(\)\-]/g, " ").replace(/[._]/g, " ");
const tokens = cleaned
.split(/\s+/)
.map((t) => t.trim())
.filter(Boolean);
if (!tokens.length) {
return { title: withoutExt.trim(), year: null };
}
const ignoredExact = new Set(
[
"hdrip",
"hdr",
"webrip",
"webdl",
"web",
"dl",
"bluray",
"bdrip",
"dvdrip",
"remux",
"multi",
"audio",
"aac",
"ddp",
"dts",
"xvid",
"x264",
"x265",
"x266",
"h264",
"h265",
"hevc",
"hdr10",
"hdr10plus",
"amzn",
"nf",
"netflix",
"disney",
"imax",
"atmos",
"dubbed",
"dublado",
"ita",
"eng",
"turkce",
"multi-audio",
"eazy",
"tbmovies",
"tbm",
"bone"
].map((t) => t.toLowerCase())
);
const yearIndex = tokens.findIndex((token) => /^(19|20)\d{2}$/.test(token));
if (yearIndex > 0) {
const yearToken = tokens[yearIndex];
const candidateTokens = tokens.slice(0, yearIndex);
const filteredTitleTokens = candidateTokens.filter((token) => {
const lower = token.toLowerCase();
if (ignoredExact.has(lower)) return false;
if (/^\d{3,4}p$/.test(lower)) return false;
if (/^(ac3|aac\d*|ddp\d*|dts|dubbed|dual|multi|hc)$/.test(lower))
return false;
if (/^(x|h)?26[45]$/.test(lower)) return false;
if (lower.includes("hdrip") || lower.includes("web-dl")) return false;
if (lower.includes("multi-audio")) return false;
return true;
});
const titleTokens = filteredTitleTokens.length
? filteredTitleTokens
: candidateTokens;
const title = titleTokens.join(" ").replace(/\s+/g, " ").trim();
return {
title: title || withoutExt.trim(),
year: Number(yearToken)
};
}
let year = null;
const filtered = [];
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
const lower = token.toLowerCase();
if (!year && /^(19|20)\d{2}$/.test(lower)) {
year = Number(lower);
continue;
}
if (/^\d{3,4}p$/.test(lower)) continue;
if (lower === "web" && tokens[i + 1]?.toLowerCase() === "dl") {
i += 1;
continue;
}
if (/^(ac3|aac\d*|ddp\d*|dts|dubbed|dual|multi|hc)$/.test(lower)) continue;
if (/^(x|h)?26[45]$/.test(lower)) continue;
if (ignoredExact.has(lower)) continue;
if (lower.includes("hdrip") || lower.includes("web-dl")) continue;
if (lower.includes("multi-audio")) continue;
filtered.push(token);
}
const title = filtered.join(" ").replace(/\s+/g, " ").trim();
return { title: title || withoutExt.trim(), year };
}
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 safe = sanitizeRelative(rootFolder);
if (!safe) return null;
const baseDir = path.join(DOWNLOAD_DIR, safe);
if (!fs.existsSync(baseDir)) return null;
let bestRelPath = null;
let bestSize = 0;
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()) {
stack.push(relPath);
continue;
}
if (!entry.isFile()) 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}`);
continue;
}
if (size >= bestSize) {
bestSize = size;
bestRelPath = relPath;
}
}
}
return bestRelPath ? bestRelPath.replace(/\\/g, "/") : null;
}
async function ensureMovieData(
rootFolder,
displayName,
bestVideoPath,
precomputedMediaInfo = null
) {
if (!TMDB_API_KEY) return precomputedMediaInfo || null;
console.log("🎬 ensureMovieData çağrıldı:", { rootFolder, displayName });
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(rootFolder);
return precomputedMediaInfo || null;
}
const paths = movieDataPaths(rootFolder);
const normalizedRoot = sanitizeRelative(rootFolder);
const normalizedVideoPath = bestVideoPath
? bestVideoPath.replace(/\\/g, "/")
: null;
let metadata = null;
if (fs.existsSync(paths.metadata)) {
try {
metadata = JSON.parse(fs.readFileSync(paths.metadata, "utf-8"));
console.log("🎬 Mevcut metadata bulundu:", rootFolder);
} catch (err) {
console.warn(
`⚠️ metadata.json okunamadı (${paths.metadata}): ${err.message}`
);
}
}
let fetchedMetadata = false;
const hasTmdbMetadata = isTmdbMetadata(metadata);
if (!hasTmdbMetadata) {
const { title, year } = parseTitleAndYear(displayName);
console.log("🎬 TMDB araması için analiz:", { displayName, title, year });
if (title) {
const fetched = await fetchMovieMetadata(title, year);
if (fetched) {
metadata = fetched;
fetchedMetadata = true;
}
}
}
if (!isTmdbMetadata(metadata)) {
console.log(
"🎬 TMDB verisi bulunamadı, movie_data oluşturulmadı:",
rootFolder
);
removeMovieData(rootFolder);
return precomputedMediaInfo || null;
}
ensureDirForFile(paths.metadata);
let videoPath =
(normalizedVideoPath && normalizedVideoPath.trim()) ||
metadata._dupe?.videoPath ||
guessPrimaryVideo(rootFolder) ||
null;
if (videoPath) videoPath = videoPath.replace(/\\/g, "/");
const videoAbsPath = videoPath
? path.join(DOWNLOAD_DIR, normalizedRoot, videoPath)
: null;
let mediaInfo = metadata._dupe?.mediaInfo || precomputedMediaInfo || null;
if (!mediaInfo && videoAbsPath && fs.existsSync(videoAbsPath)) {
mediaInfo = await extractMediaInfo(videoAbsPath);
if (mediaInfo) {
console.log("🎬 Video teknik bilgileri elde edildi:", {
rootFolder,
mediaInfo
});
}
}
const posterUrl = metadata.poster_path
? `${TMDB_IMG_BASE}${metadata.poster_path}`
: null;
const backdropUrl = metadata.backdrop_path
? `${TMDB_IMG_BASE}${metadata.backdrop_path}`
: null;
const enriched = {
...metadata,
_dupe: {
...(metadata._dupe || {}),
folder: rootFolder,
videoPath,
mediaInfo,
source: "tmdb",
fetchedAt: fetchedMetadata
? Date.now()
: metadata._dupe?.fetchedAt || Date.now()
}
};
fs.writeFileSync(paths.metadata, JSON.stringify(enriched, null, 2), "utf-8");
if (posterUrl && (!fs.existsSync(paths.poster) || fetchedMetadata)) {
await downloadImage(posterUrl, paths.poster);
}
if (backdropUrl && (!fs.existsSync(paths.backdrop) || fetchedMetadata)) {
await downloadImage(backdropUrl, paths.backdrop);
}
console.log(`🎬 TMDB verisi hazır: ${rootFolder}`);
return mediaInfo;
}
function removeMovieData(rootFolder) {
const dir = movieDataDir(rootFolder);
if (fs.existsSync(dir)) {
try {
fs.rmSync(dir, { recursive: true, force: true });
console.log(`🧹 Movie metadata silindi: ${dir}`);
} catch (err) {
console.warn(`⚠️ Movie metadata temizlenemedi (${dir}): ${err.message}`);
}
}
}
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 buildTvShowDir(rootFolder) {
const dir = tvSeriesDir(rootFolder);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
return dir;
}
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(rootFolder, relativePath) {
const encoded = sanitizeRelative(rootFolder)
.split(path.sep)
.map(encodeURIComponent)
.join("/");
return relativePath
? `/tv-data/${encoded}/${relativePath.split(path.sep).map(encodeURIComponent).join("/")}`
: `/tv-data/${encoded}`;
}
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 showDir = buildTvShowDir(rootFolder);
const seriesMetaPath = path.join(showDir, "series.json");
let seriesData = {};
if (fs.existsSync(seriesMetaPath)) {
try {
seriesData = JSON.parse(fs.readFileSync(seriesMetaPath, "utf-8")) || {};
} catch (err) {
console.warn(`⚠️ series.json okunamadı (${seriesMetaPath}): ${err.message}`);
}
}
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;
}
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: rootFolder
};
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(rootFolder, 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(rootFolder, 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(rootFolder, path.relative(showDir, stillPath))
: null,
file: relativeFilePath,
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");
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(rootFolder, "poster.jpg")
: null,
backdrop: fs.existsSync(path.join(showDir, "backdrop.jpg"))
? encodeTvDataPath(rootFolder, "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]
};
}
function tvSeriesDir(rootFolder) {
return path.join(TV_DATA_ROOT, sanitizeRelative(rootFolder));
}
function tvSeriesPaths(rootFolder) {
const dir = tvSeriesDir(rootFolder);
return {
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")
};
}
function seasonAssetPaths(rootFolder, seasonNumber) {
const baseDir = tvSeriesDir(rootFolder);
const padded = String(seasonNumber).padStart(2, "0");
const dir = path.join(baseDir, "seasons", `season-${padded}`);
return {
dir,
poster: path.join(dir, "poster.jpg")
};
}
function removeSeriesData(rootFolder) {
const dir = tvSeriesDir(rootFolder);
if (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 safeRoot = sanitizeRelative(rootFolder);
if (!safeRoot) return;
const seriesMetaPath = tvSeriesPaths(safeRoot).metadata;
if (!fs.existsSync(seriesMetaPath)) return;
let seriesData;
try {
seriesData = JSON.parse(fs.readFileSync(seriesMetaPath, "utf-8"));
} catch (err) {
console.warn(
`⚠️ series.json okunamadı (${seriesMetaPath}): ${err.message}`
);
return;
}
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) return;
if (!Object.keys(seasons).length) {
removeSeriesData(safeRoot);
return;
}
seriesData.seasons = seasons;
seriesData.updatedAt = Date.now();
try {
fs.writeFileSync(seriesMetaPath, JSON.stringify(seriesData, null, 2), "utf-8");
} catch (err) {
console.warn(
`⚠️ series.json güncellenemedi (${seriesMetaPath}): ${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 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 metadataPath = tvSeriesPaths(rootFolder).metadata;
if (!fs.existsSync(metadataPath)) return;
let seriesData;
try {
seriesData = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
} catch (err) {
console.warn(`⚠️ series.json okunamadı (${metadataPath}): ${err.message}`);
return;
}
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));
}
// --- Snapshot (thumbnail dahil, tracker + tarih eklendi) ---
function snapshot() {
return 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,
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
};
}
);
}
// --- Basit kimlik doğrulama sistemi ---
const USERNAME = process.env.USERNAME;
const PASSWORD = process.env.PASSWORD;
let activeTokens = new Set();
app.post("/api/login", (req, res) => {
const { username, password } = req.body;
if (username === USERNAME && password === PASSWORD) {
const token = crypto.randomBytes(24).toString("hex");
activeTokens.add(token);
return res.json({ token });
}
res.status(401).json({ error: "Invalid credentials" });
});
function requireAuth(req, res, next) {
const token = req.headers.authorization?.split(" ")[1] || req.query.token;
if (!token || !activeTokens.has(token))
return res.status(401).json({ error: "Unauthorized" });
next();
}
// --- 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 = crypto.randomBytes(16).toString("hex");
activeTokens.add(mediaToken);
setTimeout(() => activeTokens.delete(mediaToken), ttl * 1000);
// 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();
torrents.set(torrent.infoHash, {
torrent,
selectedIndex: 0,
savePath,
added,
paused: false
});
// --- Metadata geldiğinde ---
torrent.on("ready", () => {
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,
createdAt: added,
folder: rootFolder
});
broadcastFileUpdate(rootFolder);
res.json({
ok: true,
infoHash: torrent.infoHash,
name: torrent.name,
selectedIndex,
tracker: torrent.announce?.[0] || null,
added,
files: torrent.files.map((f, i) => ({
index: i,
name: f.name,
length: f.length
}))
});
broadcastSnapshot();
});
// --- İndirme tamamlandığında thumbnail oluştur ---
torrent.on("done", async () => {
const entry = torrents.get(torrent.infoHash);
if (!entry) return;
console.log(`✅ Torrent tamamlandı: ${torrent.name}`);
const rootFolder = path.basename(entry.savePath);
const bestVideoIndex = pickBestVideoFile(torrent);
const bestVideo =
torrent.files[bestVideoIndex] || torrent.files[0] || null;
const displayName = bestVideo?.name || torrent.name || rootFolder;
const bestVideoPath = bestVideo?.path
? bestVideo.path.replace(/\\/g, "/")
: null;
const perFileMetadata = {};
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
};
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
};
}
} 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
};
if (bestVideoPath) infoUpdate.primaryVideoPath = bestVideoPath;
if (Object.keys(seriesEpisodes).length) {
infoUpdate.seriesEpisodes = seriesEpisodes;
}
const ensuredMedia = await ensureMovieData(
rootFolder,
displayName,
bestVideoPath,
primaryMediaInfo
);
if (ensuredMedia) infoUpdate.primaryMediaInfo = ensuredMedia;
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));
}
}
broadcastSnapshot();
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// --- Thumbnail endpoint ---
app.get("/thumbnails/:path(*)", requireAuth, (req, res) => {
const relThumb = req.params.path || "";
const fullPath = resolveThumbnailAbsolute(relThumb);
if (!fullPath) return res.status(400).send("Geçersiz thumbnail yolu");
if (!fs.existsSync(fullPath)) return res.status(404).send("Thumbnail yok");
res.sendFile(fullPath);
});
app.get("/movie-data/:path(*)", requireAuth, (req, res) => {
const relPath = req.params.path || "";
const fullPath = resolveMovieDataAbsolute(relPath);
if (!fullPath) return res.status(400).send("Geçersiz movie data yolu");
if (!fs.existsSync(fullPath)) return res.status(404).send("Dosya bulunamadı");
// 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("/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) return res.status(404).json({ error: "torrent bulunamadı" });
entry.selectedIndex = Number(req.params.index) || 0;
res.json({ ok: true, selectedIndex: entry.selectedIndex });
});
// --- Torrent silme (disk dahil) ---
app.delete("/api/torrents/:hash", requireAuth, (req, res) => {
const entry = torrents.get(req.params.hash);
if (!entry) return res.status(404).json({ error: "torrent bulunamadı" });
const { torrent, savePath } = entry;
const isComplete = torrent?.done || (torrent?.progress ?? 0) >= 1;
const rootFolder = savePath ? path.basename(savePath) : null;
torrent.destroy(() => {
torrents.delete(req.params.hash);
if (!isComplete) {
if (savePath && fs.existsSync(savePath)) {
try {
fs.rmSync(savePath, { recursive: true, force: true });
console.log(`🗑️ ${savePath} klasörü silindi`);
} catch (err) {
console.warn(`⚠️ ${savePath} silinemedi:`, err.message);
}
}
if (rootFolder) {
purgeRootFolder(rootFolder);
broadcastFileUpdate(rootFolder);
}
} else {
console.log(
` ${req.params.hash} torrent'i tamamlandığı için yalnızca Transfers listesinden kaldırıldı; dosyalar tutuldu.`
);
}
broadcastSnapshot();
res.json({
ok: true,
filesRemoved: !isComplete
});
});
});
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) {
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 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) {
trashEntry = addTrashEntry(folderId, {
path: relWithinRoot,
originalPath: safePath,
isDirectory,
deletedAt: Date.now(),
type: isDirectory
? "inode/directory"
: mime.lookup(fullPath) || "application/octet-stream"
});
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();
}
res.json({ ok: true, filesRemoved: true });
} catch (err) {
console.error("❌ Dosya silinemedi:", err.message);
res.status(500).json({ error: err.message });
}
});
// --- 📁 Dosya gezgini (🆕 type ve url alanları eklendi; resim thumb'ı) ---
app.get("/api/files", requireAuth, (req, res) => {
// --- 🧩 .ignoreFiles içeriğini oku ---
let ignoreList = [];
const ignorePath = path.join(__dirname, ".ignoreFiles");
if (fs.existsSync(ignorePath)) {
try {
const raw = fs.readFileSync(ignorePath, "utf-8");
ignoreList = raw
.split("\n")
.map((l) => l.trim().toLowerCase())
.filter((l) => l && !l.startsWith("#"));
} catch (err) {
console.warn("⚠️ .ignoreFiles okunamadı:", err.message);
}
}
// --- 🔍 Yardımcı fonksiyon: dosya ignoreList'te mi? ---
const isIgnored = (name) => {
const lower = name.toLowerCase();
const ext = path.extname(lower).replace(".", "");
return ignoreList.some(
(ignored) =>
lower === ignored ||
lower.endsWith(ignored) ||
lower.endsWith(`.${ignored}`) ||
ext === ignored.replace(/^\./, "")
);
};
const infoCache = new Map();
const getInfo = (relPath) => {
const root = rootFromRelPath(relPath);
if (!root) return null;
if (!infoCache.has(root)) {
infoCache.set(root, readInfoForRoot(root));
}
return infoCache.get(root);
};
// --- 📁 Klasörleri dolaş ---
const walk = (dir) => {
let result = [];
const list = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of list) {
const full = path.join(dir, entry.name);
const rel = path.relative(DOWNLOAD_DIR, full);
// 🔥 Ignore kontrolü (hem dosya hem klasör için)
if (isIgnored(entry.name) || isIgnored(rel)) continue;
if (entry.isDirectory()) {
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;
result.push({
name: safeRel,
size: 0,
type: "inode/directory",
isDirectory: true,
rootFolder,
added,
completedAt,
tracker,
torrentName,
infoHash,
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;
const isPrimaryVideo =
!!info.primaryVideoPath && info.primaryVideoPath === relWithinRoot;
result.push({
name: safeRel,
size,
type,
url,
thumbnail: thumb,
rootFolder,
added,
completedAt,
tracker,
torrentName,
infoHash,
extension: extensionForFile,
mediaInfo: mediaInfoForFile,
primaryVideoPath: info.primaryVideoPath || null,
primaryMediaInfo: info.primaryMediaInfo || null,
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ı" });
}
console.log(`♻️ Öğe geri yüklendi: ${safeName}`);
broadcastFileUpdate(rootFolder);
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 folder = dirent.name;
const paths = movieDataPaths(folder);
if (!fs.existsSync(paths.metadata)) return null;
try {
const metadata = JSON.parse(
fs.readFileSync(paths.metadata, "utf-8")
);
if (!isTmdbMetadata(metadata)) {
removeMovieData(folder);
return null;
}
const encodedFolder = folder
.split(path.sep)
.map(encodeURIComponent)
.join("/");
const posterExists = fs.existsSync(paths.poster);
const backdropExists = fs.existsSync(paths.backdrop);
const releaseDate = metadata.release_date || metadata.first_air_date;
const year = releaseDate
? Number(releaseDate.slice(0, 4))
: metadata.matched_year || null;
const runtimeMinutes =
metadata.runtime ??
(Array.isArray(metadata.episode_run_time) &&
metadata.episode_run_time.length
? metadata.episode_run_time[0]
: null);
const dupe = metadata._dupe || {};
return {
folder,
id: metadata.id ?? folder,
title: metadata.title || metadata.matched_title || folder,
originalTitle: metadata.original_title || null,
year,
runtime: runtimeMinutes || null,
overview: metadata.overview || "",
voteAverage: metadata.vote_average || null,
voteCount: metadata.vote_count || null,
genres: Array.isArray(metadata.genres)
? metadata.genres.map((g) => g.name)
: [],
poster: posterExists
? `/movie-data/${encodedFolder}/poster.jpg`
: null,
backdrop: backdropExists
? `/movie-data/${encodedFolder}/backdrop.jpg`
: null,
videoPath: dupe.videoPath || null,
mediaInfo: dupe.mediaInfo || null,
metadata
};
} catch (err) {
console.warn(
`⚠️ metadata.json okunamadı (${paths.metadata}): ${err.message}`
);
return null;
}
})
.filter(Boolean);
movies.sort((a, b) => {
const yearA = a.year || 0;
const yearB = b.year || 0;
if (yearA !== yearB) return yearB - yearA;
return a.title.localeCompare(b.title);
});
res.json(movies);
} catch (err) {
console.error("🎬 Movies API error:", err);
res.status(500).json({ error: err.message });
}
});
app.post("/api/movies/refresh", requireAuth, async (req, res) => {
if (!TMDB_API_KEY) {
return res.status(400).json({ error: "TMDB API key tanımlı değil." });
}
try {
const folders = fs
.readdirSync(DOWNLOAD_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
const processed = [];
for (const folder of folders) {
const info = readInfoForRoot(folder);
const displayName = info?.name || folder;
const primaryVideo = info?.primaryVideoPath || guessPrimaryVideo(folder);
const candidateMedia =
info?.files?.[primaryVideo]?.mediaInfo || info?.primaryMediaInfo || null;
const ensured = await ensureMovieData(
folder,
displayName,
primaryVideo,
candidateMedia
);
if (primaryVideo || ensured) {
const update = {};
if (primaryVideo) update.primaryVideoPath = primaryVideo;
if (ensured) update.primaryMediaInfo = ensured;
if (Object.keys(update).length) {
upsertInfoFile(path.join(DOWNLOAD_DIR, folder), update);
}
}
processed.push(folder);
}
res.json({ ok: true, processed });
} catch (err) {
console.error("🎬 Movies refresh error:", err);
res.status(500).json({ error: err.message });
}
});
// --- 📺 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 folder = sanitizeRelative(dirent.name);
if (!folder) continue;
const paths = tvSeriesPaths(folder);
if (!fs.existsSync(paths.metadata)) continue;
const infoForFolder = readInfoForRoot(folder) || {};
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, folder, 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(folder);
continue;
}
let dataChanged = false;
const encodedFolder = folder
.split(path.sep)
.map(encodeURIComponent)
.join("/");
const showId =
seriesData.id ?? seriesData.tvdbId ?? seriesData.slug ?? seriesData.name ?? folder;
const showKey = String(showId).toLowerCase();
const record =
aggregated.get(showKey) ||
(() => {
const base = {
id: seriesData.id ?? seriesData.tvdbId ?? folder,
title: seriesData.name || folder,
overview: seriesData.overview || "",
year: seriesData.year || null,
status: seriesData.status || null,
poster: fs.existsSync(paths.poster)
? `/tv-data/${encodedFolder}/poster.jpg`
: null,
backdrop: fs.existsSync(paths.backdrop)
? `/tv-data/${encodedFolder}/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: folder,
folders: new Set([folder])
};
aggregated.set(showKey, base);
return base;
})();
record.folders.add(folder);
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 = `/tv-data/${encodedFolder}/poster.jpg`;
}
if (!record.backdrop && fs.existsSync(paths.backdrop)) {
record.backdrop = `/tv-data/${encodedFolder}/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(folder, 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,
folder,
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(
tvSeriesDir(folder),
seasonPaths.poster
);
seasonRecord.poster = encodeTvDataPath(folder, 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 = `${folder}/${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(`${folder}/`) &&
!videoPath.startsWith(`/${folder}/`);
if (needsFolderPrefix) {
videoPath = `${folder}/${videoPath}`.replace(/\\/g, "/");
}
const finalPath = videoPath.replace(/^\/+/, "");
if (finalPath !== rawVideoPath) {
dataChanged = true;
}
normalizedEpisode.videoPath = finalPath;
} else if (relativeFile) {
normalizedEpisode.videoPath = `${folder}/${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 = folder;
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
};
})
.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 });
}
});
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 folders = fs
.readdirSync(DOWNLOAD_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
const processed = [];
for (const folder of folders) {
const safeFolder = sanitizeRelative(folder);
if (!safeFolder) continue;
const rootDir = path.join(DOWNLOAD_DIR, safeFolder);
if (!fs.existsSync(rootDir)) continue;
const info = readInfoForRoot(safeFolder) || {};
const infoFiles = info.files || {};
const detected = {};
const walkDir = async (currentDir, relativeBase = "") => {
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
for (const entry of entries) {
const relPath = relativeBase
? `${relativeBase}/${entry.name}`
: entry.name;
const absPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await walkDir(absPath, relPath);
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 =
infoFiles?.[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(
safeFolder,
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
};
}
} catch (err) {
console.warn(
`⚠️ TV metadata yenilenemedi (${safeFolder} - ${entry.name}): ${
err?.message || err
}`
);
}
}
};
await walkDir(rootDir);
if (Object.keys(detected).length) {
upsertInfoFile(rootDir, { seriesEpisodes: detected });
}
processed.push({
folder: safeFolder,
episodes: Object.keys(detected).length
});
}
res.json({ ok: true, processed });
} catch (err) {
console.error("📺 TvShows refresh error:", err);
res.status(500).json({ error: err.message });
}
});
// --- Stream endpoint (torrent içinden) ---
app.get("/stream/:hash", requireAuth, (req, res) => {
const entry = torrents.get(req.params.hash);
if (!entry) return res.status(404).end();
const file =
entry.torrent.files[entry.selectedIndex] || entry.torrent.files[0];
const total = file.length;
const type = mime.lookup(file.name) || "video/mp4";
const range = req.headers.range;
if (!range) {
res.writeHead(200, {
"Content-Length": total,
"Content-Type": type,
"Accept-Ranges": "bytes"
});
return file.createReadStream().pipe(res);
}
const [s, e] = range.replace(/bytes=/, "").split("-");
const start = parseInt(s, 10);
const end = e ? parseInt(e, 10) : total - 1;
res.writeHead(206, {
"Content-Range": `bytes ${start}-${end}/${total}`,
"Accept-Ranges": "bytes",
"Content-Length": end - start + 1,
"Content-Type": type
});
const stream = file.createReadStream({ start, end });
stream.on("error", (err) => console.warn("Stream error:", err.message));
res.on("close", () => stream.destroy());
stream.pipe(res);
});
console.log("🗄️ Download path:", DOWNLOAD_DIR);
// --- ✅ Client build (frontend) dosyalarını sun ---
const publicDir = path.join(__dirname, "public");
if (fs.existsSync(publicDir)) {
app.use(express.static(publicDir));
app.get("*", (req, res, next) => {
if (req.path.startsWith("/api")) return next();
res.sendFile(path.join(publicDir, "index.html"));
});
}
const server = app.listen(PORT, () =>
console.log(`🐔 du.pe server ${PORT} portunda çalışıyor`)
);
wss = new WebSocketServer({ server });
wss.on("connection", (ws) => {
ws.send(JSON.stringify({ type: "progress", torrents: snapshot() }));
// Bağlantı kurulduğunda disk space bilgisi gönder
broadcastDiskSpace();
ws.on("error", (error) => {
console.error("🔌 WebSocket error:", error);
});
});
// --- ⏱️ 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 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);
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,
safePath.split('/').slice(1).join('/'),
mediaInfo
);
if (movieDataResult) {
// info.json'u güncelle - eski verileri temizle
infoData.primaryVideoPath = safePath.split('/').slice(1).join('/');
infoData.primaryMediaInfo = movieDataResult;
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()
};
// 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()
};
// 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 });
}
});
// Recursive klasör kopyalama fonksiyonu
function copyFolderRecursiveSync(source, target) {
// 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 stats = fs.statSync(sourcePath);
if (stats.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);
});