From 41c602104e4eb95d126bc84acfac637f2af4575b Mon Sep 17 00:00:00 2001 From: wisecolt Date: Wed, 28 Jan 2026 21:48:18 +0300 Subject: [PATCH] =?UTF-8?q?feat(anime):=20anime=20y=C3=B6netimi=20ve=20ara?= =?UTF-8?q?y=C3=BCz=C3=BC=20ekle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kullanıcı arayüzünde Anime sekmesi ve oynatıcı entegrasyonu eklendi. Sunucu tarafında Anime için özel bir veri yapısı ve API uç noktaları oluşturuldu. - Anime içerikleri için `_anime` klasöründe ayrı metadata saklama alanı eklendi. - Kök dizindeki (root) dosyaların çöpe taşınması ve geri yüklenmesi için 'root-trash' sistemi tanımlandı. - TVDB sorgularında Anime için İngilizce dil tercihi uygulandı. - Mail.ru indirmelerinde anime kapsamı (scope) desteği eklendi. --- client/src/App.svelte | 5 + client/src/components/Sidebar.svelte | 23 +- client/src/routes/Anime.svelte | 1870 ++++++++++++++++++++++++++ client/src/routes/Transfers.svelte | 3 +- client/src/stores/animeStore.js | 43 + server/server.js | 944 +++++++++++-- 6 files changed, 2784 insertions(+), 104 deletions(-) create mode 100644 client/src/routes/Anime.svelte create mode 100644 client/src/stores/animeStore.js diff --git a/client/src/App.svelte b/client/src/App.svelte index 1992d9b..b167fae 100644 --- a/client/src/App.svelte +++ b/client/src/App.svelte @@ -9,6 +9,7 @@ import Trash from "./routes/Trash.svelte"; import Movies from "./routes/Movies.svelte"; import TvShows from "./routes/TvShows.svelte"; + import Anime from "./routes/Anime.svelte"; import Music from "./routes/Music.svelte"; import Profile from "./routes/Profile.svelte"; import Settings from "./routes/Settings.svelte"; @@ -16,6 +17,7 @@ import { API, getAccessToken } from "./utils/api.js"; import { refreshMovieCount } from "./stores/movieStore.js"; import { refreshTvShowCount } from "./stores/tvStore.js"; + import { refreshAnimeCount } from "./stores/animeStore.js"; import { refreshMusicCount } from "./stores/musicStore.js"; import { fetchTrashItems } from "./stores/trashStore.js"; import { setAvatarUrl } from "./stores/avatarStore.js"; @@ -34,6 +36,7 @@ await Promise.all([ refreshMovieCount(), refreshTvShowCount(), + refreshAnimeCount(), refreshMusicCount(), fetchTrashItems() ]); @@ -85,6 +88,7 @@ if (token) { refreshMovieCount(); refreshTvShowCount(); + refreshAnimeCount(); refreshMusicCount(); fetchTrashItems(); loadUserProfile(); @@ -150,6 +154,7 @@ + diff --git a/client/src/components/Sidebar.svelte b/client/src/components/Sidebar.svelte index d2f1469..2f5c1f4 100644 --- a/client/src/components/Sidebar.svelte +++ b/client/src/components/Sidebar.svelte @@ -3,6 +3,7 @@ import { createEventDispatcher, onDestroy, onMount, tick } from "svelte"; import { movieCount } from "../stores/movieStore.js"; import { tvShowCount } from "../stores/tvStore.js"; +import { animeCount } from "../stores/animeStore.js"; import { musicCount } from "../stores/musicStore.js"; import { trashCount } from "../stores/trashStore.js"; import { apiFetch } from "../utils/api.js"; @@ -11,6 +12,7 @@ import { musicCount } from "../stores/musicStore.js"; const dispatch = createEventDispatcher(); let hasMovies = false; let hasShows = false; +let hasAnime = false; let hasTrash = false; let hasMusic = false; // Svelte store kullanarak reaktivite sağla @@ -18,7 +20,7 @@ let hasMusic = false; const diskSpaceStore = writable({ totalGB: '0', usedGB: '0', usedPercent: 0 }); let diskSpace; let hasMedia = false; - $: hasMedia = hasMovies || hasShows || hasMusic; + $: hasMedia = hasMovies || hasShows || hasAnime || hasMusic; // Store subscription'ı temizlemek için let unsubscribeDiskSpace; @@ -41,6 +43,10 @@ let hasMusic = false; const unsubscribeTv = tvShowCount.subscribe((count) => { hasShows = (count ?? 0) > 0; }); + +const unsubscribeAnime = animeCount.subscribe((count) => { + hasAnime = (count ?? 0) > 0; +}); const unsubscribeTrash = trashCount.subscribe((count) => { hasTrash = (count ?? 0) > 0; @@ -53,6 +59,7 @@ const unsubscribeMusic = musicCount.subscribe((count) => { onDestroy(() => { unsubscribeMovie(); unsubscribeTv(); + unsubscribeAnime(); unsubscribeTrash(); unsubscribeMusic(); if (unsubscribeDiskSpace) { @@ -192,6 +199,20 @@ const unsubscribeMusic = musicCount.subscribe((count) => { {/if} + {#if hasAnime} + ({ + class: isCurrent ? "item active" : "item", + })} + on:click={handleLinkClick} + > + + Anime + + {/if} + {#if hasMusic} + import { onMount, tick } from "svelte"; + import { API, apiFetch } from "../utils/api.js"; + import { cleanFileName } from "../utils/filename.js"; + import { animeCount } from "../stores/animeStore.js"; + import { + activeSearchTerm, + setSearchScope + } from "../stores/searchStore.js"; + + let shows = []; + let loading = true; + let refreshing = false; + let rescanning = false; + let error = null; + + let selectedShow = null; + let selectedSeason = null; + let selectedEpisode = null; + +let playerItems = []; +let showPlayerModal = false; +let selectedVideo = null; +let videoEl; +let isPlaying = false; +let currentTime = 0; +let duration = 0; +let volume = 1; +let subtitleURL = null; +let subtitleLabel = "Custom Subtitles"; +let currentIndex = -1; +let seasonPlaylist = []; +let seasonPlaylistIndex = -1; +let canPlayPrev = false; +let canPlayNext = false; +let searchTerm = ""; +let hasSearch = false; +let filteredShows = []; + + function runtimeToText(runtime) { + if (!runtime || Number.isNaN(runtime)) return null; + const minutes = Math.round(Number(runtime)); + const hours = Math.floor(minutes / 60); + const remain = minutes % 60; + if (hours && remain) return `${hours}h ${remain}m`; + if (hours) return `${hours}h`; + return `${remain}m`; + } + + function formatVideoInfo(video) { + if (!video) return null; + const codec = video.codec ? video.codec.toUpperCase() : null; + const resolution = + video.resolution || (video.height ? `${video.height}p` : null); + const hdr = video.colorSpace?.toUpperCase().includes("HDR") + ? "HDR" + : null; + return [codec, resolution, hdr].filter(Boolean).join(" • ") || null; + } + + function formatAudioInfo(audio) { + if (!audio) return null; + const codec = audio.codec ? audio.codec.toUpperCase() : null; + let channels = null; + if (audio.channelLayout) channels = audio.channelLayout.toUpperCase(); + else if (audio.channels) { + channels = + audio.channels === 6 + ? "5.1" + : audio.channels === 2 + ? "2.0" + : `${audio.channels}`; + } + return [codec, channels].filter(Boolean).join(" • ") || null; + } + + function assetUrl(pathname) { + if (!pathname) return null; + const token = localStorage.getItem("token"); + return `${API}${pathname}?token=${token}`; + } + + function posterUrl(show) { + return assetUrl(show?.poster); + } + + function backdropUrl(show) { + return assetUrl(show?.backdrop); + } + + function stillUrl(episode) { + return assetUrl(episode?.still); + } + + function formatAirDate(value) { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date.toLocaleDateString(); + } + + function formatEpisodeCode(episode) { + if (!episode) return ""; + if (episode.code) return episode.code; + const season = String(episode.seasonNumber || 0).padStart(2, "0"); + const ep = String(episode.episodeNumber || 0).padStart(2, "0"); + return `S${season}E${ep}`; + } + + function updateSeasonPlaylist() { + if (!selectedVideo || !playerItems.length) { + seasonPlaylist = []; + seasonPlaylistIndex = -1; + canPlayPrev = false; + canPlayNext = false; + return; + } + const currentSeason = selectedVideo.episode?.seasonNumber; + const showId = selectedVideo.show?.id || selectedVideo.show?.title; + seasonPlaylist = playerItems.filter( + (item) => + (item.show?.id || item.show?.title) === showId && + item.episode?.seasonNumber === currentSeason + ); + seasonPlaylistIndex = seasonPlaylist.findIndex( + (item) => + item.episode?.videoPath === selectedVideo.episode?.videoPath && + (item.show?.id || item.show?.title) === showId + ); + canPlayPrev = seasonPlaylistIndex > 0; + canPlayNext = + seasonPlaylistIndex !== -1 && + seasonPlaylistIndex < seasonPlaylist.length - 1; + } + + function normalizeShow(show) { + const seasons = Array.isArray(show.seasons) + ? show.seasons + .map((season) => { + const seasonNumber = Number( + season.seasonNumber ?? season.number ?? 0 + ); + const name = season.name || `Season ${seasonNumber}`; + const episodes = Array.isArray(season.episodes) + ? season.episodes + .map((episode) => { + const episodeNumber = Number( + episode.episodeNumber ?? episode.number ?? 0 + ); + const seasonNo = + Number(episode.seasonNumber ?? seasonNumber) || seasonNumber; + const videoPath = episode.videoPath + ? episode.videoPath.replace(/\\/g, "/") + : episode.file + ? `${show.folder}/${episode.file}`.replace(/\\/g, "/") + : null; + return { + ...episode, + episodeNumber, + seasonNumber: seasonNo, + videoPath + }; + }) + .filter((episode) => episode.videoPath) + .sort((a, b) => a.episodeNumber - b.episodeNumber) + : []; + return { + ...season, + seasonNumber, + name, + episodes + }; + }) + .filter((season) => season.episodes.length) + .sort((a, b) => a.seasonNumber - b.seasonNumber) + : []; + return { + ...show, + seasons + }; + } + + async function loadShows() { + loading = true; + error = null; + try { + const resp = await apiFetch("/api/anime"); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const list = await resp.json(); + shows = Array.isArray(list) ? list.map(normalizeShow) : []; + animeCount.set(shows.length); + } catch (err) { + error = err?.message || "Anime alınamadı."; + shows = []; + animeCount.set(0); + } finally { + loading = false; + } + } + + $: searchTerm = $activeSearchTerm; + $: hasSearch = searchTerm.trim().length > 0; + $: filteredShows = (() => { + const query = searchTerm.trim().toLowerCase(); + if (!query) return shows; + return shows.filter((show) => { + const fields = [ + show.title, + show.originalTitle, + show.metadata?.matched_title, + show.metadata?.original_name, + show.folder + ]; + return fields + .filter(Boolean) + .some((value) => String(value).toLowerCase().includes(query)); + }); + })(); + + async function refreshShows() { + try { + refreshing = true; + const resp = await apiFetch("/api/anime/refresh", { method: "POST" }); + if (!resp.ok) { + const data = await resp.json().catch(() => ({})); + throw new Error(data?.error || `HTTP ${resp.status}`); + } + await loadShows(); + } catch (err) { + console.error("TV metadata refresh error:", err); + error = err?.message || "Metadata yenilenemedi."; + } finally { + refreshing = false; + } + } + + async function rescanShows() { + try { + rescanning = true; + const resp = await apiFetch("/api/anime/rescan", { method: "POST" }); + if (!resp.ok) { + const data = await resp.json().catch(() => ({})); + throw new Error(data?.error || `HTTP ${resp.status}`); + } + await loadShows(); + } catch (err) { + console.error("TV metadata rescan error:", err); + error = err?.message || "Tam tarama sırasında bir sorun oluştu."; + } finally { + rescanning = false; + } + } + + function openShow(show) { + if (!show) return; + selectedShow = show; + selectedSeason = show.seasons?.[0] || null; + selectedEpisode = selectedSeason?.episodes?.[0] || null; + } + + function closeShow() { + selectedShow = null; + selectedSeason = null; + selectedEpisode = null; + } + + function selectSeasonNumber(seasonNumber) { + if (!selectedShow) return; + const season = selectedShow.seasons.find( + (s) => s.seasonNumber === seasonNumber + ); + if (!season) return; + selectedSeason = season; + selectedEpisode = season.episodes?.[0] || null; + } + + function selectEpisode(episode) { + if (!episode) return; + selectedEpisode = episode; + } + + function episodeRuntime(episode) { + if (!episode) return null; + if (episode.runtime) return runtimeToText(episode.runtime); + const seconds = Number(episode.mediaInfo?.format?.duration); + if (seconds) return runtimeToText(Math.round(seconds / 60)); + return null; + } + + $: selectedRuntime = episodeRuntime(selectedEpisode); + $: selectedVideoInfo = selectedEpisode?.mediaInfo + ? formatVideoInfo(selectedEpisode.mediaInfo.video) + : null; + $: selectedAudioInfo = selectedEpisode?.mediaInfo + ? formatAudioInfo(selectedEpisode.mediaInfo.audio) + : null; + $: selectedAirDate = formatAirDate(selectedEpisode?.aired); + + function mapEpisodeToPlayerItem(show, episode) { + if (!show || !episode?.videoPath) return null; + const name = (episode.videoPath || "").replace(/^\/+/, ""); + const ext = name.split(".").pop()?.toLowerCase() || ""; + const inferredType = ext ? `video/${ext}` : "video/mp4"; + const size = + Number(episode.fileSize) || + Number(episode.mediaInfo?.format?.size) || + null; + return { + name, + type: inferredType.startsWith("video/") ? inferredType : "video/mp4", + size, + show, + episode + }; + } + + $: playerItems = shows + .flatMap((show) => + show.seasons.flatMap((season) => + season.episodes.map((episode) => mapEpisodeToPlayerItem(show, episode)) + ) + ) + .filter(Boolean); + + const encodePathSegments = (value) => + value + ? value + .split(/[\\/]/) + .map((segment) => encodeURIComponent(segment)) + .join("/") + : ""; + + $: selectedName = selectedVideo?.name ?? ""; + $: encName = encodePathSegments(selectedName); + $: downloadHref = encName + ? `${API}/downloads/${encName}?token=${localStorage.getItem("token") || ""}` + : "#"; +$: selectedLabel = selectedVideo?.episode + ? `${selectedVideo.show.title} · ${formatEpisodeCode( + selectedVideo.episode + )}${ + selectedVideo.episode.title + ? ` — ${selectedVideo.episode.title}` + : "" + }` + : cleanFileName(selectedName); + + $: if (showPlayerModal && selectedVideo) { + const idx = playerItems.findIndex( + (item) => + item.episode.videoPath === selectedVideo.episode.videoPath + ); + if (idx === -1) { + closePlayer(); + } else if (idx !== currentIndex) { + currentIndex = idx; + selectedVideo = playerItems[idx]; + } + updateSeasonPlaylist(); + } + + async function handlePlayEpisode(episode) { + if (!episode) return; + await openPlayerForEpisode(episode); + } + + async function openPlayerForEpisode(episode) { + if (!playerItems.length || !episode?.videoPath) return; + const idx = playerItems.findIndex( + (item) => item.episode.videoPath === episode.videoPath + ); + if (idx === -1) return; + await openVideoAtIndex(idx); + } + +async function openVideoAtIndex(index) { + if (!playerItems.length) return; + const safeIndex = + ((index % playerItems.length) + playerItems.length) % + playerItems.length; + stopCurrentVideo(); + currentIndex = safeIndex; + selectedVideo = playerItems[safeIndex]; + subtitleURL = null; + await tick(); + showPlayerModal = true; + isPlaying = false; + currentTime = 0; + duration = 0; + updateSeasonPlaylist(); + + if (selectedShow && selectedVideo) { + const targetSeason = selectedShow.seasons?.find( + (season) => + season.seasonNumber === selectedVideo.episode?.seasonNumber + ); + if (targetSeason) { + const targetEpisode = targetSeason.episodes?.find( + (episode) => + episode.videoPath === selectedVideo.episode?.videoPath + ); + if (targetEpisode) { + selectedSeason = targetSeason; + selectedEpisode = targetEpisode; + } + } + } + + // Video element'in yüklendiğinden emin ol + await tick(); + setTimeout(() => { + if (videoEl) { + console.log("Video element found after timeout:", videoEl); + console.log("Video src:", videoEl.src); + console.log("Video readyState:", videoEl.readyState); + } else { + console.error("Video element not found after timeout"); + } + }, 1000); +} + + function stopCurrentVideo() { + if (videoEl) { + try { + videoEl.pause(); + videoEl.src = ""; + videoEl.load(); + } catch (err) { + console.warn("Video stop error:", err?.message || err); + } + } + } + + function closePlayer() { + stopCurrentVideo(); + showPlayerModal = false; + selectedVideo = null; + subtitleURL = null; + isPlaying = false; + seasonPlaylist = []; + seasonPlaylistIndex = -1; + canPlayPrev = false; + canPlayNext = false; + } + + function getVideoURL() { + if (!selectedName) return ""; + const token = localStorage.getItem("token"); + const encoded = encodePathSegments(selectedName); + if (!encoded) return ""; + return `${API}/media/${encoded}?token=${token}`; + } + + function playEpisodeFromCard(episode) { + if (!episode) return; + selectEpisode(episode); + handlePlayEpisode(episode); + } + + function openSeasonNeighbor(offset) { + updateSeasonPlaylist(); + if (!seasonPlaylist.length) return; + const target = seasonPlaylist[seasonPlaylistIndex + offset]; + if (!target) return; + const idx = playerItems.findIndex( + (item) => + item.episode?.videoPath === target.episode?.videoPath && + (item.show?.id || item.show?.title) === + (target.show?.id || target.show?.title) + ); + if (idx !== -1) { + openVideoAtIndex(idx); + } + } + + function playPrevEpisode() { + if (!canPlayPrev) return; + openSeasonNeighbor(-1); + } + + function playNextEpisode() { + if (!canPlayNext) return; + openSeasonNeighbor(1); + } + + function handleSeasonButtonKey(event, seasonNumber) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + selectSeasonNumber(seasonNumber); + } + } + + async function togglePlay() { + if (!videoEl) return; + if (videoEl.paused) { + try { + await videoEl.play(); + isPlaying = true; + } catch (err) { + console.warn("Play rejected:", err?.message || err); + isPlaying = false; + } + } else { + videoEl.pause(); + isPlaying = false; + } + } + + function updateProgress() { + currentTime = videoEl?.currentTime || 0; + } + + function updateDuration() { + duration = videoEl?.duration || 0; + } + + function seekVideo(event) { + if (!videoEl) return; + const value = Number(event.target.value); + videoEl.currentTime = value; + currentTime = value; + } + + function changeVolume(event) { + const value = Number(event.target.value); + volume = value; + if (videoEl) videoEl.volume = value; + event.target.style.setProperty("--fill", value * 100); + } + + function toggleFullscreen() { + if (!videoEl) return; + if (document.fullscreenElement) document.exitFullscreen(); + else videoEl.requestFullscreen(); + } + + function formatSize(bytes) { + if (!bytes) return "0 MB"; + if (bytes < 1e6) return (bytes / 1e3).toFixed(1) + " KB"; + if (bytes < 1e9) return (bytes / 1e6).toFixed(1) + " MB"; + return (bytes / 1e9).toFixed(2) + " GB"; + } + + function formatTime(seconds) { + const m = Math.floor(seconds / 60) + .toString() + .padStart(2, "0"); + const s = Math.floor(seconds % 60) + .toString() + .padStart(2, "0"); + return `${m}:${s}`; + } + + function handleSubtitleUpload(event) { + const file = event.target.files?.[0]; + if (!file) return; + const ext = file.name.split(".").pop().toLowerCase(); + const reader = new FileReader(); + reader.onload = (ev) => { + const decoder = new TextDecoder("utf-8"); + const content = + typeof ev.target.result === "string" + ? ev.target.result + : decoder.decode(ev.target.result); + if (ext === "srt") { + const vttText = + "\uFEFFWEBVTT\n\n" + content.replace(/\r+/g, "").replace(/,/g, "."); + const blob = new Blob([vttText], { type: "text/vtt;charset=utf-8" }); + subtitleURL = URL.createObjectURL(blob); + } else if (ext === "vtt") { + const blob = new Blob([content], { type: "text/vtt;charset=utf-8" }); + subtitleURL = URL.createObjectURL(blob); + } else { + alert("Yalnızca .srt veya .vtt dosyaları destekleniyor."); + } + }; + reader.readAsArrayBuffer(file); + } + + const TEXT_INPUT_TYPES = new Set([ + "text", + "search", + "email", + "password", + "number", + "url", + "tel" + ]); + + function isEditableTarget(target) { + if (!target) return false; + const tag = target.tagName; + const type = target.type?.toLowerCase(); + if (tag === "TEXTAREA") return true; + if (tag === "INPUT" && TEXT_INPUT_TYPES.has(type)) return true; + return Boolean(target.isContentEditable); + } + + $: if (videoEl) { + videoEl.volume = volume; + } + + onMount(() => { + setSearchScope("anime"); + loadShows(); + const handleKey = (event) => { + if (!showPlayerModal) return; + if (isEditableTarget(event.target)) return; + + const isCmd = event.metaKey || event.ctrlKey; + if (isCmd && event.key.toLowerCase() === "a") { + // Text input'larda çalıştırma + if (isEditableTarget(event.target)) return; + event.preventDefault(); + // TvShows sayfasında tümünü seçme işlevi yok, sadece engelleme yapıyoruz + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + closePlayer(); + } else if (event.key === " " || event.key === "Spacebar") { + event.preventDefault(); + togglePlay(); + } + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }); + + +
+
+
+

Anime

+
+ + +
+
+ + {#if loading} +
Loading shows…
+ {:else if error} +
{error}
+ {:else if shows.length === 0} +
No TV metadata found yet.
+ {:else if hasSearch && filteredShows.length === 0} +
Aramanıza uyan dizi bulunamadı.
+ {:else} +
+ {#each filteredShows as show} +
openShow(show)}> + {#if show.poster} +
+ {show.title} +
+ {:else} +
+ +
+ {/if} +
+
{show.title}
+ {#if show.year} +
{show.year}
+ {/if} +
+
+ {/each} +
+ {/if} +
+ +{#if selectedShow} +
+
+
+ +
+
+ {#if selectedShow.poster} + {selectedShow.title} + {:else} +
+ +
+ {/if} +
+
+

{selectedShow.title}

+
+ {#if selectedShow.year} + {selectedShow.year} + {/if} + {#if selectedShow.status} + • {selectedShow.status} + {/if} + {#if selectedSeason} + + • + {selectedSeason.name || `Season ${selectedSeason.seasonNumber}`} + + {/if} +
+ {#if selectedShow.genres?.length} +
+ {selectedShow.genres.join(" • ")} +
+ {/if} +
+ {selectedShow.overview || "No synopsis found."} +
+ {#if selectedRuntime || selectedVideoInfo || selectedAudioInfo || selectedAirDate} +
+ {#if selectedRuntime} +
+ + {selectedRuntime} +
+ {/if} + {#if selectedAirDate} +
+ + {selectedAirDate} +
+ {/if} + {#if selectedVideoInfo} +
+ + {selectedVideoInfo} +
+ {/if} + {#if selectedAudioInfo} +
+ + {selectedAudioInfo} +
+ {/if} +
+ {/if} + +
+
+ + {#if selectedShow.seasons?.length} +
+ {#each selectedShow.seasons as season} + + {/each} +
+ {/if} + +
+ {#if selectedSeason?.episodes?.length} + {#each selectedSeason.episodes as episode} +
playEpisodeFromCard(episode)} + > +
+ {#if episode.still} + {`${selectedShow.title} + {:else} +
+ +
+ {/if} +
+ +
+
+
+
+ {formatEpisodeCode(episode)} · {episode.title || "Untitled"} +
+
+ {#if episodeRuntime(episode)} + + + {episodeRuntime(episode)} + + {/if} + {#if formatAirDate(episode.aired)} + + + {formatAirDate(episode.aired)} + + {/if} + {#if formatVideoInfo(episode.mediaInfo?.video)} + + + {formatVideoInfo(episode.mediaInfo.video)} + + {/if} + {#if formatAudioInfo(episode.mediaInfo?.audio)} + + + {formatAudioInfo(episode.mediaInfo.audio)} + + {/if} +
+
+ {episode.overview || "No overview available."} +
+
+
+ {/each} + {:else} +
No episodes found for this season.
+ {/if} +
+
+
+{/if} + +{#if showPlayerModal && selectedVideo} + +{/if} + + diff --git a/client/src/routes/Transfers.svelte b/client/src/routes/Transfers.svelte index 04eba63..0da202a 100644 --- a/client/src/routes/Transfers.svelte +++ b/client/src/routes/Transfers.svelte @@ -282,7 +282,8 @@ try { const params = new URLSearchParams({ query, - type: "series" + type: "series", + scope: "anime" }); if (mailruMatchYear.trim()) { params.set("year", mailruMatchYear.trim()); diff --git a/client/src/stores/animeStore.js b/client/src/stores/animeStore.js new file mode 100644 index 0000000..5acc79a --- /dev/null +++ b/client/src/stores/animeStore.js @@ -0,0 +1,43 @@ +import { writable } from "svelte/store"; +import { apiFetch } from "../utils/api.js"; + +export const animeCount = writable(0); +let requestSeq = 0; +let lastValue = 0; +let zeroTimer = null; + +export async function refreshAnimeCount() { + const ticket = ++requestSeq; + try { + const resp = await apiFetch("/api/anime"); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const list = await resp.json(); + if (ticket !== requestSeq) return; + const nextVal = Array.isArray(list) ? list.length : 0; + if (nextVal > 0) { + if (zeroTimer) { + clearTimeout(zeroTimer); + zeroTimer = null; + } + lastValue = nextVal; + animeCount.set(nextVal); + } else if (lastValue > 0) { + if (zeroTimer) clearTimeout(zeroTimer); + const zeroTicket = requestSeq; + zeroTimer = setTimeout(() => { + if (zeroTicket === requestSeq) { + lastValue = 0; + animeCount.set(0); + } + zeroTimer = null; + }, 500); + } else { + lastValue = 0; + animeCount.set(0); + } + } catch (err) { + console.warn("⚠️ Anime sayacı güncellenemedi:", err?.message || err); + // Hata durumunda mevcut değeri koru, titreşimi önle + } +} + diff --git a/server/server.js b/server/server.js index d72213c..d1a33a2 100644 --- a/server/server.js +++ b/server/server.js @@ -38,6 +38,10 @@ if (!fs.existsSync(DOWNLOAD_DIR)) const TRASH_DIR = path.join(__dirname, "trash"); if (!fs.existsSync(TRASH_DIR)) fs.mkdirSync(TRASH_DIR, { recursive: true }); +const ROOT_TRASH_PREFIX = "__root__"; +const ROOT_TRASH_DIR = path.join(TRASH_DIR, "root"); +if (!fs.existsSync(ROOT_TRASH_DIR)) + fs.mkdirSync(ROOT_TRASH_DIR, { recursive: true }); // --- Thumbnail cache klasörü --- const CACHE_DIR = path.join(__dirname, "cache"); @@ -46,7 +50,10 @@ const VIDEO_THUMB_ROOT = path.join(THUMBNAIL_DIR, "videos"); const IMAGE_THUMB_ROOT = path.join(THUMBNAIL_DIR, "images"); const MOVIE_DATA_ROOT = path.join(CACHE_DIR, "movie_data"); const TV_DATA_ROOT = path.join(CACHE_DIR, "tv_data"); +const ANIME_DATA_ROOT = path.join(CACHE_DIR, "anime_data"); const YT_DATA_ROOT = path.join(CACHE_DIR, "yt_data"); +const ANIME_ROOT_FOLDER = "_anime"; +const ROOT_TRASH_REGISTRY = path.join(CACHE_DIR, "root-trash.json"); const MUSIC_EXTENSIONS = new Set([ ".mp3", ".m4a", @@ -65,6 +72,7 @@ for (const dir of [ IMAGE_THUMB_ROOT, MOVIE_DATA_ROOT, TV_DATA_ROOT, + ANIME_DATA_ROOT, YT_DATA_ROOT ]) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); @@ -1437,6 +1445,14 @@ function finalizeMailRuJob(job, exitCode) { const relPath = job.savePath === DOWNLOAD_DIR ? String(job.fileName || "") : path.join(job.folderId || "", job.fileName || "").replace(/\\/g, "/"); + const seriesInfo = job.match?.seriesInfo || null; + if (seriesInfo) { + extractMediaInfo(filePath) + .then((mediaInfo) => + ensureSeriesData(ANIME_ROOT_FOLDER, job.fileName, seriesInfo, mediaInfo) + ) + .catch(() => null); + } attachMailRuThumbnail(job, filePath, relPath); broadcastFileUpdate(relPath || "downloads"); scheduleSnapshotBroadcast(); @@ -2199,6 +2215,109 @@ function normalizeTrashPath(value) { return String(value).replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); } +function isRootTrashName(value) { + const normalized = normalizeTrashPath(value); + return ( + normalized === ROOT_TRASH_PREFIX || + normalized.startsWith(`${ROOT_TRASH_PREFIX}/`) + ); +} + +function parseRootTrashName(value) { + const normalized = normalizeTrashPath(value); + if (normalized === ROOT_TRASH_PREFIX) return ""; + if (!normalized.startsWith(`${ROOT_TRASH_PREFIX}/`)) return null; + return normalized.slice(ROOT_TRASH_PREFIX.length + 1); +} + +function readRootTrashRegistry() { + if (!fs.existsSync(ROOT_TRASH_REGISTRY)) return { items: [] }; + try { + const raw = JSON.parse(fs.readFileSync(ROOT_TRASH_REGISTRY, "utf-8")); + const items = Array.isArray(raw?.items) ? raw.items : []; + return { items: items.filter(Boolean) }; + } catch (err) { + console.warn(`⚠️ root-trash okunamadı (${ROOT_TRASH_REGISTRY}): ${err.message}`); + return { items: [] }; + } +} + +function writeRootTrashRegistry(registry) { + const items = Array.isArray(registry?.items) ? registry.items : []; + try { + fs.writeFileSync( + ROOT_TRASH_REGISTRY, + JSON.stringify({ updatedAt: Date.now(), items }, null, 2), + "utf-8" + ); + } catch (err) { + console.warn(`⚠️ root-trash yazılamadı (${ROOT_TRASH_REGISTRY}): ${err.message}`); + } +} + +function addRootTrashEntry(relPath, fullPath, stats) { + const safeRel = sanitizeRelative(relPath); + if (!safeRel || safeRel.includes("/")) return null; + if (!fullPath || !fs.existsSync(fullPath)) return null; + const registry = readRootTrashRegistry(); + const baseName = path.basename(safeRel); + const storedName = `${Date.now()}_${baseName}`.replace(/[\\/:*?"<>|]+/g, "_"); + const storedPath = path.join(ROOT_TRASH_DIR, storedName); + try { + fs.renameSync(fullPath, storedPath); + } catch (err) { + // Cross-device rename (EXDEV) durumunda kopyala+sil fallback'i uygula. + if (err?.code === "EXDEV") { + try { + if (stats?.isDirectory?.()) { + fs.cpSync(fullPath, storedPath, { recursive: true }); + fs.rmSync(fullPath, { recursive: true, force: true }); + } else { + fs.copyFileSync(fullPath, storedPath); + fs.rmSync(fullPath, { force: true }); + } + } catch (copyErr) { + console.warn( + `⚠️ root-trash EXDEV fallback hatası (${fullPath}): ${copyErr.message}` + ); + return null; + } + } else { + console.warn(`⚠️ root-trash taşıma hatası (${fullPath}): ${err.message}`); + return null; + } + } + const nextItems = registry.items.filter((item) => item.originalName !== baseName); + nextItems.push({ + originalName: baseName, + storedName, + deletedAt: Date.now(), + type: stats?.isDirectory?.() + ? "inode/directory" + : mime.lookup(fullPath) || "application/octet-stream" + }); + writeRootTrashRegistry({ items: nextItems }); + return nextItems[nextItems.length - 1]; +} + +function removeRootTrashEntry(originalName) { + const safeName = sanitizeRelative(originalName); + if (!safeName || safeName.includes("/")) return null; + const registry = readRootTrashRegistry(); + const kept = []; + let removed = null; + for (const item of registry.items) { + if (item.originalName === safeName && !removed) { + removed = item; + continue; + } + kept.push(item); + } + if (!removed) return null; + writeRootTrashRegistry({ items: kept }); + return removed; +} + function trashFlagPathFor(rootFolder) { const safeRoot = sanitizeRelative(rootFolder); if (!safeRoot) return null; @@ -2439,10 +2558,14 @@ function resolveMovieDataAbsolute(relPath) { function resolveTvDataAbsolute(relPath) { const normalized = sanitizeRelative(relPath); - const resolved = path.resolve(TV_DATA_ROOT, normalized); + const firstSegment = normalized.split("/").filter(Boolean)[0] || ""; + const { rootFolder } = parseTvSeriesKey(firstSegment); + const dataRoot = + rootFolder === ANIME_ROOT_FOLDER ? ANIME_DATA_ROOT : TV_DATA_ROOT; + const resolved = path.resolve(dataRoot, normalized); if ( - resolved !== TV_DATA_ROOT && - !resolved.startsWith(TV_DATA_ROOT + path.sep) + resolved !== dataRoot && + !resolved.startsWith(dataRoot + path.sep) ) { return null; } @@ -2720,6 +2843,34 @@ function parseSeriesInfo(rawName) { }; } +function parseAnimeSeriesInfo(rawName) { + if (!rawName) return null; + const parsed = parseSeriesInfo(rawName); + if (parsed) return parsed; + const withoutExt = String(rawName || "").replace(/\.[^/.]+$/, ""); + const match = withoutExt.match( + /(.+?)[\s._-]*S(\d{1,2})xE(\d{1,2})/i + ); + if (!match) return null; + const rawTitle = match[1] + .replace(/[._]+/g, " ") + .replace(/\s+-\s+/g, " - ") + .replace(/[-_]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!rawTitle) return null; + const season = Number(match[2]); + const episode = Number(match[3]); + if (!Number.isFinite(season) || !Number.isFinite(episode)) return null; + return { + title: titleCase(rawTitle), + searchTitle: rawTitle, + season, + episode, + key: `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}` + }; +} + async function tmdbFetch(endpoint, params = {}) { if (!TMDB_API_KEY) return null; const url = new URL(`${TMDB_BASE_URL}${endpoint}`); @@ -3312,9 +3463,39 @@ async function fetchTvdbEpisode(seriesId, season, episode) { return seasonEpisodes.get(episode) || null; } -async function fetchTvdbEpisodeExtended(episodeId) { +function tvdbPickTranslation(list, field, preferEn = false) { + if (!Array.isArray(list)) return null; + const preferred = preferEn + ? ["en", "eng", "english", "en-us"] + : ["tr", "tur", "turkish", "tr-tr", "tr_tur"]; + const fallback = preferEn + ? ["tr", "tur", "turkish", "tr-tr", "tr_tur"] + : ["en", "eng", "english", "en-us"]; + const pickByLang = (langs) => + langs + .map((lng) => lng.toLowerCase()) + .map((lng) => + list.find((item) => { + const code = String( + item?.language || + item?.iso6391 || + item?.iso_639_1 || + item?.locale || + item?.languageCode || + "" + ).toLowerCase(); + return code === lng; + }) + ) + .find(Boolean); + const match = pickByLang(preferred) || pickByLang(fallback); + if (!match) return null; + return match[field] ?? match.value ?? match.translation?.[field] ?? null; +} + +async function fetchTvdbEpisodeExtended(episodeId, preferEn = false) { if (!episodeId) return null; - const cacheKey = `episode-${episodeId}-extended`; + const cacheKey = `episode-${episodeId}-extended-${preferEn ? "en" : "tr"}`; if (tvdbEpisodeDetailCache.has(cacheKey)) return tvdbEpisodeDetailCache.get(cacheKey); const resp = await tvdbFetch( @@ -3364,44 +3545,21 @@ async function fetchTvdbEpisodeExtended(episodeId) { const nameTranslations = translations.nameTranslations || translations.names || []; - const pickTranslation = (list, field) => { - if (!Array.isArray(list)) return null; - const preferred = ["tr", "turkish", "tr-tr", "tr_tur"]; - const fallback = ["en", "english", "en-us", "eng"]; - const pickByLang = (langs) => - langs - .map((lng) => lng.toLowerCase()) - .map((lng) => - list.find((item) => { - const code = String( - item?.language || - item?.iso6391 || - item?.iso_639_1 || - item?.locale || - item?.languageCode || - "" - ).toLowerCase(); - return code === lng; - }) - ) - .find(Boolean); - const preferredMatch = pickByLang(preferred) || pickByLang(fallback); - if (!preferredMatch) return null; - return ( - preferredMatch[field] ?? - preferredMatch.value ?? - preferredMatch.translation?.[field] ?? - null - ); - }; - if (!base.overview) { - const localizedOverview = pickTranslation(overviewTranslations, "overview"); + const localizedOverview = tvdbPickTranslation( + overviewTranslations, + "overview", + preferEn + ); if (localizedOverview) base.overview = localizedOverview; } if (!base.name) { - const localizedName = pickTranslation(nameTranslations, "name"); + const localizedName = tvdbPickTranslation( + nameTranslations, + "name", + preferEn + ); if (localizedName) base.name = localizedName; } @@ -3504,7 +3662,7 @@ async function ensureSeriesData( } } - if (!seriesData && candidateKeys.length) { + if (!seriesData && candidateKeys.length && normalizedRoot !== ANIME_ROOT_FOLDER) { for (const key of candidateKeys) { const candidatePaths = tvSeriesPathsByKey(key); if (!fs.existsSync(candidatePaths.metadata)) continue; @@ -3521,7 +3679,11 @@ async function ensureSeriesData( } const legacyPaths = tvSeriesPaths(normalizedRoot); - if (!seriesData && fs.existsSync(legacyPaths.metadata)) { + if ( + !seriesData && + normalizedRoot !== ANIME_ROOT_FOLDER && + fs.existsSync(legacyPaths.metadata) + ) { try { seriesData = JSON.parse(fs.readFileSync(legacyPaths.metadata, "utf-8")) || {}; existingPaths = legacyPaths; @@ -3596,32 +3758,35 @@ async function ensureSeriesData( translations.overviewTranslations || translations.overviews || []; - const localizedName = - nameTranslations.find((t) => - ["tr", "tur", "turkish"].includes(String(t?.language || t?.iso6391).toLowerCase()) - )?.value || - nameTranslations.find((t) => - ["en", "eng", "english"].includes(String(t?.language || t?.iso6391).toLowerCase()) - )?.value || - null; - const localizedOverview = - overviewTranslations.find((t) => - ["tr", "tur", "turkish"].includes(String(t?.language || t?.iso6391).toLowerCase()) - )?.overview || - overviewTranslations.find((t) => - ["en", "eng", "english"].includes(String(t?.language || t?.iso6391).toLowerCase()) - )?.overview || - null; + const preferEn = rootFolder === ANIME_ROOT_FOLDER; + const localizedName = tvdbPickTranslation( + nameTranslations, + "name", + preferEn + ); + const localizedOverview = tvdbPickTranslation( + overviewTranslations, + "overview", + preferEn + ); - seriesData.name = - seriesData.name || - info.name || - info.seriesName || - localizedName || - seriesInfo.title; + if (preferEn && localizedName) { + seriesData.name = localizedName; + } else { + seriesData.name = + seriesData.name || + info.name || + info.seriesName || + localizedName || + seriesInfo.title; + } seriesData.slug = seriesData.slug || info.slug || info.slugged || null; - seriesData.overview = - seriesData.overview || info.overview || localizedOverview || ""; + if (preferEn && localizedOverview) { + seriesData.overview = localizedOverview; + } else { + seriesData.overview = + seriesData.overview || info.overview || localizedOverview || ""; + } const firstAired = info.firstAired || info.firstAirDate || @@ -3810,7 +3975,8 @@ async function ensureSeriesData( !detailedEpisode.name) ) { const extendedEpisode = await fetchTvdbEpisodeExtended( - detailedEpisode.id + detailedEpisode.id, + preferEn ); if (extendedEpisode) { detailedEpisode = { @@ -3997,9 +4163,20 @@ function parseTvSeriesKey(key) { return { rootFolder, seriesId: suffix || null, key: normalized }; } +function tvDataRootForRoot(rootFolder) { + const safeRoot = sanitizeRelative(rootFolder); + return safeRoot === ANIME_ROOT_FOLDER ? ANIME_DATA_ROOT : TV_DATA_ROOT; +} + +function tvDataRootForKey(key) { + const { rootFolder } = parseTvSeriesKey(key); + return tvDataRootForRoot(rootFolder); +} + function tvSeriesPathsByKey(key) { const normalizedKey = sanitizeRelative(key); - const dir = path.join(TV_DATA_ROOT, normalizedKey); + const dataRoot = tvDataRootForKey(normalizedKey); + const dir = path.join(dataRoot, normalizedKey); return { key: normalizedKey, dir, @@ -4008,7 +4185,8 @@ function tvSeriesPathsByKey(key) { backdrop: path.join(dir, "backdrop.jpg"), episodesDir: path.join(dir, "episodes"), seasonsDir: path.join(dir, "seasons"), - rootFolder: parseTvSeriesKey(normalizedKey).rootFolder + rootFolder: parseTvSeriesKey(normalizedKey).rootFolder, + dataRoot }; } @@ -4054,10 +4232,11 @@ function seasonAssetPaths(paths, seasonNumber) { function listTvSeriesKeysForRoot(rootFolder) { const normalizedRoot = rootFolder ? sanitizeRelative(rootFolder) : null; if (!normalizedRoot) return []; - if (!fs.existsSync(TV_DATA_ROOT)) return []; + const dataRoot = tvDataRootForRoot(normalizedRoot); + if (!fs.existsSync(dataRoot)) return []; const keys = []; try { - const entries = fs.readdirSync(TV_DATA_ROOT, { withFileTypes: true }); + const entries = fs.readdirSync(dataRoot, { withFileTypes: true }); for (const dirent of entries) { if (!dirent.isDirectory()) continue; const name = dirent.name; @@ -4070,7 +4249,7 @@ function listTvSeriesKeysForRoot(rootFolder) { } } catch (err) { console.warn( - `⚠️ TV metadata dizini listelenemedi (${TV_DATA_ROOT}): ${err.message}` + `⚠️ TV metadata dizini listelenemedi (${dataRoot}): ${err.message}` ); } return keys; @@ -4644,7 +4823,8 @@ function renameRootCaches(oldRoot, newRoot) { VIDEO_THUMB_ROOT, IMAGE_THUMB_ROOT, MOVIE_DATA_ROOT, - TV_DATA_ROOT + TV_DATA_ROOT, + ANIME_DATA_ROOT ]; for (const base of pairs) { @@ -5763,8 +5943,21 @@ app.delete("/api/file", requireAuth, (req, res) => { const safePath = sanitizeRelative(filePath); const fullPath = path.join(DOWNLOAD_DIR, safePath); - const folderId = (safePath.split(/[\/]/)[0] || "").trim(); - const rootDir = folderId ? path.join(DOWNLOAD_DIR, folderId) : null; + let folderId = (safePath.split(/[\/]/)[0] || "").trim(); + let rootDir = folderId ? path.join(DOWNLOAD_DIR, folderId) : null; + let folderIsDirectory = false; + if (rootDir && fs.existsSync(rootDir)) { + try { + folderIsDirectory = fs.statSync(rootDir).isDirectory(); + } catch (err) { + folderIsDirectory = false; + } + } + // Kök dosyalarda ilk segment dosya adıdır; klasör değilse root davranışı uygula + if (folderId && !folderIsDirectory) { + folderId = ""; + rootDir = null; + } let mediaFlags = { movies: false, tv: false }; let stats = null; @@ -5776,7 +5969,7 @@ app.delete("/api/file", requireAuth, (req, res) => { } if (!stats || !fs.existsSync(fullPath)) { - if (folderId && (!rootDir || !fs.existsSync(rootDir))) { + if (folderId && folderIsDirectory && (!rootDir || !fs.existsSync(rootDir))) { purgeRootFolder(folderId); broadcastFileUpdate(folderId); return res.json({ ok: true, alreadyRemoved: true }); @@ -5788,7 +5981,7 @@ app.delete("/api/file", requireAuth, (req, res) => { const isDirectory = stats.isDirectory(); const relWithinRoot = safePath.split(/[\\/]/).slice(1).join("/"); let trashEntry = null; - if (folderId && rootDir && fs.existsSync(rootDir) && fs.statSync(rootDir).isDirectory()) { + if (folderId && folderIsDirectory && rootDir) { const infoBeforeDelete = readInfoForRoot(folderId); mediaFlags = detectMediaFlagsForPath( infoBeforeDelete, @@ -5799,7 +5992,7 @@ app.delete("/api/file", requireAuth, (req, res) => { mediaFlags = { movies: false, tv: false }; } - if (folderId && rootDir && fs.existsSync(rootDir) && fs.statSync(rootDir).isDirectory()) { + if (folderId && folderIsDirectory && rootDir) { trashEntry = addTrashEntry(folderId, { path: relWithinRoot, originalPath: safePath, @@ -5827,11 +6020,17 @@ app.delete("/api/file", requireAuth, (req, res) => { } if (!folderId) { - // Kök klasöre ait olmayan dosyaları doğrudan sil - if (fs.existsSync(fullPath)) { - fs.rmSync(fullPath, { recursive: true, force: true }); + // Kök dosyaları root-trash sistemine taşı + const rootTrashEntry = addRootTrashEntry(safePath, fullPath, stats); + if (!rootTrashEntry) { + return res.status(500).json({ error: "Kök dosya çöpe taşınamadı" }); } + // Anime cache/metadata'dan ilgili bölümü kaldır + removeSeriesEpisode(ANIME_ROOT_FOLDER, safePath); removeThumbnailsForPath(safePath); + broadcastFileUpdate("downloads"); + broadcastDiskSpace(); + return res.json({ ok: true, filesRemoved: true, rootTrashed: true }); } if (folderId) { @@ -6307,6 +6506,42 @@ app.get("/api/trash", requireAuth, (req, res) => { } } + // Root trash öğelerini ekle (kök dosyalar için) + const rootRegistry = readRootTrashRegistry(); + for (const item of rootRegistry.items) { + const originalName = sanitizeRelative(item.originalName || ""); + const storedName = sanitizeRelative(item.storedName || ""); + if (!originalName || !storedName) continue; + const storedPath = path.join(ROOT_TRASH_DIR, storedName); + if (!fs.existsSync(storedPath)) { + removeRootTrashEntry(originalName); + continue; + } + let stat = null; + try { + stat = fs.statSync(storedPath); + } catch (err) { + continue; + } + const isDirectory = stat?.isDirectory?.() || false; + const type = isDirectory + ? "inode/directory" + : mime.lookup(originalName) || item.type || "application/octet-stream"; + const trashName = `${ROOT_TRASH_PREFIX}/${originalName}`; + result.push({ + name: trashName, + trashName, + size: stat?.size ?? 0, + type, + isDirectory, + thumbnail: null, + mediaInfo: null, + movedAt: Number(item.deletedAt) || Date.now(), + originalPath: originalName, + folderId: ROOT_TRASH_PREFIX + }); + } + result.sort((a, b) => (b.movedAt || 0) - (a.movedAt || 0)); res.json(result); } catch (err) { @@ -6316,15 +6551,91 @@ app.get("/api/trash", requireAuth, (req, res) => { }); // --- 🗑️ Çöpten geri yükleme API (.trash flag sistemi) --- -app.post("/api/trash/restore", requireAuth, (req, res) => { +app.post("/api/trash/restore", requireAuth, async (req, res) => { try { const { trashName } = req.body; - + if (!trashName) { return res.status(400).json({ error: "trashName gerekli" }); } - + const safeName = sanitizeRelative(trashName); + if (isRootTrashName(safeName)) { + const originalName = parseRootTrashName(safeName); + if (!originalName) { + return res.status(400).json({ error: "Geçersiz root trashName" }); + } + + const removed = removeRootTrashEntry(originalName); + if (!removed?.storedName) { + return res.status(404).json({ error: "Root çöp öğesi bulunamadı" }); + } + + const storedName = sanitizeRelative(removed.storedName); + const storedPath = path.join(ROOT_TRASH_DIR, storedName); + const targetPath = path.join(DOWNLOAD_DIR, originalName); + + if (!fs.existsSync(storedPath)) { + return res + .status(404) + .json({ error: "Root çöp dosyası bulunamadı" }); + } + + ensureDirForFile(targetPath); + try { + fs.renameSync(storedPath, targetPath); + } catch (err) { + if (err?.code === "EXDEV") { + let stat = null; + try { + stat = fs.statSync(storedPath); + } catch (statErr) { + return res.status(500).json({ error: "Root çöp dosyası okunamadı" }); + } + try { + if (stat?.isDirectory?.()) { + fs.cpSync(storedPath, targetPath, { recursive: true }); + fs.rmSync(storedPath, { recursive: true, force: true }); + } else { + fs.copyFileSync(storedPath, targetPath); + fs.rmSync(storedPath, { force: true }); + } + } catch (copyErr) { + console.warn( + `⚠️ root-trash restore EXDEV hatası (${storedPath}): ${copyErr.message}` + ); + return res + .status(500) + .json({ error: "Root çöp dosyası taşınamadı" }); + } + } else { + throw err; + } + } + + console.log(`♻️ Root öğe geri yüklendi: ${originalName}`); + + const animeSeriesInfo = parseAnimeSeriesInfo(originalName); + if (animeSeriesInfo) { + const mediaInfo = await extractMediaInfo(targetPath).catch(() => null); + await ensureSeriesData( + ANIME_ROOT_FOLDER, + originalName, + animeSeriesInfo, + mediaInfo + ); + } + + broadcastFileUpdate("downloads"); + broadcastDiskSpace(); + + return res.json({ + success: true, + message: "Öğe başarıyla geri yüklendi", + folderId: "downloads" + }); + } + const segments = safeName.split(/[\\/]/).filter(Boolean); if (!segments.length) { return res.status(400).json({ error: "Geçersiz trashName" }); @@ -6372,6 +6683,40 @@ app.delete("/api/trash", requireAuth, (req, res) => { } const safeName = sanitizeRelative(trashName); + if (isRootTrashName(safeName)) { + const originalName = parseRootTrashName(safeName); + if (!originalName) { + return res.status(400).json({ error: "Geçersiz root trashName" }); + } + + const removed = removeRootTrashEntry(originalName); + if (!removed?.storedName) { + return res.status(404).json({ error: "Root çöp öğesi bulunamadı" }); + } + + const storedName = sanitizeRelative(removed.storedName); + const storedPath = path.join(ROOT_TRASH_DIR, storedName); + if (fs.existsSync(storedPath)) { + try { + fs.rmSync(storedPath, { recursive: true, force: true }); + } catch (err) { + console.warn( + `⚠️ Root çöp öğesi silinemedi (${storedPath}): ${err.message}` + ); + } + } + + console.log(`🗑️ Root öğe kalıcı olarak silindi: ${originalName}`); + + broadcastFileUpdate("downloads"); + broadcastDiskSpace(); + + return res.json({ + success: true, + message: "Öğe tamamen silindi" + }); + } + const segments = safeName.split(/[\\/]/).filter(Boolean); if (!segments.length) { return res.status(400).json({ error: "Geçersiz trashName" }); @@ -6803,15 +7148,28 @@ app.post("/api/mailru/match", requireAuth, async (req, res) => { const safeSeason = Number(season) || 1; const safeEpisode = Number(episode) || 1; const title = metadata.title || metadata.name || "Anime"; + const seriesInfo = { + title, + searchTitle: title, + season: safeSeason, + episode: safeEpisode, + key: `S${String(safeSeason).padStart(2, "0")}E${String(safeEpisode).padStart(2, "0")}` + }; job.match = { id: metadata.id || null, title, season: safeSeason, episode: safeEpisode, - matchedAt: Date.now() + matchedAt: Date.now(), + rootFolder: ANIME_ROOT_FOLDER, + seriesInfo }; job.fileName = formatMailRuSeriesFilename(title, safeSeason, safeEpisode); job.title = job.fileName; + // Anime metadata'yı TVDB mantığıyla önceden hazırla (dosya henüz inmemiş olabilir) + ensureSeriesData(ANIME_ROOT_FOLDER, job.fileName, seriesInfo, null).catch( + () => null + ); const started = await beginMailRuDownload(job); if (!started) { return res.status(500).json({ ok: false, error: job.error || "Mail.ru indirimi başlatılamadı." }); @@ -6986,6 +7344,7 @@ app.get("/api/tvshows", requireAuth, (req, res) => { if (!paths || !fs.existsSync(paths.metadata)) continue; const { rootFolder } = parseTvSeriesKey(key); if (!rootFolder) continue; + if (rootFolder === ANIME_ROOT_FOLDER) continue; const infoForFolder = readInfoForRoot(rootFolder) || {}; const infoFiles = infoForFolder.files || {}; @@ -7326,6 +7685,369 @@ app.get("/api/tvshows", requireAuth, (req, res) => { } }); +function buildAnimeShows() { + if (!fs.existsSync(ANIME_DATA_ROOT)) { + return []; + } + + const dirEntries = fs + .readdirSync(ANIME_DATA_ROOT, { withFileTypes: true }) + .filter((d) => d.isDirectory()); + + const aggregated = new Map(); + + const mergeEpisode = (existing, incoming) => { + if (!existing) return incoming; + const merged = { ...existing, ...incoming }; + if (existing.still && !incoming.still) merged.still = existing.still; + if (!existing.still && incoming.still) merged.still = incoming.still; + if (existing.mediaInfo && !incoming.mediaInfo) merged.mediaInfo = existing.mediaInfo; + if (!existing.mediaInfo && incoming.mediaInfo) merged.mediaInfo = incoming.mediaInfo; + if (existing.overview && !incoming.overview) merged.overview = existing.overview; + return merged; + }; + + for (const dirent of dirEntries) { + const key = sanitizeRelative(dirent.name); + if (!key) continue; + const paths = tvSeriesPathsByKey(key); + if (!paths || !fs.existsSync(paths.metadata)) continue; + const { rootFolder } = parseTvSeriesKey(key); + if (rootFolder !== ANIME_ROOT_FOLDER) continue; + + let seriesData; + try { + seriesData = JSON.parse(fs.readFileSync(paths.metadata, "utf-8")); + } catch (err) { + console.warn(`⚠️ anime series.json okunamadı (${paths.metadata}): ${err.message}`); + continue; + } + + const seasonsObj = seriesData?.seasons || {}; + if (!Object.keys(seasonsObj).length) continue; + + let dataChanged = false; + const showId = seriesData.id ?? seriesData.tvdbId ?? seriesData.slug ?? seriesData.name ?? key; + const showKey = String(showId).toLowerCase(); + const record = + aggregated.get(showKey) || + (() => { + const base = { + id: seriesData.id ?? seriesData.tvdbId ?? key, + title: seriesData.name || "Anime", + overview: seriesData.overview || "", + year: seriesData.year || null, + status: seriesData.status || null, + poster: fs.existsSync(paths.poster) + ? encodeTvDataPath(paths.key, "poster.jpg") + : null, + backdrop: fs.existsSync(paths.backdrop) + ? encodeTvDataPath(paths.key, "backdrop.jpg") + : null, + genres: new Set( + Array.isArray(seriesData.genres) + ? seriesData.genres + .map((g) => (typeof g === "string" ? g : g?.name || null)) + .filter(Boolean) + : [] + ), + seasons: new Map(), + primaryFolder: ANIME_ROOT_FOLDER, + folders: new Set([ANIME_ROOT_FOLDER]) + }; + aggregated.set(showKey, base); + return base; + })(); + + if ( + seriesData.overview && + seriesData.overview.length > (record.overview?.length || 0) + ) { + record.overview = seriesData.overview; + } + if (!record.status && seriesData.status) record.status = seriesData.status; + if (!record.year || (seriesData.year && Number(seriesData.year) < Number(record.year))) { + record.year = seriesData.year || record.year; + } + if (!record.poster && fs.existsSync(paths.poster)) { + record.poster = encodeTvDataPath(paths.key, "poster.jpg"); + } + if (!record.backdrop && fs.existsSync(paths.backdrop)) { + record.backdrop = encodeTvDataPath(paths.key, "backdrop.jpg"); + } + if (Array.isArray(seriesData.genres)) { + seriesData.genres + .map((g) => (typeof g === "string" ? g : g?.name || null)) + .filter(Boolean) + .forEach((genre) => record.genres.add(genre)); + } + + for (const [seasonKey, rawSeason] of Object.entries(seasonsObj)) { + if (!rawSeason?.episodes) continue; + const seasonNumber = toFiniteNumber( + rawSeason.seasonNumber ?? rawSeason.number ?? seasonKey + ); + if (!Number.isFinite(seasonNumber)) continue; + + const seasonPaths = seasonAssetPaths(paths, seasonNumber); + const rawEpisodes = rawSeason.episodes || {}; + + for (const [episodeKey, rawEpisode] of Object.entries(rawEpisodes)) { + if (!rawEpisode || typeof rawEpisode !== "object") continue; + const relativeFile = (rawEpisode.file || "").replace(/\\/g, "/"); + if (!relativeFile) continue; + const absEpisodePath = path.join(DOWNLOAD_DIR, relativeFile); + const controlPath = `${absEpisodePath}.aria2`; + const isComplete = fs.existsSync(absEpisodePath) && !fs.existsSync(controlPath); + if (!isComplete) { + delete rawEpisodes[episodeKey]; + dataChanged = true; + } + } + + if (!Object.keys(rawSeason.episodes || {}).length) { + delete seasonsObj[seasonKey]; + dataChanged = true; + continue; + } + + let seasonRecord = record.seasons.get(seasonNumber); + if (!seasonRecord) { + seasonRecord = { + seasonNumber, + name: rawSeason.name || `Season ${seasonNumber}`, + overview: rawSeason.overview || "", + poster: rawSeason.poster || null, + tvdbId: rawSeason.tvdbId || null, + slug: rawSeason.slug || null, + episodeCount: rawSeason.episodeCount || null, + episodes: new Map() + }; + record.seasons.set(seasonNumber, seasonRecord); + } + + if (!seasonRecord.poster && fs.existsSync(seasonPaths.poster)) { + const relPoster = path.relative(paths.dir, seasonPaths.poster); + seasonRecord.poster = encodeTvDataPath(paths.key, relPoster); + } + + for (const [episodeKey, rawEpisode] of Object.entries(rawSeason.episodes)) { + if (!rawEpisode || typeof rawEpisode !== "object") continue; + const episodeNumber = toFiniteNumber( + rawEpisode.episodeNumber ?? rawEpisode.number ?? episodeKey + ); + if (!Number.isFinite(episodeNumber)) continue; + + const normalizedEpisode = { ...rawEpisode }; + normalizedEpisode.seasonNumber = seasonNumber; + normalizedEpisode.episodeNumber = episodeNumber; + if (!normalizedEpisode.code) { + normalizedEpisode.code = `S${String(seasonNumber).padStart(2, "0")}E${String( + episodeNumber + ).padStart(2, "0")}`; + } + + const relativeFile = (normalizedEpisode.file || "").replace(/\\/g, "/"); + if (relativeFile) { + const absVideo = path.join(DOWNLOAD_DIR, relativeFile); + const ext = path.extname(relativeFile).toLowerCase(); + if (fs.existsSync(absVideo) && VIDEO_EXTS.includes(ext)) { + normalizedEpisode.videoPath = relativeFile; + const stats = fs.statSync(absVideo); + normalizedEpisode.fileSize = Number(stats.size); + } else { + normalizedEpisode.videoPath = null; + } + } + + normalizedEpisode.folder = ANIME_ROOT_FOLDER; + + const existingEpisode = seasonRecord.episodes.get(episodeNumber); + seasonRecord.episodes.set( + episodeNumber, + mergeEpisode(existingEpisode, normalizedEpisode) + ); + } + + if (!seasonRecord.episodeCount && seasonRecord.episodes.size) { + seasonRecord.episodeCount = seasonRecord.episodes.size; + } + } + + if (dataChanged) { + try { + seriesData.seasons = seasonsObj; + seriesData.updatedAt = Date.now(); + fs.writeFileSync(paths.metadata, JSON.stringify(seriesData, null, 2), "utf-8"); + } catch (err) { + console.warn(`⚠️ anime series.json güncellenemedi (${paths.metadata}): ${err.message}`); + } + } + } + + const shows = Array.from(aggregated.values()) + .map((record) => { + const seasons = Array.from(record.seasons.values()) + .map((season) => { + const episodes = Array.from(season.episodes.values()) + .filter((episode) => { + if (!episode?.videoPath) return false; + const ext = path.extname(episode.videoPath).toLowerCase(); + if (!VIDEO_EXTS.includes(ext)) return false; + const absVideo = path.join(DOWNLOAD_DIR, episode.videoPath); + if (!fs.existsSync(absVideo)) return false; + const controlPath = `${absVideo}.aria2`; + return !fs.existsSync(controlPath); + }) + .sort((a, b) => a.episodeNumber - b.episodeNumber); + return { + seasonNumber: season.seasonNumber, + name: season.name || `Season ${season.seasonNumber}`, + overview: season.overview || "", + poster: season.poster || null, + tvdbSeasonId: season.tvdbId || null, + slug: season.slug || null, + episodeCount: season.episodeCount || episodes.length, + episodes + }; + }) + .sort((a, b) => a.seasonNumber - b.seasonNumber); + + return { + folder: record.primaryFolder, + id: record.id || record.title, + title: record.title, + overview: record.overview || "", + year: record.year || null, + genres: Array.from(record.genres).filter(Boolean), + status: record.status || null, + poster: record.poster || null, + backdrop: record.backdrop || null, + seasons, + folders: Array.from(record.folders) + }; + }) + .filter((show) => show.seasons.length > 0); + + shows.sort((a, b) => a.title.localeCompare(b.title, "en")); + return shows; +} + +async function rebuildAnimeMetadata({ clearCache = false } = {}) { + if (clearCache && fs.existsSync(ANIME_DATA_ROOT)) { + try { + fs.rmSync(ANIME_DATA_ROOT, { recursive: true, force: true }); + console.log("🧹 Anime cache temizlendi."); + } catch (err) { + console.warn( + `⚠️ Anime cache temizlenemedi (${ANIME_DATA_ROOT}): ${err.message}` + ); + } + } + + if (!fs.existsSync(ANIME_DATA_ROOT)) { + fs.mkdirSync(ANIME_DATA_ROOT, { recursive: true }); + } + if (clearCache) { + tvdbSeriesCache.clear(); + tvdbEpisodeCache.clear(); + tvdbEpisodeDetailCache.clear(); + } + + let processed = 0; + let rootEntries = []; + try { + rootEntries = fs.readdirSync(DOWNLOAD_DIR, { withFileTypes: true }); + } catch (err) { + console.warn(`⚠️ downloads kökü okunamadı: ${err.message}`); + } + + for (const dirent of rootEntries) { + if (!dirent.isFile()) continue; + const safeName = sanitizeRelative(dirent.name); + if (!safeName || safeName.startsWith(".")) continue; + if (safeName.endsWith(".aria2")) continue; + const absPath = path.join(DOWNLOAD_DIR, safeName); + if (!fs.existsSync(absPath)) continue; + const mimeType = mime.lookup(absPath) || ""; + if (!String(mimeType).startsWith("video/")) continue; + const seriesInfo = parseAnimeSeriesInfo(safeName); + if (!seriesInfo) continue; + const mediaInfo = await extractMediaInfo(absPath).catch(() => null); + await ensureSeriesData( + ANIME_ROOT_FOLDER, + safeName, + seriesInfo, + mediaInfo + ); + processed += 1; + } + + const shows = buildAnimeShows(); + for (const show of shows) { + for (const season of show.seasons || []) { + for (const episode of season.episodes || []) { + if (!episode?.videoPath) continue; + const absVideo = path.join(DOWNLOAD_DIR, episode.videoPath); + if (!fs.existsSync(absVideo)) continue; + const seriesInfo = { + title: show.title, + searchTitle: show.title, + season: season.seasonNumber, + episode: episode.episodeNumber, + key: episode.code || `S${String(season.seasonNumber).padStart(2, "0")}E${String( + episode.episodeNumber + ).padStart(2, "0")}` + }; + const mediaInfo = await extractMediaInfo(absVideo).catch(() => null); + await ensureSeriesData(ANIME_ROOT_FOLDER, episode.videoPath, seriesInfo, mediaInfo); + processed += 1; + } + } + } + return processed; +} + +app.get("/api/anime", requireAuth, (req, res) => { + try { + const shows = buildAnimeShows(); + res.json(shows); + } catch (err) { + console.error("🧿 Anime API error:", err); + res.status(500).json({ error: err.message }); + } +}); + +app.post("/api/anime/refresh", requireAuth, async (req, res) => { + if (!TVDB_API_KEY) { + return res + .status(400) + .json({ error: "TVDB API erişimi için gerekli anahtar tanımlı değil." }); + } + try { + const processed = await rebuildAnimeMetadata(); + res.json({ ok: true, processed }); + } catch (err) { + console.error("🧿 Anime refresh error:", err); + res.status(500).json({ error: err.message }); + } +}); + +app.post("/api/anime/rescan", requireAuth, async (req, res) => { + if (!TVDB_API_KEY) { + return res + .status(400) + .json({ error: "TVDB API erişimi için gerekli anahtar tanımlı değil." }); + } + try { + const processed = await rebuildAnimeMetadata({ clearCache: true }); + res.json({ ok: true, processed }); + } catch (err) { + console.error("🧿 Anime rescan error:", err); + res.status(500).json({ error: err.message }); + } +}); + function collectMusicEntries() { const entries = []; const dirEntries = fs @@ -7805,12 +8527,13 @@ app.get("/api/disk-space", requireAuth, async (req, res) => { // --- 🔍 TMDB/TVDB Arama Endpoint'i --- app.get("/api/search/metadata", requireAuth, async (req, res) => { try { - const { query, year, type } = req.query; - + const { query, year, type, scope } = req.query; + const preferEnForSeries = type === "series" && scope === "anime"; + if (!query) { return res.status(400).json({ error: "query parametresi gerekli" }); } - + if (type === "movie") { // TMDB Film Araması if (!TMDB_API_KEY) { @@ -7882,22 +8605,22 @@ app.get("/api/search/metadata", requireAuth, async (req, res) => { if (!TVDB_API_KEY) { return res.status(400).json({ error: "TVDB API key tanımlı değil" }); } - + const params = new URLSearchParams({ type: "series", query: query }); const resp = await tvdbFetch(`/search?${params.toString()}`); - + if (!resp || !resp.data) { return res.json({ results: [] }); } - + const allData = Array.isArray(resp.data) ? resp.data : []; - + const resultsWithDetails = await Promise.all( allData.slice(0, 20).map(async (item) => { try { const seriesId = item.tvdb_id || item.id; const extended = await fetchTvdbSeriesExtended(seriesId); - + if (extended) { const info = extended.series || extended; const artworks = Array.isArray(extended.artworks) ? extended.artworks : []; @@ -7905,11 +8628,28 @@ app.get("/api/search/metadata", requireAuth, async (req, res) => { const type = String(a?.type || a?.artworkType || "").toLowerCase(); return type.includes("poster") || type === "series" || type === "2"; }); - + + const translations = + extended.translations || info.translations || {}; + const nameTranslations = + translations.nameTranslations || translations.names || []; + const overviewTranslations = + translations.overviewTranslations || translations.overviews || []; + const localizedName = tvdbPickTranslation( + nameTranslations, + "name", + preferEnForSeries + ); + const localizedOverview = tvdbPickTranslation( + overviewTranslations, + "overview", + preferEnForSeries + ); + const genres = Array.isArray(info.genres) ? info.genres.map(g => typeof g === "string" ? g : g?.name || g?.genre).filter(Boolean) : []; - + // Yıl bilgisini çeşitli yerlerden al let seriesYear = null; if (info.year) { @@ -7921,12 +8661,12 @@ app.get("/api/search/metadata", requireAuth, async (req, res) => { const yearMatch = dateStr.match(/(\d{4})/); if (yearMatch) seriesYear = Number(yearMatch[1]); } - + return { id: seriesId, - title: info.name || item.name, + title: localizedName || info.name || item.name, year: seriesYear, - overview: info.overview || item.overview || "", + overview: localizedOverview || info.overview || item.overview || "", poster: posterArtwork?.image ? tvdbImageUrl(posterArtwork.image) : (item.image ? tvdbImageUrl(item.image) : null), genres: genres, status: info.status?.name || info.status || null, @@ -7936,7 +8676,7 @@ app.get("/api/search/metadata", requireAuth, async (req, res) => { } catch (err) { console.warn(`⚠️ Dizi detayı alınamadı:`, err.message); } - + // Fallback için yıl bilgisini al let itemYear = null; if (item.year) { @@ -7946,7 +8686,7 @@ app.get("/api/search/metadata", requireAuth, async (req, res) => { const yearMatch = dateStr.match(/(\d{4})/); if (yearMatch) itemYear = Number(yearMatch[1]); } - + return { id: item.tvdb_id || item.id, title: item.name || item.seriesName, @@ -7957,23 +8697,23 @@ app.get("/api/search/metadata", requireAuth, async (req, res) => { }; }) ); - + // Yıl filtresi detaylı bilgiler alındıktan SONRA uygula let filtered = resultsWithDetails.filter(Boolean); if (year && year.trim()) { const targetYear = Number(year); console.log(`🔍 TVDB Yıl filtresi uygulanıyor: ${targetYear}`); - + filtered = filtered.filter(item => { const itemYear = item.year ? Number(item.year) : null; const matches = itemYear && itemYear === targetYear; console.log(` - ${item.title}: yıl=${itemYear}, eşleşme=${matches}`); return matches; }); - + console.log(`🔍 Yıl filtresinden sonra: ${filtered.length} sonuç`); } - + res.json({ results: filtered.slice(0, 10) }); } else { res.status(400).json({ error: "type parametresi 'movie' veya 'series' olmalı" });