From b7014ee27e8d0854a7a9ca06614087f778e0b192 Mon Sep 17 00:00:00 2001 From: wisecolt Date: Sat, 31 Jan 2026 18:28:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(webdav):=20webdav=20deste=C4=9Fi=20ekle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 12 ++ docker-compose.yml | 6 + server/package.json | 1 + server/server.js | 373 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 391 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 02f69d4..b3f06bf 100644 --- a/.env.example +++ b/.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 diff --git a/docker-compose.yml b/docker-compose.yml index c549a27..7a69c20 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/server/package.json b/server/package.json index 7b966be..70cfdcb 100644 --- a/server/package.json +++ b/server/package.json @@ -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" } diff --git a/server/server.js b/server/server.js index d1a33a2..ec13cf7 100644 --- a/server/server.js +++ b/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");