diff --git a/src/lib/apis/tmdb/tmdbApi.ts b/src/lib/apis/tmdb/tmdbApi.ts index 4e00c78..eb0b927 100644 --- a/src/lib/apis/tmdb/tmdbApi.ts +++ b/src/lib/apis/tmdb/tmdbApi.ts @@ -90,7 +90,11 @@ export const getTmdbSeriesFromTvdbId = async (tvdbId: string) => }).then((res) => res.data?.tv_results?.[0] as TmdbSeries2 | undefined); export const getTmdbIdFromTvdbId = async (tvdbId: number) => - getTmdbSeriesFromTvdbId(String(tvdbId)).then((res: any) => res?.id as number | undefined); + getTmdbSeriesFromTvdbId(String(tvdbId)).then((res: any) => { + const id = res?.id as number | undefined; + if (!id) return Promise.reject(); + return id; + }); export const getTmdbSeries = async (tmdbId: number): Promise => await TmdbApiOpen.get('/3/tv/{series_id}', { diff --git a/src/lib/components/Card/Card.svelte b/src/lib/components/Card/Card.svelte index bbe3070..4a04056 100644 --- a/src/lib/components/Card/Card.svelte +++ b/src/lib/components/Card/Card.svelte @@ -55,7 +55,7 @@ )} on:click={() => { if (openInModal) { - openTitleModal(tmdbId, type); + openTitleModal({ type, id: tmdbId, provider: 'tmdb' }); } else { window.location.href = `/${type}/${tmdbId}`; } diff --git a/src/lib/components/EpisodeCard/EpisodeCard.svelte b/src/lib/components/EpisodeCard/EpisodeCard.svelte index df82a74..2849f7a 100644 --- a/src/lib/components/EpisodeCard/EpisodeCard.svelte +++ b/src/lib/components/EpisodeCard/EpisodeCard.svelte @@ -28,6 +28,7 @@ if (!jellyfinId) return; watched = true; + progress = 0; setJellyfinItemWatched(jellyfinId).finally(() => jellyfinItemsStore.refreshIn(5000)); } diff --git a/src/lib/components/LazyImg.svelte b/src/lib/components/LazyImg.svelte index 4209275..7697d34 100644 --- a/src/lib/components/LazyImg.svelte +++ b/src/lib/components/LazyImg.svelte @@ -12,17 +12,21 @@
+
diff --git a/src/lib/components/Poster/Poster.svelte b/src/lib/components/Poster/Poster.svelte index 69fc867..22466ff 100644 --- a/src/lib/components/Poster/Poster.svelte +++ b/src/lib/components/Poster/Poster.svelte @@ -6,9 +6,11 @@ import { playerState } from '../VideoPlayer/VideoPlayer'; import LazyImg from '../LazyImg.svelte'; import { Star } from 'radix-icons-svelte'; + import { openTitleModal } from '$lib/stores/modal.store'; export let tmdbId: number | undefined = undefined; export let tvdbId: number | undefined = undefined; + export let openInModal = true; export let jellyfinId: string = ''; export let type: TitleType = 'movie'; export let backdropUrl: string; @@ -22,10 +24,20 @@ export let orientation: 'portrait' | 'landscape' = 'landscape'; - { + if (openInModal) { + if (tmdbId) { + openTitleModal({ type, id: tmdbId, provider: 'tmdb' }); + } else if (tvdbId) { + openTitleModal({ type, id: tvdbId, provider: 'tvdb' }); + } + } else { + window.location.href = tmdbId || tvdbId ? `/${type}/${tmdbId || tvdbId}` : '#'; + } + }} class={classNames( - 'relative flex shadow-lg rounded-xl selectable group hover:text-inherit flex-shrink-0 overflow-hidden', + 'relative flex shadow-lg rounded-xl selectable group hover:text-inherit flex-shrink-0 overflow-hidden text-left', { 'aspect-video': orientation === 'landscape', 'aspect-[2/3]': orientation === 'portrait', @@ -40,9 +52,19 @@ )} > +
+ +
+
{/if} -
+ diff --git a/src/lib/components/TitlePageLayout/TitlePageLayout.svelte b/src/lib/components/TitlePageLayout/TitlePageLayout.svelte index 2ace8a2..85b5f80 100644 --- a/src/lib/components/TitlePageLayout/TitlePageLayout.svelte +++ b/src/lib/components/TitlePageLayout/TitlePageLayout.svelte @@ -6,6 +6,7 @@ import Carousel from '../Carousel/Carousel.svelte'; import CarouselPlaceholderItems from '../Carousel/CarouselPlaceholderItems.svelte'; import IconButton from '../IconButton.svelte'; + import LazyImg from '../LazyImg.svelte'; export let isModal = false; export let handleCloseModal: () => void = () => {}; @@ -33,7 +34,30 @@ +
+ +
+ +
+ + +
+ +
+ +
+ + -
-
+
-->
- import type { TitleType } from '$lib/types'; + import type { TitleId } from '$lib/types'; import { fly } from 'svelte/transition'; import MoviePage from '../../../routes/movie/[id]/MoviePage.svelte'; import SeriesPage from '../../../routes/series/[id]/SeriesPage.svelte'; import { modalStack } from '../../stores/modal.store'; - export let tmdbId: number; - export let type: TitleType; + export let titleId: TitleId; export let modalId: symbol; function handleCloseModal() { @@ -22,10 +21,10 @@ in:fly|global={{ y: 20, duration: 200, delay: 200 }} out:fly|global={{ y: 20, duration: 200 }} > - {#if type === 'movie'} - + {#if titleId.type === 'movie'} + {:else} - + {/if}
diff --git a/src/lib/components/TitlePageLayout/TitlePagePlaceholder.svelte b/src/lib/components/TitlePageLayout/TitlePagePlaceholder.svelte new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/components/TitleShowcase/TitleShowcase.svelte b/src/lib/components/TitleShowcase/TitleShowcase.svelte index e4947c6..7c99628 100644 --- a/src/lib/components/TitleShowcase/TitleShowcase.svelte +++ b/src/lib/components/TitleShowcase/TitleShowcase.svelte @@ -123,7 +123,11 @@ out:fade|global={{ duration: ANIMATION_DURATION }} >
- {#if trailerId} diff --git a/src/lib/stores/data.store.ts b/src/lib/stores/data.store.ts index 1ef4b76..168f92a 100644 --- a/src/lib/stores/data.store.ts +++ b/src/lib/stores/data.store.ts @@ -65,13 +65,21 @@ function _createDataFetchStore(fn: () => Promise) { export const jellyfinItemsStore = _createDataFetchStore(getJellyfinItems); -export function createJellyfinItemStore(tmdbId: number) { - const store = derived(jellyfinItemsStore, (s) => { - return { - loading: s.loading, - item: s.data?.find((i) => i.ProviderIds?.Tmdb === String(tmdbId)) - }; +export function createJellyfinItemStore(tmdbId: number | Promise) { + const store = writable<{ loading: boolean; item?: JellyfinItem }>({ + loading: true, + item: undefined }); + + jellyfinItemsStore.subscribe(async (s) => { + const awaited = await tmdbId; + + store.set({ + loading: s.loading, + item: s.data?.find((i) => i.ProviderIds?.Tmdb === String(awaited)) + }); + }); + return { subscribe: store.subscribe, refresh: jellyfinItemsStore.refresh, diff --git a/src/lib/stores/modal.store.ts b/src/lib/stores/modal.store.ts index a5ae225..4b99c7e 100644 --- a/src/lib/stores/modal.store.ts +++ b/src/lib/stores/modal.store.ts @@ -1,4 +1,4 @@ -import type { TitleType } from '$lib/types'; +import type { TitleId, TitleType } from '$lib/types'; import { writable } from 'svelte/store'; import TitlePageModal from '../components/TitlePageLayout/TitlePageModal.svelte'; @@ -61,9 +61,11 @@ function createDynamicModalStack() { export const modalStack = createDynamicModalStack(); let lastTitleModal: symbol | undefined = undefined; -export function openTitleModal(tmdbId: number, type: TitleType) { +export function openTitleModal(titleId: TitleId) { if (lastTitleModal) { modalStack.close(lastTitleModal); } - lastTitleModal = modalStack.create(TitlePageModal, { tmdbId, type }); + lastTitleModal = modalStack.create(TitlePageModal, { + titleId + }); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 359d9e7..50467d3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1 +1,6 @@ export type TitleType = 'movie' | 'series'; +export type TitleId = { + id: number; + provider: 'tmdb' | 'tvdb'; + type: TitleType; +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 8c29265..8b4bbd5 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -22,8 +22,10 @@ let continueWatchingP = getJellyfinContinueWatching(); let nextUpProps = Promise.all([nextUpP, continueWatchingP]) - .then(([nextUp, continueWatching]) => [...(continueWatching || []), ...(nextUp || [])]) - .then(log) + .then(([nextUp, continueWatching]) => [ + ...(continueWatching || []), + ...(nextUp?.filter((i) => !continueWatching?.find((c) => c.SeriesId === i.SeriesId)) || []) + ]) .then((items) => Promise.all( items?.map(async (item) => { diff --git a/src/routes/series/[id]/+page.svelte b/src/routes/series/[id]/+page.svelte index 4c024df..bc7db0e 100644 --- a/src/routes/series/[id]/+page.svelte +++ b/src/routes/series/[id]/+page.svelte @@ -1,13 +1,14 @@ -{#key tmdbId} - +{#key titleId} + {/key} diff --git a/src/routes/series/[id]/SeriesPage.svelte b/src/routes/series/[id]/SeriesPage.svelte index 821a93b..cefb3f2 100644 --- a/src/routes/series/[id]/SeriesPage.svelte +++ b/src/routes/series/[id]/SeriesPage.svelte @@ -2,6 +2,7 @@ import { getJellyfinEpisodes, type JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi'; import { addSeriesToSonarr, type SonarrSeries } from '$lib/apis/sonarr/sonarrApi'; import { + getTmdbIdFromTvdbId, getTmdbSeries, getTmdbSeriesRecommendations, getTmdbSeriesSeasons, @@ -27,99 +28,153 @@ } from '$lib/stores/data.store'; import { modalStack } from '$lib/stores/modal.store'; import { settings } from '$lib/stores/settings.store'; + import type { TitleId } from '$lib/types'; import { capitalize, formatMinutesToTime, formatSize } from '$lib/utils'; import classNames from 'classnames'; import { Archive, ChevronLeft, ChevronRight, Plus } from 'radix-icons-svelte'; import type { ComponentProps } from 'svelte'; + import { get } from 'svelte/store'; - export let tmdbId: number; + export let titleId: TitleId; export let isModal = false; export let handleCloseModal: () => void = () => {}; - const tmdbUrl = 'https://www.themoviedb.org/tv/' + tmdbId; - const tmdbSeriesPromise = getTmdbSeries(tmdbId); - const tmdbSeasonsPromise = tmdbSeriesPromise.then((s) => - getTmdbSeriesSeasons(tmdbId, s?.number_of_seasons || 0) - ); + let data = loadInitialPageData(); - const jellyfinItemStore = createJellyfinItemStore(tmdbId); - const sonarrSeriesStore = createSonarrSeriesStore(tmdbSeriesPromise.then((s) => s?.name || '')); + const jellyfinItemStore = createJellyfinItemStore(data.then((d) => d.tmdbId)); + const sonarrSeriesStore = createSonarrSeriesStore(data.then((d) => d.tmdbSeries?.name || '')); const sonarrDownloadStore = createSonarrDownloadStore(sonarrSeriesStore); - let sonarrSeries: undefined | SonarrSeries = undefined; - let jellyfinItem = $jellyfinItemStore.item; - - sonarrSeriesStore.subscribe((s) => (sonarrSeries = s.item)); - let seasonSelectVisible = false; let visibleSeasonNumber: number | undefined = undefined; let visibleEpisodeIndex: number | undefined = undefined; - function openRequestModal() { - if (!sonarrSeries?.id || !sonarrSeries?.statistics?.seasonCount) return; + async function loadInitialPageData() { + const tmdbId = await (titleId.provider === 'tvdb' + ? getTmdbIdFromTvdbId(titleId.id) + : Promise.resolve(titleId.id)); - modalStack.create(SeriesRequestModal, { - sonarrId: sonarrSeries?.id || 0, - seasons: sonarrSeries?.statistics?.seasonCount || 0, - heading: sonarrSeries?.title || 'Series' - }); + const tmdbSeriesPromise = getTmdbSeries(tmdbId); + const tmdbSeasonsPromise = tmdbSeriesPromise.then((s) => + getTmdbSeriesSeasons(s?.id || 0, s?.number_of_seasons || 0) + ); + + const tmdbUrl = 'https://www.themoviedb.org/tv/' + tmdbId; + + const tmdbRecommendationPropsPromise = getTmdbSeriesRecommendations(tmdbId).then((r) => + Promise.all(r.map(fetchCardTmdbProps)) + ); + const tmdbSimilarPropsPromise = getTmdbSeriesSimilar(tmdbId) + .then((r) => Promise.all(r.map(fetchCardTmdbProps))) + .then((r) => r.filter((p) => p.backdropUrl)); + + const castPropsPromise: Promise[]> = tmdbSeriesPromise.then((s) => + Promise.all( + s?.aggregate_credits?.cast?.slice(0, 20)?.map((m) => ({ + tmdbId: m.id || 0, + backdropUri: m.profile_path || '', + name: m.name || '', + subtitle: m.roles?.[0]?.character || m.known_for_department || '' + })) || [] + ) + ); + + const tmdbEpisodePropsPromise: Promise[][]> = + tmdbSeasonsPromise.then((seasons) => + seasons.map( + (season) => + season?.episodes?.map((episode) => ({ + title: episode?.name || '', + subtitle: `Episode ${episode?.episode_number}`, + backdropUrl: TMDB_BACKDROP_SMALL + episode?.still_path || '', + airDate: + episode.air_date && new Date(episode.air_date) > new Date() + ? new Date(episode.air_date) + : undefined + })) || [] + ) + ); + + return { + tmdbId, + tmdbSeries: await tmdbSeriesPromise, + tmdbSeasons: await tmdbSeasonsPromise, + tmdbUrl, + tmdbRecommendationProps: await tmdbRecommendationPropsPromise, + tmdbSimilarProps: await tmdbSimilarPropsPromise, + castProps: await castPropsPromise, + tmdbEpisodeProps: await tmdbEpisodePropsPromise + }; } - let episodeProps: ComponentProps[][] = []; + let jellyfinEpisodeData: { + [key: string]: { + jellyfinId: string | undefined; + progress: number; + watched: boolean; + }; + } = {}; let episodeComponents: HTMLDivElement[] = []; let nextJellyfinEpisode: JellyfinItem | undefined = undefined; - const tmdbRecommendationProps = getTmdbSeriesRecommendations(tmdbId).then((r) => - Promise.all(r.map(fetchCardTmdbProps)) - ); - const tmdbSimilarProps = getTmdbSeriesSimilar(tmdbId) - .then((r) => Promise.all(r.map(fetchCardTmdbProps))) - .then((r) => r.filter((p) => p.backdropUrl)); - const castProps: Promise[]> = tmdbSeriesPromise.then((s) => - Promise.all( - s?.aggregate_credits?.cast?.slice(0, 20)?.map((m) => ({ - tmdbId: m.id || 0, - backdropUri: m.profile_path || '', - name: m.name || '', - subtitle: m.roles?.[0]?.character || m.known_for_department || '' - })) || [] - ) - ); + // Refresh jellyfin episode data + jellyfinItemStore.subscribe(async (value) => { + const item = value.item; + if (!item?.Id) return; + const episodes = await getJellyfinEpisodes(item.Id); - jellyfinItemStore.promise.then(async (jellyfinItem) => { - const jellyfinEpisodes = jellyfinItem?.Id ? await getJellyfinEpisodes(jellyfinItem?.Id) : []; - const tmdbSeasons = await tmdbSeasonsPromise; + episodes?.forEach((episode) => { + const key = `S${episode?.ParentIndexNumber}E${episode?.IndexNumber}`; - tmdbSeasons.forEach((season) => { - const episodes: ComponentProps[] = []; - season?.episodes?.forEach((tmdbEpisode) => { - const jellyfinEpisode = jellyfinEpisodes?.find( - (e) => - e?.IndexNumber === tmdbEpisode?.episode_number && - e?.ParentIndexNumber === tmdbEpisode?.season_number - ); + if (!nextJellyfinEpisode && episode?.UserData?.Played === false) { + nextJellyfinEpisode = episode; + } - if (!nextJellyfinEpisode && jellyfinEpisode?.UserData?.Played === false) { - nextJellyfinEpisode = jellyfinEpisode; - } - - episodes.push({ - title: tmdbEpisode?.name || '', - subtitle: `Episode ${tmdbEpisode?.episode_number}`, - backdropUrl: TMDB_BACKDROP_SMALL + tmdbEpisode?.still_path || '', - progress: jellyfinEpisode?.UserData?.PlayedPercentage || 0, - watched: jellyfinEpisode?.UserData?.Played || false, - jellyfinId: jellyfinEpisode?.Id, - airDate: tmdbEpisode.air_date ? new Date(tmdbEpisode.air_date) : undefined - }); - }); - episodeProps[season?.season_number || 0] = episodes; + jellyfinEpisodeData[key] = { + jellyfinId: episode?.Id, + progress: episode?.UserData?.PlayedPercentage || 0, + watched: episode?.UserData?.Played || false + }; }); - if (!nextJellyfinEpisode) nextJellyfinEpisode = jellyfinEpisodes?.[0]; + if (!nextJellyfinEpisode) nextJellyfinEpisode = episodes?.[0]; visibleSeasonNumber = nextJellyfinEpisode?.ParentIndexNumber || visibleSeasonNumber || 1; }); + // jellyfinItemStore.promise.then(async (jellyfinItem) => { + // const jellyfinEpisodes = jellyfinItem?.Id ? await getJellyfinEpisodes(jellyfinItem?.Id) : []; + // const tmdbSeasons = await tmdbSeasonsPromise; + + // tmdbSeasons.forEach((season) => { + // const episodes: ComponentProps[] = []; + // season?.episodes?.forEach((tmdbEpisode) => { + // const jellyfinEpisode = jellyfinEpisodes?.find( + // (e) => + // e?.IndexNumber === tmdbEpisode?.episode_number && + // e?.ParentIndexNumber === tmdbEpisode?.season_number + // ); + + // if (!nextJellyfinEpisode && jellyfinEpisode?.UserData?.Played === false) { + // nextJellyfinEpisode = jellyfinEpisode; + // } + + // episodes.push({ + // title: tmdbEpisode?.name || '', + // subtitle: `Episode ${tmdbEpisode?.episode_number}`, + // backdropUrl: TMDB_BACKDROP_SMALL + tmdbEpisode?.still_path || '', + // progress: jellyfinEpisode?.UserData?.PlayedPercentage || 0, + // watched: jellyfinEpisode?.UserData?.Played || false, + // jellyfinId: jellyfinEpisode?.Id, + // airDate: tmdbEpisode.air_date ? new Date(tmdbEpisode.air_date) : undefined + // }); + // }); + // episodeProps[season?.season_number || 0] = episodes; + // }); + + // if (!nextJellyfinEpisode) nextJellyfinEpisode = jellyfinEpisodes?.[0]; + // visibleSeasonNumber = nextJellyfinEpisode?.ParentIndexNumber || visibleSeasonNumber || 1; + // }); + function playNextEpisode() { if (nextJellyfinEpisode?.Id) playerState.streamJellyfinId(nextJellyfinEpisode?.Id || ''); } @@ -129,13 +184,27 @@ } let addToSonarrLoading = false; - function addToSonarr() { + async function addToSonarr() { + const tmdbId = await data.then((d) => d.tmdbId); addToSonarrLoading = true; addSeriesToSonarr(tmdbId) .then(refreshSonarr) .finally(() => (addToSonarrLoading = false)); } + async function openRequestModal() { + const sonarrSeries = get(sonarrSeriesStore).item; + + if (!sonarrSeries?.id || !sonarrSeries?.statistics?.seasonCount) return; + + modalStack.create(SeriesRequestModal, { + sonarrId: sonarrSeries?.id || 0, + seasons: sonarrSeries?.statistics?.seasonCount || 0, + heading: sonarrSeries?.title || 'Series' + }); + } + + // Focus next episode on load let didFocusNextEpisode = false; $: { if (episodeComponents && !didFocusNextEpisode) { @@ -162,24 +231,24 @@ } -{#await tmdbSeriesPromise then series} +{#await data then { tmdbSeries, tmdbId, ...data }} b.file_path || '') || []} - posterPath={series?.poster_path || ''} - title={series?.name || ''} - tagline={series?.tagline || series?.name || ''} - overview={series?.overview || ''} + backdropUriCandidates={tmdbSeries?.images?.backdrops?.map((b) => b.file_path || '') || []} + posterPath={tmdbSeries?.poster_path || ''} + title={tmdbSeries?.name || ''} + tagline={tmdbSeries?.tagline || tmdbSeries?.name || ''} + overview={tmdbSeries?.overview || ''} > - {new Date(series?.first_air_date || Date.now()).getFullYear()} + {new Date(tmdbSeries?.first_air_date || Date.now()).getFullYear()} - {series?.status} + {tmdbSeries?.status} - {series?.vote_average?.toFixed(1)} TMDB + {tmdbSeries?.vote_average?.toFixed(1)} TMDB @@ -189,7 +258,13 @@ {#if $jellyfinItemStore.loading || $sonarrSeriesStore.loading}
{:else} - + {#if !!nextJellyfinEpisode} - {:else if !sonarrSeries && $settings.sonarr.apiKey && $settings.sonarr.baseUrl} + {:else if !$sonarrSeriesStore.item && $settings.sonarr.apiKey && $settings.sonarr.baseUrl} - {:else if sonarrSeries} + {:else if $sonarrSeriesStore.item} @@ -218,8 +293,8 @@ })} > - {#each [...Array(series?.number_of_seasons || 0).keys()].map((i) => i + 1) as seasonNumber} - {@const season = series?.seasons?.find((s) => s.season_number === seasonNumber)} + {#each [...Array(tmdbSeries?.number_of_seasons || 0).keys()].map((i) => i + 1) as seasonNumber} + {@const season = tmdbSeries?.seasons?.find((s) => s.season_number === seasonNumber)} {@const isSelected = season?.season_number === (visibleSeasonNumber || 1)} {/each} {#key visibleSeasonNumber} - {#each episodeProps[visibleSeasonNumber || 1] || [] as props, i} + {#each data.tmdbEpisodeProps[(visibleSeasonNumber || 1) - 1] || [] as props, i} + {@const jellyfinData = jellyfinEpisodeData[`S${visibleSeasonNumber}E${i + 1}`]}
- (visibleEpisodeIndex = i)} /> + (visibleEpisodeIndex = i)} + />
{:else} @@ -268,13 +354,13 @@

Created By

-

{series?.created_by?.map((c) => c.name).join(', ')}

+

{tmdbSeries?.created_by?.map((c) => c.name).join(', ')}

- {#if series?.first_air_date} + {#if tmdbSeries?.first_air_date}

First Air Date

- {new Date(series?.first_air_date).toLocaleDateString('en', { + {new Date(tmdbSeries?.first_air_date).toLocaleDateString('en', { year: 'numeric', month: 'short', day: 'numeric' @@ -282,22 +368,22 @@

{/if} - {#if series?.next_episode_to_air} + {#if tmdbSeries?.next_episode_to_air}

Next Air Date

- {new Date(series.next_episode_to_air?.air_date).toLocaleDateString('en', { + {new Date(tmdbSeries.next_episode_to_air?.air_date).toLocaleDateString('en', { year: 'numeric', month: 'short', day: 'numeric' })}

- {:else if series?.last_air_date} + {:else if tmdbSeries?.last_air_date}

Last Air Date

- {new Date(series.last_air_date).toLocaleDateString('en', { + {new Date(tmdbSeries.last_air_date).toLocaleDateString('en', { year: 'numeric', month: 'short', day: 'numeric' @@ -307,21 +393,22 @@ {/if}

Networks

-

{series?.networks?.map((n) => n.name).join(', ')}

+

{tmdbSeries?.networks?.map((n) => n.name).join(', ')}

Episode Run Time

-

{series?.episode_run_time} Minutes

+

{tmdbSeries?.episode_run_time} Minutes

Spoken Languages

- {series?.spoken_languages?.map((l) => capitalize(l.english_name || '')).join(', ')} + {tmdbSeries?.spoken_languages?.map((l) => capitalize(l.english_name || '')).join(', ')}

+ {@const sonarrSeries = $sonarrSeriesStore.item} {#if sonarrSeries} {#if sonarrSeries?.statistics?.episodeFileCount}
@@ -371,7 +458,7 @@
Cast & Crew
- {#await castProps} + {#await data.castProps} {:then props} {#each props as prop} @@ -382,7 +469,7 @@
Recommendations
- {#await tmdbRecommendationProps} + {#await data.tmdbRecommendationProps} {:then props} {#each props as prop} @@ -393,7 +480,7 @@
Similar Series
- {#await tmdbSimilarProps} + {#await data.tmdbSimilarProps} {:then props} {#each props as prop}