Updated movie page

This commit is contained in:
Aleksi Lassila
2023-08-05 14:53:47 +03:00
parent 7f86bb07e9
commit 4bee7c2413
12 changed files with 477 additions and 162 deletions

View File

@@ -260,6 +260,24 @@ export const getTmdbSeriesCredits = (tmdbId: number) =>
}
}).then((res) => res.data?.cast || []);
export const getTmdbMovieRecommendations = (tmdbId: number) =>
TmdbApiOpen.get('/3/movie/{movie_id}/recommendations', {
params: {
path: {
movie_id: tmdbId
}
}
}).then((res) => res.data?.results || []);
export const getTmdbMovieSimilar = (tmdbId: number) =>
TmdbApiOpen.get('/3/movie/{movie_id}/similar', {
params: {
path: {
movie_id: tmdbId
}
}
}).then((res) => res.data?.results || []);
// Deprecated hereon forward
/** @deprecated */

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { TMDB_IMAGES_ORIGINAL, TMDB_POSTER_SMALL } from '$lib/constants';
import { DotFilled } from 'radix-icons-svelte';
import { fade } from 'svelte/transition';
import Carousel from '../Carousel/Carousel.svelte';
import CarouselPlaceholderItems from '../Carousel/CarouselPlaceholderItems.svelte';
export let backdropPath: string;
export let posterPath: string;
export let title: string;
export let tagline: string;
export let overview: string;
</script>
<div class="flex flex-col max-h-screen bg-black pb-2" transition:fade>
<div
style={"background-image: url('" + TMDB_IMAGES_ORIGINAL + backdropPath + "')"}
class="flex-shrink relative flex pt-24 aspect-video min-h-[70vh] p-4 sm:p-8 bg-center bg-cover w-screen sm:bg-fixed"
>
<div class="absolute inset-0 bg-gradient-to-t from-black to-50% to-darken" />
<div class="z-[1] flex-1 flex justify-end gap-8 items-end">
<div
class="aspect-[2/3] w-52 bg-center bg-cover rounded-md hidden sm:block"
style={"background-image: url('" + TMDB_POSTER_SMALL + posterPath + "')"}
/>
<div class="flex-1 flex gap-4 justify-between flex-col lg:flex-row lg:items-end">
<div>
<div class="text-zinc-300 text-sm uppercase font-semibold flex items-center gap-1">
<p class="flex-shrink-0">
<slot name="title-info-1" />
</p>
<DotFilled />
<p class="flex-shrink-0">
<slot name="title-info-2" />
</p>
<DotFilled />
<p class="flex-shrink-0"><slot name="title-info-3" /></p>
<!-- <DotFilled />
<p class="line-clamp-1">{series?.genres?.map((g) => g.name).join(', ')}</p> -->
</div>
<h1 class="text-4xl sm:text-5xl md:text-6xl font-semibold">{title}</h1>
</div>
<div class="flex-shrink-0">
<slot name="title-right" />
</div>
</div>
</div>
</div>
<slot name="episodes-carousel" />
</div>
<div class="flex flex-col py-4 gap-8 bg-black">
<div
class="mx-4 sm:mx-8 py-4 sm:py-6 px-6 sm:px-10 grid grid-cols-4 sm:grid-cols-6 gap-4 sm:gap-x-8 bg-zinc-900 rounded-xl"
>
<slot name="info-description">
<div
class="flex flex-col gap-3 max-w-5xl row-span-3 col-span-4 sm:col-span-6 lg:col-span-3 mb-4 lg:mr-8"
>
<div class="flex gap-4 justify-between">
<h1 class="font-semibold text-xl sm:text-2xl">{tagline}</h1>
<!-- <div class="flex items-center gap-4">
<a
target="_blank"
href={'https://www.themoviedb.org/tv/' + tmdbId}
class="opacity-60 hover:opacity-100"
>
<img src="/tmdb.svg" alt="tmdb" width="25px" />
</a>
{#if $itemStore.item?.sonarrSeries?.titleSlug}
<a
target="_blank"
href={PUBLIC_SONARR_BASE_URL +
'/series/' +
$itemStore.item?.sonarrSeries?.titleSlug}
class="opacity-60 hover:opacity-100"
>
<img src="/sonarr.svg" alt="sonarr" width="15px" />
</a>
{/if}
{#if series?.homepage}
<a
target="_blank"
href={series.homepage}
class="flex gap-1 items-center opacity-60 hover:opacity-100"
>
<Globe size={15} />
</a>
{/if} -->
</div>
<p class="pl-4 border-l-2 text-sm sm:text-base">{overview}</p>
</div>
</slot>
<slot name="info-components" />
<slot name="servarr-components">
<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>
</slot>
</div>
<div>
<Carousel gradientFromColor="from-black">
<slot name="cast-crew-carousel-title" slot="title" />
<slot name="cast-crew-carousel">
<CarouselPlaceholderItems />
</slot>
</Carousel>
</div>
<div>
<Carousel gradientFromColor="from-black">
<slot name="recommendations-carousel-title" slot="title" />
<slot name="recommendations-carousel">
<CarouselPlaceholderItems />
</slot>
</Carousel>
</div>
<div>
<Carousel gradientFromColor="from-black">
<slot name="similar-carousel-title" slot="title" />
<slot name="similar-carousel">
<CarouselPlaceholderItems />
</slot>
</Carousel>
</div>
</div>

View File

@@ -48,7 +48,7 @@ export function createModalProps(onClose: () => void, onBack?: () => void) {
return {
close,
back,
back: onBack ? back : undefined,
id
};
}

View File

@@ -1,8 +0,0 @@
import { getTmdbMovie } from '$lib/apis/tmdb/tmdbApi';
import type { PageServerLoad } from './$types';
export const load = (async ({ params }) => {
return {
movie: await getTmdbMovie(Number(params.id))
};
}) satisfies PageServerLoad;

View File

@@ -1,28 +1,13 @@
<script lang="ts">
import ResourceDetails from '$lib/components/ResourceDetails/ResourceDetails.svelte';
import { library } from '$lib/stores/library.store';
import type { PageData } from './$types';
import MoviePage from './MoviePage.svelte';
export let data: PageData;
let tmdbId: number;
$: tmdbId = Number(data.tmdbId);
</script>
{#await $library then libraryData}
{#if data.movie}
{@const movie = data.movie}
<ResourceDetails
tmdbId={movie?.id || 0}
type="movie"
title={movie?.title || ''}
releaseDate={new Date(movie?.release_date || Date.now())}
tagline={movie?.tagline || ''}
overview={movie?.overview || ''}
genres={movie?.genres?.map((g) => g.name || '') || []}
runtime={movie?.runtime || 0}
tmdbRating={movie?.vote_average || 0}
starring={movie?.credits?.cast?.slice(0, 5)}
videos={movie.videos?.results || []}
backdropPath={movie?.backdrop_path || ''}
showDetails={true}
jellyfinId={libraryData.items[movie.id]?.jellyfinId}
/>
{/if}
{/await}
{#key tmdbId}
<MoviePage {tmdbId} />
{/key}

View File

@@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
export const load = (async ({ params }) => {
return {
tmdbId: params.id
};
}) satisfies PageLoad;

View File

@@ -1,39 +0,0 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { getJellyfinItemByTmdbId } from '$lib/apis/jellyfin/jellyfinApi';
import { getRadarrMovieByTmdbId, getRadarrDownloadsById } from '$lib/apis/radarr/radarrApi';
export const _parseMovieId = (params: any) => {
const { id: tmdbId } = params;
if (!tmdbId) throw error(400, 'NO_TMDB_ID');
return tmdbId;
};
export const GET = (async ({ params }) => {
const tmdbId = _parseMovieId(params);
const jellyfinMoviePromise = getJellyfinItemByTmdbId(tmdbId);
const radarrMoviePromise = getRadarrMovieByTmdbId(tmdbId);
const radarrMovieQueuedPromise = radarrMoviePromise.then((movie) =>
movie?.id ? getRadarrDownloadsById(movie.id) : undefined
);
const [jellyfinItem, radarrMovie, radarrDownloads] = await Promise.all([
jellyfinMoviePromise,
radarrMoviePromise,
radarrMovieQueuedPromise
]);
return json({
canStream: !!jellyfinItem,
hasLocalFiles: radarrMovie?.hasFile || !!jellyfinItem,
isAdded: !!radarrMovie,
isDownloading: !!radarrDownloads?.length,
jellyfinItem,
radarrMovie,
radarrDownloads
});
}) satisfies RequestHandler;

View File

@@ -0,0 +1,306 @@
<script lang="ts">
import { addMovieToRadarr } from '$lib/apis/radarr/radarrApi';
import {
getTmdbMovie,
getTmdbMovieRecommendations,
getTmdbMovieSimilar
} 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 CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte';
import DetailsPage from '$lib/components/DetailsPage/DetailsPage.svelte';
import { createModalProps } from '$lib/components/Modal/Modal';
import PeopleCard from '$lib/components/PeopleCard/PeopleCard.svelte';
import RequestModal from '$lib/components/RequestModal/RequestModal.svelte';
import { playerState } from '$lib/components/VideoPlayer/VideoPlayer';
import { createLibraryItemStore, library } from '$lib/stores/library.store';
import { formatMinutesToTime, formatSize } from '$lib/utils';
import { Archive, ChevronRight, Plus } from 'radix-icons-svelte';
import type { ComponentProps } from 'svelte';
export let tmdbId: number;
const itemStore = createLibraryItemStore(tmdbId);
let requestModalVisible = false;
const requestModalProps = createModalProps(() => (requestModalVisible = false));
const tmdbMoviePromise = getTmdbMovie(tmdbId);
const tmdbRecommendationProps = getTmdbMovieRecommendations(tmdbId)
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
.then((r) => r.filter((p) => p.backdropUri));
const tmdbSimilarProps = getTmdbMovieSimilar(tmdbId)
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
.then((r) => r.filter((p) => p.backdropUri));
const castProps: Promise<ComponentProps<PeopleCard>[]> = tmdbMoviePromise.then((m) =>
Promise.all(
m?.credits?.cast?.map((m) => ({
tmdbId: m.id || 0,
name: m.name || '',
backdropUri: m.profile_path || '',
department: m.known_for_department || ''
})) || []
)
);
function stream() {
if ($itemStore.item?.jellyfinItem?.Id)
playerState.streamJellyfinId($itemStore.item?.jellyfinItem?.Id);
}
async function refresh() {
await library.refresh();
}
let addToRadarrLoading = false;
function addToRadarr() {
addToRadarrLoading = true;
addMovieToRadarr(tmdbId)
.then(refresh)
.finally(() => (addToRadarrLoading = false));
}
</script>
{#await tmdbMoviePromise then movie}
<DetailsPage
title={movie?.title || 'Movie'}
backdropPath={movie?.backdrop_path || ''}
posterPath={movie?.poster_path || ''}
tagline={movie?.tagline || movie?.title || ''}
overview={movie?.overview || ''}
>
<svelte:fragment slot="title-info-1"
>{new Date(movie?.release_date || Date.now()).getFullYear()}</svelte:fragment
>
<svelte:fragment slot="title-info-2">{movie?.runtime} min</svelte:fragment>
<svelte:fragment slot="title-info-3">{movie?.vote_average?.toFixed(1)} TMDB</svelte:fragment>
<svelte:fragment slot="episodes-carousel">
{@const progress = $itemStore.item?.continueWatching?.progress}
{#if progress}
<div
class="h-1 bg-zinc-800 rounded-full overflow-hidden group-hover:opacity-0 transition-opacity mx-4 sm:mx-8"
>
<div style={'width: ' + progress + '%'} class="h-full bg-zinc-400" />
</div>
{/if}
</svelte:fragment>
<svelte:fragment slot="title-right">
{#if $itemStore.loading}
<div class="placeholder h-10 w-48 rounded-xl" />
{:else if $itemStore.item?.jellyfinItem}
<Button type="primary" on:click={stream}>
<span>Stream</span><ChevronRight size={20} />
</Button>
{:else if !$itemStore.item?.radarrMovie}
<Button type="primary" disabled={addToRadarrLoading} on:click={addToRadarr}>
<span>Add to Radarr</span><Plus size={20} />
</Button>
{:else}
<Button type="primary" on:click={() => (requestModalVisible = true)}>
<span class="mr-2">Request Movie</span><Plus size={20} />
</Button>
{/if}
</svelte:fragment>
<svelte:fragment slot="info-components">
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Directed By</p>
<h2 class="font-medium">
{movie?.credits.crew?.filter((c) => c.job == 'Director').map((p) => p.name)}
</h2>
</div>
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Release Date</p>
<h2 class="font-medium">
{new Date(movie?.release_date || Date.now()).toLocaleDateString('en', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</h2>
</div>
{#if movie?.budget}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Budget</p>
<h2 class="font-medium">
{movie?.budget?.toLocaleString('en-US', {
style: 'currency',
currency: 'USD'
})}
</h2>
</div>
{/if}
{#if movie?.revenue}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Revenue</p>
<h2 class="font-medium">
{movie?.revenue?.toLocaleString('en-US', {
style: 'currency',
currency: 'USD'
})}
</h2>
</div>
{/if}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Status</p>
<h2 class="font-medium">
{movie?.status}
</h2>
</div>
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Runtime</p>
<h2 class="font-medium">
{movie?.runtime} Minutes
</h2>
</div>
<!-- {#if series?.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(series?.first_air_date).toLocaleDateString('en', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</h2>
</div>
{/if}
{#if series?.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(series.next_episode_to_air?.air_date).toLocaleDateString('en', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</h2>
</div>
{:else if series?.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(series.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">{series?.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">{series?.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">
{series?.spoken_languages?.map((l) => capitalize(l.name || '')).join(', ')}
</h2>
</div> -->
</svelte:fragment>
<svelte:fragment slot="servarr-components">
{#if !$itemStore.loading && $itemStore.item}
{@const item = $itemStore.item}
{#if item.radarrMovie?.movieFile?.quality}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Video</p>
<h2 class="font-medium">
{item.radarrMovie?.movieFile?.quality.quality?.name}
</h2>
</div>
{/if}
{#if item.radarrMovie?.movieFile?.size}
<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(item.radarrMovie?.movieFile?.size || 0)}
</h2>
</div>
{/if}
{#if $itemStore.item?.download}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Download Completed In</p>
<h2 class="font-medium">
{formatMinutesToTime(
(new Date($itemStore.item?.download.completionTime).getTime() - Date.now()) /
1000 /
60
)}
</h2>
</div>
{/if}
<div class="flex gap-4 flex-wrap col-span-4 sm:col-span-6 mt-4">
<Button on:click={() => (requestModalVisible = true)}>
<span class="mr-2">Request Movie</span><Plus size={20} />
</Button>
<Button>
<span class="mr-2">Manage</span><Archive size={20} />
</Button>
</div>
<!-- <div
class="flex-1 flex justify-between py-2 gap-4 items-start sm:items-center flex-col sm:flex-row"
>
<div>
<h1 class="font-medium text-lg">No sources found</h1>
<p class="text-sm text-zinc-400">Check your source settings</p>
</div>
<Button type="secondary"><span>Add to Sonarr</span><Plus size={20} /></Button>
</div> -->
{:else if $itemStore.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>
<div slot="cast-crew-carousel-title" class="font-medium text-lg">Cast & Crew</div>
<svelte:fragment slot="cast-crew-carousel">
{#await castProps}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop}
<PeopleCard {...prop} />
{/each}
{/await}
</svelte:fragment>
<div slot="recommendations-carousel-title" class="font-medium text-lg">Recommendations</div>
<svelte:fragment slot="recommendations-carousel">
{#await tmdbRecommendationProps}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop}
<Card {...prop} />
{/each}
{/await}
</svelte:fragment>
<div slot="similar-carousel-title" class="font-medium text-lg">Similar Titles</div>
<svelte:fragment slot="similar-carousel">
{#await tmdbSimilarProps}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop}
<Card {...prop} />
{/each}
{/await}
</svelte:fragment>
</DetailsPage>
{/await}
{#if requestModalVisible}
{@const radarrMovie = $itemStore.item?.radarrMovie}
{#if radarrMovie && radarrMovie.id && radarrMovie?.movieFile}
<RequestModal modalProps={requestModalProps} radarrId={radarrMovie.id} />
{/if}
{/if}

View File

@@ -1,13 +0,0 @@
import type { RequestHandler } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
import { _parseMovieId } from '../+server';
import { addMovieToRadarr, deleteRadarrMovie } from '$lib/apis/radarr/radarrApi';
// Delete download
export const DELETE = (async ({ params }) => {
const radarrMovieId = _parseMovieId(params);
const success = await deleteRadarrMovie(radarrMovieId);
return json({ success });
}) satisfies RequestHandler;

View File

@@ -1,21 +0,0 @@
import { _parseMovieId } from '../+server';
import { addMovieToRadarr, getRadarrMovieByTmdbId } from '$lib/apis/radarr/radarrApi';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
// Add to radarr
export const POST = (async ({ params }) => {
const tmdbId = _parseMovieId(params);
const response = await addMovieToRadarr(Number(tmdbId));
return json(response);
}) satisfies RequestHandler;
export const GET = (async ({ params }) => {
const tmdbId = _parseMovieId(params);
const response = await getRadarrMovieByTmdbId(tmdbId);
return json(response);
}) satisfies RequestHandler;

View File

@@ -1,50 +0,0 @@
import type { RequestHandler } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
import { _parseMovieId } from '../+server';
import {
cancelDownloadRadarrMovie,
addMovieToRadarr,
fetchRadarrReleases,
downloadRadarrMovie
} from '$lib/apis/radarr/radarrApi';
export const GET = (async ({ params }) => {
const radarrId = _parseMovieId(params);
const releases: any[] = (await fetchRadarrReleases(radarrId)) || [];
let filtered = releases.slice();
filtered.sort((a, b) => b.seeders - a.seeders);
filtered = filtered.filter((release) => release.quality.quality.resolution > 720).slice(0, 5);
const releasesSkipped = releases.length - filtered.length;
releases.sort((a, b) => b.size - a.size);
filtered.sort((a, b) => b.size - a.size);
return json({
filtered,
releasesSkipped,
allReleases: releases
});
}) satisfies RequestHandler;
// Download movie
export const POST = (async ({ params, request }) => {
const body = await request.json();
if (!body.guid) throw new Error('NO_GUID');
const response = await downloadRadarrMovie(body.guid);
return json(response);
}) satisfies RequestHandler;
export const DELETE = (async ({ params }) => {
const downloadId = _parseMovieId(params);
const success = await cancelDownloadRadarrMovie(downloadId);
return json({ success });
}) satisfies RequestHandler;

View File

@@ -23,7 +23,7 @@
import { capitalize, formatMinutesToTime, formatSize } from '$lib/utils';
import classNames from 'classnames';
import { Archive, ChevronLeft, ChevronRight, DotFilled, Plus } from 'radix-icons-svelte';
import { tick, type ComponentProps, onMount } from 'svelte';
import type { ComponentProps } from 'svelte';
import { fade } from 'svelte/transition';
export let tmdbId: number;
@@ -35,7 +35,10 @@
let visibleEpisodeIndex: number | undefined = undefined;
let requestModalVisible = false;
const requestModalProps = createModalProps(() => (requestModalVisible = false));
const requestModalProps = createModalProps(
() => (requestModalVisible = false),
() => {}
);
let episodeProps: ComponentProps<EpisodeCard>[][] = [];
let episodeComponents: HTMLDivElement[] = [];
@@ -104,12 +107,12 @@
await library.refresh();
}
let addToRadarrLoading = false;
let addToSonarrLoading = false;
function addToSonarr() {
addToRadarrLoading = true;
addToSonarrLoading = true;
addSeriesToSonarr(tmdbId)
.then(refresh)
.finally(() => (addToRadarrLoading = false));
.finally(() => (addToSonarrLoading = false));
}
let didFocusNextEpisode = false;
@@ -174,7 +177,7 @@
<span>Next Episode</span><ChevronRight size={20} />
</Button>
{:else if !$itemStore.item?.sonarrSeries}
<Button type="primary" disabled={addToRadarrLoading} on:click={addToSonarr}>
<Button type="primary" disabled={addToSonarrLoading} on:click={addToSonarr}>
<span>Add to Sonarr</span><Plus size={20} />
</Button>
{:else}
@@ -344,7 +347,7 @@
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Spoken Languages</p>
<h2 class="font-medium">
{series?.spoken_languages?.map((l) => capitalize(l.name || '')).join(', ')}
{series?.spoken_languages?.map((l) => capitalize(l.english_name || '')).join(', ')}
</h2>
</div>