9 Commits
ph ... mobile

Author SHA1 Message Date
6ac608a0b1 feat(movies): film tekrarlarını ve önbelleği temizle
Aynı film için birden fazla önbellek girdisi olduğunda en güncel olanı
tutup eski önbellekleri temizleyen mekanizma eklendi. Video yolu
bulunamayan filmlerin metadatası otomatik silinir.
2026-02-01 18:02:08 +03:00
e7925aa39f feat(webdav): film verilerini taşıma desteği ekle
Dosya taşıma işlemi sırasında etkilenen film verilerini ve metadatasını
korumak için yeni mantık eklendi. `collectMovieRelPathsForMove` ile
etkilenen yollar tespit edilirken, `moveMovieDataDir` ile fiziksel veri
klasörleri ve metadata.json dosyaları taşınarak `_dupe` referansları
güncelleniyor. Aynı kök dizin içinde veya farklı kök dizinler arasında
taşıma işlemleri destekleniyor.
2026-02-01 17:47:52 +03:00
e20b3ad591 feat(webdav): dizi metadatasını taşıma desteği ekle
Diziler ve bölümler kökler arası taşınırken ilişkili metadata
dosyalarının (.tvmetadata, series.json) güncellenmesini sağlar.
collectSeriesIdsForPath ile etkilenen dizileri tespit eder,
moveSeriesDataBetweenRoots ile metadata klasörlerini taşır ve
updateSeriesJsonAfterRootMove ile içindeki yolları günceller.
2026-02-01 17:35:53 +03:00
66aa99f0f7 feat(webdav): info.json tabanlı dizi indeksleme ekle
info.json dosyalarını okuyarak WebDAV dizin yapısını oluşturma
özelliği eklendi. Bu özellik, mevcut TV ve Anime veri köklerinde
olmayan ancak indirme dizininde bulunan dizileri indeksler.
2026-02-01 13:16:46 +03:00
b7014ee27e 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.
2026-01-31 18:28:31 +03:00
3b98df4348 docs: tanım cümlesinden vurguyu kaldır 2026-01-31 10:39:00 +03:00
e937a67090 docs: arayüz açıklamasına vurgu ekle 2026-01-31 10:38:08 +03:00
569a7975de refactor(files): konum senkronizasyonunu sağlamlaştır
URL tabanlı konum yönetimini tek bir fonksiyon altında toplayarak
tarayıcı navigasyonu ve history API olaylarının tutarlı şekilde işlenmesini
sağla. pushState ve replaceState metodlarını patch ederek özel locationchange
olayı oluşturur ve bileşen yok edildiğinde patch işlemini geri alır.
2026-01-31 10:36:07 +03:00
5e6da2b445 chore(deps): client bağımlılıklarını güncelle 2026-01-31 10:35:28 +03:00
6 changed files with 2275 additions and 41 deletions

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; # 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

File diff suppressed because it is too large Load Diff

View File

@@ -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) {

View File

@@ -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}

View File

@@ -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"
} }

View File

