3571 lines
107 KiB
JavaScript
3571 lines
107 KiB
JavaScript
import express from "express";
|
||
import cors from "cors";
|
||
import multer from "multer";
|
||
import WebTorrent from "webtorrent";
|
||
import fs from "fs";
|
||
import path from "path";
|
||
import mime from "mime-types";
|
||
import { WebSocketServer } from "ws";
|
||
import { fileURLToPath } from "url";
|
||
import { exec } from "child_process";
|
||
import crypto from "crypto"; // 🔒 basit token üretimi için
|
||
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 });
|
||
|
||
// --- 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;
|
||
}
|
||
}
|
||
|
||
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 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 => {
|
||
console.log("🔄 Broadcasting disk space:", 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();
|
||
}
|
||
|
||
// --- 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();
|
||
|
||
broadcastSnapshot();
|
||
});
|
||
} catch (err) {
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// --- Thumbnail endpoint ---
|
||
app.get("/thumbnails/:path(*)", requireAuth, (req, res) => {
|
||
const relThumb = req.params.path || "";
|
||
const fullPath = resolveThumbnailAbsolute(relThumb);
|
||
if (!fullPath) return res.status(400).send("Geçersiz thumbnail yolu");
|
||
if (!fs.existsSync(fullPath)) return res.status(404).send("Thumbnail yok");
|
||
res.sendFile(fullPath);
|
||
});
|
||
|
||
app.get("/movie-data/:path(*)", requireAuth, (req, res) => {
|
||
const relPath = req.params.path || "";
|
||
const fullPath = resolveMovieDataAbsolute(relPath);
|
||
if (!fullPath) return res.status(400).send("Geçersiz movie data yolu");
|
||
if (!fs.existsSync(fullPath)) return res.status(404).send("Dosya bulunamadı");
|
||
res.sendFile(fullPath);
|
||
});
|
||
|
||
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ı");
|
||
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) => {
|
||
const relPath = req.params.path;
|
||
const fullPath = path.join(DOWNLOAD_DIR, relPath);
|
||
if (!fs.existsSync(fullPath)) return res.status(404).send("File not found");
|
||
|
||
const stat = fs.statSync(fullPath);
|
||
const fileSize = stat.size;
|
||
const type = mime.lookup(fullPath) || "application/octet-stream";
|
||
const isVideo = String(type).startsWith("video/");
|
||
const range = req.headers.range;
|
||
|
||
if (isVideo && range) {
|
||
const [startStr, endStr] = range.replace(/bytes=/, "").split("-");
|
||
const start = parseInt(startStr, 10);
|
||
const end = endStr ? parseInt(endStr, 10) : fileSize - 1;
|
||
const chunkSize = end - start + 1;
|
||
const file = fs.createReadStream(fullPath, { start, end });
|
||
const head = {
|
||
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
|
||
"Accept-Ranges": "bytes",
|
||
"Content-Length": chunkSize,
|
||
"Content-Type": type
|
||
};
|
||
res.writeHead(206, head);
|
||
file.pipe(res);
|
||
} else {
|
||
const head = {
|
||
"Content-Length": fileSize,
|
||
"Content-Type": type,
|
||
"Accept-Ranges": isVideo ? "bytes" : "none"
|
||
};
|
||
res.writeHead(200, head);
|
||
fs.createReadStream(fullPath).pipe(res);
|
||
}
|
||
});
|
||
|
||
// --- 🗑️ Tekil dosya veya torrent klasörü silme ---
|
||
app.delete("/api/file", requireAuth, (req, res) => {
|
||
const filePath = req.query.path;
|
||
if (!filePath) return res.status(400).json({ error: "path gerekli" });
|
||
|
||
const safePath = sanitizeRelative(filePath);
|
||
const fullPath = path.join(DOWNLOAD_DIR, safePath);
|
||
const folderId = (safePath.split(/[\/]/)[0] || "").trim();
|
||
const rootDir = folderId ? path.join(DOWNLOAD_DIR, folderId) : null;
|
||
|
||
let stats = null;
|
||
try {
|
||
stats = fs.statSync(fullPath);
|
||
} catch (err) {
|
||
const message = err?.message || String(err);
|
||
console.warn(`⚠️ Silme sırasında stat alınamadı (${fullPath}): ${message}`);
|
||
}
|
||
|
||
if (!stats || !fs.existsSync(fullPath)) {
|
||
if (folderId && (!rootDir || !fs.existsSync(rootDir))) {
|
||
purgeRootFolder(folderId);
|
||
broadcastFileUpdate(folderId);
|
||
return res.json({ ok: true, alreadyRemoved: true });
|
||
}
|
||
return res.status(404).json({ error: "Dosya bulunamadı" });
|
||
}
|
||
|
||
try {
|
||
fs.rmSync(fullPath, { recursive: true, force: true });
|
||
console.log(`🗑️ Dosya/klasör silindi: ${fullPath}`);
|
||
removeThumbnailsForPath(safePath);
|
||
|
||
if (folderId) {
|
||
const relWithinRoot = safePath.split(/[\/]/).slice(1).join("/");
|
||
const rootExists = rootDir && fs.existsSync(rootDir);
|
||
|
||
if (!relWithinRoot || !rootExists) {
|
||
purgeRootFolder(folderId);
|
||
} else {
|
||
const remaining = fs.readdirSync(rootDir);
|
||
const meaningful = remaining.filter((name) => {
|
||
if (!name) return false;
|
||
if (name === INFO_FILENAME) return false;
|
||
if (name.startsWith(".")) return false;
|
||
const full = path.join(rootDir, name);
|
||
try {
|
||
const stat = fs.statSync(full);
|
||
if (stat.isDirectory()) {
|
||
const subItems = fs.readdirSync(full);
|
||
return subItems.some((entry) => !entry.startsWith("."));
|
||
}
|
||
} catch (err) {
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
if (meaningful.length === 0 || stats?.isDirectory?.()) {
|
||
purgeRootFolder(folderId);
|
||
} else {
|
||
pruneInfoEntry(folderId, relWithinRoot);
|
||
const infoAfter = readInfoForRoot(folderId);
|
||
const displayName = infoAfter?.name || folderId;
|
||
const primaryVideo = infoAfter?.primaryVideoPath || guessPrimaryVideo(folderId);
|
||
if (primaryVideo) {
|
||
const candidateMedia =
|
||
infoAfter?.files?.[primaryVideo]?.mediaInfo ||
|
||
infoAfter?.primaryMediaInfo ||
|
||
null;
|
||
ensureMovieData(folderId, displayName, primaryVideo, candidateMedia).catch(
|
||
(err) =>
|
||
console.warn(
|
||
`⚠️ Movie metadata yenilenemedi (${folderId}): ${err?.message || err}`
|
||
)
|
||
);
|
||
}
|
||
removeSeriesEpisode(folderId, relWithinRoot);
|
||
}
|
||
}
|
||
|
||
broadcastFileUpdate(folderId);
|
||
}
|
||
|
||
if (folderId) {
|
||
let matchedInfoHash = null;
|
||
for (const [infoHash, entry] of torrents.entries()) {
|
||
const lastDir = path.basename(entry.savePath);
|
||
if (lastDir === folderId) {
|
||
matchedInfoHash = infoHash;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (matchedInfoHash) {
|
||
const entry = torrents.get(matchedInfoHash);
|
||
entry?.torrent?.destroy(() => {
|
||
torrents.delete(matchedInfoHash);
|
||
console.log(`🧹 Torrent kaydı da temizlendi: ${matchedInfoHash}`);
|
||
broadcastSnapshot();
|
||
|
||
// 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()) {
|
||
result = result.concat(walk(full));
|
||
} else {
|
||
if (entry.name.toLowerCase() === INFO_FILENAME) continue;
|
||
|
||
const size = fs.statSync(full).size;
|
||
const type = mime.lookup(full) || "application/octet-stream";
|
||
|
||
const safeRel = sanitizeRelative(rel);
|
||
const urlPath = safeRel
|
||
.split(/[\\/]/)
|
||
.map(encodeURIComponent)
|
||
.join("/");
|
||
const url = `/media/${urlPath}`;
|
||
|
||
const isImage = String(type).startsWith("image/");
|
||
const isVideo = String(type).startsWith("video/");
|
||
|
||
let thumb = null;
|
||
|
||
if (isVideo) {
|
||
const { relThumb, absThumb } = getVideoThumbnailPaths(safeRel);
|
||
if (fs.existsSync(absThumb)) thumb = thumbnailUrl(relThumb);
|
||
else queueVideoThumbnail(full, safeRel);
|
||
}
|
||
|
||
if (isImage) {
|
||
const { relThumb, absThumb } = getImageThumbnailPaths(safeRel);
|
||
if (fs.existsSync(absThumb)) thumb = thumbnailUrl(relThumb);
|
||
else queueImageThumbnail(full, safeRel);
|
||
}
|
||
|
||
const info = getInfo(safeRel) || {};
|
||
const rootFolder = rootFromRelPath(safeRel);
|
||
const added = info.added ?? info.createdAt ?? null;
|
||
const completedAt = info.completedAt ?? null;
|
||
const tracker = info.tracker ?? null;
|
||
const torrentName = info.name ?? null;
|
||
const infoHash = info.infoHash ?? null;
|
||
const relWithinRoot = safeRel.split(/[\\/]/).slice(1).join("/");
|
||
const fileMeta = relWithinRoot
|
||
? info.files?.[relWithinRoot] || null
|
||
: null;
|
||
const extensionForFile = fileMeta?.extension || path.extname(entry.name).replace(/^\./, "").toLowerCase() || null;
|
||
const mediaInfoForFile = fileMeta?.mediaInfo || null;
|
||
const seriesEpisodeInfo = relWithinRoot
|
||
? info.seriesEpisodes?.[relWithinRoot] || null
|
||
: null;
|
||
|
||
result.push({
|
||
name: safeRel,
|
||
size,
|
||
type,
|
||
url,
|
||
thumbnail: thumb,
|
||
rootFolder,
|
||
added,
|
||
completedAt,
|
||
tracker,
|
||
torrentName,
|
||
infoHash,
|
||
extension: extensionForFile,
|
||
mediaInfo: mediaInfoForFile,
|
||
primaryVideoPath: info.primaryVideoPath || null,
|
||
primaryMediaInfo: info.primaryMediaInfo || null,
|
||
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 });
|
||
}
|
||
});
|
||
|
||
// --- 🎬 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;
|
||
|
||
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 relativeFile =
|
||
normalizedEpisode.file || normalizedEpisode.videoPath || "";
|
||
if (!normalizedEpisode.videoPath && relativeFile) {
|
||
const joined = relativeFile.includes("/")
|
||
? relativeFile
|
||
: `${folder}/${relativeFile}`;
|
||
normalizedEpisode.videoPath = joined.replace(/\\/g, "/");
|
||
} else if (normalizedEpisode.videoPath) {
|
||
normalizedEpisode.videoPath = normalizedEpisode.videoPath.replace(
|
||
/\\/g,
|
||
"/"
|
||
);
|
||
}
|
||
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()).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(`✅ WebTorrent server ${PORT} portunda çalışıyor`)
|
||
);
|
||
|
||
wss = new WebSocketServer({ server });
|
||
wss.on("connection", (ws) => {
|
||
console.log("🔌 New WebSocket connection established");
|
||
ws.send(JSON.stringify({ type: "progress", torrents: snapshot() }));
|
||
// Bağlantı kurulduğunda disk space bilgisi gönder
|
||
broadcastDiskSpace();
|
||
|
||
ws.on("close", () => {
|
||
console.log("🔌 WebSocket connection closed");
|
||
});
|
||
|
||
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 });
|
||
}
|
||
});
|
||
|
||
client.on("error", (err) => {
|
||
if (!String(err).includes("uTP"))
|
||
console.error("WebTorrent error:", err.message);
|
||
});
|