mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-22 00:35:12 +02:00
Remove sveltekit dependency, create simple test project
This commit is contained in:
487
src/lib/components/SeriesPage.svelte
Normal file
487
src/lib/components/SeriesPage.svelte
Normal file
@@ -0,0 +1,487 @@
|
||||
<script lang="ts">
|
||||
import { getJellyfinEpisodes, type JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import { addSeriesToSonarr, type SonarrSeries } from '$lib/apis/sonarr/sonarrApi';
|
||||
import {
|
||||
getTmdbIdFromTvdbId,
|
||||
getTmdbSeries,
|
||||
getTmdbSeriesRecommendations,
|
||||
getTmdbSeriesSeasons,
|
||||
getTmdbSeriesSimilar
|
||||
} from '$lib/apis/tmdb/tmdbApi';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Card from '$lib/components/Card/Card.svelte';
|
||||
import { fetchCardTmdbProps } from '$lib/components/Card/card';
|
||||
import Carousel from '$lib/components/Carousel/Carousel.svelte';
|
||||
import CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte';
|
||||
import UiCarousel from '$lib/components/Carousel/UICarousel.svelte';
|
||||
import EpisodeCard from '$lib/components/EpisodeCard/EpisodeCard.svelte';
|
||||
import PersonCard from '$lib/components/PersonCard/PersonCard.svelte';
|
||||
import SeriesRequestModal from '$lib/components/RequestModal/SeriesRequestModal.svelte';
|
||||
import OpenInButton from '$lib/components/TitlePageLayout/OpenInButton.svelte';
|
||||
import TitlePageLayout from '$lib/components/TitlePageLayout/TitlePageLayout.svelte';
|
||||
import { playerState } from '$lib/components/VideoPlayer/VideoPlayer';
|
||||
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
|
||||
import {
|
||||
createJellyfinItemStore,
|
||||
createSonarrDownloadStore,
|
||||
createSonarrSeriesStore
|
||||
} 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, DotFilled, Plus } from 'radix-icons-svelte';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export let titleId: TitleId;
|
||||
export let isModal = false;
|
||||
export let handleCloseModal: () => void = () => {};
|
||||
|
||||
let data = loadInitialPageData();
|
||||
|
||||
const jellyfinItemStore = createJellyfinItemStore(data.then((d) => d.tmdbId));
|
||||
const sonarrSeriesStore = createSonarrSeriesStore(data.then((d) => d.tmdbSeries?.name || ''));
|
||||
const sonarrDownloadStore = createSonarrDownloadStore(sonarrSeriesStore);
|
||||
|
||||
let seasonSelectVisible = false;
|
||||
let visibleSeasonNumber: number = 1;
|
||||
let visibleEpisodeIndex: number | undefined = undefined;
|
||||
|
||||
let jellyfinEpisodeData: {
|
||||
[key: string]: {
|
||||
jellyfinId: string | undefined;
|
||||
progress: number;
|
||||
watched: boolean;
|
||||
};
|
||||
} = {};
|
||||
let episodeComponents: HTMLDivElement[] = [];
|
||||
let nextJellyfinEpisode: JellyfinItem | undefined = undefined;
|
||||
|
||||
// Refresh jellyfin episode data
|
||||
jellyfinItemStore.subscribe(async (value) => {
|
||||
const item = value.item;
|
||||
if (!item?.Id) return;
|
||||
const episodes = await getJellyfinEpisodes(item.Id);
|
||||
|
||||
episodes?.forEach((episode) => {
|
||||
const key = `S${episode?.ParentIndexNumber}E${episode?.IndexNumber}`;
|
||||
|
||||
if (!nextJellyfinEpisode && episode?.UserData?.Played === false) {
|
||||
nextJellyfinEpisode = episode;
|
||||
}
|
||||
|
||||
jellyfinEpisodeData[key] = {
|
||||
jellyfinId: episode?.Id,
|
||||
progress: episode?.UserData?.PlayedPercentage || 0,
|
||||
watched: episode?.UserData?.Played || false
|
||||
};
|
||||
});
|
||||
|
||||
if (!nextJellyfinEpisode) nextJellyfinEpisode = episodes?.[0];
|
||||
visibleSeasonNumber = nextJellyfinEpisode?.ParentIndexNumber || visibleSeasonNumber;
|
||||
});
|
||||
|
||||
async function loadInitialPageData() {
|
||||
const tmdbId = await (titleId.provider === 'tvdb'
|
||||
? getTmdbIdFromTvdbId(titleId.id)
|
||||
: Promise.resolve(titleId.id));
|
||||
|
||||
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<ComponentProps<PersonCard>[]> = 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<ComponentProps<EpisodeCard>[][]> =
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
function playNextEpisode() {
|
||||
if (nextJellyfinEpisode?.Id) playerState.streamJellyfinId(nextJellyfinEpisode?.Id || '');
|
||||
}
|
||||
|
||||
async function refreshSonarr() {
|
||||
await sonarrSeriesStore.refreshIn();
|
||||
}
|
||||
|
||||
let addToSonarrLoading = false;
|
||||
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) {
|
||||
const episodeComponent = nextJellyfinEpisode?.IndexNumber
|
||||
? episodeComponents[nextJellyfinEpisode?.IndexNumber - 1]
|
||||
: undefined;
|
||||
|
||||
if (episodeComponent && nextJellyfinEpisode?.ParentIndexNumber === visibleSeasonNumber) {
|
||||
const parent = episodeComponent.offsetParent;
|
||||
|
||||
if (parent) {
|
||||
parent.scrollTo({
|
||||
left:
|
||||
episodeComponent.offsetLeft -
|
||||
document.body.clientWidth / 2 +
|
||||
episodeComponent.clientWidth / 2,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
didFocusNextEpisode = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await data}
|
||||
<TitlePageLayout {isModal} {handleCloseModal}>
|
||||
<div slot="episodes-carousel">
|
||||
<Carousel
|
||||
gradientFromColor="from-stone-950"
|
||||
class={classNames('px-2 sm:px-4 lg:px-8', {
|
||||
'2xl:px-0': !isModal
|
||||
})}
|
||||
heading="Episodes"
|
||||
>
|
||||
<CarouselPlaceholderItems />
|
||||
</Carousel>
|
||||
</div>
|
||||
</TitlePageLayout>
|
||||
{:then { tmdbSeries, tmdbId, ...data }}
|
||||
<TitlePageLayout
|
||||
titleInformation={{
|
||||
tmdbId,
|
||||
type: 'series',
|
||||
backdropUriCandidates: tmdbSeries?.images?.backdrops?.map((b) => b.file_path || '') || [],
|
||||
posterPath: tmdbSeries?.poster_path || '',
|
||||
title: tmdbSeries?.name || '',
|
||||
tagline: tmdbSeries?.tagline || tmdbSeries?.name || '',
|
||||
overview: tmdbSeries?.overview || ''
|
||||
}}
|
||||
{isModal}
|
||||
{handleCloseModal}
|
||||
>
|
||||
<svelte:fragment slot="title-info">
|
||||
{new Date(tmdbSeries?.first_air_date || Date.now()).getFullYear()}
|
||||
<DotFilled />
|
||||
{tmdbSeries?.status}
|
||||
<DotFilled />
|
||||
<a href={data.tmdbUrl} target="_blank">{tmdbSeries?.vote_average?.toFixed(1)} TMDB</a>
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="title-right">
|
||||
<div
|
||||
class="flex gap-2 items-center flex-row-reverse justify-end lg:flex-row lg:justify-start"
|
||||
>
|
||||
{#if $jellyfinItemStore.loading || $sonarrSeriesStore.loading}
|
||||
<div class="placeholder h-10 w-48 rounded-xl" />
|
||||
{:else}
|
||||
<OpenInButton
|
||||
title={tmdbSeries?.name}
|
||||
jellyfinItem={$jellyfinItemStore.item}
|
||||
sonarrSeries={$sonarrSeriesStore.item}
|
||||
type="series"
|
||||
{tmdbId}
|
||||
/>
|
||||
{#if !!nextJellyfinEpisode}
|
||||
<Button type="primary" on:click={playNextEpisode}>
|
||||
<span>
|
||||
Play {`S${nextJellyfinEpisode?.ParentIndexNumber}E${nextJellyfinEpisode?.IndexNumber}`}
|
||||
</span>
|
||||
<ChevronRight size={20} />
|
||||
</Button>
|
||||
{:else if !$sonarrSeriesStore.item && $settings.sonarr.apiKey && $settings.sonarr.baseUrl}
|
||||
<Button type="primary" disabled={addToSonarrLoading} on:click={addToSonarr}>
|
||||
<span>Add to Sonarr</span><Plus size={20} />
|
||||
</Button>
|
||||
{:else if $sonarrSeriesStore.item}
|
||||
<Button type="primary" on:click={openRequestModal}>
|
||||
<span class="mr-2">Request Series</span><Plus size={20} />
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
<div slot="episodes-carousel">
|
||||
<Carousel
|
||||
gradientFromColor="from-stone-950"
|
||||
class={classNames('px-2 sm:px-4 lg:px-8', {
|
||||
'2xl:px-0': !isModal
|
||||
})}
|
||||
>
|
||||
<UiCarousel slot="title" class="flex gap-6">
|
||||
{#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}
|
||||
<button
|
||||
class={classNames(
|
||||
'font-medium tracking-wide transition-colors flex-shrink-0 flex items-center gap-1',
|
||||
{
|
||||
'text-zinc-200': isSelected && seasonSelectVisible,
|
||||
'text-zinc-500 hover:text-zinc-200 cursor-pointer':
|
||||
(!isSelected || seasonSelectVisible === false) &&
|
||||
tmdbSeries?.number_of_seasons !== 1,
|
||||
'text-zinc-500 cursor-default': tmdbSeries?.number_of_seasons === 1,
|
||||
hidden:
|
||||
!seasonSelectVisible && visibleSeasonNumber !== (season?.season_number || 1)
|
||||
}
|
||||
)}
|
||||
on:click={() => {
|
||||
if (tmdbSeries?.number_of_seasons === 1) return;
|
||||
|
||||
if (seasonSelectVisible) {
|
||||
visibleSeasonNumber = season?.season_number || 1;
|
||||
seasonSelectVisible = false;
|
||||
} else {
|
||||
seasonSelectVisible = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronLeft
|
||||
size={20}
|
||||
class={(seasonSelectVisible || tmdbSeries?.number_of_seasons === 1) && 'hidden'}
|
||||
/>
|
||||
Season {season?.season_number}
|
||||
</button>
|
||||
{/each}
|
||||
</UiCarousel>
|
||||
{#key visibleSeasonNumber}
|
||||
{#each data.tmdbEpisodeProps[visibleSeasonNumber - 1] || [] as props, i}
|
||||
{@const jellyfinData = jellyfinEpisodeData[`S${visibleSeasonNumber}E${i + 1}`]}
|
||||
<div bind:this={episodeComponents[i]}>
|
||||
<EpisodeCard
|
||||
{...props}
|
||||
{...jellyfinData
|
||||
? {
|
||||
watched: jellyfinData.watched,
|
||||
progress: jellyfinData.progress,
|
||||
jellyfinId: jellyfinData.jellyfinId
|
||||
}
|
||||
: {}}
|
||||
on:click={() => (visibleEpisodeIndex = i)}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<CarouselPlaceholderItems />
|
||||
{/each}
|
||||
{/key}
|
||||
</Carousel>
|
||||
</div>
|
||||
|
||||
<svelte:fragment slot="info-components">
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Created By</p>
|
||||
<h2 class="font-medium">{tmdbSeries?.created_by?.map((c) => c.name).join(', ')}</h2>
|
||||
</div>
|
||||
{#if tmdbSeries?.first_air_date}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">First Air Date</p>
|
||||
<h2 class="font-medium">
|
||||
{new Date(tmdbSeries?.first_air_date).toLocaleDateString('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
{#if tmdbSeries?.next_episode_to_air}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Next Air Date</p>
|
||||
<h2 class="font-medium">
|
||||
{new Date(tmdbSeries.next_episode_to_air?.air_date).toLocaleDateString('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
{:else if tmdbSeries?.last_air_date}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Last Air Date</p>
|
||||
<h2 class="font-medium">
|
||||
{new Date(tmdbSeries.last_air_date).toLocaleDateString('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Networks</p>
|
||||
<h2 class="font-medium">{tmdbSeries?.networks?.map((n) => n.name).join(', ')}</h2>
|
||||
</div>
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Episode Run Time</p>
|
||||
<h2 class="font-medium">{tmdbSeries?.episode_run_time} Minutes</h2>
|
||||
</div>
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Spoken Languages</p>
|
||||
<h2 class="font-medium">
|
||||
{tmdbSeries?.spoken_languages?.map((l) => capitalize(l.english_name || '')).join(', ')}
|
||||
</h2>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="servarr-components">
|
||||
{@const sonarrSeries = $sonarrSeriesStore.item}
|
||||
{#if sonarrSeries}
|
||||
{#if sonarrSeries?.statistics?.episodeFileCount}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Available</p>
|
||||
<h2 class="font-medium">
|
||||
{sonarrSeries?.statistics?.episodeFileCount || 0} Episodes
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
{#if sonarrSeries?.statistics?.sizeOnDisk}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Size On Disk</p>
|
||||
<h2 class="font-medium">
|
||||
{formatSize(sonarrSeries?.statistics?.sizeOnDisk || 0)}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $sonarrDownloadStore.downloads?.length}
|
||||
{@const download = $sonarrDownloadStore.downloads?.[0]}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Download Completed In</p>
|
||||
<h2 class="font-medium">
|
||||
{download?.estimatedCompletionTime
|
||||
? formatMinutesToTime(
|
||||
(new Date(download?.estimatedCompletionTime).getTime() - Date.now()) / 1000 / 60
|
||||
)
|
||||
: 'Stalled'}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-4 flex-wrap col-span-4 sm:col-span-6 mt-4">
|
||||
<Button on:click={openRequestModal}>
|
||||
<span class="mr-2">Request Series</span><Plus size={20} />
|
||||
</Button>
|
||||
<Button>
|
||||
<span class="mr-2">Manage</span><Archive size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
{:else if $sonarrSeriesStore.loading}
|
||||
<div class="flex gap-4 flex-wrap col-span-4 sm:col-span-6 mt-4">
|
||||
<div class="placeholder h-10 w-40 rounded-xl" />
|
||||
<div class="placeholder h-10 w-40 rounded-xl" />
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="carousels">
|
||||
{#await data}
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Cast & Crew</div>
|
||||
<CarouselPlaceholderItems />
|
||||
</Carousel>
|
||||
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Recommendations</div>
|
||||
<CarouselPlaceholderItems />
|
||||
</Carousel>
|
||||
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Similar Series</div>
|
||||
<CarouselPlaceholderItems />
|
||||
</Carousel>
|
||||
{:then { castProps, tmdbRecommendationProps, tmdbSimilarProps }}
|
||||
{#if castProps?.length}
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Cast & Crew</div>
|
||||
{#each castProps as prop}
|
||||
<PersonCard {...prop} />
|
||||
{/each}
|
||||
</Carousel>
|
||||
{/if}
|
||||
|
||||
{#if tmdbRecommendationProps?.length}
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Recommendations</div>
|
||||
{#each tmdbRecommendationProps as prop}
|
||||
<Card {...prop} openInModal={isModal} />
|
||||
{/each}
|
||||
</Carousel>
|
||||
{/if}
|
||||
|
||||
{#if tmdbSimilarProps?.length}
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Similar Series</div>
|
||||
{#each tmdbSimilarProps as prop}
|
||||
<Card {...prop} openInModal={isModal} />
|
||||
{/each}
|
||||
</Carousel>
|
||||
{/if}
|
||||
{/await}
|
||||
</svelte:fragment>
|
||||
</TitlePageLayout>
|
||||
{/await}
|
||||
Reference in New Issue
Block a user