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