Improved cards, library and discover page performance

This commit is contained in:
Aleksi Lassila
2023-06-22 15:52:04 +03:00
parent db9cbd4f79
commit b97befa31c
11 changed files with 247 additions and 152 deletions

View File

@@ -1,38 +1,14 @@
# create-svelte
# Reiverr
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
TODO:
- [ ] Sonarr support
- [ ] Onboarding setup & sources
- [ ] Settings page
- [ ] Plex and Jellyfin sync
- [ ] Mass edit local files & show space left
- [ ] Finish discover page
- [ ] Event notifications & show indexer status
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
Further ideas
- [ ] Similar movies & shows, actor pages and recommendations
- [ ] Watchlist management

View File

@@ -5,7 +5,7 @@ import type { components } from '$lib/radarr/radarr-types';
import { fetchTmdbMovie } from '$lib/tmdb-api';
import { RADARR_API_KEY, RADARR_BASE_URL } from '$env/static/private';
export type MovieResource = components['schemas']['MovieResource'];
export type RadarrMovie = components['schemas']['MovieResource'];
export type MovieFileResource = components['schemas']['MovieFileResource'];
export type ReleaseResource = components['schemas']['ReleaseResource'];
@@ -134,4 +134,4 @@ const getMovieByTmdbIdByTmdbId = (tmdbId: string) =>
tmdbId: Number(tmdbId)
}
}
}).then((r) => r.data as any as MovieResource);
}).then((r) => r.data as any as RadarrMovie);

View File

