main #6

Closed
wisecolt wants to merge 6 commits from main into ph
4 changed files with 391 additions and 1 deletions
Showing only changes of commit b7014ee27e - Show all commits

View File

@@ -31,3 +31,15 @@ AUTO_PAUSE_ON_COMPLETE=0
# Medya işleme adımlarını (ffprobe/ffmpeg, thumbnail ve TMDB/TVDB metadata) devre dışı bırakır;
# CPU ve disk kullanımını düşürür, ancak kapalıyken medya bilgileri eksik kalır.
DISABLE_MEDIA_PROCESSING=0
# WebDAV erişimi; Infuse gibi istemciler için salt-okuma paylaşımlar.
WEBDAV_ENABLED=1
# WebDAV Basic Auth kullanıcı adı.
WEBDAV_USERNAME=dupe
# WebDAV Basic Auth şifresi (güçlü bir parola kullanın).
WEBDAV_PASSWORD=superpassword
# WebDAV kök path'i (proxy üzerinden erişilecek).
WEBDAV_PATH=/webdav
# WebDAV salt-okuma modu.
WEBDAV_READONLY=1
# WebDAV index yeniden oluşturma süresi (ms).
WEBDAV_INDEX_TTL=60000

View File

@@ -19,3 +19,9 @@ services:
DEBUG_CPU: ${DEBUG_CPU}
AUTO_PAUSE_ON_COMPLETE: ${AUTO_PAUSE_ON_COMPLETE}
DISABLE_MEDIA_PROCESSING: ${DISABLE_MEDIA_PROCESSING}
WEBDAV_ENABLED: ${WEBDAV_ENABLED}
WEBDAV_USERNAME: ${WEBDAV_USERNAME}
WEBDAV_PASSWORD: ${WEBDAV_PASSWORD}
WEBDAV_PATH: ${WEBDAV_PATH}
WEBDAV_READONLY: ${WEBDAV_READONLY}
WEBDAV_INDEX_TTL: ${WEBDAV_INDEX_TTL}

View File

@@ -12,6 +12,7 @@
"mime-types": "^2.1.35",
"multer": "^1.4.5-lts.1",
"node-fetch": "^3.3.2",
"webdav-server": "^2.6.2",
"webtorrent": "^1.9.7",
"ws": "^8.18.0"
}

View File