@@ -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ı");
@@ -4612,6 +5052,316 @@ function moveInfoDataBetweenRoots(oldRoot, newRoot, oldRel, newRel, isDirectory)
return true; return true;
} }
function collectSeriesIdsForPath(info, oldRel, isDirectory) {
const ids = new Set();
if (!info || typeof info !== "object") return ids;
const normalizedOldRel = normalizeTrashPath(oldRel);
const shouldMatch = (key) => {
if (!normalizedOldRel) return true;
const normalizedKey = normalizeTrashPath(key);
if (normalizedKey === normalizedOldRel) return true;
return (
isDirectory &&
normalizedOldRel &&
normalizedKey.startsWith(`${normalizedOldRel}/`)
);
};
const episodes = info.seriesEpisodes || {};
for (const [key, value] of Object.entries(episodes)) {
if (!shouldMatch(key)) continue;
const id = value?.showId ?? value?.id ?? null;
if (id) ids.add(id);
}
const files = info.files || {};
for (const [key, value] of Object.entries(files)) {
if (!shouldMatch(key)) continue;
const id = value?.seriesMatch?.id ?? null;
if (id) ids.add(id);
}
return ids;
}
function collectMovieRelPathsForMove(info, oldRel, isDirectory) {
const relPaths = new Set();
if (!info || typeof info !== "object") return relPaths;
const normalizedOldRel = normalizeTrashPath(oldRel);
const shouldMatch = (key) => {
if (!normalizedOldRel) return true;
const normalizedKey = normalizeTrashPath(key);
if (normalizedKey === normalizedOldRel) return true;
return (
isDirectory &&
normalizedOldRel &&
normalizedKey.startsWith(`${normalizedOldRel}/`)
);
};
if (info.primaryVideoPath && shouldMatch(info.primaryVideoPath)) {
relPaths.add(normalizeTrashPath(info.primaryVideoPath));
}
const files = info.files || {};
for (const [key, value] of Object.entries(files)) {
if (!value?.movieMatch) continue;
if (!shouldMatch(key)) continue;
relPaths.add(normalizeTrashPath(key));
}
return relPaths;
}
function mapRelPathForMove(oldRel, newRel, relPath, isDirectory) {
const normalizedOldRel = normalizeTrashPath(oldRel);
const normalizedNewRel = normalizeTrashPath(newRel);
const normalizedRel = normalizeTrashPath(relPath);
if (!normalizedOldRel) return normalizedRel;
if (normalizedRel === normalizedOldRel) return normalizedNewRel;
if (
isDirectory &&
normalizedRel.startsWith(`${normalizedOldRel}/`)
) {
const suffix = normalizedRel.slice(normalizedOldRel.length).replace(/^\/+/, "");
return normalizedNewRel
? `${normalizedNewRel}${suffix ? `/${suffix}` : ""}`
: suffix;
}
return normalizedRel;
}
function moveMovieDataDir(oldKey, newKey, oldRoot, newRoot, newRelPath) {
if (!oldKey || !newKey) return false;
if (oldKey === newKey) return false;
const oldDir = movieDataPathsByKey(oldKey).dir;
const newDir = movieDataPathsByKey(newKey).dir;
if (!fs.existsSync(oldDir)) return false;
if (fs.existsSync(newDir)) {
try {
fs.rmSync(newDir, { recursive: true, force: true });
} catch (err) {
console.warn(`⚠️ Movie metadata hedefi temizlenemedi (${newDir}): ${err.message}`);
}
}
try {
fs.renameSync(oldDir, newDir);
} catch (err) {
try {
fs.cpSync(oldDir, newDir, { recursive: true });
fs.rmSync(oldDir, { recursive: true, force: true });
} catch (copyErr) {
console.warn(`⚠️ Movie metadata taşınamadı (${oldDir}): ${copyErr.message}`);
return false;
}
}
const metadataPath = path.join(newDir, "metadata.json");
if (fs.existsSync(metadataPath)) {
try {
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
if (metadata?._dupe) {
metadata._dupe.folder = newRoot;
metadata._dupe.videoPath = newRelPath;
metadata._dupe.cacheKey = newKey;
}
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), "utf-8");
} catch (err) {
console.warn(`⚠️ movie metadata güncellenemedi (${metadataPath}): ${err.message}`);
}
}
return true;
}
function moveMovieDataBetweenRoots(oldRoot, newRoot, oldRel, newRel, relPaths, isDirectory) {
if (!oldRoot || !newRoot) return false;
if (!relPaths || relPaths.size === 0) return false;
let movedAny = false;
for (const relPath of relPaths) {
const mappedRel = mapRelPathForMove(oldRel, newRel, relPath, isDirectory);
const oldKey = movieDataKey(oldRoot, relPath);
const newKey = movieDataKey(newRoot, mappedRel);
if (moveMovieDataDir(oldKey, newKey, oldRoot, newRoot, mappedRel)) {
movedAny = true;
}
}
return movedAny;
}
function moveMovieDataWithinRoot(rootFolder, oldRel, newRel, relPaths, isDirectory) {
if (!rootFolder) return false;
if (!relPaths || relPaths.size === 0) return false;
let movedAny = false;
for (const relPath of relPaths) {
const mappedRel = mapRelPathForMove(oldRel, newRel, relPath, isDirectory);
const oldKey = movieDataKey(rootFolder, relPath);
const newKey = movieDataKey(rootFolder, mappedRel);
if (moveMovieDataDir(oldKey, newKey, rootFolder, rootFolder, mappedRel)) {
movedAny = true;
}
}
return movedAny;
}
function updateSeriesJsonAfterRootMove(seriesData, oldRoot, newRoot, oldRel, newRel) {
if (!seriesData || typeof seriesData !== "object") return false;
let changed = false;
const oldKey = seriesData?._dupe?.key || null;
if (seriesData._dupe) {
seriesData._dupe.folder = newRoot;
seriesData._dupe.key = tvSeriesKey(newRoot, seriesData._dupe.seriesId);
changed = true;
}
const encodeKey = (key) =>
String(key || "")
.split(path.sep)
.map(encodeURIComponent)
.join("/");
const oldKeyEncoded = oldKey ? encodeKey(oldKey) : null;
const newKeyEncoded = seriesData?._dupe?.key
? encodeKey(seriesData._dupe.key)
: null;
const oldPrefix = oldKeyEncoded ? `/tv-data/${oldKeyEncoded}/` : null;
const newPrefix = newKeyEncoded ? `/tv-data/${newKeyEncoded}/` : null;
const replaceTvDataPath = (value) => {
if (!value || !oldPrefix || !newPrefix || typeof value !== "string") {
return value;
}
if (value.includes(oldPrefix)) {
changed = true;
return value.replace(oldPrefix, newPrefix);
}
return value;
};
if (seriesData.poster) seriesData.poster = replaceTvDataPath(seriesData.poster);
if (seriesData.backdrop)
seriesData.backdrop = replaceTvDataPath(seriesData.backdrop);
const oldRelNorm = normalizeTrashPath(oldRel);
const newRelNorm = normalizeTrashPath(newRel);
const shouldTransform = (value) => {
const normalized = normalizeTrashPath(value);
if (!oldRelNorm) return true;
if (normalized === oldRelNorm) return true;
return (
oldRelNorm && normalized.startsWith(`${oldRelNorm}/`)
);
};
const transformRel = (value) => {
const normalized = normalizeTrashPath(value);
if (!shouldTransform(normalized)) return value;
const suffix = oldRelNorm
? normalized.slice(oldRelNorm.length).replace(/^\/+/, "")
: normalized;
const next = newRelNorm ? `${newRelNorm}${suffix ? `/${suffix}` : ""}` : suffix;
if (next !== value) changed = true;
return next;
};
const seasons = seriesData?.seasons || {};
for (const season of Object.values(seasons)) {
if (!season) continue;
if (season.poster) season.poster = replaceTvDataPath(season.poster);
if (!season.episodes) continue;
for (const episode of Object.values(season.episodes)) {
if (!episode) continue;
if (episode.still) episode.still = replaceTvDataPath(episode.still);
if (episode.file) {
const nextFile = transformRel(episode.file);
if (nextFile !== episode.file) {
episode.file = nextFile;
changed = true;
}
}
if (episode.videoPath) {
const video = String(episode.videoPath).replace(/\\/g, "/");
if (video.startsWith(`${oldRoot}/`)) {
episode.videoPath = `${newRoot}/${video.slice(oldRoot.length + 1)}`;
changed = true;
} else {
const nextVideo = transformRel(video);
if (nextVideo !== video) {
episode.videoPath = nextVideo;
changed = true;
}
}
}
}
}
return changed;
}
function moveSeriesDataBetweenRoots(oldRoot, newRoot, oldRel, newRel, seriesIds) {
if (!oldRoot || !newRoot) return false;
if (!seriesIds || !seriesIds.size) return false;
let movedAny = false;
for (const seriesId of seriesIds) {
if (!seriesId) continue;
const oldPaths = tvSeriesPaths(oldRoot, seriesId);
if (!oldPaths || !fs.existsSync(oldPaths.dir)) continue;
const newKey = tvSeriesKey(newRoot, seriesId);
if (!newKey) continue;
const newPaths = tvSeriesPathsByKey(newKey);
if (!newPaths) continue;
if (fs.existsSync(newPaths.dir)) {
try {
fs.rmSync(newPaths.dir, { recursive: true, force: true });
} catch (err) {
console.warn(`⚠️ TV metadata hedefi temizlenemedi (${newPaths.dir}): ${err.message}`);
}
}
try {
fs.renameSync(oldPaths.dir, newPaths.dir);
} catch (err) {
try {
fs.cpSync(oldPaths.dir, newPaths.dir, { recursive: true });
fs.rmSync(oldPaths.dir, { recursive: true, force: true });
} catch (copyErr) {
console.warn(`⚠️ TV metadata taşınamadı (${oldPaths.dir}): ${copyErr.message}`);
continue;
}
}
const metadataPath = path.join(newPaths.dir, "series.json");
if (fs.existsSync(metadataPath)) {
try {
const seriesData = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
const changed = updateSeriesJsonAfterRootMove(
seriesData,
oldRoot,
newRoot,
oldRel,
newRel
);
if (changed) {
fs.writeFileSync(
metadataPath,
JSON.stringify(seriesData, null, 2),
"utf-8"
);
}
} catch (err) {
console.warn(`⚠️ series.json güncellenemedi (${metadataPath}): ${err.message}`);
}
}
movedAny = true;
}
if (movedAny) {
renameSeriesDataPaths(newRoot, oldRel, newRel);
}
return movedAny;
}
function renameInfoPaths(rootFolder, oldRel, newRel) { function renameInfoPaths(rootFolder, oldRel, newRel) {
if (!rootFolder) return; if (!rootFolder) return;
const info = readInfoForRoot(rootFolder); const info = readInfoForRoot(rootFolder);
@@ -6161,6 +6911,18 @@ app.post("/api/file/move", requireAuth, (req, res) => {
.json({ error: "Kök klasör bu yöntemle taşınamaz" }); .json({ error: "Kök klasör bu yöntemle taşınamaz" });
} }
const preMoveInfo = sourceRoot ? readInfoForRoot(sourceRoot) : null;
const affectedSeriesIds = collectSeriesIdsForPath(
preMoveInfo,
sourceRelWithinRoot,
isDirectory
);
const affectedMovieRelPaths = collectMovieRelPathsForMove(
preMoveInfo,
sourceRelWithinRoot,
isDirectory
);
fs.renameSync(sourceFullPath, newFullPath); fs.renameSync(sourceFullPath, newFullPath);
const sameRoot = const sameRoot =
@@ -6172,6 +6934,15 @@ app.post("/api/file/move", requireAuth, (req, res) => {
renameInfoPaths(sourceRoot, sourceRelWithinRoot, destRelWithinRoot); renameInfoPaths(sourceRoot, sourceRelWithinRoot, destRelWithinRoot);
renameSeriesDataPaths(sourceRoot, sourceRelWithinRoot, destRelWithinRoot); renameSeriesDataPaths(sourceRoot, sourceRelWithinRoot, destRelWithinRoot);
renameTrashEntries(sourceRoot, sourceRelWithinRoot, destRelWithinRoot); renameTrashEntries(sourceRoot, sourceRelWithinRoot, destRelWithinRoot);
if (affectedMovieRelPaths.size) {
moveMovieDataWithinRoot(
sourceRoot,
sourceRelWithinRoot,
destRelWithinRoot,
affectedMovieRelPaths,
isDirectory
);
}
if (isDirectory) { if (isDirectory) {
removeThumbnailsForDirectory(sourceRoot, sourceRelWithinRoot); removeThumbnailsForDirectory(sourceRoot, sourceRelWithinRoot);
} else { } else {
@@ -6187,6 +6958,25 @@ app.post("/api/file/move", requireAuth, (req, res) => {
destRelWithinRoot, destRelWithinRoot,
isDirectory isDirectory
); );
if (affectedSeriesIds.size) {
moveSeriesDataBetweenRoots(
sourceRoot,
destRoot,
sourceRelWithinRoot,
destRelWithinRoot,
affectedSeriesIds
);
}
if (affectedMovieRelPaths.size) {
moveMovieDataBetweenRoots(
sourceRoot,
destRoot,
sourceRelWithinRoot,
destRelWithinRoot,
affectedMovieRelPaths,
isDirectory
);
}
if (isDirectory) { if (isDirectory) {
removeThumbnailsForDirectory(sourceRoot, sourceRelWithinRoot); removeThumbnailsForDirectory(sourceRoot, sourceRelWithinRoot);
} else { } else {
@@ -6775,7 +7565,7 @@ app.get("/api/movies", requireAuth, (req, res) => {
.readdirSync(MOVIE_DATA_ROOT, { withFileTypes: true }) .readdirSync(MOVIE_DATA_ROOT, { withFileTypes: true })
.filter((d) => d.isDirectory()); .filter((d) => d.isDirectory());
const movies = entries const rawMovies = entries
.map((dirent) => { .map((dirent) => {
const key = dirent.name; const key = dirent.name;
const paths = movieDataPathsByKey(key); const paths = movieDataPathsByKey(key);
@@ -6797,6 +7587,17 @@ app.get("/api/movies", requireAuth, (req, res) => {
const dupe = metadata._dupe || {}; const dupe = metadata._dupe || {};
const rootFolder = dupe.folder || key; const rootFolder = dupe.folder || key;
const videoPath = dupe.videoPath || metadata.videoPath || null; const videoPath = dupe.videoPath || metadata.videoPath || null;
const absVideo = resolveMovieVideoAbsPath(metadata);
if (!absVideo) {
try {
fs.rmSync(paths.dir, { recursive: true, force: true });
} catch (err) {
console.warn(
`⚠️ Movie metadata temizlenemedi (${paths.dir}): ${err.message}`
);
}
return null;
}
const encodedKey = key const encodedKey = key
.split(path.sep) .split(path.sep)
.map(encodeURIComponent) .map(encodeURIComponent)
@@ -6815,6 +7616,14 @@ app.get("/api/movies", requireAuth, (req, res) => {
: null); : null);
const cacheKey = paths.key; const cacheKey = paths.key;
return { return {
_absVideo: absVideo,
_cacheDir: paths.dir,
_cacheKey: cacheKey,
_dedupeKey:
typeof metadata.id === "number" && metadata.id
? `id:${metadata.id}`
: `title:${(metadata.title || metadata.matched_title || rootFolder)
.toLowerCase()}-${year || "unknown"}`,
folder: rootFolder, folder: rootFolder,
cacheKey, cacheKey,
id: id:
@@ -6849,6 +7658,51 @@ app.get("/api/movies", requireAuth, (req, res) => {
}) })
.filter(Boolean); .filter(Boolean);
const dedupedMap = new Map();
for (const item of rawMovies) {
const key = item._dedupeKey;
if (!dedupedMap.has(key)) {
dedupedMap.set(key, item);
continue;
}
const existing = dedupedMap.get(key);
const existingScore =
existing?.metadata?._dupe?.fetchedAt ||
existing?.metadata?._dupe?.matchedAt ||
existing?.metadata?.matchedAt ||
0;
const nextScore =
item?.metadata?._dupe?.fetchedAt ||
item?.metadata?._dupe?.matchedAt ||
item?.metadata?.matchedAt ||
0;
if (nextScore > existingScore) {
if (existing?._cacheDir) {
try {
fs.rmSync(existing._cacheDir, { recursive: true, force: true });
} catch (err) {
console.warn(
`⚠️ Movie metadata temizlenemedi (${existing._cacheDir}): ${err.message}`
);
}
}
dedupedMap.set(key, item);
} else if (item?._cacheDir) {
try {
fs.rmSync(item._cacheDir, { recursive: true, force: true });
} catch (err) {
console.warn(
`⚠️ Movie metadata temizlenemedi (${item._cacheDir}): ${err.message}`
);
}
}
}
const movies = Array.from(dedupedMap.values()).map((item) => {
const { _absVideo, _cacheDir, _cacheKey, _dedupeKey, ...rest } = item;
return rest;
});
movies.sort((a, b) => { movies.sort((a, b) => {
const yearA = a.year || 0; const yearA = a.year || 0;
const yearB = b.year || 0; const yearB = b.year || 0;
@@ -8479,6 +9333,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");