@@ -1,13 +1,20 @@
<script lang="ts">
import type { TmdbMovie, TmdbMovieFull } from '$lib/tmdb-api';
import type { Genre, TmdbMovie, TmdbMovieFull } from '$lib/tmdb-api';
import { formatGenres, formatMinutesToTime } from '$lib/utils';
import classNames from 'classnames';
import { TMDB_IMAGES } from '$lib/constants';
import { onMount } from 'svelte';
import { fetchTmdbMovie, fetchTmdbMovieImages, TmdbApi } from '$lib/tmdb-api';
import CardPlaceholder from './CardPlaceholder.svelte';
import { Clock, Star, StarFilled } from 'radix-icons-svelte';
export let tmdbId: string;
export let tmdbId;
export let title;
export let genres: string[];
export let runtimeMinutes;
export let completionTime;
export let backdropUrl;
export let rating: number;
export let available = true;
export let progress = 0;
@@ -17,68 +24,64 @@
if (randomProgress) {
progress = Math.random() > 0.3 ? Math.random() * 100 : 0;
}
let tmdbMovie: TmdbMovie;
let backdropUrl;
onMount(async () => {
if (!tmdbId) return;
fetchTmdbMovieImages(String(tmdbId))
.then(
(r) =>
(backdropUrl = TMDB_IMAGES + r.backdrops.filter((b) => b.iso_639_1 === 'en')[0].file_path)
)
.catch((err) => (backdropUrl = null));
fetchTmdbMovie(tmdbId).then((movie) => (tmdbMovie = movie));
});
</script>
{#if !tmdbMovie || !backdropUrl}
<CardPlaceholder {large} />
{:else}
<div
class={classNames('rounded overflow-hidden relative shadow-2xl shrink-0', {
'h-40 w-72': !large,
'h-60 w-96': large
})}
>
<div style={'width: ' + progress + '%'} class="h-[2px] bg-zinc-200 bottom-0 absolute z-[1]" />
<div
class={classNames('rounded overflow-hidden relative shadow-2xl shrink-0', {
'h-40 w-72': !large,
'h-60 w-96': large
})}
on:click={() => window.open('/movie/' + tmdbId, '_self')}
class="h-full w-full opacity-0 hover:opacity-100 transition-opacity flex flex-col justify-between cursor-pointer p-2 px-3 relative z-[1] peer"
style={progress > 0 ? 'padding-bottom: 0.6rem;' : ''}
>
<div style={'width: ' + progress + '%'} class="h-[2px] bg-zinc-200 bottom-0 absolute z-[1]" />
<div
on:click={() => window.open('/movie/' + tmdbMovie.id, '_self')}
class="h-full w-full opacity-0 hover:opacity-100 transition-opacity flex flex-col justify-between cursor-pointer p-2 px-3 relative z-[1] peer"
style={progress > 0 ? 'padding-bottom: 0.6rem;' : ''}
>
<div>
<h1 class="font-bold tracking-wider text-lg">{tmdbMovie.original_title}</h1>
<div class="text-xs text-zinc-300 tracking-wider font-medium">
{formatGenres(tmdbMovie.genres)}
</div>
</div>
<div class="flex justify-between items-end">
{#if progressType === 'watched'}
<div class="text-sm font-medium text-zinc-200">
{progress
? formatMinutesToTime(tmdbMovie.runtime - tmdbMovie.runtime * (progress / 100)) +
' left'
: formatMinutesToTime(tmdbMovie.runtime)}
</div>
{:else if progressType === 'downloading'}
<div class="text-sm font-medium text-zinc-200">
{Math.floor(progress) + '% Downloaded'}
</div>
{/if}
<div>
<h1 class="font-bold tracking-wider text-lg">{title}</h1>
<div class="text-xs text-zinc-300 tracking-wider font-medium">
{genres.map((genre) => genre.charAt(0).toUpperCase() + genre.slice(1)).join(', ')}
</div>
</div>
<div
style={"background-image: url('" + backdropUrl + "')"}
class="absolute inset-0 bg-center bg-cover peer-hover:scale-105 transition-transform"
/>
<div
class={classNames('absolute inset-0 transition-opacity', {
'bg-darken opacity-0 peer-hover:opacity-100': available,
'bg-[#00000055] peer-hover:bg-darken': !available
})}
/>
<div class="flex justify-between items-end">
{#if completionTime}
<div class="text-sm font-medium text-zinc-200 tracking-wide">
Downloaded in <b
>{formatMinutesToTime((new Date(completionTime).getTime() - Date.now()) / 1000 / 60)}</b
>
</div>
{:else}
{#if runtimeMinutes}
<div class="flex gap-1.5 items-center">
<Clock />
<div class="text-sm text-zinc-200">
{progress
? formatMinutesToTime(runtimeMinutes - runtimeMinutes * (progress / 100)) + ' left'
: formatMinutesToTime(runtimeMinutes)}
</div>
</div>
{/if}
{#if rating}
<div class="flex gap-1.5 items-center">
<Star />
<div class="text-sm text-zinc-200">
{rating.toFixed(1)}
</div>
</div>
{/if}
{/if}
</div>
</div>
{/if}
<div
style={"background-image: url('" + TMDB_IMAGES + backdropUrl + "')"}
class="absolute inset-0 bg-center bg-cover peer-hover:scale-105 transition-transform"
/>
<div
class={classNames('absolute inset-0 transition-opacity', {
'bg-darken opacity-0 peer-hover:opacity-100': available,
'bg-[#00000055] peer-hover:bg-darken': !available
})}
/>
</div>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import type { TmdbMovie } from '$lib/tmdb-api';
import { onMount } from 'svelte';
import { fetchTmdbMovie, fetchTmdbMovieImages } from '$lib/tmdb-api';
import { TMDB_IMAGES } from '$lib/constants';
import { getJellyfinItemByTmdbId } from '$lib/jellyfin/jellyfin';
import CardPlaceholder from './CardPlaceholder.svelte';
import Card from './Card.svelte';
export let tmdbId: string;
export let type: 'default' | 'download' | 'in-library' = 'default';
let tmdbMoviePromise: Promise<TmdbMovie>;
let jellyfinItemPromise;
let radarrItemPromise;
let backdropUrlPromise;
onMount(async () => {
if (!tmdbId) throw new Error('No tmdbId provided');
backdropUrlPromise = fetchTmdbMovieImages(String(tmdbId)).then(
(r) => TMDB_IMAGES + r.backdrops.filter((b) => b.iso_639_1 === 'en')[0].file_path
);
tmdbMoviePromise = fetchTmdbMovie(tmdbId);
if (type === 'in-library') jellyfinItemPromise = getJellyfinItemByTmdbId(tmdbId);
if (type === 'download')
radarrItemPromise = fetch(`/movie/${tmdbId}/radarr`).then((r) => r.json());
});
</script>
{#await Promise.all([tmdbMoviePromise, jellyfinItemPromise, backdropUrlPromise])}
<CardPlaceholder {...$$restProps} />
{:then [tmdbMovie, jellyfinItem, backdropUrl]}
<Card {...$$restProps} {tmdbMovie} {backdropUrl} {jellyfinItem} />
{:catch err}
Error
{/await}

View File

@@ -0,0 +1,42 @@
import type { RadarrMovie } from '$lib/radarr/radarr';
import { fetchTmdbMovieImages } from '$lib/tmdb-api';
import type { TmdbMovie } from '$lib/tmdb-api';
export interface CardProps {
tmdbId: string;
title: string;
genres: string[];
runtimeMinutes: number;
backdropUrl: string;
rating: number;
}
export const fetchCardProps = async (movie: RadarrMovie): Promise<CardProps> => {
const backdropUrl = fetchTmdbMovieImages(String(movie.tmdbId)).then(
(r) => r.backdrops.filter((b) => b.iso_639_1 === 'en')[0].file_path
);
return {
tmdbId: String(movie.tmdbId),
title: String(movie.title),
genres: movie.genres as string[],
runtimeMinutes: movie.runtime as any,
backdropUrl: await backdropUrl,
rating: movie.ratings?.tmdb?.value || movie.ratings?.imdb?.value || 0
};
};
export const fetchCardPropsTmdb = async (movie: TmdbMovie): Promise<CardProps> => {
const backdropUrl = fetchTmdbMovieImages(String(movie.id))
.then((r) => r.backdrops.filter((b) => b.iso_639_1 === 'en')[0]?.file_path)
.catch(console.error);
return {
tmdbId: String(movie.id),
title: String(movie.original_title),
genres: movie.genres.map((g) => g.name),
runtimeMinutes: movie.runtime,
backdropUrl: (await backdropUrl) || '',
rating: movie.vote_average || 0
};
};

View File

@@ -0,0 +1,19 @@
import type { PageServerLoad } from './$types';
import { fetchTmdbMovie, fetchTmdbPopularMovies } from '$lib/tmdb-api';
import { fetchCardPropsTmdb } from '../components/Card/card';
export const load = (() => {
const popularMoviesPromise = fetchTmdbPopularMovies();
const popularMovies = popularMoviesPromise.then((movies) => {
return Promise.all(
movies.map(async (movie) => fetchCardPropsTmdb(await fetchTmdbMovie(String(movie.id))))
);
});
return {
streamed: {
popularMovies
}
};
}) satisfies PageServerLoad;

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { fetchTmdbPopularMovies, requestTmdbPopularMovies } from '$lib/tmdb-api';
import Card from '../components/Card/Card.svelte';
import Carousel from '../components/Carousel/Carousel.svelte';
import CarouselPlaceholderItems from '../components/Carousel/CarouselPlaceholderItems.svelte';
@@ -7,17 +6,13 @@
import HboCard from './HboCard.svelte';
import DisneyCard from './DisneyCard.svelte';
import AmazonCard from './AmazonCard.svelte';
import AppleCard from './AppleCard.svelte';
import HuluCard from './HuluCard.svelte';
import { onMount } from 'svelte';
import type { PageData } from './$types';
const headerStyle = 'uppercase tracking-widest font-bold';
let popularMovies;
onMount(() => {
popularMovies = fetchTmdbPopularMovies();
});
export let data: PageData;
$: console.log(data);
</script>
<div class="pb-24 flex flex-col gap-4">
@@ -25,11 +20,11 @@
<div class="pt-24 bg-black">
<Carousel>
<div slot="title" class={headerStyle}>For You</div>
{#await popularMovies}
{#await data.streamed.popularMovies}
<CarouselPlaceholderItems large={true} />
{:then movies}
{#each movies ? [...movies].reverse() : [] as movie (movie.id)}
<Card large={true} tmdbId={movie.id} />
{#each movies ? [...movies].reverse() : [] as movie (movie.tmdbId)}
<Card large={true} {...movie} />
{/each}
{/await}
</Carousel>
@@ -37,11 +32,11 @@
<div>
<Carousel>
<div slot="title" class={headerStyle}>Popular Movies</div>
{#await popularMovies}
{#await data.streamed.popularMovies}
<CarouselPlaceholderItems />
{:then movies}
{#each movies || [] as movie (movie.id)}
<Card tmdbId={movie.id} />
{#each movies || [] as movie (movie.tmdbId)}
<Card {...movie} />
{/each}
{/await}
</Carousel>

View File

@@ -1,5 +1,7 @@
import type { PageServerLoad } from './$types';
import { RadarrApi } from '$lib/radarr/radarr';
import type { CardProps } from '../components/Card/card';
import { fetchCardProps } from '../components/Card/card';
export const load = (() => {
const radarrMovies = RadarrApi.get('/api/v3/movie', {
@@ -14,42 +16,60 @@ export const load = (() => {
}
}).then((r) => r.data?.records?.filter((record) => record.movie));
const downloading = downloadingRadarrMovies.then(async (movies) => {
return movies?.map((m) => ({
tmdbId: m.movie?.tmdbId,
size: m.size,
sizeleft: m.sizeleft
}));
});
const unavailable = radarrMovies.then(async (movies) => {
const downloadingMovies = await downloading;
return movies?.filter(
(m) =>
(!m.movieFile || !m.hasFile || !m.isAvailable) &&
!downloadingMovies?.find((d) => d.tmdbId === m.tmdbId)
const unavailable: Promise<CardProps[]> = radarrMovies.then(async (movies) => {
const downloadingMovies = await downloadingRadarrMovies;
return await Promise.all(
movies
?.filter(
(m) =>
(!m.movieFile || !m.movieFile || !m.isAvailable) &&
!downloadingMovies?.find((d) => d.movie?.tmdbId === m.tmdbId)
)
.map(async (m) => fetchCardProps(m)) || []
);
});
const available = radarrMovies.then(async (movies) => {
const available: Promise<CardProps[]> = radarrMovies.then(async (movies) => {
const downloadingMovies = await downloading;
const unavailableMovies = await unavailable;
if (!downloadingMovies || !movies) return [];
return movies
.filter((movie) => {
return !downloadingMovies.find((downloadingMovie) => downloadingMovie.tmdbId === movie.id);
})
.filter(
(movie) => !unavailableMovies?.find((unavailableMovie) => unavailableMovie.id === movie.id)
);
return await Promise.all(
movies
.filter((movie) => {
return !downloadingMovies.find(
(downloadingMovie) => downloadingMovie.tmdbId === String(movie.tmdbId)
);
})
.filter(
(movie) =>
!unavailableMovies?.find(
(unavailableMovie) => unavailableMovie.tmdbId === String(movie.tmdbId)
)
)
.map(async (m) => fetchCardProps(m)) || []
);
});
const downloading: Promise<CardProps[]> = downloadingRadarrMovies.then(async (movies) => {
return Promise.all(
movies
?.filter((m) => m?.movie?.tmdbId)
?.map(async (m) => ({
...(await fetchCardProps(m.movie as any)),
progress: m.sizeleft && m.size ? ((m.size - m.sizeleft) / m.size) * 100 : 0,
completionTime: m.estimatedCompletionTime
})) || []
);
});
// radarrMovies.then((d) => console.log(d.map((m) => m.ratings)));
return {
streamed: {
available,
downloading,
available,
unavailable
}
};

View File

@@ -1,10 +1,9 @@
<script lang="ts">
import type { PageData } from './$types';
import SmallHorizontalPoster from '../components/Card/Card.svelte';
import type { TmdbMovieFull } from '$lib/tmdb-api';
import Card from '../components/Card/Card.svelte';
import { TMDB_IMAGES } from '$lib/constants.js';
import { afterNavigate, beforeNavigate } from '$app/navigation';
import CardPlaceholder from '../components/Card/CardPlaceholder.svelte';
import CardProvider from '../components/Card/CardProvider.svelte';
export let data: PageData;
const watched = [];
@@ -33,11 +32,12 @@
<h1 class={headerStyle}>Downloading</h1>
<div class={posterGridStyle}>
{#each downloading as movie (movie.tmdbId)}
<SmallHorizontalPoster
tmdbId={movie.tmdbId}
progress={(movie.sizeleft / movie.size) * 100}
<Card
{...movie}
progress={movie.progress}
progressType="downloading"
available={false}
type="download"
/>
{/each}
</div>
@@ -47,7 +47,7 @@
<h1 class={headerStyle}>Available</h1>
<div class={posterGridStyle}>
{#each available as movie (movie.tmdbId)}
<SmallHorizontalPoster randomProgress={true} tmdbId={movie.tmdbId} />
<Card {...movie} randomProgress={false} />
{/each}
</div>
{/if}
@@ -56,7 +56,7 @@
<h1 class={headerStyle}>Unavailable</h1>
<div class={posterGridStyle}>
{#each unavailable as movie (movie.tmdbId)}
<SmallHorizontalPoster available={false} tmdbId={movie.tmdbId} />
<Card {...movie} available={false} />
{/each}
</div>
{/if}

View File

@@ -3,14 +3,7 @@ import { json } from '@sveltejs/kit';
import { parseMovieId } from '../+server';
import { addRadarrMovie, deleteRadarrMovie } from '$lib/radarr/radarr';
export const POST = (async ({ params }) => {
const tmdbId = parseMovieId(params);
const response = await addRadarrMovie(tmdbId);
return json(response);
}) satisfies RequestHandler;
// Delete download
export const DELETE = (async ({ params }) => {
const radarrMovieId = parseMovieId(params);

View File

@@ -1,8 +1,9 @@
import { parseMovieId } from '../+server';
import { addRadarrMovie } from '$lib/radarr/radarr';
import { addRadarrMovie, getRadarrMovie } from '$lib/radarr/radarr';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
// Add to radarr
export const POST = (async ({ params }) => {
const tmdbId = parseMovieId(params);
@@ -10,3 +11,11 @@ export const POST = (async ({ params }) => {
return json(response);
}) satisfies RequestHandler;
export const GET = (async ({ params }) => {
const tmdbId = parseMovieId(params);
const response = await getRadarrMovie(tmdbId);
return json(response);
}) satisfies RequestHandler;