feat(webdav): webdav desteği ekle
webdav-server paketi kullanılarak WebDAV sunucusu entegre edildi. Film, TV ve Anime dizinleri WebDAV istemcileri (örn. Infuse) için otomatik olarak indekslenir ve sembolik bağlantılarla sunulur. Yapılandırma, Basic Auth ve salt-okuma modu için yeni ortam değişkenleri ve docker-compose ayarları eklendi.
This commit is contained in:
12
.env.example
12
.env.example
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
373
server/server.js
373
server/server.js
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user