main #6
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;
|
# 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.
|
# CPU ve disk kullanımını düşürür, ancak kapalıyken medya bilgileri eksik kalır.
|
||||||
DISABLE_MEDIA_PROCESSING=0
|
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
|
||||||
|
|||||||
1292
client/package-lock.json
generated
Normal file
1292
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount, tick } from "svelte";
|
import { onDestroy, onMount, tick } from "svelte";
|
||||||
import MatchModal from "../components/MatchModal.svelte";
|
import MatchModal from "../components/MatchModal.svelte";
|
||||||
import { API, apiFetch, moveEntry, renameFolder, copyEntry } from "../utils/api.js";
|
import { API, apiFetch, moveEntry, renameFolder, copyEntry } from "../utils/api.js";
|
||||||
import { cleanFileName, extractTitleAndYear } from "../utils/filename.js";
|
import { cleanFileName, extractTitleAndYear } from "../utils/filename.js";
|
||||||
@@ -452,6 +452,7 @@
|
|||||||
const VIEW_KEY = "filesViewMode";
|
const VIEW_KEY = "filesViewMode";
|
||||||
let viewMode = "grid";
|
let viewMode = "grid";
|
||||||
let initialPath = "";
|
let initialPath = "";
|
||||||
|
let unpatchHistory = null;
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const storedView = window.localStorage.getItem(VIEW_KEY);
|
const storedView = window.localStorage.getItem(VIEW_KEY);
|
||||||
if (storedView === "grid" || storedView === "list") {
|
if (storedView === "grid" || storedView === "list") {
|
||||||
@@ -491,14 +492,16 @@
|
|||||||
let clipboardItem = null;
|
let clipboardItem = null;
|
||||||
let clipboardOperation = null; // 'cut' veya 'copy'
|
let clipboardOperation = null; // 'cut' veya 'copy'
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
const syncFromLocation = ({ replace = false } = {}) => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const pathParam = params.get("path");
|
const pathParam = params.get("path");
|
||||||
|
let nextPath = "";
|
||||||
if (pathParam) {
|
if (pathParam) {
|
||||||
try {
|
try {
|
||||||
initialPath = normalizePath(decodeURIComponent(pathParam));
|
nextPath = normalizePath(decodeURIComponent(pathParam));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
initialPath = normalizePath(pathParam);
|
nextPath = normalizePath(pathParam);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const playParam = params.get("play");
|
const playParam = params.get("play");
|
||||||
@@ -509,16 +512,49 @@
|
|||||||
pendingPlayTarget = playParam;
|
pendingPlayTarget = playParam;
|
||||||
}
|
}
|
||||||
params.delete("play");
|
params.delete("play");
|
||||||
|
const search = params.toString();
|
||||||
|
const newUrl = `${window.location.pathname}${search ? `?${search}` : ""}${window.location.hash}`;
|
||||||
|
window.history.replaceState(
|
||||||
|
{ path: nextPath, originalPath: null },
|
||||||
|
"",
|
||||||
|
newUrl,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const search = params.toString();
|
|
||||||
const newUrl = `${window.location.pathname}${search ? `?${search}` : ""}${window.location.hash}`;
|
const state = window.history.state || {};
|
||||||
window.history.replaceState(
|
const nextOriginal =
|
||||||
{ path: initialPath, originalPath: null },
|
typeof state.originalPath === "string"
|
||||||
"",
|
? normalizePath(state.originalPath)
|
||||||
newUrl,
|
: resolveOriginalPathForDisplay(nextPath);
|
||||||
);
|
|
||||||
|
if (
|
||||||
|
normalizePath(currentPath) === nextPath &&
|
||||||
|
normalizePath(currentOriginalPath) === nextOriginal
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hadSearch = searchTerm.trim().length > 0;
|
||||||
|
const nextSearchTerm = hadSearch ? "" : searchTerm;
|
||||||
|
currentPath = nextPath;
|
||||||
|
currentOriginalPath = nextOriginal;
|
||||||
|
if (hadSearch) {
|
||||||
|
clearSearch("files");
|
||||||
|
}
|
||||||
|
selectedItems = new Set();
|
||||||
|
activeMenu = null;
|
||||||
|
if (isCreatingFolder) cancelCreateFolder();
|
||||||
|
updateVisibleState(files, nextPath);
|
||||||
|
renderedEntries = filterEntriesBySearch(visibleEntries, nextSearchTerm);
|
||||||
|
if (replace) {
|
||||||
|
updateUrlPath(nextPath, nextOriginal, { replace: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
syncFromLocation({ replace: true });
|
||||||
|
initialPath = currentPath;
|
||||||
}
|
}
|
||||||
currentPath = normalizePath(initialPath);
|
|
||||||
// 🎬 Player kontrolleri
|
// 🎬 Player kontrolleri
|
||||||
let videoEl;
|
let videoEl;
|
||||||
let isPlaying = false;
|
let isPlaying = false;
|
||||||
@@ -1694,34 +1730,37 @@
|
|||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const handlePopState = (event) => {
|
const handlePopState = () => {
|
||||||
if (typeof window === "undefined") return;
|
syncFromLocation();
|
||||||
const statePath =
|
|
||||||
event?.state && typeof event.state.path === "string"
|
|
||||||
? event.state.path
|
|
||||||
: null;
|
|
||||||
const stateOriginal =
|
|
||||||
event?.state && typeof event.state.originalPath === "string"
|
|
||||||
? event.state.originalPath
|
|
||||||
: null;
|
|
||||||
if (statePath !== null) {
|
|
||||||
currentPath = normalizePath(statePath);
|
|
||||||
} else {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const paramPath = params.get("path");
|
|
||||||
currentPath = normalizePath(paramPath || "");
|
|
||||||
}
|
|
||||||
if (stateOriginal !== null) {
|
|
||||||
currentOriginalPath = normalizePath(stateOriginal);
|
|
||||||
} else {
|
|
||||||
currentOriginalPath = resolveOriginalPathForDisplay(
|
|
||||||
currentPath,
|
|
||||||
currentOriginalPath,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
selectedItems = new Set();
|
|
||||||
activeMenu = null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLocationChange = () => {
|
||||||
|
syncFromLocation();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const originalPushState = window.history.pushState;
|
||||||
|
const originalReplaceState = window.history.replaceState;
|
||||||
|
const triggerChange = () => {
|
||||||
|
window.dispatchEvent(new Event("locationchange"));
|
||||||
|
};
|
||||||
|
window.history.pushState = function (...args) {
|
||||||
|
originalPushState.apply(this, args);
|
||||||
|
triggerChange();
|
||||||
|
};
|
||||||
|
window.history.replaceState = function (...args) {
|
||||||
|
originalReplaceState.apply(this, args);
|
||||||
|
triggerChange();
|
||||||
|
};
|
||||||
|
window.addEventListener("locationchange", handleLocationChange);
|
||||||
|
window.addEventListener("popstate", handlePopState);
|
||||||
|
unpatchHistory = () => {
|
||||||
|
window.history.pushState = originalPushState;
|
||||||
|
window.history.replaceState = originalReplaceState;
|
||||||
|
window.removeEventListener("locationchange", handleLocationChange);
|
||||||
|
window.removeEventListener("popstate", handlePopState);
|
||||||
|
};
|
||||||
|
}
|
||||||
ws.onmessage = async (event) => {
|
ws.onmessage = async (event) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
@@ -1949,6 +1988,7 @@
|
|||||||
|
|
||||||
window.removeEventListener("click", handleClickOutside);
|
window.removeEventListener("click", handleClickOutside);
|
||||||
window.removeEventListener("popstate", handlePopState);
|
window.removeEventListener("popstate", handlePopState);
|
||||||
|
if (unpatchHistory) unpatchHistory();
|
||||||
|
|
||||||
// Resize observer'ı temizle
|
// Resize observer'ı temizle
|
||||||
if (breadcrumbContainer) {
|
if (breadcrumbContainer) {
|
||||||
|
|||||||
@@ -19,3 +19,9 @@ services:
|
|||||||
DEBUG_CPU: ${DEBUG_CPU}
|
DEBUG_CPU: ${DEBUG_CPU}
|
||||||
AUTO_PAUSE_ON_COMPLETE: ${AUTO_PAUSE_ON_COMPLETE}
|
AUTO_PAUSE_ON_COMPLETE: ${AUTO_PAUSE_ON_COMPLETE}
|
||||||
DISABLE_MEDIA_PROCESSING: ${DISABLE_MEDIA_PROCESSING}
|
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",
|
"mime-types": "^2.1.35",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
|
"webdav-server": "^2.6.2",
|
||||||
"webtorrent": "^1.9.7",
|
"webtorrent": "^1.9.7",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
}
|
}
|
||||||
|
|||||||
471
server/server.js
471
server/server.js
@@ -5,6 +5,7 @@ import WebTorrent from "webtorrent";
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import mime from "mime-types";
|
import mime from "mime-types";
|
||||||
|
import { v2 as webdav } from "webdav-server";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { exec, spawn } from "child_process";
|
import { exec, spawn } from "child_process";
|
||||||
import crypto from "crypto"; // 🔒 basit token üretimi için
|
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 DEBUG_CPU = process.env.DEBUG_CPU === "1";
|
||||||
const DISABLE_MEDIA_PROCESSING = process.env.DISABLE_MEDIA_PROCESSING === "1";
|
const DISABLE_MEDIA_PROCESSING = process.env.DISABLE_MEDIA_PROCESSING === "1";
|
||||||
const AUTO_PAUSE_ON_COMPLETE = process.env.AUTO_PAUSE_ON_COMPLETE === "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 ---
|
// --- İndirilen dosyalar için klasör oluştur ---
|
||||||
const DOWNLOAD_DIR = path.join(__dirname, "downloads");
|
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 TV_DATA_ROOT = path.join(CACHE_DIR, "tv_data");
|
||||||
const ANIME_DATA_ROOT = path.join(CACHE_DIR, "anime_data");
|
const ANIME_DATA_ROOT = path.join(CACHE_DIR, "anime_data");
|
||||||
const YT_DATA_ROOT = path.join(CACHE_DIR, "yt_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 ANIME_ROOT_FOLDER = "_anime";
|
||||||
const ROOT_TRASH_REGISTRY = path.join(CACHE_DIR, "root-trash.json");
|
const ROOT_TRASH_REGISTRY = path.join(CACHE_DIR, "root-trash.json");
|
||||||
const MUSIC_EXTENSIONS = new Set([
|
const MUSIC_EXTENSIONS = new Set([
|
||||||
@@ -73,7 +85,8 @@ for (const dir of [
|
|||||||
MOVIE_DATA_ROOT,
|
MOVIE_DATA_ROOT,
|
||||||
TV_DATA_ROOT,
|
TV_DATA_ROOT,
|
||||||
ANIME_DATA_ROOT,
|
ANIME_DATA_ROOT,
|
||||||
YT_DATA_ROOT
|
YT_DATA_ROOT,
|
||||||
|
WEBDAV_ROOT
|
||||||
]) {
|
]) {
|
||||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -2572,6 +2585,433 @@ function resolveTvDataAbsolute(relPath) {
|
|||||||
return resolved;
|
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,
|
||||||
|
usedShowNames,
|
||||||
|
coveredRoots
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const dirs = fs.readdirSync(dataRoot, { withFileTypes: true });
|
||||||
|
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);
|
||||||
|
if (coveredRoots) coveredRoots.add(rootFolder);
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tvUsedShowNames = new Set();
|
||||||
|
const animeUsedShowNames = new Set();
|
||||||
|
const coveredTvRoots = new Set();
|
||||||
|
buildSeriesCategory(TV_DATA_ROOT, "TV Shows", tvUsedShowNames, coveredTvRoots);
|
||||||
|
buildSeriesCategory(ANIME_DATA_ROOT, "Anime", animeUsedShowNames, null);
|
||||||
|
|
||||||
|
const buildSeriesFromInfoJson = (categoryLabel, usedShowNames, coveredRoots) => {
|
||||||
|
try {
|
||||||
|
const roots = fs.readdirSync(DOWNLOAD_DIR, { withFileTypes: true });
|
||||||
|
for (const dirent of roots) {
|
||||||
|
if (!dirent.isDirectory()) continue;
|
||||||
|
const rootFolder = dirent.name;
|
||||||
|
if (coveredRoots?.has(rootFolder)) continue;
|
||||||
|
const infoPath = path.join(DOWNLOAD_DIR, rootFolder, "info.json");
|
||||||
|
if (!fs.existsSync(infoPath)) continue;
|
||||||
|
let info = null;
|
||||||
|
try {
|
||||||
|
info = JSON.parse(fs.readFileSync(infoPath, "utf-8"));
|
||||||
|
} catch (err) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const episodes = info?.seriesEpisodes;
|
||||||
|
if (!episodes || typeof episodes !== "object") continue;
|
||||||
|
|
||||||
|
const showBuckets = new Map();
|
||||||
|
for (const [relPath, episode] of Object.entries(episodes)) {
|
||||||
|
if (!episode) continue;
|
||||||
|
if (shouldSkip(relPath)) continue;
|
||||||
|
const absVideo = path.join(DOWNLOAD_DIR, rootFolder, relPath);
|
||||||
|
if (!fs.existsSync(absVideo)) continue;
|
||||||
|
const ext = path.extname(absVideo).toLowerCase();
|
||||||
|
if (!isVideoExt(ext)) continue;
|
||||||
|
|
||||||
|
const showTitleRaw =
|
||||||
|
episode.showTitle || info?.name || rootFolder || "Unknown";
|
||||||
|
const showKey = `${episode.showId || ""}__${showTitleRaw}`;
|
||||||
|
if (!showBuckets.has(showKey)) {
|
||||||
|
showBuckets.set(showKey, {
|
||||||
|
title: showTitleRaw,
|
||||||
|
episodes: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showBuckets.get(showKey).episodes.push({
|
||||||
|
absVideo,
|
||||||
|
relPath,
|
||||||
|
season: episode.season,
|
||||||
|
episode: episode.episode,
|
||||||
|
key: episode.key
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bucket of showBuckets.values()) {
|
||||||
|
const showTitle = sanitizeWebdavSegment(bucket.title);
|
||||||
|
const uniqueShow = makeUniqueWebdavName(
|
||||||
|
showTitle,
|
||||||
|
usedShowNames,
|
||||||
|
bucket.title
|
||||||
|
);
|
||||||
|
const showDir = path.join(WEBDAV_ROOT, categoryLabel, uniqueShow);
|
||||||
|
let createdSeasonCount = 0;
|
||||||
|
for (const entry of bucket.episodes) {
|
||||||
|
const seasonNumber = Number(entry.season);
|
||||||
|
const episodeNumber = Number(entry.episode);
|
||||||
|
const seasonLabel = Number.isFinite(seasonNumber)
|
||||||
|
? `Season ${String(seasonNumber).padStart(2, "0")}`
|
||||||
|
: "Season";
|
||||||
|
const seasonDir = path.join(showDir, seasonLabel);
|
||||||
|
if (createdSeasonCount === 0) {
|
||||||
|
ensureDirSync(showDir);
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(seasonDir)) {
|
||||||
|
ensureDirSync(seasonDir);
|
||||||
|
createdSeasonCount += 1;
|
||||||
|
}
|
||||||
|
const ext = path.extname(entry.absVideo);
|
||||||
|
const code =
|
||||||
|
entry.key ||
|
||||||
|
makeEpisodeCode(seasonNumber, episodeNumber) ||
|
||||||
|
`S${String(seasonNumber || 0).padStart(2, "0")}E${String(
|
||||||
|
Number(episodeNumber) || 0
|
||||||
|
).padStart(2, "0")}`;
|
||||||
|
const fileName = sanitizeWebdavSegment(
|
||||||
|
`${uniqueShow} - ${code}${ext}`
|
||||||
|
);
|
||||||
|
const linkPath = path.join(seasonDir, fileName);
|
||||||
|
createSymlinkSafe(entry.absVideo, linkPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`⚠️ WebDAV ${categoryLabel} info.json index oluşturulamadı: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
buildSeriesFromInfoJson("TV Shows", tvUsedShowNames, coveredTvRoots);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 } = {}) {
|
function serveCachedFile(req, res, filePath, { maxAgeSeconds = 86400 } = {}) {
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
return res.status(404).send("Dosya bulunamadı");
|
return res.status(404).send("Dosya bulunamadı");
|
||||||
@@ -8479,6 +8919,35 @@ if (restored.length) {
|
|||||||
console.log(`♻️ ${restored.length} torrent yeniden eklendi.`);
|
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 ---
|
// --- ✅ Client build (frontend) dosyalarını sun ---
|
||||||
const publicDir = path.join(__dirname, "public");
|
const publicDir = path.join(__dirname, "public");
|
||||||
|
|||||||
Reference in New Issue
Block a user