mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-17 21:53:12 +02:00
488 lines
16 KiB
Svelte
488 lines
16 KiB
Svelte
<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}
|