diff --git a/Dockerfile b/Dockerfile index e6df726..8cc139e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ RUN npm run build # Build server FROM node:22-slim -RUN apt-get update && apt-get install -y ffmpeg curl && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y ffmpeg curl aria2 && rm -rf /var/lib/apt/lists/* RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \ && chmod a+rx /usr/local/bin/yt-dlp WORKDIR /app/server diff --git a/client/src/App.svelte b/client/src/App.svelte index 274041e..1b428f4 100644 --- a/client/src/App.svelte +++ b/client/src/App.svelte @@ -10,6 +10,7 @@ import Rabbit from "./routes/Rabbit.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"; @@ -17,6 +18,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 { refreshRabbitCount } from "./stores/rabbitStore.js"; import { fetchTrashItems } from "./stores/trashStore.js"; @@ -36,6 +38,7 @@ await Promise.all([ refreshMovieCount(), refreshTvShowCount(), + refreshAnimeCount(), refreshMusicCount(), refreshRabbitCount(), fetchTrashItems() @@ -88,6 +91,7 @@ if (token) { refreshMovieCount(); refreshTvShowCount(); + refreshAnimeCount(); refreshMusicCount(); refreshRabbitCount(); fetchTrashItems(); @@ -154,6 +158,7 @@ + diff --git a/client/src/components/MatchModal.svelte b/client/src/components/MatchModal.svelte new file mode 100644 index 0000000..98e3ff1 --- /dev/null +++ b/client/src/components/MatchModal.svelte @@ -0,0 +1,611 @@ + + +{#if show} +
+
+ + +
+

+ + {headerTitle} +

+
+ {#if fileName} + + + {fileLabel}: {fileName} + + {/if} + {#if fileName && sizeText} + | + {/if} + {#if sizeText} + + + {sizeText} + + {/if} +
+
+ +
+
+
+ + +
+ {#if showYearInput} +
+ + +
+ {/if} +
+ +
+ + {#if searching} +
+ + Aranıyor... +
+ {:else if results.length > 0} +
+ {#each results as result} +
onSelect(result)} + > +
+ {#if result.poster} + {result.title} + {:else} +
+ +
+ {/if} +
+
+
{result.title}
+
+ {#if result.year} + + + {result.year} + + {/if} + {#if result.runtime} + + + + {result.runtime} dk + + {/if} + {#if result.status} + + + + {result.status} + + {/if} +
+ {#if result.genres && result.genres.length > 0} +
+ {result.genres.slice(0, 3).join(", ")} +
+ {/if} + {#if result.cast && result.cast.length > 0} +
+ + {result.cast.join(", ")} +
+ {/if} + {#if result.overview} +
{result.overview}
+ {/if} +
+ {#if $$slots.resultActions} +
+ +
+ {/if} +
+ {/each} +
+ {:else if showEmpty} +
{emptyText}
+ {/if} +
+
+
+{/if} + + diff --git a/client/src/components/Sidebar.svelte b/client/src/components/Sidebar.svelte index f04ea4a..6150f7e 100644 --- a/client/src/components/Sidebar.svelte +++ b/client/src/components/Sidebar.svelte @@ -3,6 +3,7 @@ import { Link } from "svelte-routing"; import { createEventDispatcher, onDestroy, onMount } 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 { rabbitCount } from "../stores/rabbitStore.js"; import { trashCount } from "../stores/trashStore.js"; @@ -12,6 +13,7 @@ import { apiFetch, getAccessToken } from "../utils/api.js"; const dispatch = createEventDispatcher(); let hasMovies = false; let hasShows = false; +let hasAnime = false; let hasTrash = false; let hasMusic = false; let hasRabbit = false; @@ -20,7 +22,7 @@ let hasRabbit = false; const diskSpaceStore = writable({ totalGB: '0', usedGB: '0', usedPercent: 0 }); let diskSpace; let hasMedia = false; -$: hasMedia = hasMovies || hasShows || hasMusic || hasRabbit; +$: hasMedia = hasMovies || hasShows || hasAnime || hasMusic || hasRabbit; // Store subscription'ı temizlemek için let unsubscribeDiskSpace; @@ -43,6 +45,10 @@ $: hasMedia = hasMovies || hasShows || hasMusic || hasRabbit; 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; @@ -58,6 +64,7 @@ const unsubscribeRabbit = rabbitCount.subscribe((count) => { onDestroy(() => { unsubscribeMovie(); unsubscribeTv(); + unsubscribeAnime(); unsubscribeTrash(); unsubscribeMusic(); unsubscribeRabbit(); @@ -216,6 +223,20 @@ const unsubscribeRabbit = rabbitCount.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/Files.svelte b/client/src/routes/Files.svelte index 2fb45b3..78e16e3 100644 --- a/client/src/routes/Files.svelte +++ b/client/src/routes/Files.svelte @@ -1,9 +1,11 @@