From 3e07e2a2700435e05dbb1be2ff9faaa676b8529d Mon Sep 17 00:00:00 2001 From: szbk Date: Sun, 2 Nov 2025 00:15:06 +0300 Subject: [PATCH] Trash eklendi --- client/src/App.svelte | 4 +- client/src/components/Sidebar.svelte | 40 +- client/src/routes/Files.svelte | 469 +++++++++-- client/src/routes/Trash.svelte | 1130 +++++++++++++++++++++++++- client/src/routes/TvShows.svelte | 21 +- client/src/stores/trashStore.js | 67 ++ client/src/utils/api.js | 33 + server/server.js | 1041 ++++++++++++++++++++++-- 8 files changed, 2670 insertions(+), 135 deletions(-) create mode 100644 client/src/stores/trashStore.js diff --git a/client/src/App.svelte b/client/src/App.svelte index c386df3..3317a54 100644 --- a/client/src/App.svelte +++ b/client/src/App.svelte @@ -12,6 +12,7 @@ import { API } from "./utils/api.js"; import { refreshMovieCount } from "./stores/movieStore.js"; import { refreshTvShowCount } from "./stores/tvStore.js"; + import { fetchTrashItems } from "./stores/trashStore.js"; const token = localStorage.getItem("token"); @@ -24,7 +25,7 @@ refreshTimer = setTimeout(async () => { refreshTimer = null; try { - await Promise.all([refreshMovieCount(), refreshTvShowCount()]); + await Promise.all([refreshMovieCount(), refreshTvShowCount(), fetchTrashItems()]); } catch (err) { console.warn("Medya sayacı yenileme başarısız:", err); } @@ -45,6 +46,7 @@ if (token) { refreshMovieCount(); refreshTvShowCount(); + fetchTrashItems(); const authToken = localStorage.getItem("token"); if (authToken) { const wsUrl = `${API.replace("http", "ws")}?token=${authToken}`; diff --git a/client/src/components/Sidebar.svelte b/client/src/components/Sidebar.svelte index 7753b09..6381239 100644 --- a/client/src/components/Sidebar.svelte +++ b/client/src/components/Sidebar.svelte @@ -1,14 +1,16 @@ + +
+
+
+

Trash

+
+
+ {#if filteredTrashItems.length > 0 && selectedItems.size > 0} + {selectedItems.size} öğe seçildi + {/if} + {#if filteredTrashItems.length > 0 && selectedItems.size > 0} + + {/if} + {#if selectedItems.size > 0} + + + {/if} + {#if $trashItems.length > 0} + + {/if} + +
+
+ + {#if isLoading} +
+
+
Çöp yükleniyor...
+
+ {:else if filteredTrashItems.length === 0} +
+
+
+ {#if hasSearch} + Aramanla eşleşen öğe bulunamadı + {:else} + Çöp boş + {/if} +
+
+ {:else} + + {/if} +
+ +{#if activeMenu} + +{/if} + + diff --git a/client/src/routes/TvShows.svelte b/client/src/routes/TvShows.svelte index 0044ef7..bcc9999 100644 --- a/client/src/routes/TvShows.svelte +++ b/client/src/routes/TvShows.svelte @@ -280,8 +280,8 @@ let filteredShows = []; const ext = name.split(".").pop()?.toLowerCase() || ""; const inferredType = ext ? `video/${ext}` : "video/mp4"; const size = + Number(episode.fileSize) || Number(episode.mediaInfo?.format?.size) || - Number(episode.mediaInfo?.format?.bit_rate) || null; return { name, @@ -300,9 +300,19 @@ let filteredShows = []; ) .filter(Boolean); + const encodePathSegments = (value) => + value + ? value + .split(/[\\/]/) + .map((segment) => encodeURIComponent(segment)) + .join("/") + : ""; + $: selectedName = selectedVideo?.name ?? ""; - $: encName = selectedName ? encodeURIComponent(selectedName) : ""; - $: downloadHref = encName ? `${API}/downloads/${encName}` : "#"; + $: encName = encodePathSegments(selectedName); + $: downloadHref = encName + ? `${API}/downloads/${encName}?token=${localStorage.getItem("token") || ""}` + : "#"; $: selectedLabel = selectedVideo?.episode ? `${selectedVideo.show.title} · ${formatEpisodeCode( selectedVideo.episode @@ -414,8 +424,9 @@ async function openVideoAtIndex(index) { function getVideoURL() { if (!selectedName) return ""; const token = localStorage.getItem("token"); - // selectedName zaten encode edilmiş, tekrar encode etme - return `${API}/media/${selectedName}?token=${token}`; + const encoded = encodePathSegments(selectedName); + if (!encoded) return ""; + return `${API}/media/${encoded}?token=${token}`; } function playEpisodeFromCard(episode) { diff --git a/client/src/stores/trashStore.js b/client/src/stores/trashStore.js new file mode 100644 index 0000000..24747bc --- /dev/null +++ b/client/src/stores/trashStore.js @@ -0,0 +1,67 @@ +import { writable } from 'svelte/store'; +import { getTrashItems, restoreFromTrash, deleteFromTrash } from '../utils/api'; + +export const trashItems = writable([]); +export const trashCount = writable(0); + +// Çöp öğelerini API'den al +export async function fetchTrashItems() { + try { + const items = await getTrashItems(); + + const processedItems = Array.isArray(items) + ? items.map((item) => { + const segments = String(item.name || "") + .split(/[\\/]/) + .filter(Boolean); + const displayName = + segments.length > 0 ? segments[segments.length - 1] : item.name; + const parentPath = + segments.length > 1 ? segments.slice(0, -1).join("/") : ""; + return { + ...item, + displayName, + parentPath + }; + }) + : []; + + trashItems.set(processedItems); + trashCount.set(processedItems.length); + return processedItems; + } catch (error) { + console.error('Çöp öğeleri alınırken hata:', error); + trashCount.set(0); + } + return []; +} + +// Çöpten geri yükle +export async function restoreItem(trashName) { + try { + const result = await restoreFromTrash(trashName); + if (result.success) { + // Listeyi yenile + await fetchTrashItems(); + } + return result; + } catch (error) { + console.error('Öğe geri yüklenirken hata:', error); + throw error; + } +} + +// Çöpten tamamen sil +export async function deleteItemPermanently(trashName) { + try { + const result = await deleteFromTrash(trashName); + if (result.success) { + // Listeyi yenile + await fetchTrashItems(); + } + return result; + } catch (error) { + console.error('Öğe silinirken hata:', error); + throw error; + } +} diff --git a/client/src/utils/api.js b/client/src/utils/api.js index ca63aaf..3543d97 100644 --- a/client/src/utils/api.js +++ b/client/src/utils/api.js @@ -19,3 +19,36 @@ export async function apiFetch(path, options = {}) { } return res; } + +// 🗑️ Çöp API'leri +export async function getTrashItems() { + const res = await apiFetch("/api/trash"); + return res.json(); +} + +export async function restoreFromTrash(trashName) { + const res = await apiFetch("/api/trash/restore", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ trashName }) + }); + return res.json(); +} + +export async function deleteFromTrash(trashName) { + const res = await apiFetch(`/api/trash`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ trashName }) + }); + return res.json(); +} + +export async function renameFolder(path, newName) { + const res = await apiFetch("/api/folder", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, newName }) + }); + return res.json(); +} diff --git a/server/server.js b/server/server.js index 70311da..c8acd16 100644 --- a/server/server.js +++ b/server/server.js @@ -26,6 +26,11 @@ const DOWNLOAD_DIR = path.join(__dirname, "downloads"); if (!fs.existsSync(DOWNLOAD_DIR)) fs.mkdirSync(DOWNLOAD_DIR, { recursive: true }); +// --- Çöp klasörü oluştur --- +const TRASH_DIR = path.join(__dirname, "trash"); +if (!fs.existsSync(TRASH_DIR)) + fs.mkdirSync(TRASH_DIR, { recursive: true }); + // --- Thumbnail cache klasörü --- const CACHE_DIR = path.join(__dirname, "cache"); const THUMBNAIL_DIR = path.join(CACHE_DIR, "thumbnails"); @@ -627,6 +632,228 @@ function cleanupEmptyDirs(startDir) { } } +// --- 🗑️ .trash yardımcı fonksiyonları --- +const trashStateCache = new Map(); + +function normalizeTrashPath(value) { + if (value === null || value === undefined) return ""; + return String(value).replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); +} + +function trashFlagPathFor(rootFolder) { + const safeRoot = sanitizeRelative(rootFolder); + if (!safeRoot) return null; + return path.join(DOWNLOAD_DIR, safeRoot, ".trash"); +} + +function readTrashRegistry(rootFolder) { + const flagPath = trashFlagPathFor(rootFolder); + if (!flagPath || !fs.existsSync(flagPath)) return null; + try { + const raw = JSON.parse(fs.readFileSync(flagPath, "utf-8")); + if (!raw || typeof raw !== "object") return null; + if (!Array.isArray(raw.items)) raw.items = []; + raw.items = raw.items + .map((item) => { + if (!item || typeof item !== "object") return null; + const normalizedPath = normalizeTrashPath(item.path); + return { + ...item, + path: normalizedPath, + originalPath: item.originalPath || normalizedPath, + deletedAt: Number(item.deletedAt) || Date.now(), + isDirectory: Boolean(item.isDirectory), + type: item.type || (item.isDirectory ? "inode/directory" : null) + }; + }) + .filter(Boolean); + return raw; + } catch (err) { + console.warn(`⚠️ .trash dosyası okunamadı (${flagPath}): ${err.message}`); + return null; + } +} + +function writeTrashRegistry(rootFolder, registry) { + const flagPath = trashFlagPathFor(rootFolder); + if (!flagPath) return; + const items = Array.isArray(registry?.items) + ? registry.items.filter(Boolean) + : []; + if (!items.length) { + try { + if (fs.existsSync(flagPath)) fs.rmSync(flagPath, { force: true }); + } catch (err) { + console.warn(`⚠️ .trash kaldırılırken hata (${flagPath}): ${err.message}`); + } + trashStateCache.delete(rootFolder); + return; + } + const payload = { + updatedAt: Date.now(), + items: items.map((item) => ({ + ...item, + path: normalizeTrashPath(item.path), + originalPath: item.originalPath || normalizeTrashPath(item.path), + deletedAt: Number(item.deletedAt) || Date.now(), + isDirectory: Boolean(item.isDirectory), + type: item.type || (item.isDirectory ? "inode/directory" : null) + })) + }; + try { + fs.writeFileSync(flagPath, JSON.stringify(payload, null, 2), "utf-8"); + } catch (err) { + console.warn(`⚠️ .trash yazılamadı (${flagPath}): ${err.message}`); + } + trashStateCache.delete(rootFolder); +} + +function addTrashEntry(rootFolder, entry) { + if (!rootFolder || !entry) return null; + const safeRoot = sanitizeRelative(rootFolder); + if (!safeRoot) return null; + + const registry = readTrashRegistry(safeRoot) || { items: [] }; + const normalizedPath = normalizeTrashPath(entry.path); + const isDirectory = Boolean(entry.isDirectory); + const timestamp = Number(entry.deletedAt) || Date.now(); + const type = + entry.type || + (isDirectory + ? "inode/directory" + : mime.lookup(entry.originalPath || normalizedPath) || + "application/octet-stream"); + + let items = registry.items.filter((item) => { + const itemPath = normalizeTrashPath(item.path); + if (isDirectory) { + if (!itemPath) return false; + if (itemPath === normalizedPath) return false; + if (itemPath.startsWith(`${normalizedPath}/`)) return false; + return true; + } + + // üst klasör çöpteyse tekrar eklemeye gerek yok + if (item.isDirectory) { + const normalizedItemPath = itemPath; + if ( + !normalizedPath || + normalizedPath === normalizedItemPath || + normalizedPath.startsWith(`${normalizedItemPath}/`) + ) { + return true; + } + } + return itemPath !== normalizedPath; + }); + + if (!isDirectory) { + const ancestor = items.find( + (item) => + item.isDirectory && + (normalizeTrashPath(item.path) === "" || + normalizedPath === normalizeTrashPath(item.path) || + normalizedPath.startsWith(`${normalizeTrashPath(item.path)}/`)) + ); + if (ancestor) { + trashStateCache.delete(safeRoot); + return ancestor; + } + } + + const newEntry = { + ...entry, + path: normalizedPath, + originalPath: + entry.originalPath || + (normalizedPath ? `${safeRoot}/${normalizedPath}` : safeRoot), + deletedAt: timestamp, + isDirectory, + type + }; + items.push(newEntry); + writeTrashRegistry(safeRoot, { ...registry, items }); + return newEntry; +} + +function removeTrashEntry(rootFolder, relPath) { + if (!rootFolder) return null; + const safeRoot = sanitizeRelative(rootFolder); + if (!safeRoot) return null; + const registry = readTrashRegistry(safeRoot); + if (!registry || !Array.isArray(registry.items)) return null; + + const normalized = normalizeTrashPath(relPath); + const removed = []; + const kept = []; + + for (const item of registry.items) { + const itemPath = normalizeTrashPath(item.path); + if ( + itemPath === normalized || + (item.isDirectory && + normalized && + normalized.startsWith(`${itemPath}/`)) + ) { + removed.push(item); + continue; + } + kept.push(item); + } + + if (!removed.length) return null; + writeTrashRegistry(safeRoot, { ...registry, items: kept }); + return removed[0]; +} + +function getTrashStateForRoot(rootFolder) { + if (!rootFolder) return null; + if (trashStateCache.has(rootFolder)) return trashStateCache.get(rootFolder); + + const registry = readTrashRegistry(rootFolder); + if (!registry || !Array.isArray(registry.items) || !registry.items.length) { + trashStateCache.set(rootFolder, null); + return null; + } + + const directories = []; + const files = new Set(); + + for (const item of registry.items) { + const normalizedPath = normalizeTrashPath(item.path); + if (item.isDirectory) { + directories.push(normalizedPath); + } else { + files.add(normalizedPath); + } + } + + directories.sort((a, b) => a.length - b.length); + + const result = { registry, directories, files }; + trashStateCache.set(rootFolder, result); + return result; +} + +function isPathTrashed(rootFolder, relPath, isDirectory = false) { + if (!rootFolder) return false; + const state = getTrashStateForRoot(rootFolder); + if (!state) return false; + + const normalized = normalizeTrashPath(relPath); + for (const dirPath of state.directories) { + if (!dirPath) return true; + if (normalized === dirPath) return true; + if (normalized.startsWith(`${dirPath}/`)) return true; + } + + if (!isDirectory && state.files.has(normalized)) { + return true; + } + + return false; +} + function resolveThumbnailAbsolute(relThumbPath) { const normalized = sanitizeRelative(relThumbPath); const resolved = path.resolve(THUMBNAIL_DIR, normalized); @@ -2074,6 +2301,301 @@ function pruneInfoEntry(rootFolder, relativePath) { } } +function pruneInfoForDirectory(rootFolder, relativeDir) { + if (!rootFolder) return; + const info = readInfoForRoot(rootFolder); + if (!info) return; + + const normalizedDir = normalizeTrashPath(relativeDir); + const prefix = normalizedDir ? `${normalizedDir}/` : ""; + const removedEpisodePaths = []; + let changed = false; + + if (info.files && typeof info.files === "object") { + for (const key of Object.keys(info.files)) { + if (key === normalizedDir || (prefix && key.startsWith(prefix))) { + delete info.files[key]; + changed = true; + } + } + if (Object.keys(info.files).length === 0) delete info.files; + } + + if (info.seriesEpisodes && typeof info.seriesEpisodes === "object") { + for (const key of Object.keys(info.seriesEpisodes)) { + if (key === normalizedDir || (prefix && key.startsWith(prefix))) { + removedEpisodePaths.push(key); + delete info.seriesEpisodes[key]; + changed = true; + } + } + if (Object.keys(info.seriesEpisodes).length === 0) { + delete info.seriesEpisodes; + } + } + + if (info.primaryVideoPath) { + if ( + info.primaryVideoPath === normalizedDir || + (prefix && info.primaryVideoPath.startsWith(prefix)) + ) { + delete info.primaryVideoPath; + delete info.primaryMediaInfo; + delete info.movieMatch; + changed = true; + } + } + + if (changed) { + const safe = sanitizeRelative(rootFolder); + if (!safe) return; + const target = path.join(DOWNLOAD_DIR, safe, INFO_FILENAME); + try { + info.updatedAt = Date.now(); + fs.writeFileSync(target, JSON.stringify(info, null, 2), "utf-8"); + } catch (err) { + console.warn(`⚠️ info.json güncellenemedi (${target}): ${err.message}`); + } + } + + // TV metadata dosyalarından da temizle + for (const relPath of removedEpisodePaths) { + try { + removeSeriesEpisode(rootFolder, relPath); + } catch (err) { + console.warn( + `⚠️ Serie metadata temizlenemedi (${rootFolder}/${relPath}): ${err.message}` + ); + } + } +} + +function renameInfoPaths(rootFolder, oldRel, newRel) { + if (!rootFolder) return; + const info = readInfoForRoot(rootFolder); + if (!info) return; + + const oldPrefix = normalizeTrashPath(oldRel); + const newPrefix = normalizeTrashPath(newRel); + if (!oldPrefix || oldPrefix === newPrefix) return; + + const transformKey = (key) => { + const normalizedKey = normalizeTrashPath(key); + if ( + normalizedKey === oldPrefix || + normalizedKey.startsWith(`${oldPrefix}/`) + ) { + const suffix = normalizedKey.slice(oldPrefix.length).replace(/^\/+/, ""); + return newPrefix + ? `${newPrefix}${suffix ? `/${suffix}` : ""}` + : suffix; + } + return normalizedKey; + }; + + let changed = false; + + if (info.files && typeof info.files === "object") { + const nextFiles = {}; + for (const [key, value] of Object.entries(info.files)) { + const nextKey = transformKey(key); + if (nextKey !== key) changed = true; + nextFiles[nextKey] = value; + } + info.files = nextFiles; + } + + if (info.seriesEpisodes && typeof info.seriesEpisodes === "object") { + const nextEpisodes = {}; + for (const [key, value] of Object.entries(info.seriesEpisodes)) { + const nextKey = transformKey(key); + if (nextKey !== key) changed = true; + nextEpisodes[nextKey] = value; + } + info.seriesEpisodes = nextEpisodes; + } + + if ( + info.primaryVideoPath && + (info.primaryVideoPath === oldPrefix || + info.primaryVideoPath.startsWith(`${oldPrefix}/`)) + ) { + const suffix = info.primaryVideoPath.slice(oldPrefix.length).replace(/^\/+/, ""); + info.primaryVideoPath = newPrefix + ? `${newPrefix}${suffix ? `/${suffix}` : ""}` + : suffix; + changed = true; + } + + if (changed) { + const safe = sanitizeRelative(rootFolder); + if (!safe) return; + const target = path.join(DOWNLOAD_DIR, safe, INFO_FILENAME); + try { + info.updatedAt = Date.now(); + fs.writeFileSync(target, JSON.stringify(info, null, 2), "utf-8"); + } catch (err) { + console.warn(`⚠️ info.json güncellenemedi (${target}): ${err.message}`); + } + } +} + +function renameSeriesDataPaths(rootFolder, oldRel, newRel) { + if (!rootFolder) return; + const oldPrefix = normalizeTrashPath(oldRel); + const newPrefix = normalizeTrashPath(newRel); + if (!oldPrefix || oldPrefix === newPrefix) return; + + const metadataPath = tvSeriesPaths(rootFolder).metadata; + if (!fs.existsSync(metadataPath)) return; + + let seriesData; + try { + seriesData = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); + } catch (err) { + console.warn(`⚠️ series.json okunamadı (${metadataPath}): ${err.message}`); + return; + } + + const transform = (value) => { + const normalized = normalizeTrashPath(value); + if ( + normalized === oldPrefix || + normalized.startsWith(`${oldPrefix}/`) + ) { + const suffix = normalized.slice(oldPrefix.length).replace(/^\/+/, ""); + return newPrefix + ? `${newPrefix}${suffix ? `/${suffix}` : ""}` + : suffix; + } + return value; + }; + + let changed = false; + + const seasons = seriesData?.seasons || {}; + for (const season of Object.values(seasons)) { + if (!season?.episodes) continue; + for (const episode of Object.values(season.episodes)) { + if (!episode || typeof episode !== "object") continue; + if (episode.file) { + const nextFile = transform(episode.file); + if (nextFile !== episode.file) { + episode.file = nextFile; + changed = true; + } + } + if (episode.videoPath) { + const nextVideo = transform(episode.videoPath); + if (nextVideo !== episode.videoPath) { + episode.videoPath = nextVideo; + changed = true; + } + } + } + } + + if (changed) { + try { + fs.writeFileSync(metadataPath, JSON.stringify(seriesData, null, 2), "utf-8"); + } catch (err) { + console.warn( + `⚠️ series.json güncellenemedi (${metadataPath}): ${err.message}` + ); + } + } +} + +function removeThumbnailsForDirectory(rootFolder, relativeDir) { + const normalizedRoot = sanitizeRelative(rootFolder); + if (!normalizedRoot) return; + const normalizedDir = normalizeTrashPath(relativeDir); + const segments = normalizedDir ? normalizedDir.split("/") : []; + + const videoThumbDir = path.join( + VIDEO_THUMB_ROOT, + normalizedRoot, + ...segments + ); + const imageThumbDir = path.join( + IMAGE_THUMB_ROOT, + normalizedRoot, + ...segments + ); + + for (const dir of [videoThumbDir, imageThumbDir]) { + if (!dir.startsWith(THUMBNAIL_DIR)) continue; + if (fs.existsSync(dir)) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch (err) { + console.warn(`⚠️ Thumbnail klasörü silinemedi (${dir}): ${err.message}`); + } + } + cleanupEmptyDirs(path.dirname(dir)); + } +} + +function renameTrashEntries(rootFolder, oldRel, newRel) { + if (!rootFolder) return; + const registry = readTrashRegistry(rootFolder); + if (!registry || !Array.isArray(registry.items)) return; + + const oldPrefix = normalizeTrashPath(oldRel); + const newPrefix = normalizeTrashPath(newRel); + if (!oldPrefix || oldPrefix === newPrefix) return; + + let changed = false; + const updatedItems = registry.items.map((item) => { + const itemPath = normalizeTrashPath(item.path); + if ( + itemPath === oldPrefix || + itemPath.startsWith(`${oldPrefix}/`) + ) { + const suffix = itemPath.slice(oldPrefix.length).replace(/^\/+/, ""); + const nextPath = newPrefix + ? `${newPrefix}${suffix ? `/${suffix}` : ""}` + : suffix; + changed = true; + return { + ...item, + path: nextPath, + originalPath: nextPath + ? `${rootFolder}/${nextPath}` + : rootFolder + }; + } + return item; + }); + + if (changed) { + writeTrashRegistry(rootFolder, { ...registry, items: updatedItems }); + } +} + +function renameRootCaches(oldRoot, newRoot) { + const pairs = [ + VIDEO_THUMB_ROOT, + IMAGE_THUMB_ROOT, + MOVIE_DATA_ROOT, + TV_DATA_ROOT + ]; + + for (const base of pairs) { + const from = path.join(base, oldRoot); + if (!fs.existsSync(from)) continue; + const to = path.join(base, newRoot); + try { + fs.mkdirSync(path.dirname(to), { recursive: true }); + fs.renameSync(from, to); + } catch (err) { + console.warn( + `⚠️ Kök cache klasörü yeniden adlandırılamadı (${from} -> ${to}): ${err.message}` + ); + } + } +} + function broadcastFileUpdate(rootFolder) { if (!wss) return; const data = JSON.stringify({ @@ -2389,6 +2911,19 @@ app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => { // Torrent tamamlandığında disk space bilgisini güncelle broadcastDiskSpace(); + // Medya tespiti tamamlandığında özel bildirim gönder + if (Object.keys(seriesEpisodes).length > 0 || infoUpdate.primaryMediaInfo) { + if (wss) { + const data = JSON.stringify({ + type: "mediaDetected", + rootFolder, + hasSeriesEpisodes: Object.keys(seriesEpisodes).length > 0, + hasMovieMatch: !!infoUpdate.primaryMediaInfo + }); + wss.clients.forEach((c) => c.readyState === 1 && c.send(data)); + } + } + broadcastSnapshot(); }); } catch (err) { @@ -2735,8 +3270,6 @@ app.get("/media/:path(*)", requireAuth, (req, res) => { const isVideo = String(type).startsWith("video/"); const range = req.headers.range; - console.log("Media info:", { fileSize, type, isVideo, range }); // Debug için log ekle - // CORS headers ekle res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS"); @@ -2767,7 +3300,7 @@ app.get("/media/:path(*)", requireAuth, (req, res) => { } }); -// --- 🗑️ Tekil dosya veya torrent klasörü silme --- +// --- 🗑️ Tekil dosya veya torrent klasörüne .trash flag'i ekleme --- app.delete("/api/file", requireAuth, (req, res) => { const filePath = req.query.path; if (!filePath) return res.status(400).json({ error: "path gerekli" }); @@ -2782,7 +3315,7 @@ app.delete("/api/file", requireAuth, (req, res) => { stats = fs.statSync(fullPath); } catch (err) { const message = err?.message || String(err); - console.warn(`⚠️ Silme sırasında stat alınamadı (${fullPath}): ${message}`); + console.warn(`⚠️ Silme işlemi sırasında stat alınamadı (${fullPath}): ${message}`); } if (!stats || !fs.existsSync(fullPath)) { @@ -2795,60 +3328,47 @@ app.delete("/api/file", requireAuth, (req, res) => { } try { - fs.rmSync(fullPath, { recursive: true, force: true }); - console.log(`🗑️ Dosya/klasör silindi: ${fullPath}`); - removeThumbnailsForPath(safePath); + const isDirectory = stats.isDirectory(); + const relWithinRoot = safePath.split(/[\\/]/).slice(1).join("/"); + let trashEntry = null; - if (folderId) { - const relWithinRoot = safePath.split(/[\/]/).slice(1).join("/"); - const rootExists = rootDir && fs.existsSync(rootDir); + if (folderId && rootDir) { + trashEntry = addTrashEntry(folderId, { + path: relWithinRoot, + originalPath: safePath, + isDirectory, + deletedAt: Date.now(), + type: isDirectory + ? "inode/directory" + : mime.lookup(fullPath) || "application/octet-stream" + }); - if (!relWithinRoot || !rootExists) { - purgeRootFolder(folderId); + if (isDirectory) { + pruneInfoForDirectory(folderId, relWithinRoot); } else { - const remaining = fs.readdirSync(rootDir); - const meaningful = remaining.filter((name) => { - if (!name) return false; - if (name === INFO_FILENAME) return false; - if (name.startsWith(".")) return false; - return true; - }); - pruneInfoEntry(folderId, relWithinRoot); removeSeriesEpisode(folderId, relWithinRoot); - - if (meaningful.length === 0) { - removeAllThumbnailsForRoot(folderId); - removeMovieData(folderId); - removeSeriesData(folderId); - const infoPath = path.join(rootDir, INFO_FILENAME); - if (fs.existsSync(infoPath)) { - try { - fs.rmSync(infoPath, { force: true }); - } catch (err) { - console.warn(`⚠️ info.json kaldırılamadı (${infoPath}): ${err.message}`); - } - } - } else { - const infoAfter = readInfoForRoot(folderId); - const displayName = infoAfter?.name || folderId; - const primaryVideo = infoAfter?.primaryVideoPath || guessPrimaryVideo(folderId); - if (primaryVideo) { - const candidateMedia = - infoAfter?.files?.[primaryVideo]?.mediaInfo || - infoAfter?.primaryMediaInfo || - null; - ensureMovieData(folderId, displayName, primaryVideo, candidateMedia).catch( - (err) => - console.warn( - `⚠️ Movie metadata yenilenemedi (${folderId}): ${err?.message || err}` - ) - ); - } - } } + } + if (isDirectory) { + console.log(`🗑️ Klasör çöpe taşındı (işaretlendi): ${safePath}`); + } else { + console.log(`🗑️ Dosya çöpe taşındı (işaretlendi): ${fullPath}`); + removeThumbnailsForPath(safePath); + } + + if (!folderId) { + // Kök klasöre ait olmayan dosyaları doğrudan sil + if (fs.existsSync(fullPath)) { + fs.rmSync(fullPath, { recursive: true, force: true }); + } + removeThumbnailsForPath(safePath); + } + + if (folderId) { broadcastFileUpdate(folderId); + trashStateCache.delete(folderId); } if (folderId) { @@ -2903,7 +3423,7 @@ app.get("/api/files", requireAuth, (req, res) => { } } - // --- 🔍 Yardımcı fonksiyon: dosya ignoreList’te mi? --- + // --- 🔍 Yardımcı fonksiyon: dosya ignoreList'te mi? --- const isIgnored = (name) => { const lower = name.toLowerCase(); const ext = path.extname(lower).replace(".", ""); @@ -2942,8 +3462,13 @@ app.get("/api/files", requireAuth, (req, res) => { const safeRel = sanitizeRelative(rel); if (!safeRel) continue; - const dirInfo = getInfo(safeRel) || {}; const rootFolder = rootFromRelPath(safeRel); + const relWithinRoot = safeRel.split(/[\\/]/).slice(1).join("/"); + + // 🗑️ Çöpte işaretli klasörleri atla + if (isPathTrashed(rootFolder, relWithinRoot, true)) continue; + + const dirInfo = getInfo(safeRel) || {}; const added = dirInfo.added ?? dirInfo.createdAt ?? null; const completedAt = dirInfo.completedAt ?? null; const tracker = dirInfo.tracker ?? null; @@ -2974,10 +3499,16 @@ app.get("/api/files", requireAuth, (req, res) => { } else { if (entry.name.toLowerCase() === INFO_FILENAME) continue; + const safeRel = sanitizeRelative(rel); + const rootFolder = rootFromRelPath(safeRel); + const relWithinRoot = safeRel.split(/[\\/]/).slice(1).join("/"); + + // 🗑️ Çöpte işaretli dosyaları atla + if (isPathTrashed(rootFolder, relWithinRoot, false)) continue; + const size = fs.statSync(full).size; const type = mime.lookup(full) || "application/octet-stream"; - const safeRel = sanitizeRelative(rel); const urlPath = safeRel .split(/[\\/]/) .map(encodeURIComponent) @@ -3002,13 +3533,11 @@ app.get("/api/files", requireAuth, (req, res) => { } const info = getInfo(safeRel) || {}; - const rootFolder = rootFromRelPath(safeRel); const added = info.added ?? info.createdAt ?? null; const completedAt = info.completedAt ?? null; const tracker = info.tracker ?? null; const torrentName = info.name ?? null; const infoHash = info.infoHash ?? null; - const relWithinRoot = safeRel.split(/[\\/]/).slice(1).join("/"); const fileMeta = relWithinRoot ? info.files?.[relWithinRoot] || null : null; @@ -3054,6 +3583,194 @@ app.get("/api/files", requireAuth, (req, res) => { } }); +// --- 🗑️ Çöp listesi API (.trash flag sistemi) --- +app.get("/api/trash", requireAuth, (req, res) => { + try { + const result = []; + const roots = fs + .readdirSync(DOWNLOAD_DIR, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()); + + for (const dirent of roots) { + const rootFolder = sanitizeRelative(dirent.name); + if (!rootFolder) continue; + + const state = getTrashStateForRoot(rootFolder); + if (!state || !Array.isArray(state.registry?.items)) continue; + + const info = readInfoForRoot(rootFolder) || {}; + + for (const item of state.registry.items) { + const relWithinRoot = normalizeTrashPath(item.path); + const displayPath = relWithinRoot + ? `${rootFolder}/${relWithinRoot}` + : rootFolder; + const fullPath = path.join(DOWNLOAD_DIR, displayPath); + + if (!fs.existsSync(fullPath)) { + removeTrashEntry(rootFolder, relWithinRoot); + continue; + } + + let stat = null; + try { + stat = fs.statSync(fullPath); + } catch (err) { + console.warn( + `⚠️ Çöp öğesi stat okunamadı (${fullPath}): ${err.message}` + ); + } + + const isDirectory = item.isDirectory || stat?.isDirectory() || false; + const type = isDirectory + ? "inode/directory" + : mime.lookup(fullPath) || item.type || "application/octet-stream"; + const size = stat?.size ?? 0; + + let thumbnail = null; + let mediaInfo = null; + + if (!isDirectory) { + const isVideo = String(type).startsWith("video/"); + const isImage = String(type).startsWith("image/"); + + if (isVideo) { + const { relThumb, absThumb } = getVideoThumbnailPaths(displayPath); + if (fs.existsSync(absThumb)) { + thumbnail = thumbnailUrl(relThumb); + } + } else if (isImage) { + const { relThumb, absThumb } = getImageThumbnailPaths(displayPath); + if (fs.existsSync(absThumb)) { + thumbnail = thumbnailUrl(relThumb); + } + } + + const metaKey = relWithinRoot || null; + if (metaKey && info.files && info.files[metaKey]) { + mediaInfo = info.files[metaKey].mediaInfo || null; + } + } + + result.push({ + name: displayPath, + trashName: displayPath, + size, + type, + isDirectory, + thumbnail, + mediaInfo, + movedAt: Number(item.deletedAt) || Date.now(), + originalPath: displayPath, + folderId: rootFolder + }); + } + } + + result.sort((a, b) => (b.movedAt || 0) - (a.movedAt || 0)); + res.json(result); + } catch (err) { + console.error("🗑️ Trash API error:", err); + res.status(500).json({ error: err.message }); + } +}); + +// --- 🗑️ Çöpten geri yükleme API (.trash flag sistemi) --- +app.post("/api/trash/restore", requireAuth, (req, res) => { + try { + const { trashName } = req.body; + + if (!trashName) { + return res.status(400).json({ error: "trashName gerekli" }); + } + + const safeName = sanitizeRelative(trashName); + const segments = safeName.split(/[\\/]/).filter(Boolean); + if (!segments.length) { + return res.status(400).json({ error: "Geçersiz trashName" }); + } + + const rootFolder = segments[0]; + const relWithinRoot = segments.slice(1).join("/"); + + const removed = removeTrashEntry(rootFolder, relWithinRoot); + if (!removed) { + return res.status(404).json({ error: "Çöp öğesi bulunamadı" }); + } + + console.log(`♻️ Öğe geri yüklendi: ${safeName}`); + + broadcastFileUpdate(rootFolder); + + res.json({ + success: true, + message: "Öğe başarıyla geri yüklendi", + folderId: rootFolder + }); + } catch (err) { + console.error("❌ Restore error:", err); + res.status(500).json({ error: err.message }); + } +}); + +// --- 🗑️ Çöpü tamamen silme API (.trash flag sistemi) --- +app.delete("/api/trash", requireAuth, (req, res) => { + try { + const trashName = + req.body?.trashName || req.query?.trashName || req.params?.trashName; + + if (!trashName) { + return res.status(400).json({ error: "trashName gerekli" }); + } + + const safeName = sanitizeRelative(trashName); + const segments = safeName.split(/[\\/]/).filter(Boolean); + if (!segments.length) { + return res.status(400).json({ error: "Geçersiz trashName" }); + } + + const rootFolder = segments[0]; + const relWithinRoot = segments.slice(1).join("/"); + + const removed = removeTrashEntry(rootFolder, relWithinRoot); + if (!removed) { + return res.status(404).json({ error: "Çöp öğesi bulunamadı" }); + } + + const fullPath = path.join(DOWNLOAD_DIR, safeName); + if (fs.existsSync(fullPath)) { + try { + fs.rmSync(fullPath, { recursive: true, force: true }); + } catch (err) { + console.warn(`⚠️ Çöp öğesi silinemedi (${fullPath}): ${err.message}`); + } + } + + if (!relWithinRoot) { + purgeRootFolder(rootFolder); + } else if (removed.isDirectory) { + pruneInfoForDirectory(rootFolder, relWithinRoot); + } else { + pruneInfoEntry(rootFolder, relWithinRoot); + removeThumbnailsForPath(safeName); + removeSeriesEpisode(rootFolder, relWithinRoot); + } + + console.log(`🗑️ Öğe kalıcı olarak silindi: ${safeName}`); + + broadcastFileUpdate(rootFolder); + broadcastDiskSpace(); + + res.json({ + success: true, + message: "Öğe tamamen silindi" + }); + } catch (err) { + console.error("❌ Delete trash error:", err); + res.status(500).json({ error: err.message }); + } +}); + // --- 🎬 Film listesi --- app.get("/api/movies", requireAuth, (req, res) => { try { @@ -3220,6 +3937,36 @@ app.get("/api/tvshows", requireAuth, (req, res) => { const paths = tvSeriesPaths(folder); if (!fs.existsSync(paths.metadata)) continue; + const infoForFolder = readInfoForRoot(folder) || {}; + const infoFiles = infoForFolder.files || {}; + const infoEpisodes = infoForFolder.seriesEpisodes || {}; + const infoEpisodeIndex = new Map(); + + for (const [relPath, meta] of Object.entries(infoEpisodes)) { + if (!meta) continue; + const seasonNumber = toFiniteNumber( + meta.season ?? meta.seasonNumber ?? meta.seasonNum + ); + const episodeNumber = toFiniteNumber( + meta.episode ?? meta.episodeNumber ?? meta.episodeNum + ); + if (!Number.isFinite(seasonNumber) || !Number.isFinite(episodeNumber)) continue; + + const normalizedRel = normalizeTrashPath(relPath); + const ext = path.extname(normalizedRel).toLowerCase(); + if (!VIDEO_EXTS.includes(ext)) continue; + + const absVideo = normalizedRel + ? path.join(DOWNLOAD_DIR, folder, normalizedRel) + : null; + if (!absVideo || !fs.existsSync(absVideo)) continue; + + infoEpisodeIndex.set(`${seasonNumber}-${episodeNumber}`, { + relPath: normalizedRel, + meta + }); + } + let seriesData; try { seriesData = JSON.parse(fs.readFileSync(paths.metadata, "utf-8")); @@ -3390,8 +4137,22 @@ app.get("/api/tvshows", requireAuth, (req, res) => { if (!normalizedEpisode.code) { normalizedEpisode.code = `S${String(seasonNumber).padStart( 2, - "0" - )}E${String(episodeNumber).padStart(2, "0")}`; + "0" + )}E${String(episodeNumber).padStart(2, "0")}`; + } + const infoEpisode = infoEpisodeIndex.get(`${seasonNumber}-${episodeNumber}`); + if (infoEpisode?.relPath) { + const normalizedRel = infoEpisode.relPath.replace(/^\/+/, ""); + const withRoot = `${folder}/${normalizedRel}`.replace(/^\/+/, ""); + normalizedEpisode.file = normalizedRel; + normalizedEpisode.videoPath = withRoot; + const fileMeta = infoFiles[normalizedRel]; + if (fileMeta?.mediaInfo && !normalizedEpisode.mediaInfo) { + normalizedEpisode.mediaInfo = fileMeta.mediaInfo; + } + if (fileMeta?.size) { + normalizedEpisode.fileSize = Number(fileMeta.size); + } } const relativeFile = normalizedEpisode.file || normalizedEpisode.videoPath || ""; @@ -3419,6 +4180,28 @@ app.get("/api/tvshows", requireAuth, (req, res) => { dataChanged = true; } } + if (normalizedEpisode.videoPath && !/^https?:\/\//i.test(normalizedEpisode.videoPath)) { + const ext = path.extname(normalizedEpisode.videoPath).toLowerCase(); + if (!VIDEO_EXTS.includes(ext)) { + const absVideo = path.join(DOWNLOAD_DIR, normalizedEpisode.videoPath); + if ( + !fs.existsSync(absVideo) || + !VIDEO_EXTS.includes(path.extname(absVideo).toLowerCase()) + ) { + normalizedEpisode.videoPath = null; + } + } + if (normalizedEpisode.videoPath) { + const absVideo = path.join(DOWNLOAD_DIR, normalizedEpisode.videoPath); + if (!fs.existsSync(absVideo)) { + normalizedEpisode.videoPath = null; + } + if (fs.existsSync(absVideo)) { + const stats = fs.statSync(absVideo); + normalizedEpisode.fileSize = Number(stats.size); + } + } + } normalizedEpisode.folder = folder; const existingEpisode = seasonRecord.episodes.get(episodeNumber); @@ -3454,9 +4237,16 @@ app.get("/api/tvshows", requireAuth, (req, res) => { .map((record) => { const seasons = Array.from(record.seasons.values()) .map((season) => { - const episodes = Array.from(season.episodes.values()).sort( - (a, b) => a.episodeNumber - b.episodeNumber - ); + const episodes = Array.from(season.episodes.values()) + .filter((episode) => { + if (!episode?.videoPath) return false; + if (/^https?:\/\//i.test(episode.videoPath)) return true; + const ext = path.extname(episode.videoPath).toLowerCase(); + if (!VIDEO_EXTS.includes(ext)) return false; + const absVideo = path.join(DOWNLOAD_DIR, episode.videoPath); + return fs.existsSync(absVideo); + }) + .sort((a, b) => a.episodeNumber - b.episodeNumber); return { seasonNumber: season.seasonNumber, name: season.name || `Season ${season.seasonNumber}`, @@ -3644,7 +4434,7 @@ app.get("/stream/:hash", requireAuth, (req, res) => { stream.pipe(res); }); -console.log("🗄️ Download path:", DOWNLOAD_DIR); +console.log("🗄️ Download path:", DOWNLOAD_DIR); // --- ✅ Client build (frontend) dosyalarını sun --- @@ -4172,6 +4962,135 @@ app.post("/api/folder", requireAuth, async (req, res) => { } }); +app.patch("/api/folder", requireAuth, (req, res) => { + try { + const { path: targetPath, newName } = req.body || {}; + if (!targetPath || !newName) { + return res.status(400).json({ error: "path ve newName gerekli" }); + } + + const safePath = sanitizeRelative(targetPath); + if (!safePath) { + return res.status(400).json({ error: "Geçersiz hedef yol" }); + } + + const segments = safePath.split(/[\\/]/).filter(Boolean); + if (!segments.length) { + return res.status(400).json({ error: "Geçersiz hedef yol" }); + } + + const trimmedName = String(newName).trim(); + if (!trimmedName || /[\\/]/.test(trimmedName)) { + return res.status(400).json({ error: "Geçersiz yeni isim" }); + } + + const parentSegments = segments.slice(0, -1); + const newSegments = [...parentSegments, trimmedName]; + const newRelativePath = newSegments.join("/"); + const safeNewRelativePath = sanitizeRelative(newRelativePath); + if (!safeNewRelativePath) { + return res.status(400).json({ error: "Geçersiz yeni yol" }); + } + + const oldFullPath = path.join(DOWNLOAD_DIR, safePath); + const newFullPath = path.join(DOWNLOAD_DIR, safeNewRelativePath); + + if (!fs.existsSync(oldFullPath)) { + return res.status(404).json({ error: "Yeniden adlandırılacak klasör bulunamadı" }); + } + + if (fs.existsSync(newFullPath)) { + return res.status(409).json({ error: "Yeni isimde bir klasör zaten var" }); + } + + // Yeniden adlandırmayı gerçekleştir + fs.renameSync(oldFullPath, newFullPath); + + const rootFolder = segments[0]; + const newRootFolder = newSegments[0]; + const oldRelWithinRoot = segments.slice(1).join("/"); + const newRelWithinRoot = newSegments.slice(1).join("/"); + + if (!oldRelWithinRoot) { + // Kök klasör yeniden adlandırıldı + renameRootCaches(rootFolder, newRootFolder); + trashStateCache.delete(rootFolder); + trashStateCache.delete(newRootFolder); + + // Torrent kayıtlarını güncelle + for (const entry of torrents.values()) { + if (!entry?.savePath) continue; + const baseName = path.basename(entry.savePath); + if (baseName === rootFolder) { + entry.savePath = path.join(path.dirname(entry.savePath), newRootFolder); + } + } + + console.log(`📁 Kök klasör yeniden adlandırıldı: ${rootFolder} -> ${newRootFolder}`); + broadcastFileUpdate(rootFolder); + broadcastFileUpdate(newRootFolder); + broadcastSnapshot(); + return res.json({ + success: true, + message: "Klasör yeniden adlandırıldı", + oldPath: safePath, + newPath: safeNewRelativePath + }); + } + + // Alt klasör yeniden adlandırıldı + renameInfoPaths(rootFolder, oldRelWithinRoot, newRelWithinRoot); + renameSeriesDataPaths(rootFolder, oldRelWithinRoot, newRelWithinRoot); + renameTrashEntries(rootFolder, oldRelWithinRoot, newRelWithinRoot); + removeThumbnailsForDirectory(rootFolder, oldRelWithinRoot); + trashStateCache.delete(rootFolder); + + console.log( + `📁 Klasör yeniden adlandırıldı: ${safePath} -> ${safeNewRelativePath}` + ); + + broadcastFileUpdate(rootFolder); + + res.json({ + success: true, + message: "Klasör yeniden adlandırıldı", + oldPath: safePath, + newPath: safeNewRelativePath + }); + } catch (err) { + console.error("❌ Folder rename error:", err); + res.status(500).json({ error: err.message }); + } +}); + +// Recursive klasör kopyalama fonksiyonu +function copyFolderRecursiveSync(source, target) { + // Hedef klasörü oluştur + if (!fs.existsSync(target)) { + fs.mkdirSync(target, { recursive: true }); + } + + // Kaynak klasördeki tüm öğeleri oku + const files = fs.readdirSync(source); + + // Her öğeyi işle + files.forEach(file => { + const sourcePath = path.join(source, file); + const targetPath = path.join(target, file); + + // Dosya istatistiklerini al + const stats = fs.statSync(sourcePath); + + if (stats.isDirectory()) { + // Alt klasörse recursive olarak kopyala + copyFolderRecursiveSync(sourcePath, targetPath); + } else { + // Dosyaysa kopyala + fs.copyFileSync(sourcePath, targetPath); + } + }); +} + client.on("error", (err) => { if (!String(err).includes("uTP")) console.error("WebTorrent error:", err.message);