Files
dupe/server/server.js
2025-10-28 15:38:30 +03:00

3213 lines
96 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import express from "express";
import cors from "cors";
import multer from "multer";
import WebTorrent from "webtorrent";
import fs from "fs";
import path from "path";
import mime from "mime-types";
import { WebSocketServer } from "ws";
import { fileURLToPath } from "url";
import { exec } from "child_process";
import crypto from "crypto"; // 🔒 basit token üretimi için
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 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;
}
}
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 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];
const backdropArtwork = artworksRaw.find((a) => {
const type = String(
a?.type ||
a?.artworkType ||
a?.type2 ||
a?.name ||
a?.artwork
).toLowerCase();
return (
type.includes("fanart") ||
type.includes("background") ||
type === "1"
);
});
const posterImage =
posterArtwork?.image ||
posterArtwork?.file ||
posterArtwork?.fileName ||
posterArtwork?.thumbnail ||
posterArtwork?.url ||
null;
const backdropImage =
backdropArtwork?.image ||
backdropArtwork?.file ||
backdropArtwork?.fileName ||
backdropArtwork?.thumbnail ||
backdropArtwork?.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)) {
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 broadcastSnapshot() {
if (!wss) return;
const data = JSON.stringify({ type: "progress", torrents: snapshot() });
wss.clients.forEach((c) => c.readyState === 1 && c.send(data));
}
// --- Snapshot (thumbnail dahil, tracker + tarih eklendi) ---
function snapshot() {
return Array.from(torrents.values()).map(
({ torrent, selectedIndex, savePath, added }) => {
const rootFolder = path.basename(savePath);
const bestVideoIndex = pickBestVideoFile(torrent);
const bestVideo = torrent.files[bestVideoIndex];
let thumbnail = null;
if (bestVideo) {
const relPath = path.join(rootFolder, bestVideo.path);
const { relThumb, absThumb } = getVideoThumbnailPaths(relPath);
if (fs.existsSync(absThumb)) thumbnail = thumbnailUrl(relThumb);
else if (torrent.progress === 1)
queueVideoThumbnail(path.join(savePath, bestVideo.path), relPath);
}
return {
infoHash: torrent.infoHash,
name: torrent.name,
progress: torrent.progress,
downloaded: torrent.downloaded,
downloadSpeed: torrent.downloadSpeed,
uploadSpeed: torrent.uploadSpeed,
numPeers: torrent.numPeers,
tracker: torrent.announce?.[0] || null,
added,
savePath, // 🆕 BURASI!
files: torrent.files.map((f, i) => ({
index: i,
name: f.name,
length: f.length
})),
selectedIndex,
thumbnail
};
}
);
}
// --- Basit kimlik doğrulama sistemi ---
const USERNAME = process.env.USERNAME;
const PASSWORD = process.env.PASSWORD;
let activeTokens = new Set();
app.post("/api/login", (req, res) => {
const { username, password } = req.body;
if (username === USERNAME && password === PASSWORD) {
const token = crypto.randomBytes(24).toString("hex");
activeTokens.add(token);
return res.json({ token });
}
res.status(401).json({ error: "Invalid credentials" });
});
function requireAuth(req, res, next) {
const token = req.headers.authorization?.split(" ")[1] || req.query.token;
if (!token || !activeTokens.has(token))
return res.status(401).json({ error: "Unauthorized" });
next();
}
// --- Torrent veya magnet ekleme ---
app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => {
try {
let source = req.body.magnet;
if (req.file) source = fs.readFileSync(req.file.path);
if (!source)
return res.status(400).json({ error: "magnet veya .torrent gerekli" });
// Her torrent için ayrı klasör
const savePath = path.join(DOWNLOAD_DIR, Date.now().toString());
fs.mkdirSync(savePath, { recursive: true });
const torrent = client.add(source, { announce: [], path: savePath });
// 🆕 Torrent eklendiği anda tarih kaydedelim
const added = Date.now();
torrents.set(torrent.infoHash, {
torrent,
selectedIndex: 0,
savePath,
added
});
// --- Metadata geldiğinde ---
torrent.on("ready", () => {
const selectedIndex = pickBestVideoFile(torrent);
torrents.set(torrent.infoHash, {
torrent,
selectedIndex,
savePath,
added
});
const rootFolder = path.basename(savePath);
upsertInfoFile(savePath, {
infoHash: torrent.infoHash,
name: torrent.name,
tracker: torrent.announce?.[0] || null,
added,
createdAt: added,
folder: rootFolder
});
broadcastFileUpdate(rootFolder);
res.json({
ok: true,
infoHash: torrent.infoHash,
name: torrent.name,
selectedIndex,
tracker: torrent.announce?.[0] || null,
added,
files: torrent.files.map((f, i) => ({
index: i,
name: f.name,
length: f.length
}))
});
broadcastSnapshot();
});
// --- İndirme tamamlandığında thumbnail oluştur ---
torrent.on("done", async () => {
const entry = torrents.get(torrent.infoHash);
if (!entry) return;
console.log(`✅ Torrent tamamlandı: ${torrent.name}`);
const rootFolder = path.basename(entry.savePath);
const bestVideoIndex = pickBestVideoFile(torrent);
const bestVideo =
torrent.files[bestVideoIndex] || torrent.files[0] || null;
const displayName = bestVideo?.name || torrent.name || rootFolder;
const bestVideoPath = bestVideo?.path
? bestVideo.path.replace(/\\/g, "/")
: null;
const perFileMetadata = {};
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);
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
});
});
});
// --- 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();
});
} 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 ignoreListte 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) => {
ws.send(JSON.stringify({ type: "progress", torrents: snapshot() }));
});
// --- ⏱️ Her 2 saniyede bir aktif torrent durumu yayınla ---
setInterval(() => {
if (torrents.size > 0) {
broadcastSnapshot();
}
}, 2000);
client.on("error", (err) => {
if (!String(err).includes("uTP"))
console.error("WebTorrent error:", err.message);
});