mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-23 17:25:14 +02:00
Initial work on library page
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Nunito+Sans:wght@200;300;400;500;600;700;800;900;1000&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Nunito+Sans:wght@200;300;400;500;600;700;800;900;1000&display=swap" rel="stylesheet">
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover" class="bg-zinc-950 min-h-screen text-white">
|
<body data-sveltekit-preload-data="hover" class="bg-stone-950 min-h-screen text-white">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,14 +8,37 @@ export const TmdbApi = axios.create({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function fetchMovieDetails(imdbId: string | number) {
|
export async function fetchMovieDetails(imdbId: string | number): Promise<TmdbMovieFull> {
|
||||||
return {
|
return {
|
||||||
...(await TmdbApi.get('/movie/' + imdbId).then((res) => res.data)),
|
...(await TmdbApi.get('/movie/' + imdbId).then((res) => res.data)),
|
||||||
videos: await TmdbApi.get('/movie/' + imdbId + '/videos').then((res) => res.data.results),
|
videos: await TmdbApi.get<VideosResponse>('/movie/' + imdbId + '/videos').then(
|
||||||
credits: await TmdbApi.get('/movie/' + imdbId + '/credits').then((res) => res.data.cast)
|
(res) => res.data.results
|
||||||
|
),
|
||||||
|
images: await TmdbApi.get<ImagesResponse>('/movie/' + imdbId + '/images').then((res) => {
|
||||||
|
return {
|
||||||
|
backdrops: res.data.backdrops,
|
||||||
|
logos: res.data.logos,
|
||||||
|
posters: res.data.posters
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
credits: await TmdbApi.get<CreditsResponse>('/movie/' + imdbId + '/credits').then(
|
||||||
|
(res) => res.data.cast
|
||||||
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TmdbMovieFull extends TmdbMovie {
|
||||||
|
videos: Video[];
|
||||||
|
images: {
|
||||||
|
backdrops: Backdrop[];
|
||||||
|
logos: Logo[];
|
||||||
|
posters: Poster[];
|
||||||
|
};
|
||||||
|
credits: CastMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MovieDetailsResponse = TmdbMovie;
|
||||||
|
|
||||||
export interface TmdbMovie {
|
export interface TmdbMovie {
|
||||||
adult: boolean;
|
adult: boolean;
|
||||||
backdrop_path: string;
|
backdrop_path: string;
|
||||||
@@ -119,3 +142,40 @@ export interface Video {
|
|||||||
published_at: string;
|
published_at: string;
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImagesResponse {
|
||||||
|
backdrops: Backdrop[];
|
||||||
|
id: number;
|
||||||
|
logos: Logo[];
|
||||||
|
posters: Poster[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Backdrop {
|
||||||
|
aspect_ratio: number;
|
||||||
|
height: number;
|
||||||
|
iso_639_1?: string;
|
||||||
|
file_path: string;
|
||||||
|
vote_average: number;
|
||||||
|
vote_count: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Logo {
|
||||||
|
aspect_ratio: number;
|
||||||
|
height: number;
|
||||||
|
iso_639_1: string;
|
||||||
|
file_path: string;
|
||||||
|
vote_average: number;
|
||||||
|
vote_count: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Poster {
|
||||||
|
aspect_ratio: number;
|
||||||
|
height: number;
|
||||||
|
iso_639_1?: string;
|
||||||
|
file_path: string;
|
||||||
|
vote_average: number;
|
||||||
|
vote_count: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|||||||
12
src/lib/utils.ts
Normal file
12
src/lib/utils.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Genre } from '$lib/tmdb-api';
|
||||||
|
|
||||||
|
export function getRuntime(minutes: number) {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = Math.floor(minutes % 60);
|
||||||
|
|
||||||
|
return `${hours > 0 ? hours + 'h ' : ''}${mins}min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatGenres(genres: Genre[]) {
|
||||||
|
return genres.map((genre) => genre.name.charAt(0).toUpperCase() + genre.name.slice(1)).join(', ');
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { setClient } from 'svelte-apollo';
|
import { setClient } from 'svelte-apollo';
|
||||||
import Navbar from './Navbar.svelte';
|
import Navbar from './components/Navbar.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="app">
|
<div class="app">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SmallPoster from './SmallPoster.svelte';
|
import SmallPoster from './components/SmallPoster/SmallPoster.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import ResourceDetails from './ResourceDetails/ResourceDetails.svelte';
|
import ResourceDetails from './components/ResourceDetails/ResourceDetails.svelte';
|
||||||
import ResourceDetailsControls from './ResourceDetailsControls.svelte';
|
import ResourceDetailsControls from './ResourceDetailsControls.svelte';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|||||||
@@ -18,6 +18,6 @@
|
|||||||
<ChevronRight size="24" />
|
<ChevronRight size="24" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute inset-x-0 bottom-6 flex justify-center mx-auto opacity-50">
|
<!--<div class="absolute inset-x-0 bottom-6 flex justify-center mx-auto opacity-50">-->
|
||||||
<ChevronDown size="20" />
|
<!-- <ChevronDown size="20" />-->
|
||||||
</div>
|
<!--</div>-->
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
remoteResource.backdrop_path +
|
remoteResource.backdrop_path +
|
||||||
"')"}
|
"')"}
|
||||||
>
|
>
|
||||||
<div class="youtube-container absolute h-full scale-[150%]">
|
<div class="youtube-container absolute h-full scale-[150%] hidden sm:block">
|
||||||
{#if video.key}
|
{#if video.key}
|
||||||
<iframe
|
<iframe
|
||||||
class={classNames('transition-opacity', {
|
class={classNames('transition-opacity', {
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class={classNames(
|
class={classNames(
|
||||||
'bg-gradient-to-b from-[#070501bf] via-20% via-transparent transition-opacity absolute inset-0 z-[1]',
|
'bg-gradient-to-b from-darken via-20% via-transparent transition-opacity absolute inset-0 z-[1]',
|
||||||
{
|
{
|
||||||
'opacity-100': focusTrailer,
|
'opacity-100': focusTrailer,
|
||||||
'opacity-0': !focusTrailer
|
'opacity-0': !focusTrailer
|
||||||
@@ -95,12 +95,12 @@
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class={classNames(
|
class={classNames(
|
||||||
'h-full w-full px-16 pb-12 pt-32',
|
'h-full w-full px-16 pb-8 pt-32',
|
||||||
'grid grid-cols-[1fr_max-content] grid-rows-[1fr_min-content] gap-x-16 gap-y-8 relative z-[2]',
|
'grid grid-cols-[1fr_max-content] grid-rows-[1fr_min-content] gap-x-16 gap-y-8 relative z-[2]',
|
||||||
'transition-colors',
|
'transition-colors',
|
||||||
{
|
{
|
||||||
'bg-[#070501bf]': !focusTrailer,
|
'bg-darken': !focusTrailer,
|
||||||
'bg-[#00000000]': focusTrailer
|
'bg-transparent': focusTrailer
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
15
src/routes/components/SmallHorizontalPoster/PosterTag.svelte
Normal file
15
src/routes/components/SmallHorizontalPoster/PosterTag.svelte
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script>
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
export let value = '';
|
||||||
|
export let filled = false;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={classNames('border rounded p-[0px] px-1 text-[10px] font-medium', {
|
||||||
|
'text-zinc-200 border-zinc-500': !filled,
|
||||||
|
'bg-zinc-200 border-zinc-200 text-zinc-900': filled
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { TmdbMovieFull } from '$lib/tmdb-api';
|
||||||
|
import { formatGenres, getRuntime } from '$lib/utils';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
export let tmdbMovie: TmdbMovieFull;
|
||||||
|
|
||||||
|
export let available = true;
|
||||||
|
export let progress = 0;
|
||||||
|
export let progressType: 'watched' | 'downloading' = 'watched';
|
||||||
|
export let randomProgress = false;
|
||||||
|
if (randomProgress) {
|
||||||
|
progress = Math.random() > 0.3 ? Math.random() * 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backdropUrl =
|
||||||
|
'https://www.themoviedb.org/t/p/original' +
|
||||||
|
tmdbMovie.images.backdrops.filter((b) => b.iso_639_1 === 'en')[0].file_path;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={"background-image: url('" + backdropUrl + "')"}
|
||||||
|
class="bg-center bg-cover h-40 w-72 rounded overflow-hidden relative drop-shadow-2xl"
|
||||||
|
>
|
||||||
|
<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">{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-xs font-medium text-zinc-200">
|
||||||
|
{progress
|
||||||
|
? getRuntime(tmdbMovie.runtime - tmdbMovie.runtime * (progress / 100)) + ' left'
|
||||||
|
: getRuntime(tmdbMovie.runtime)}
|
||||||
|
</div>
|
||||||
|
{:else if progressType === 'downloading'}
|
||||||
|
<div class="text-xs font-medium text-zinc-200">
|
||||||
|
{Math.floor(progress) + '% Downloaded'}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class={classNames('absolute inset-0', {
|
||||||
|
'bg-darken opacity-0 peer-hover:opacity-100': available,
|
||||||
|
'bg-[#00000055] peer-hover:bg-darken': !available
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
@@ -3,14 +3,15 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
export let tmdbId;
|
export let tmdbId;
|
||||||
|
export let progress = 0;
|
||||||
|
export let randomProgress = false;
|
||||||
|
if (randomProgress) progress = Math.random() > 0.5 ? Math.round(Math.random() * 100) : 100;
|
||||||
|
|
||||||
export let type: 'movie' | 'tv' = 'movie';
|
export let type: 'movie' | 'tv' = 'movie';
|
||||||
|
|
||||||
let bg = '';
|
let bg = '';
|
||||||
let title = 'Loading...';
|
let title = 'Loading...';
|
||||||
|
|
||||||
const progress = Math.random() > 0.5 ? Math.round(Math.random() * 100) : 100;
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
TmdbApi.get('/' + type + '/' + tmdbId)
|
TmdbApi.get('/' + type + '/' + tmdbId)
|
||||||
.then((res) => res.data)
|
.then((res) => res.data)
|
||||||
@@ -35,9 +36,7 @@
|
|||||||
class="bg-center bg-cover aspect-[2/3] h-72 shadow-2xl m-1.5"
|
class="bg-center bg-cover aspect-[2/3] h-72 shadow-2xl m-1.5"
|
||||||
style={"background-image: url('" + bg + "')"}
|
style={"background-image: url('" + bg + "')"}
|
||||||
>
|
>
|
||||||
<div
|
<div class="w-full h-full hover:bg-darken transition-all flex">
|
||||||
class="w-full h-full bg-gradient-to-b from-[#00000099] via-20% via-transparent hover:bg-[#00000099] transition-all flex"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="opacity-0 group-hover:opacity-100 transition-opacity p-2 flex flex-col justify-between flex-1 cursor-pointer"
|
class="opacity-0 group-hover:opacity-100 transition-opacity p-2 flex flex-col justify-between flex-1 cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -1,6 +1,75 @@
|
|||||||
<div class="pt-24">
|
<script lang="ts">
|
||||||
Contains all the titles available locally, the ones already watched previously (greyed out at the
|
import type { PageData } from './$types';
|
||||||
bottom), and the ones that are in some sort of watchlist and available via any source.
|
import SmallHorizontalPoster from '../components/SmallHorizontalPoster/SmallHorizontalPoster.svelte';
|
||||||
|
import type { TmdbMovieFull } from '$lib/tmdb-api';
|
||||||
|
export let data: PageData;
|
||||||
|
console.log(data);
|
||||||
|
|
||||||
<div>Library</div>
|
const allMovies: Record<string, TmdbMovieFull> = {};
|
||||||
|
data.tmdbMovies.forEach((m) => (allMovies[m.id] = m));
|
||||||
|
|
||||||
|
const tmdbIdToDownloading = {};
|
||||||
|
(data.downloading as any).forEach((d) => (tmdbIdToDownloading[d.movie.tmdbId] = d));
|
||||||
|
|
||||||
|
const tmdbIdToRadarrMovie = {};
|
||||||
|
(data.radarrMovies as any).forEach((r) => (tmdbIdToRadarrMovie[r.tmdbId] = r));
|
||||||
|
|
||||||
|
const downloading = data.tmdbMovies.filter((m) => tmdbIdToDownloading[m.id] !== undefined);
|
||||||
|
const available = data.tmdbMovies.filter((m) => tmdbIdToDownloading[m.id] === undefined);
|
||||||
|
const unavailable = data.tmdbMovies.filter(
|
||||||
|
(m) => !tmdbIdToRadarrMovie[m.id]?.hasFile && !tmdbIdToDownloading[m.id]
|
||||||
|
);
|
||||||
|
const watched = [];
|
||||||
|
|
||||||
|
const posterGridStyle = 'flex flex-wrap justify-center gap-x-4 gap-y-8';
|
||||||
|
const headerStyle = 'uppercase tracking-widest font-bold text-center mt-2';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="background-image: url('https://www.themoviedb.org/t/p/original/vvjYv7bSWerbsi0LsMjLnTVOX7c.jpg')"
|
||||||
|
>
|
||||||
|
<div class="py-24 backdrop-blur-2xl bg-darken px-8 flex flex-col gap-4">
|
||||||
|
<!-- Contains all the titles available locally, the ones already watched previously (greyed out at the-->
|
||||||
|
<!-- bottom), and the ones that are in some sort of watchlist and not available via any source.-->
|
||||||
|
|
||||||
|
<!-- <div>Library</div>-->
|
||||||
|
|
||||||
|
{#if downloading.length > 0}
|
||||||
|
<h1 class={headerStyle}>Downloading</h1>
|
||||||
|
<div class={posterGridStyle}>
|
||||||
|
{#each downloading as movie (movie.id)}
|
||||||
|
<SmallHorizontalPoster
|
||||||
|
progress={(tmdbIdToDownloading[movie.id].sizeleft /
|
||||||
|
tmdbIdToDownloading[movie.id].size) *
|
||||||
|
100}
|
||||||
|
progressType="downloading"
|
||||||
|
available={false}
|
||||||
|
tmdbMovie={movie}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if available.length > 0}
|
||||||
|
<h1 class={headerStyle}>Available</h1>
|
||||||
|
<div class={posterGridStyle}>
|
||||||
|
{#each available as movie (movie.id)}
|
||||||
|
<SmallHorizontalPoster randomProgress={true} tmdbMovie={movie} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if unavailable.length > 0}
|
||||||
|
<h1 class={headerStyle}>Unavailable</h1>
|
||||||
|
<div class={posterGridStyle}>
|
||||||
|
{#each unavailable as movie (movie.id)}
|
||||||
|
<SmallHorizontalPoster available={false} tmdbMovie={movie} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if watched.length > 0}
|
||||||
|
<h1 class={headerStyle}>Watched</h1>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
34
src/routes/library/+page.ts
Normal file
34
src/routes/library/+page.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { PageLoad } from './$types';
|
||||||
|
import { radarrApi } from '$lib/servarr-api';
|
||||||
|
import { fetchMovieDetails } from '$lib/tmdb-api';
|
||||||
|
|
||||||
|
export const load = (async () => {
|
||||||
|
const radarrMovies = await radarrApi
|
||||||
|
.get('/api/v3/movie', {
|
||||||
|
params: {}
|
||||||
|
})
|
||||||
|
.then((r) => r.data);
|
||||||
|
|
||||||
|
let tmdbMovies;
|
||||||
|
if (radarrMovies) {
|
||||||
|
tmdbMovies = await Promise.all(
|
||||||
|
radarrMovies.filter((m) => m.tmdbId).map((m) => fetchMovieDetails(m.tmdbId as any))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('radarrMovies', radarrMovies);
|
||||||
|
|
||||||
|
return {
|
||||||
|
radarrMovies,
|
||||||
|
tmdbMovies,
|
||||||
|
downloading: await radarrApi
|
||||||
|
.get('/api/v3/queue', {
|
||||||
|
params: {
|
||||||
|
query: {
|
||||||
|
includeMovie: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((r) => r.data?.records)
|
||||||
|
};
|
||||||
|
}) satisfies PageLoad;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import ResourceDetails from '../../ResourceDetails/ResourceDetails.svelte';
|
import ResourceDetails from '../../components/ResourceDetails/ResourceDetails.svelte';
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ResourceDetails trailer={false} resource={data.movie} remoteResource={data.remoteMovie} />
|
<ResourceDetails resource={data.movie} remoteResource={data.remoteMovie} />
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export default {
|
|||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Inter', 'sans-serif'],
|
sans: ['Inter', 'sans-serif'],
|
||||||
display: ['Inter', 'system', 'sans-serif']
|
display: ['Inter', 'system', 'sans-serif']
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
darken: '#070501bf'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user