Merge branch 'dev'

This commit is contained in:
Aleksi Lassila
2024-03-10 01:22:33 +02:00
15 changed files with 455 additions and 269 deletions

View File

@@ -119,7 +119,7 @@ I'm not a designer, so if you have any ideas for improving the UI, I'd love to l
To get started with development:
1. Clone the repository
2. Check out the `dev` branch
2. Checkout the `dev` branch
3. Run `npm install`
4. Run `npm run dev`

56
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "reiverr",
"version": "0.8.0",
"version": "0.8.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "reiverr",
"version": "0.8.0",
"version": "0.8.1",
"dependencies": {
"@jellyfin/sdk": "^0.7.0",
"axios": "^1.4.0",
@@ -22,7 +22,7 @@
"devDependencies": {
"@fontsource/fira-mono": "^4.5.10",
"@neoconfetti/svelte": "^1.0.0",
"@playwright/test": "^1.28.1",
"@playwright/test": "^1.41.2",
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/adapter-node": "^1.3.1",
"@sveltejs/kit": "^1.5.0",
@@ -1197,22 +1197,18 @@
}
},
"node_modules/@playwright/test": {
"version": "1.35.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.35.0.tgz",
"integrity": "sha512-6qXdd5edCBynOwsz1YcNfgX8tNWeuS9fxy5o59D0rvHXxRtjXRebB4gE4vFVfEMXl/z8zTnAzfOs7aQDEs8G4Q==",
"version": "1.41.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.2.tgz",
"integrity": "sha512-qQB9h7KbibJzrDpkXkYvsmiDJK14FULCCZgEcoe2AvFAS64oCirWTwzTlAYEbKaRxWs5TFesE1Na6izMv3HfGg==",
"dev": true,
"dependencies": {
"@types/node": "*",
"playwright-core": "1.35.0"
"playwright": "1.41.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=16"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/@polka/url": {
@@ -4265,6 +4261,20 @@
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
@@ -5965,10 +5975,28 @@
"node": ">= 6"
}
},
"node_modules/playwright": {
"version": "1.41.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.2.tgz",
"integrity": "sha512-v0bOa6H2GJChDL8pAeLa/LZC4feoAMbSQm1/jF/ySsWWoaNItvrMP7GEkvEEFyCTUYKMxjQKaTSg5up7nR6/8A==",
"dev": true,
"dependencies": {
"playwright-core": "1.41.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=16"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.35.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.35.0.tgz",
"integrity": "sha512-muMXyPmIx/2DPrCHOD1H1ePT01o7OdKxKj2ebmCAYvqhUy+Y1bpal7B0rdoxros7YrXI294JT/DWw2LqyiqTPA==",
"version": "1.41.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.2.tgz",
"integrity": "sha512-VaTvwCA4Y8kxEe+kfm2+uUUw5Lubf38RxF7FpBxLPmGe5sdNkSg5e3ChEigaGrX7qdqT3pt2m/98LiyvU2x6CA==",
"dev": true,
"bin": {
"playwright-core": "cli.js"

View File

@@ -1,6 +1,6 @@
{
"name": "reiverr",
"version": "0.8.0",
"version": "0.8.1",
"repository": {
"type": "git",
"url": "https://github.com/aleksilassila/reiverr"
@@ -22,7 +22,7 @@
"devDependencies": {
"@fontsource/fira-mono": "^4.5.10",
"@neoconfetti/svelte": "^1.0.0",
"@playwright/test": "^1.28.1",
"@playwright/test": "^1.41.2",
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/adapter-node": "^1.3.1",
"@sveltejs/kit": "^1.5.0",

View File

@@ -135,10 +135,8 @@ export const getTmdbSeriesSeason = async (
}
}).then((res) => res.data);
export const getTmdbSeriesSeasons = async (tmdbId: number, seasons: number) =>
Promise.all([...Array(seasons).keys()].map((i) => getTmdbSeriesSeason(tmdbId, i + 1))).then(
(r) => r.filter((s) => s) as TmdbSeason[]
);
export const getTmdbSeriesSeasons = (tmdbId: number, seasons: number) =>
[...Array(seasons).keys()].map((i) => getTmdbSeriesSeason(tmdbId, i + 1));
export const getTmdbSeriesImages = async (tmdbId: number) =>
TmdbApiOpen.get('/3/tv/{series_id}/images', {

View File

@@ -155,7 +155,7 @@
)}
>
<div
class="grid grid-cols-4 sm:grid-cols-6 gap-4 sm:gap-x-8 rounded-xl max-w-screen-2xl 2xl:mx-auto py-4"
class="grid grid-cols-4 sm:grid-cols-6 gap-4 sm:gap-x-8 rounded-xl py-4 max-w-screen-2xl 2xl:mx-auto"
>
<slot name="info-description">
<div
@@ -221,30 +221,9 @@
</div>
</slot>
</div>
<div class="flex flex-col gap-6 max-w-screen-2xl 2xl:mx-auto">
<!-- TODO: Remove mx-auto as it's bugged when in modal and on firefox -->
<slot name="carousels" />
<!-- <div class="max-w-screen-2xl 2xl:mx-auto w-full">
<Carousel gradientFromColor="from-stone-950">
<slot name="cast-crew-carousel-title" slot="title" />
<slot name="cast-crew-carousel">
<CarouselPlaceholderItems />
</slot>
</Carousel>
</div>
<div class="max-w-screen-2xl 2xl:mx-auto w-full">
<Carousel gradientFromColor="from-stone-950">
<slot name="recommendations-carousel-title" slot="title" />
<slot name="recommendations-carousel">
<CarouselPlaceholderItems />
</slot>
</Carousel>
</div>
<div class="max-w-screen-2xl 2xl:mx-auto w-full">
<Carousel gradientFromColor="from-stone-950">
<slot name="similar-carousel-title" slot="title" />
<slot name="similar-carousel">
<CarouselPlaceholderItems />
</slot>
</Carousel>
</div> -->
</div>
</div>

View File

@@ -11,9 +11,7 @@
const ANIMATION_DURATION = $settings.animationDuration;
export let tmdbId: number;
export let trailerId: string | undefined = undefined;
export let lazyTrailerId: Promise<string | undefined>;
export let backdropUri: string;
let scrollY: number;
@@ -23,6 +21,9 @@
export let UIVisible = true;
$: UIVisible = !(hoverTrailer && trailerVisible);
let trailerId: string | undefined = undefined;
lazyTrailerId.then((v) => (trailerId = v));
let trailerShowTimeout: NodeJS.Timeout | undefined = undefined;
$: {
tmdbId;
@@ -64,7 +65,7 @@
<svelte:window bind:scrollY on:scroll={handleWindowScroll} />
{#if !trailerVisible}
{#if !trailerVisible || !trailerId}
{#key tmdbId}
<div
style={"background-image: url('" + TMDB_IMAGES_ORIGINAL + backdropUri + "');"}

View File

@@ -8,6 +8,7 @@
import type { TitleType } from '$lib/types';
import { openTitleModal } from '$lib/stores/modal.store';
import { settings } from '$lib/stores/settings.store';
import { TMDB_MOVIE_GENRES } from '$lib/apis/tmdb/tmdbApi';
const ANIMATION_DURATION = $settings.animationDuration;
@@ -15,8 +16,8 @@
export let type: TitleType;
export let title: string;
export let genres: string[];
export let runtime: number;
export let genreIds: number[];
export let lazyRuntime: Promise<number>;
export let releaseDate: Date;
export let tmdbRating: number;
@@ -24,7 +25,14 @@
export let hideUI = false;
let runtime = 0;
let loadingAdditionalDetails = true;
lazyRuntime.then((rn) => (runtime = rn)).finally(() => (loadingAdditionalDetails = false));
$: tmdbUrl = `https://www.themoviedb.org/${type}/${tmdbId}`;
$: genres = genreIds
.map((gId) => TMDB_MOVIE_GENRES.find((g) => g.id === gId)?.name)
.filter<string>((g): g is string => typeof g === 'string');
function handleOpenTitle() {
openTitleModal({ type, id: tmdbId, provider: 'tmdb' });
@@ -60,7 +68,7 @@
>
<p class="flex-shrink-0">{releaseDate.getFullYear()}</p>
<DotFilled />
<p class="flex-shrink-0">{formatMinutesToTime(runtime)}</p>
<p class="flex-shrink-0">{loadingAdditionalDetails ? 'LOADING' : formatMinutesToTime(runtime)}</p>
<DotFilled />
<p class="flex-shrink-0"><a href={tmdbUrl}>{tmdbRating.toFixed(1)} TMDB</a></p>
</div>

View File

@@ -65,11 +65,49 @@
}
});
const tmdbPopularMoviesPromise = getTmdbPopularMovies()
.then((movies) => Promise.all(movies.map((movie) => getTmdbMovie(movie.id || 0))))
.then((movies) => movies.filter((m) => !!m).slice(0, 10));
let popularMovies: (
| {
movie: Awaited<ReturnType<typeof getTmdbPopularMovies>>[0];
lazyRuntime: Promise<number>;
lazyTrailerId: Promise<string | undefined>;
}
)[] = [];
/**
* Here we load a list of popular movies:
* * runtime & video data is not available as part of the initial request
* * If an additional detail request fails, we unload the movie from the showcase
*/
const tmdbPopularMoviesPromise = getTmdbPopularMovies().then(
(movies) =>
(popularMovies = movies.map((movie) => {
const movieDetails = getTmdbMovie(movie.id || 0);
const movieDetailsPromise = movieDetails.then((fullMovie) => ({
runtime: fullMovie?.runtime || 0,
trailerId: fullMovie?.videos?.results?.find(
(v) => v.site === 'YouTube' && v.type === 'Trailer'
)?.key
}));
movieDetails.catch(() => unloadMovie());
movieDetails.then((md) => !md && unloadMovie());
const unloadMovie = () => {
const idx = popularMovies.findIndex((m) => m.movie === movie);
popularMovies.splice(idx, 1);
popularMovies = popularMovies;
};
return {
movie,
lazyRuntime: movieDetailsPromise.then((fm) => fm.runtime),
lazyTrailerId: movieDetailsPromise.then((fm) => fm.trailerId)
};
}))
);
let showcaseIndex = 0;
$: clampedPopularMovies = popularMovies.slice(0, 10);
$: visibleShowcaseMovie = clampedPopularMovies[showcaseIndex];
async function onNext() {
showcaseIndex = (showcaseIndex + 1) % (await tmdbPopularMoviesPromise).length;
@@ -95,7 +133,7 @@
// return () => clearInterval(interval);
// });
const PADDING = 'px-4 lg:px-8 xl:px-16';
const PADDING = 'px-4 lg:px-8 2xl:px-16';
</script>
<div class="h-screen flex flex-col relative pb-6 gap-6 xl:gap-8 overflow-hidden">
@@ -105,16 +143,16 @@
PADDING
)}
>
{#await tmdbPopularMoviesPromise then movies}
{@const movie = movies[showcaseIndex]}
{#if visibleShowcaseMovie}
{@const { movie, lazyRuntime, lazyTrailerId } = visibleShowcaseMovie}
{#key movie?.id}
<TitleShowcaseVisuals
tmdbId={movie?.id || 0}
type="movie"
title={movie?.title || ''}
genres={movie?.genres?.map((g) => g.name || '') || []}
runtime={movie?.runtime || 0}
genreIds={movie.genre_ids || []}
{lazyRuntime}
releaseDate={new Date(movie?.release_date || Date.now())}
tmdbRating={movie?.vote_average || 0}
posterUri={movie?.poster_path || ''}
@@ -124,7 +162,13 @@
<div
class="md:relative self-stretch flex justify-center items-end row-start-2 row-span-1 col-start-1 col-span-2 md:row-start-1 md:row-span-2 md:col-start-2 md:col-span-2"
>
<PageDots index={showcaseIndex} length={movies.length} {onJump} {onPrevious} {onNext} />
<PageDots
index={showcaseIndex}
length={clampedPopularMovies.length}
{onJump}
{onPrevious}
{onNext}
/>
{#if !hideUI}
<div class="absolute top-1/2 right-0 z-10">
<IconButton on:click={onNext}>
@@ -133,13 +177,15 @@
</div>
{/if}
</div>
{#key movie?.id}
<TitleShowcase
tmdbId={movie?.id || 0}
trailerId={movie?.videos?.results?.find((v) => v.site === 'YouTube' && v.type === 'Trailer')
?.key}
{lazyTrailerId}
backdropUri={movie?.backdrop_path || ''}
/>
{/await}
{/key}
{/if}
</div>
<div
class={classNames('z-[1] transition-opacity', {

View File

@@ -5,4 +5,4 @@ export const TMDB_BACKDROP_SMALL = 'https://www.themoviedb.org/t/p/w780';
export const TMDB_POSTER_SMALL = 'https://www.themoviedb.org/t/p/w342';
export const TMDB_PROFILE_SMALL = 'https://www.themoviedb.org/t/p/w185';
export const PLACEHOLDER_BACKDROP = '/plcaeholder.jpg';
export const PLACEHOLDER_BACKDROP = '/placeholder.jpg';

View File

@@ -1,7 +1,7 @@
{
"appName": "Reiverr",
"setupRequiredTitle": "Willkommen zu",
"setupRequiredDescription": "Es scheint das einige Umgebungsvariables zur fehlerfreien Ausführung fehlen. Bitte gebe die folgenden Umgebungsvariablen an:",
"setupRequiredDescription": "Es scheint das einige Umgebungsvariables zur fehlerfreien Ausführung fehlen. Bitte gib die folgenden Umgebungsvariablen an:",
"navbar": {
"home": "Start",
"discover": "Entdecken",
@@ -25,7 +25,7 @@
"TVNetworks": "Anbieter"
},
"library": {
"missingConfiguration": "Konfiguriere Radarr, Sonarr und Jellyfin zum Verwalten und Abspielen der Bibliothek",
"missingConfiguration": "Konfiguriere Radarr, Sonarr und Jellyfin, zum Verwalten und Abspielen der Bibliothek",
"available": "Verfügbar",
"watched": "Geschaut",
"unavailable": "Nicht verfügbar",

View File

@@ -217,6 +217,8 @@
function parseIncludedLanguages(includedLanguages: string) {
return includedLanguages.replace(' ', '').split(',').join('|');
}
const PADDING = 'px-4 lg:px-8 2xl:px-16';
</script>
<TitleShowcases />
@@ -229,7 +231,7 @@
}}
out:fade|global={{ duration: $settings.animationDuration }}
>
<Carousel scrollClass="px-2 sm:px-8 2xl:px-16">
<Carousel scrollClass={PADDING}>
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.popularPeople')}
</div>
@@ -241,7 +243,7 @@
{/each}
{/await}
</Carousel>
<Carousel scrollClass="px-2 sm:px-8 2xl:px-16">
<Carousel scrollClass={PADDING}>
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.upcomingMovies')}
</div>
@@ -253,7 +255,7 @@
{/each}
{/await}
</Carousel>
<Carousel scrollClass="px-2 sm:px-8 2xl:px-16">
<Carousel scrollClass={PADDING}>
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.upcomingSeries')}
</div>
@@ -265,7 +267,7 @@
{/each}
{/await}
</Carousel>
<Carousel scrollClass="px-2 sm:px-8 2xl:px-16">
<Carousel scrollClass={PADDING}>
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.genres')}
</div>
@@ -273,7 +275,7 @@
<GenreCard {genre} />
{/each}
</Carousel>
<Carousel scrollClass="px-2 sm:px-8 2xl:px-16">
<Carousel scrollClass={PADDING}>
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.newDigitalReleases')}
</div>
@@ -285,7 +287,7 @@
{/each}
{/await}
</Carousel>
<Carousel scrollClass="px-2 sm:px-8 2xl:px-16">
<Carousel scrollClass={PADDING}>
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.streamingNow')}
</div>
@@ -297,7 +299,7 @@
{/each}
{/await}
</Carousel>
<Carousel scrollClass="px-2 sm:px-8 2xl:px-16">
<Carousel scrollClass={PADDING}>
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.TVNetworks')}
</div>

View File

@@ -33,15 +33,14 @@
export let handleCloseModal: () => void = () => {};
const tmdbUrl = 'https://www.themoviedb.org/movie/' + tmdbId;
const data = loadInitialPageData();
const data = getTmdbMovie(tmdbId);
const recommendationData = preloadRecommendationData();
const jellyfinItemStore = createJellyfinItemStore(tmdbId);
const radarrMovieStore = createRadarrMovieStore(tmdbId);
const radarrDownloadStore = createRadarrDownloadStore(radarrMovieStore);
async function loadInitialPageData() {
const tmdbMoviePromise = getTmdbMovie(tmdbId);
async function preloadRecommendationData() {
const tmdbRecommendationProps = getTmdbMovieRecommendations(tmdbId)
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
.then((r) => r.filter((p) => p.backdropUrl));
@@ -49,7 +48,7 @@
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
.then((r) => r.filter((p) => p.backdropUrl));
const castPropsPromise: Promise<ComponentProps<PersonCard>[]> = tmdbMoviePromise.then((m) =>
const castPropsPromise: Promise<ComponentProps<PersonCard>[]> = data.then((m) =>
Promise.all(
m?.credits?.cast?.slice(0, 20).map((m) => ({
tmdbId: m.id || 0,
@@ -61,10 +60,9 @@
);
return {
tmdbMovie: await tmdbMoviePromise,
tmdbRecommendationProps: await tmdbRecommendationProps,
tmdbSimilarProps: await tmdbSimilarProps,
castProps: await castPropsPromise
castProps: await castPropsPromise,
};
}
@@ -95,8 +93,7 @@
{#await data}
<TitlePageLayout {isModal} {handleCloseModal} />
{:then { tmdbMovie, tmdbRecommendationProps, tmdbSimilarProps, castProps }}
{@const movie = tmdbMovie}
{:then movie }
<TitlePageLayout
titleInformation={{
tmdbId,
@@ -268,7 +265,7 @@
</svelte:fragment>
<svelte:fragment slot="carousels">
{#await data}
{#await recommendationData}
<Carousel gradientFromColor="from-stone-950">
<div slot="title" class="font-medium text-lg">Cast & Crew</div>
<CarouselPlaceholderItems />

View File

@@ -1,12 +1,13 @@
<script lang="ts">
import { getJellyfinEpisodes, type JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
import { addSeriesToSonarr, type SonarrSeries } from '$lib/apis/sonarr/sonarrApi';
import { addSeriesToSonarr } from '$lib/apis/sonarr/sonarrApi';
import {
getTmdbIdFromTvdbId,
getTmdbSeries,
getTmdbSeriesRecommendations,
getTmdbSeriesSeasons,
getTmdbSeriesSimilar
getTmdbSeriesSimilar,
type TmdbSeriesFull2
} from '$lib/apis/tmdb/tmdbApi';
import Button from '$lib/components/Button.svelte';
import Card from '$lib/components/Card/Card.svelte';
@@ -39,7 +40,8 @@
export let isModal = false;
export let handleCloseModal: () => void = () => {};
let data = loadInitialPageData();
const data = loadInitialPageData();
const recommendationData = preloadRecommendationData();
const jellyfinItemStore = createJellyfinItemStore(data.then((d) => d.tmdbId));
const sonarrSeriesStore = createSonarrSeriesStore(data.then((d) => d.tmdbSeries?.name || ''));
@@ -48,16 +50,16 @@
let seasonSelectVisible = false;
let visibleSeasonNumber: number = 1;
let visibleEpisodeIndex: number | undefined = undefined;
let nextJellyfinEpisode: JellyfinItem | undefined = undefined;
let jellyfinEpisodeData: {
const jellyfinEpisodeData: {
[key: string]: {
jellyfinId: string | undefined;
progress: number;
watched: boolean;
};
} = {};
let episodeComponents: HTMLDivElement[] = [];
let nextJellyfinEpisode: JellyfinItem | undefined = undefined;
const episodeComponents: HTMLDivElement[] = [];
// Refresh jellyfin episode data
jellyfinItemStore.subscribe(async (value) => {
@@ -87,37 +89,53 @@
const tmdbId = await (titleId.provider === 'tvdb'
? getTmdbIdFromTvdbId(titleId.id)
: Promise.resolve(titleId.id));
const tmdbSeries = await getTmdbSeries(tmdbId);
const tmdbSeriesPromise = getTmdbSeries(tmdbId);
const tmdbSeasonsPromise = tmdbSeriesPromise.then((s) =>
getTmdbSeriesSeasons(s?.id || 0, s?.number_of_seasons || 0)
);
return {
tmdbId,
tmdbUrl: 'https://www.themoviedb.org/tv/' + tmdbId,
tmdbSeries,
seasonsData: preloadAndMapSeasonsData(tmdbSeries)
};
}
const tmdbUrl = 'https://www.themoviedb.org/tv/' + tmdbId;
const tmdbRecommendationPropsPromise = getTmdbSeriesRecommendations(tmdbId).then((r) =>
async function preloadRecommendationData() {
const { tmdbId, tmdbSeries } = await data;
const tmdbRecommendationProps = getTmdbSeriesRecommendations(tmdbId).then((r) =>
Promise.all(r.map(fetchCardTmdbProps))
);
const tmdbSimilarPropsPromise = getTmdbSeriesSimilar(tmdbId)
const tmdbSimilarProps = 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) => ({
const castProps: ComponentProps<PersonCard>[] =
tmdbSeries?.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 || ''
})) || []
)
})) || [];
return {
tmdbRecommendationProps: await tmdbRecommendationProps,
tmdbSimilarProps: await tmdbSimilarProps,
castProps
};
}
function preloadAndMapSeasonsData(
tmdbSeries: TmdbSeriesFull2 | undefined
): Promise<ComponentProps<EpisodeCard>[]>[] {
const tmdbSeasons = getTmdbSeriesSeasons(
tmdbSeries?.id || 0,
tmdbSeries?.number_of_seasons || 0
);
const tmdbEpisodePropsPromise: Promise<ComponentProps<EpisodeCard>[][]> =
tmdbSeasonsPromise.then((seasons) =>
seasons.map(
(season) =>
season?.episodes?.map((episode) => ({
return tmdbSeasons.map((season) =>
season.then(
(s) =>
s?.episodes?.map((episode) => ({
title: episode?.name || '',
subtitle: `Episode ${episode?.episode_number}`,
backdropUrl: TMDB_BACKDROP_SMALL + episode?.still_path || '',
@@ -128,17 +146,6 @@
})) || []
)
);
return {
tmdbId,
tmdbSeries: await tmdbSeriesPromise,
tmdbSeasons: await tmdbSeasonsPromise,
tmdbUrl,
tmdbRecommendationProps: await tmdbRecommendationPropsPromise,
tmdbSimilarProps: await tmdbSimilarPropsPromise,
castProps: await castPropsPromise,
tmdbEpisodeProps: await tmdbEpisodePropsPromise
};
}
function playNextEpisode() {
@@ -211,7 +218,7 @@
</Carousel>
</div>
</TitlePageLayout>
{:then { tmdbSeries, tmdbId, ...data }}
{:then { tmdbId, tmdbUrl, tmdbSeries, seasonsData }}
<TitlePageLayout
titleInformation={{
tmdbId,
@@ -230,7 +237,7 @@
<DotFilled />
{tmdbSeries?.status}
<DotFilled />
<a href={data.tmdbUrl} target="_blank">{tmdbSeries?.vote_average?.toFixed(1)} TMDB</a>
<a href={tmdbUrl} target="_blank">{tmdbSeries?.vote_average?.toFixed(1)} TMDB</a>
</svelte:fragment>
<svelte:fragment slot="title-right">
@@ -311,7 +318,10 @@
{/each}
</UiCarousel>
{#key visibleSeasonNumber}
{#each data.tmdbEpisodeProps[visibleSeasonNumber - 1] || [] as props, i}
{#await seasonsData[visibleSeasonNumber - 1]}
<CarouselPlaceholderItems />
{:then seasonEpisodes}
{#each seasonEpisodes || [] as props, i}
{@const jellyfinData = jellyfinEpisodeData[`S${visibleSeasonNumber}E${i + 1}`]}
<div bind:this={episodeComponents[i]}>
<EpisodeCard
@@ -329,6 +339,7 @@
{:else}
<CarouselPlaceholderItems />
{/each}
{/await}
{/key}
</Carousel>
</div>
@@ -439,7 +450,7 @@
</svelte:fragment>
<svelte:fragment slot="carousels">
{#await data}
{#await recommendationData}
<Carousel gradientFromColor="from-stone-950">
<div slot="title" class="font-medium text-lg">Cast & Crew</div>
<CarouselPlaceholderItems />

122
tests/UI.spec.ts Normal file
View File

@@ -0,0 +1,122 @@
//@ts-check
import { expect, test } from '@playwright/test';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
const file = fileURLToPath(new URL('../package.json', import.meta.url));
const json = readFileSync(file, 'utf8');
const pkg = JSON.parse(json);
test.describe('UI Tests', () => {
test('Home page', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Home')).toBeVisible();
await page.getByText('Home').click();
await test.step('Check top bar links exist', async () => {
await expect(page.getByRole('link', {name: 'Reiverr', exact: true})).toBeVisible();
await expect(page.getByRole('link', {name: 'Reiverr', exact: true})).toHaveAttribute('href', '/')
await expect(page.getByRole('link', {name: 'Home', exact: true})).toBeVisible();
await expect(page.getByRole('link', {name: 'Home', exact: true})).toHaveAttribute('href', '/')
await expect(page.getByRole('link', {name: 'Library', exact: true})).toBeVisible();
await expect(page.getByRole('link', {name: 'Library', exact: true})).toHaveAttribute('href', '/library')
await expect(page.getByRole('link', {name: 'Sources', exact: true})).toBeVisible();
await expect(page.getByRole('link', {name: 'Sources', exact: true})).toHaveAttribute('href', '/sources')
await expect(page.getByRole('link', {name: 'Settings', exact: true})).toBeVisible();
await expect(page.getByRole('link', {name: 'Settings', exact: true})).toHaveAttribute('href', '/settings')
});
await test.step('Check Carousel sections exist', async () => {
await expect(page.getByText('Popular People', {exact: true})).toBeVisible();
await expect(page.getByText('Upcoming Movies', {exact: true})).toBeVisible();
await expect(page.getByText('Upcoming Series', {exact: true})).toBeVisible();
await expect(page.getByText('Genres', {exact: true})).toBeVisible();
await expect(page.getByText('New Digital Releases', {exact: true})).toBeVisible();
await expect(page.getByText('Streaming Now', {exact: true})).toBeVisible();
await expect(page.getByText('TV Networks', {exact: true})).toBeVisible();
});
});
test('Library', async ({ page }) => {
await page.goto('/library');
await expect(page.getByText('Latest Addition')).toBeVisible();
await expect(page.getByRole('button', {name: 'Available', exact: true})).toBeVisible();
await expect(page.getByRole('button', {name: 'Watched', exact: true})).toBeVisible();
await expect(page.getByRole('button', {name: 'Unavailable', exact: true})).toBeVisible();
await expect(page.getByRole('button', {name: 'Play', exact: true})).toBeVisible();
await expect(page.getByRole('button', {name: 'Details', exact: true})).toBeVisible();
});
test('Sources', async ({ page }) => {
await page.goto('/sources');
await expect(page.getByText('Movies Provider')).toBeVisible();
await expect(page.getByText('Radarr')).toBeVisible();
await expect(page.getByText('Shows Provider')).toBeVisible();
await expect(page.getByText('Sonarr')).toBeVisible();
});
test('Settings', async ({ page }) => {
await page.goto('/settings');
await test.step('Check top bar links exist', async () => {
await expect(page.getByRole('link', {name: 'Reiverr', exact: true})).toBeVisible();
await expect(page.getByRole('link', {name: 'Reiverr', exact: true})).toHaveAttribute('href', '/')
await expect(page.getByRole('link', {name: 'Home', exact: true})).toBeVisible();
await expect(page.getByRole('link', {name: 'Home', exact: true})).toHaveAttribute('href', '/')
await expect(page.getByRole('link', {name: 'Library', exact: true})).toBeVisible();
await expect(page.getByRole('link', {name: 'Library', exact: true})).toHaveAttribute('href', '/library')
await expect(page.getByRole('link', {name: 'Sources', exact: true})).toBeVisible();
await expect(page.getByRole('link', {name: 'Sources', exact: true})).toHaveAttribute('href', '/sources')
await expect(page.getByRole('link', {name: 'Settings', exact: true})).toBeVisible();
await expect(page.getByRole('link', {name: 'Settings', exact: true})).toHaveAttribute('href', '/settings')
});
await test.step('Check side buttons exist', async () => {
await expect(page.getByRole('button', {name: 'General', exact: true})).toBeVisible();
await expect(page.getByRole('button', {name: 'Integrations', exact: true})).toBeVisible();
});
await test.step('Check bottom bar links exist', async () => {
await expect(page.getByText(pkg.version)).toBeVisible();
await expect(page.getByRole('link', {name: 'Changelog', exact: true})).toBeVisible();
await expect(page.getByRole('link', {name: 'Changelog', exact: true})).toHaveAttribute('href', 'https://github.com/aleksilassila/reiverr/releases');
await expect(page.getByRole('link', {name: 'GitHub', exact: true})).toBeVisible();
await expect(page.getByRole('link', {name: 'GitHub', exact: true})).toHaveAttribute('href', 'https://github.com/aleksilassila/reiverr');
});
await test.step('Check User Interface section', async () => {
await expect(page.getByRole('heading', {name: 'User Interface'})).toBeVisible();
await expect(page.getByRole('heading', { name: 'Language', exact: true })).toBeVisible();
await expect(page.getByRole('combobox').first()).toBeVisible();
await expect(page.getByRole('button', {name: 'Save Changes'})).toHaveClass(/cursor-not-allowed/);
await page.getByRole('combobox').first().click()
await page.getByRole('combobox').first().selectOption('en')
await expect(page.getByRole('button', {name: 'Save Changes'})).toHaveClass(/cursor-not-allowed/);
await page.getByRole('combobox').first().click()
await page.getByRole('combobox').first().selectOption('de')
await expect(page.getByRole('button', {name: 'Save Changes'})).not.toHaveClass(/cursor-not-allowed/);
await page.getByRole('combobox').first().click()
await page.getByRole('combobox').first().selectOption('es')
await expect(page.getByRole('button', {name: 'Save Changes'})).not.toHaveClass(/cursor-not-allowed/);
await page.getByRole('combobox').first().click()
await page.getByRole('combobox').first().selectOption('fr')
await expect(page.getByRole('button', {name: 'Save Changes'})).not.toHaveClass(/cursor-not-allowed/);
await page.getByRole('combobox').first().click()
await page.getByRole('combobox').first().selectOption('it')
await expect(page.getByRole('button', {name: 'Save Changes'})).not.toHaveClass(/cursor-not-allowed/);
await page.getByRole('combobox').first().click()
await page.getByRole('combobox').first().selectOption('en')
await expect(page.getByRole('button', {name: 'Save Changes'})).toHaveClass(/cursor-not-allowed/);
await expect(page.locator('.w-11').first()).toBeEnabled();
await expect(page.getByRole('spinbutton')).toHaveValue('150')
});
await test.step('Check Discovery section', async () => {
await expect(page.getByRole('heading', {name: 'Discovery', exact: true})).toBeVisible();
await expect(page.getByRole('heading', { name: 'Region', exact: true })).toBeVisible();
await expect(page.getByRole('combobox').nth(1)).toBeVisible();
await expect(page.getByRole('heading', { name: 'Exclude library items from Discovery', exact: true })).toBeVisible();
await expect(page.locator('.w-11').nth(1)).toBeEnabled();
await expect(page.getByText('Filter results based on spoken language. Takes ISO 639-1 language codes separated with commas. Leave empty to disable.')).toBeVisible();
await expect(page.getByPlaceholder('en,fr,de')).toBeVisible();
});
});
});

View File

@@ -1,6 +0,0 @@
import { expect, test } from '@playwright/test';
test('about page has expected h1', async ({ page }) => {
await page.goto('/about');
await expect(page.getByRole('heading', { name: 'About this app' })).toBeVisible();
});