@@ -5,6 +5,7 @@ import WebTorrent from "webtorrent";
import fs from "fs";
import path from "path";
import mime from "mime-types";
import { v2 as webdav } from "webdav-server";
import { fileURLToPath } from "url";
import { exec, spawn } from "child_process";
import crypto from "crypto"; // 🔒 basit token üretimi için
@@ -28,6 +29,16 @@ const PORT = process.env.PORT || 3001;
const DEBUG_CPU = process.env.DEBUG_CPU === "1";
const DISABLE_MEDIA_PROCESSING = process.env.DISABLE_MEDIA_PROCESSING === "1";
const AUTO_PAUSE_ON_COMPLETE = process.env.AUTO_PAUSE_ON_COMPLETE === "1";
const WEBDAV_ENABLED = ["1", "true", "yes", "on"].includes(
String(process.env.WEBDAV_ENABLED || "").toLowerCase()
);
const WEBDAV_USERNAME = process.env.WEBDAV_USERNAME || "";
const WEBDAV_PASSWORD = process.env.WEBDAV_PASSWORD || "";
const WEBDAV_PATH = process.env.WEBDAV_PATH || "/webdav";
const WEBDAV_READONLY = !["0", "false", "no", "off"].includes(
String(process.env.WEBDAV_READONLY || "1").toLowerCase()
);
const WEBDAV_INDEX_TTL = Number(process.env.WEBDAV_INDEX_TTL || 60000);
// --- İndirilen dosyalar için klasör oluştur ---
const DOWNLOAD_DIR = path.join(__dirname, "downloads");
@@ -52,6 +63,7 @@ const MOVIE_DATA_ROOT = path.join(CACHE_DIR, "movie_data");
const TV_DATA_ROOT = path.join(CACHE_DIR, "tv_data");
const ANIME_DATA_ROOT = path.join(CACHE_DIR, "anime_data");
const YT_DATA_ROOT = path.join(CACHE_DIR, "yt_data");
const WEBDAV_ROOT = path.join(CACHE_DIR, "webdav");
const ANIME_ROOT_FOLDER = "_anime";
const ROOT_TRASH_REGISTRY = path.join(CACHE_DIR, "root-trash.json");
const MUSIC_EXTENSIONS = new Set([
@@ -73,7 +85,8 @@ for (const dir of [
MOVIE_DATA_ROOT,
TV_DATA_ROOT,
ANIME_DATA_ROOT,
YT_DATA_ROOT
YT_DATA_ROOT,
WEBDAV_ROOT
]) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
@@ -2572,6 +2585,335 @@ function resolveTvDataAbsolute(relPath) {
return resolved;
}
function sanitizeWebdavSegment(value) {
if (!value) return "Unknown";
return String(value)
.replace(/[\\/:*?"<>|]+/g, "_")
.replace(/\s+/g, " ")
.trim();
}
function makeUniqueWebdavName(base, used, suffix = "") {
let name = base || "Unknown";
if (!used.has(name)) {
used.add(name);
return name;
}
const fallback = suffix ? `${name} [${suffix}]` : `${name} [${used.size}]`;
if (!used.has(fallback)) {
used.add(fallback);
return fallback;
}
let counter = 2;
while (used.has(`${fallback} ${counter}`)) counter += 1;
const finalName = `${fallback} ${counter}`;
used.add(finalName);
return finalName;
}
function ensureDirSync(dirPath) {
if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true });
}
function createSymlinkSafe(targetPath, linkPath) {
try {
if (fs.existsSync(linkPath)) return;
ensureDirSync(path.dirname(linkPath));
let stat = null;
try {
stat = fs.statSync(targetPath);
} catch (err) {
return;
}
if (stat?.isFile?.()) {
try {
fs.linkSync(targetPath, linkPath);
return;
} catch (err) {
if (err?.code !== "EXDEV") {
// Hardlink başarısızsa symlink'e düş
fs.symlinkSync(targetPath, linkPath);
return;
}
}
}
fs.symlinkSync(targetPath, linkPath);
} catch (err) {
console.warn(`⚠️ WebDAV link oluşturulamadı (${linkPath}): ${err.message}`);
}
}
function resolveMovieVideoAbsPath(metadata) {
const dupe = metadata?._dupe || {};
const rootFolder = sanitizeRelative(dupe.folder || "") || null;
let videoPath = dupe.videoPath || metadata?.videoPath || null;
if (!videoPath) return null;
videoPath = String(videoPath).replace(/\\/g, "/").replace(/^\/+/, "");
const hasRoot = rootFolder && videoPath.startsWith(`${rootFolder}/`);
const absPath = hasRoot
? path.join(DOWNLOAD_DIR, videoPath)
: rootFolder
? path.join(DOWNLOAD_DIR, rootFolder, videoPath)
: path.join(DOWNLOAD_DIR, videoPath);
return fs.existsSync(absPath) ? absPath : null;
}
function resolveEpisodeAbsPath(rootFolder, episode) {
if (!episode) return null;
let videoPath = episode.videoPath || episode.file || "";
if (!videoPath) return null;
videoPath = String(videoPath).replace(/\\/g, "/").replace(/^\/+/, "");
const candidates = [];
if (rootFolder === ANIME_ROOT_FOLDER) {
candidates.push(path.join(DOWNLOAD_DIR, videoPath));
if (videoPath.includes("/")) {
candidates.push(path.join(DOWNLOAD_DIR, path.basename(videoPath)));
}
} else {
if (rootFolder && !videoPath.startsWith(`${rootFolder}/`)) {
candidates.push(path.join(DOWNLOAD_DIR, rootFolder, videoPath));
}
candidates.push(path.join(DOWNLOAD_DIR, videoPath));
}
if (episode.file && episode.file !== videoPath) {
const fallbackFile = String(episode.file)
.replace(/\\/g, "/")
.replace(/^\/+/, "");
candidates.push(path.join(DOWNLOAD_DIR, fallbackFile));
if (rootFolder && !fallbackFile.startsWith(`${rootFolder}/`)) {
candidates.push(path.join(DOWNLOAD_DIR, rootFolder, fallbackFile));
}
}
for (const absPath of candidates) {
if (fs.existsSync(absPath)) return absPath;
}
return null;
}
function rebuildWebdavIndex() {
try {
if (fs.existsSync(WEBDAV_ROOT)) {
fs.rmSync(WEBDAV_ROOT, { recursive: true, force: true });
}
fs.mkdirSync(WEBDAV_ROOT, { recursive: true });
} catch (err) {
console.warn(`⚠️ WebDAV kökü temizlenemedi (${WEBDAV_ROOT}): ${err.message}`);
return;
}
const categoryDefs = [
{ label: "Movies" },
{ label: "TV Shows" },
{ label: "Anime" }
];
for (const cat of categoryDefs) {
const dir = path.join(WEBDAV_ROOT, cat.label);
ensureDirSync(dir);
}
const isVideoExt = (value) =>
VIDEO_EXTS.includes(String(value || "").toLowerCase());
const shouldSkip = (relPath) => {
if (!relPath) return true;
const normalized = relPath.replace(/\\/g, "/").replace(/^\/+/, "");
const segments = normalized.split("/").filter(Boolean);
if (!segments.length) return true;
return segments.some((seg) => seg.startsWith("yt_"));
};
const makeEpisodeCode = (seasonNum, episodeNum) => {
const season = Number(seasonNum);
const episode = Number(episodeNum);
if (!Number.isFinite(season) || !Number.isFinite(episode)) return null;
return `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`;
};
const getEpisodeNumber = (episodeKey, episode) => {
const direct =
episode?.episodeNumber ??
episode?.number ??
episode?.episode ??
null;
if (Number.isFinite(Number(direct))) return Number(direct);
const match = String(episodeKey || "").match(/(\d{1,4})/);
return match ? Number(match[1]) : null;
};
// Movies
try {
const movieDirs = fs.readdirSync(MOVIE_DATA_ROOT, { withFileTypes: true });
const usedMovieNames = new Set();
for (const dirent of movieDirs) {
if (!dirent.isDirectory()) continue;
const metaPath = path.join(MOVIE_DATA_ROOT, dirent.name, "metadata.json");
if (!fs.existsSync(metaPath)) continue;
let metadata = null;
try {
metadata = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
} catch (err) {
continue;
}
const absVideo = resolveMovieVideoAbsPath(metadata);
if (!absVideo) continue;
if (shouldSkip(absVideo.replace(DOWNLOAD_DIR, ""))) continue;
const title =
metadata?.title ||
metadata?.matched_title ||
metadata?._dupe?.displayName ||
dirent.name;
const year =
metadata?.release_date?.slice?.(0, 4) ||
metadata?.matched_year ||
metadata?.year ||
null;
const baseName = sanitizeWebdavSegment(
year ? `${title} (${year})` : title
);
const uniqueName = makeUniqueWebdavName(
baseName,
usedMovieNames,
metadata?.id || dirent.name
);
const movieDir = path.join(WEBDAV_ROOT, "Movies", uniqueName);
ensureDirSync(movieDir);
const ext = path.extname(absVideo);
const fileName = sanitizeWebdavSegment(`${uniqueName}${ext}`);
const linkPath = path.join(movieDir, fileName);
createSymlinkSafe(absVideo, linkPath);
}
} catch (err) {
console.warn(`⚠️ WebDAV movie index oluşturulamadı: ${err.message}`);
}
const buildSeriesCategory = (dataRoot, categoryLabel) => {
try {
const dirs = fs.readdirSync(dataRoot, { withFileTypes: true });
const usedShowNames = new Set();
for (const dirent of dirs) {
if (!dirent.isDirectory()) continue;
const seriesDir = path.join(dataRoot, dirent.name);
const seriesPath = path.join(seriesDir, "series.json");
if (!fs.existsSync(seriesPath)) continue;
let seriesData = null;
try {
seriesData = JSON.parse(fs.readFileSync(seriesPath, "utf-8"));
} catch (err) {
continue;
}
const { rootFolder } = parseTvSeriesKey(dirent.name);
const showTitle = sanitizeWebdavSegment(
seriesData?.name || seriesData?.title || dirent.name
);
const uniqueShow = makeUniqueWebdavName(
showTitle,
usedShowNames,
seriesData?.id || dirent.name
);
const showDir = path.join(WEBDAV_ROOT, categoryLabel, uniqueShow);
const seasons = seriesData?.seasons || {};
let createdSeasonCount = 0;
for (const seasonKey of Object.keys(seasons)) {
const season = seasons[seasonKey];
if (!season?.episodes) continue;
const seasonNumber =
season?.seasonNumber ?? Number(seasonKey) ?? null;
const seasonLabel = seasonNumber
? `Season ${String(seasonNumber).padStart(2, "0")}`
: "Season";
const seasonDir = path.join(showDir, seasonLabel);
let createdEpisodeCount = 0;
for (const episodeKey of Object.keys(season.episodes)) {
const episode = season.episodes[episodeKey];
const absVideo = resolveEpisodeAbsPath(rootFolder, episode);
if (!absVideo) continue;
if (shouldSkip(absVideo.replace(DOWNLOAD_DIR, ""))) continue;
const ext = path.extname(absVideo);
if (!isVideoExt(ext)) continue;
if (createdEpisodeCount === 0) {
ensureDirSync(seasonDir);
ensureDirSync(showDir);
createdSeasonCount += 1;
}
const episodeNumber = getEpisodeNumber(episodeKey, episode);
const code = makeEpisodeCode(seasonNumber, episodeNumber);
const safeCode =
code ||
`S${String(seasonNumber || 0).padStart(2, "0")}E${String(
Number(episodeNumber) || 0
).padStart(2, "0")}`;
const fileName = sanitizeWebdavSegment(
`${uniqueShow} - ${safeCode}${ext}`
);
const linkPath = path.join(seasonDir, fileName);
createSymlinkSafe(absVideo, linkPath);
createdEpisodeCount += 1;
}
}
if (createdSeasonCount === 0 && fs.existsSync(showDir)) {
fs.rmSync(showDir, { recursive: true, force: true });
}
}
} catch (err) {
console.warn(`⚠️ WebDAV ${categoryLabel} index oluşturulamadı: ${err.message}`);
}
};
buildSeriesCategory(TV_DATA_ROOT, "TV Shows");
buildSeriesCategory(ANIME_DATA_ROOT, "Anime");
}
let webdavIndexLast = 0;
let webdavIndexBuilding = false;
async function ensureWebdavIndexFresh() {
if (!WEBDAV_ENABLED) return;
const now = Date.now();
if (webdavIndexBuilding) return;
if (now - webdavIndexLast < WEBDAV_INDEX_TTL) return;
webdavIndexBuilding = true;
try {
rebuildWebdavIndex();
webdavIndexLast = Date.now();
} finally {
webdavIndexBuilding = false;
}
}
function webdavAuthMiddleware(req, res, next) {
if (!WEBDAV_ENABLED) return res.status(404).end();
const authHeader = req.headers.authorization || "";
if (!authHeader.startsWith("Basic ")) {
res.setHeader("WWW-Authenticate", "Basic realm=\"Dupe WebDAV\"");
return res.status(401).end();
}
const raw = Buffer.from(authHeader.slice(6), "base64")
.toString("utf-8")
.split(":");
const user = raw.shift() || "";
const pass = raw.join(":");
if (!WEBDAV_USERNAME || !WEBDAV_PASSWORD) {
return res.status(500).end();
}
if (user !== WEBDAV_USERNAME || pass !== WEBDAV_PASSWORD) {
res.setHeader("WWW-Authenticate", "Basic realm=\"Dupe WebDAV\"");
return res.status(401).end();
}
return next();
}
function webdavReadonlyGuard(req, res, next) {
if (!WEBDAV_READONLY) return next();
const allowed = new Set(["GET", "HEAD", "OPTIONS", "PROPFIND"]);
if (!allowed.has(req.method)) {
return res.status(403).end();
}
return next();
}
function serveCachedFile(req, res, filePath, { maxAgeSeconds = 86400 } = {}) {
if (!fs.existsSync(filePath)) {
return res.status(404).send("Dosya bulunamadı");
@@ -8479,6 +8821,35 @@ if (restored.length) {
console.log(`♻️ ${restored.length} torrent yeniden eklendi.`);
}
// --- 📁 WebDAV (Infuse) ---
if (WEBDAV_ENABLED) {
const webdavServer = new webdav.WebDAVServer({
strictMode: false
});
const webdavBasePath = WEBDAV_PATH.startsWith("/")
? WEBDAV_PATH
: `/${WEBDAV_PATH}`;
const userManager = new webdav.SimpleUserManager();
if (WEBDAV_USERNAME && WEBDAV_PASSWORD) {
userManager.addUser(WEBDAV_USERNAME, WEBDAV_PASSWORD, false);
}
webdavServer.httpAuthentication = new webdav.HTTPBasicAuthentication(
userManager,
"Dupe WebDAV"
);
webdavServer.setFileSystem("/", new webdav.PhysicalFileSystem(WEBDAV_ROOT));
app.use(
webdavBasePath,
webdavAuthMiddleware,
webdavReadonlyGuard,
async (req, res) => {
await ensureWebdavIndexFresh();
webdavServer.executeRequest(req, res);
}
);
console.log(`📁 WebDAV aktif: ${webdavBasePath}`);
}
// --- ✅ Client build (frontend) dosyalarını sun ---
const publicDir = path.join(__dirname, "public");