mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-21 16:25:11 +02:00
Project Refactoring
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import createClient from 'openapi-fetch';
|
||||
import type { paths } from '$lib/jellyfin/jellyfin-types';
|
||||
import type { paths } from '$lib/apis/jellyfin/jellyfin.generated';
|
||||
import { PUBLIC_JELLYFIN_API_KEY, PUBLIC_JELLYFIN_URL } from '$env/static/public';
|
||||
import { request } from '$lib/utils';
|
||||
import type { DeviceProfile } from '$lib/jellyfin/playback-profiles';
|
||||
import type { DeviceProfile } from '$lib/apis/jellyfin/playback-profiles';
|
||||
|
||||
export const JELLYFIN_DEVICE_ID = 'Reiverr Client';
|
||||
export const JELLYFIN_USER_ID = '75dcb061c9404115a7acdc893ea6bbbc';
|
||||
@@ -1,13 +1,14 @@
|
||||
import createClient from 'openapi-fetch';
|
||||
import { log, request } from '$lib/utils';
|
||||
import type { paths } from '$lib/radarr/radarr-types';
|
||||
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';
|
||||
import type { paths } from '$lib/apis/radarr/radarr.generated';
|
||||
import type { components } from '$lib/apis/radarr/radarr.generated';
|
||||
import { fetchTmdbMovie } from '$lib/apis/tmdbApi';
|
||||
import { PUBLIC_RADARR_API_KEY, PUBLIC_RADARR_BASE_URL } from '$env/static/public';
|
||||
|
||||
export type RadarrMovie = components['schemas']['MovieResource'];
|
||||
export type MovieFileResource = components['schemas']['MovieFileResource'];
|
||||
export type ReleaseResource = components['schemas']['ReleaseResource'];
|
||||
export type RadarrDownload = components['schemas']['QueueResource'] & { movie: RadarrMovie };
|
||||
|
||||
export interface RadarrMovieOptions {
|
||||
title: string;
|
||||
@@ -23,20 +24,20 @@ export interface RadarrMovieOptions {
|
||||
}
|
||||
|
||||
export const RadarrApi = createClient<paths>({
|
||||
baseUrl: RADARR_BASE_URL,
|
||||
baseUrl: PUBLIC_RADARR_BASE_URL,
|
||||
headers: {
|
||||
'X-Api-Key': RADARR_API_KEY
|
||||
'X-Api-Key': PUBLIC_RADARR_API_KEY
|
||||
}
|
||||
});
|
||||
|
||||
export const getRadarrMovies = () =>
|
||||
export const getRadarrMovies = (): Promise<RadarrMovie[]> =>
|
||||
RadarrApi.get('/api/v3/movie', {
|
||||
params: {}
|
||||
}).then((r) => r.data);
|
||||
}).then((r) => r.data || []);
|
||||
|
||||
export const requestRadarrMovie = () => request(getRadarrMovie);
|
||||
|
||||
export const getRadarrMovie = (tmdbId: string) =>
|
||||
export const getRadarrMovie = (tmdbId: string): Promise<RadarrMovie | undefined> =>
|
||||
RadarrApi.get('/api/v3/movie', {
|
||||
params: {
|
||||
query: {
|
||||
@@ -121,16 +122,17 @@ export const deleteRadarrMovie = (id: number) =>
|
||||
|
||||
export const requestRadarrQueuedById = () => request(getRadarrDownload);
|
||||
|
||||
export const getRadarrDownload = (id: string) =>
|
||||
export const getRadarrDownloads = (): Promise<RadarrDownload[]> =>
|
||||
RadarrApi.get('/api/v3/queue', {
|
||||
params: {
|
||||
query: {
|
||||
includeMovie: true
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data)
|
||||
.then((queue) => queue?.records?.filter((r) => (r?.movie?.id as any) == id));
|
||||
}).then((r) => (r.data?.records?.filter((record) => record.movie) as RadarrDownload[]) || []);
|
||||
|
||||
export const getRadarrDownload = (id: string) =>
|
||||
getRadarrDownloads().then((downloads) => downloads.find((d) => d.movie.id === Number(id)));
|
||||
|
||||
const getMovieByTmdbIdByTmdbId = (tmdbId: string) =>
|
||||
RadarrApi.get('/api/v3/movie/lookup/tmdb', {
|
||||
10
src/lib/apis/sonarr/sonarrApi.ts
Normal file
10
src/lib/apis/sonarr/sonarrApi.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import createClient from 'openapi-fetch';
|
||||
import type { paths } from '$lib/apis/sonarr/sonarr.generated';
|
||||
import { PUBLIC_SONARR_API_KEY, PUBLIC_SONARR_BASE_URL } from '$env/static/public';
|
||||
|
||||
export const SonarrApi = createClient<paths>({
|
||||
baseUrl: PUBLIC_SONARR_BASE_URL,
|
||||
headers: {
|
||||
'X-Api-Key': PUBLIC_SONARR_API_KEY
|
||||
}
|
||||
});
|
||||
42
src/lib/components/Button.svelte
Normal file
42
src/lib/components/Button.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let size: 'md' | 'sm' | 'lg' = 'md';
|
||||
export let type: 'primary' | 'secondary' | 'tertiary' = 'primary';
|
||||
export let disabled = false;
|
||||
|
||||
export let href: string | undefined = undefined;
|
||||
export let target: string | undefined = undefined;
|
||||
|
||||
let buttonStyle;
|
||||
$: buttonStyle = classNames(
|
||||
'border-2 border-white transition-all uppercase tracking-widest text-xs whitespace-nowrap',
|
||||
{
|
||||
'bg-white text-zinc-900 font-extrabold': type === 'primary',
|
||||
'hover:bg-amber-400 hover:border-amber-400': type === 'primary' && !disabled,
|
||||
'font-semibold': type === 'secondary',
|
||||
'hover:bg-white hover:text-black': type === 'secondary' && !disabled,
|
||||
'px-8 py-3.5': size === 'lg',
|
||||
'px-6 py-2.5': size === 'md',
|
||||
'px-5 py-2': size === 'sm',
|
||||
'opacity-70': disabled,
|
||||
'cursor-pointer': !disabled
|
||||
}
|
||||
);
|
||||
|
||||
const handleClick = (event) => {
|
||||
if (href) {
|
||||
if (target === '_blank') window.open(href, target).focus();
|
||||
else window.open(href, target as string);
|
||||
} else {
|
||||
dispatch('click', event);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<button class={buttonStyle} on:click={handleClick} on:mouseover on:mouseleave {disabled}>
|
||||
<slot />
|
||||
</button>
|
||||
84
src/lib/components/Card/Card.svelte
Normal file
84
src/lib/components/Card/Card.svelte
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { formatMinutesToTime } from '$lib/utils';
|
||||
import classNames from 'classnames';
|
||||
import { TMDB_IMAGES } from '$lib/constants';
|
||||
import { Clock, Star } from 'radix-icons-svelte';
|
||||
|
||||
export let tmdbId: string;
|
||||
export let title: string;
|
||||
export let genres: string[];
|
||||
export let runtimeMinutes: number;
|
||||
export let completionTime = '';
|
||||
export let backdropUrl: string;
|
||||
export let rating: number;
|
||||
|
||||
export let available = true;
|
||||
export let progress = 0;
|
||||
export let type: 'dynamic' | 'normal' | 'large' = 'normal';
|
||||
export let randomProgress = false;
|
||||
if (randomProgress) {
|
||||
progress = Math.random() > 0.3 ? Math.random() * 100 : 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={classNames('rounded overflow-hidden relative shadow-2xl shrink-0 aspect-video', {
|
||||
'h-40': type === 'normal',
|
||||
'h-60': type === 'large',
|
||||
'w-full': type === 'dynamic'
|
||||
})}
|
||||
>
|
||||
<div style={'width: ' + progress + '%'} class="h-[2px] bg-zinc-200 bottom-0 absolute z-[1]" />
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
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>
|
||||
<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 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>
|
||||
<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>
|
||||
15
src/lib/components/Card/CardPlaceholder.svelte
Normal file
15
src/lib/components/Card/CardPlaceholder.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
|
||||
export let index = 0;
|
||||
export let type: 'dynamic' | 'normal' | 'large' = 'normal';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={classNames('rounded overflow-hidden shadow-2xl placeholder shrink-0 aspect-video', {
|
||||
'h-40': type === 'normal',
|
||||
'h-60': type === 'large',
|
||||
'w-full': type === 'dynamic'
|
||||
})}
|
||||
style={'animation-delay: ' + ((index * 100) % 2000) + 'ms;'}
|
||||
/>
|
||||
38
src/lib/components/Card/CardProvider.svelte
Normal file
38
src/lib/components/Card/CardProvider.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import type { TmdbMovie } from '$lib/apis/tmdbApi';
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchTmdbMovie, fetchTmdbMovieImages } from '$lib/apis/tmdbApi';
|
||||
import { TMDB_IMAGES } from '$lib/constants';
|
||||
import { getJellyfinItemByTmdbId } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
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}
|
||||
15
src/lib/components/Card/PosterTag.svelte
Normal file
15
src/lib/components/Card/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>
|
||||
42
src/lib/components/Card/card.ts
Normal file
42
src/lib/components/Card/card.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { RadarrMovie } from '$lib/apis/radarr/radarrApi';
|
||||
import { fetchTmdbMovieImages } from '$lib/apis/tmdbApi';
|
||||
import type { TmdbMovie } from '$lib/apis/tmdbApi';
|
||||
|
||||
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
|
||||
};
|
||||
};
|
||||
37
src/lib/components/Carousel/Carousel.svelte
Normal file
37
src/lib/components/Carousel/Carousel.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
import { ChevronLeft, ChevronRight } from 'radix-icons-svelte';
|
||||
|
||||
let carousel;
|
||||
let scrollX;
|
||||
</script>
|
||||
|
||||
<div class="flex justify-between items-center mx-8">
|
||||
<slot name="title" />
|
||||
<div class="flex gap-2">
|
||||
<IconButton>
|
||||
<ChevronLeft size="20" />
|
||||
</IconButton>
|
||||
<IconButton>
|
||||
<ChevronRight size="20" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex overflow-x-scroll items-center overflow-y-hidden gap-4 relative pl-8 scrollbar-hide py-4"
|
||||
bind:this={carousel}
|
||||
on:scroll={() => (scrollX = carousel.scrollLeft)}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
{#if scrollX > 0}
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="absolute inset-y-4 left-0 w-24 bg-gradient-to-r from-darken"
|
||||
/>
|
||||
{/if}
|
||||
<div class="absolute inset-y-4 right-0 w-24 bg-gradient-to-l from-darken" />
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import CardPlaceholder from '../Card/CardPlaceholder.svelte';
|
||||
export let type: 'dynamic' | 'normal' | 'large' = 'normal';
|
||||
</script>
|
||||
|
||||
{#each Array(10) as _, i (i)}
|
||||
<CardPlaceholder {type} />
|
||||
{/each}
|
||||
16
src/lib/components/HeightHider.svelte
Normal file
16
src/lib/components/HeightHider.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
import classNames from 'classnames';
|
||||
|
||||
export let visible = false;
|
||||
export let duration = 300;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={classNames('transition-[max-height] duration-1000 overflow-hidden', {
|
||||
'max-h-0': !visible,
|
||||
'max-h-screen': visible
|
||||
})}
|
||||
style="transition-duration: {duration}ms;"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
15
src/lib/components/IconButton.svelte
Normal file
15
src/lib/components/IconButton.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script>
|
||||
import classNames from 'classnames';
|
||||
|
||||
export let disabled = false;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={classNames('text-zinc-300 hover:text-zinc-50 p-1 flex items-center justify-center', {
|
||||
'opacity-30 cursor-not-allowed pointer-events-none': disabled,
|
||||
'cursor-pointer': !disabled
|
||||
})}
|
||||
on:click|stopPropagation
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
20
src/lib/components/Modal/Modal.svelte
Normal file
20
src/lib/components/Modal/Modal.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
export let visible = false;
|
||||
export let close: () => void;
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div
|
||||
class={classNames('fixed inset-0 bg-[#00000088] justify-center items-center z-20', {
|
||||
hidden: !visible,
|
||||
'flex overflow-hidden': visible
|
||||
})}
|
||||
on:click|self={close}
|
||||
transition:fade={{ duration: 100 }}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
10
src/lib/components/Modal/ModalContent.svelte
Normal file
10
src/lib/components/Modal/ModalContent.svelte
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="max-w-3xl self-start mt-[10vh] bg-[#33333388] backdrop-blur-xl rounded overflow-hidden flex flex-col flex-1 mx-4 sm:mx-16 lg:mx-24 drop-shadow-xl"
|
||||
in:fly={{ y: 20, duration: 200 }}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
17
src/lib/components/Modal/ModalHeader.svelte
Normal file
17
src/lib/components/Modal/ModalHeader.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script>
|
||||
import IconButton from '../IconButton.svelte';
|
||||
import { Cross2 } from 'radix-icons-svelte';
|
||||
|
||||
export let text;
|
||||
export let close;
|
||||
</script>
|
||||
|
||||
<div class="flex text-zinc-200 items-center p-3 px-5 gap-4 border-b border-zinc-700">
|
||||
<slot />
|
||||
{#if text}
|
||||
<p class="font flex-1">{text}</p>
|
||||
{/if}
|
||||
<IconButton on:click={close}>
|
||||
<Cross2 size="20" />
|
||||
</IconButton>
|
||||
</div>
|
||||
57
src/lib/components/Navbar/Navbar.svelte
Normal file
57
src/lib/components/Navbar/Navbar.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { MagnifyingGlass, Person } from 'radix-icons-svelte';
|
||||
import classNames from 'classnames';
|
||||
import { page } from '$app/stores';
|
||||
import TitleSearchModal from './TitleSearchModal.svelte';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
|
||||
let y = 0;
|
||||
let transparent = true;
|
||||
let baseStyle = '';
|
||||
|
||||
let isSearchVisible = false;
|
||||
|
||||
function getLinkStyle(path: string) {
|
||||
return $page.url.pathname === path ? 'text-amber-200' : 'hover:text-zinc-50 cursor-pointer';
|
||||
}
|
||||
|
||||
$: {
|
||||
transparent = y <= 0;
|
||||
baseStyle = classNames(
|
||||
'fixed px-8 inset-x-0 grid grid-cols-[min-content_1fr_min-content] items-center z-10',
|
||||
'transition-all',
|
||||
{
|
||||
'bg-zinc-900 bg-opacity-50 backdrop-blur-2xl h-16': !transparent,
|
||||
'h-24': transparent
|
||||
}
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window bind:scrollY={y} />
|
||||
|
||||
<div class={baseStyle}>
|
||||
<a href="/" class="flex gap-2 items-center hover:text-inherit">
|
||||
<div class="rounded-full bg-amber-300 h-4 w-4" />
|
||||
<h1 class="font-display uppercase font-semibold tracking-wider text-xl">Reiverr</h1>
|
||||
</a>
|
||||
<div
|
||||
class="flex items-center justify-center gap-8 font-normal text-sm tracking-wider text-zinc-200"
|
||||
>
|
||||
<a href="/" class={$page && getLinkStyle('/')}>Home</a>
|
||||
<a href="/discover" class={$page && getLinkStyle('/discover')}>Discover</a>
|
||||
<a href="/library" class={$page && getLinkStyle('/library')}>Library</a>
|
||||
<a href="/sources" class={$page && getLinkStyle('/sources')}>Sources</a>
|
||||
<a href="/settings" class={$page && getLinkStyle('/settings')}>Settings</a>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<IconButton on:click={() => (isSearchVisible = true)}>
|
||||
<MagnifyingGlass size={20} />
|
||||
</IconButton>
|
||||
<IconButton>
|
||||
<Person size={20} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TitleSearchModal bind:visible={isSearchVisible} />
|
||||
87
src/lib/components/Navbar/TitleSearchModal.svelte
Normal file
87
src/lib/components/Navbar/TitleSearchModal.svelte
Normal file
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import Modal from '../Modal/Modal.svelte';
|
||||
import ModalContent from '../Modal/ModalContent.svelte';
|
||||
import { Cross1, Cross2, MagnifyingGlass } from 'radix-icons-svelte';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
import { TmdbApi } from '$lib/apis/tmdbApi';
|
||||
import type { MultiSearchResponse } from '$lib/apis/tmdbApi';
|
||||
import { TMDB_IMAGES } from '$lib/constants';
|
||||
import ModalHeader from '../Modal/ModalHeader.svelte';
|
||||
export let visible = false;
|
||||
let searchValue = '';
|
||||
|
||||
export let close = () => {
|
||||
visible = false;
|
||||
searchValue = '';
|
||||
fetching = false;
|
||||
results = null;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
};
|
||||
|
||||
let timeout;
|
||||
let fetching = false;
|
||||
let results: MultiSearchResponse['results'] | null = null;
|
||||
const searchTimeout = () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
searchMovie(searchValue);
|
||||
}, 700);
|
||||
};
|
||||
|
||||
const searchMovie = (query: string) => {
|
||||
fetching = true;
|
||||
TmdbApi.get<MultiSearchResponse>('/search/movie', {
|
||||
params: {
|
||||
query
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.data) results = res.data.results;
|
||||
})
|
||||
.finally(() => (fetching = false));
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal {visible} {close}>
|
||||
<ModalContent>
|
||||
<ModalHeader {close}>
|
||||
<MagnifyingGlass size="20" class="text-zinc-400" />
|
||||
<input
|
||||
bind:value={searchValue}
|
||||
on:input={searchTimeout}
|
||||
type="text"
|
||||
class="flex-1 bg-transparent font-light outline-none"
|
||||
placeholder="Search for Movies and Shows..."
|
||||
/>
|
||||
</ModalHeader>
|
||||
{#if !results || searchValue === ''}
|
||||
<div class="text-sm text-zinc-200 opacity-50 font-light p-4">No recent searches</div>
|
||||
{:else if results?.length === 0 && !fetching}
|
||||
<div class="text-sm text-zinc-200 opacity-50 font-light p-4">No search results</div>
|
||||
{:else}
|
||||
<div class="py-2">
|
||||
{#each results.filter((m) => m).slice(0, 5) as result}
|
||||
<div
|
||||
class="flex px-4 py-2 gap-4 hover:bg-highlight-dim cursor-pointer"
|
||||
on:click={() => window.open('/movie/' + result.id)}
|
||||
>
|
||||
<div
|
||||
style={"background-image: url('" + TMDB_IMAGES + result.poster_path + "');"}
|
||||
class="bg-center bg-cover w-16 h-24 rounded-sm"
|
||||
/>
|
||||
<div class="flex-1 flex flex-col gap-1">
|
||||
<div class="flex gap-2">
|
||||
<div class="font-normal tracking-wide">{result.original_title}</div>
|
||||
<div class="text-zinc-400">
|
||||
{new Date(result.release_date).getFullYear()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-zinc-300 line-clamp-3">{result.overview}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
98
src/lib/components/Poster/Poster.svelte
Normal file
98
src/lib/components/Poster/Poster.svelte
Normal file
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { TmdbApi } from '$lib/apis/tmdbApi';
|
||||
import type { TmdbMovie } from '$lib/apis/tmdbApi';
|
||||
import { getContext, onMount } from 'svelte';
|
||||
import { TMDB_IMAGES } from '$lib/constants';
|
||||
import { formatMinutesToTime } from '$lib/utils';
|
||||
import { getJellyfinItemByTmdbId } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
|
||||
export let tmdbId;
|
||||
export let progress = 0;
|
||||
export let length = 0;
|
||||
export let randomProgress = false;
|
||||
if (randomProgress) progress = Math.random() > 0.5 ? Math.round(Math.random() * 100) : 100;
|
||||
|
||||
const { streamJellyfinId } = getContext('player');
|
||||
|
||||
export let type: 'movie' | 'tv' = 'movie';
|
||||
|
||||
let bg = '';
|
||||
let title = 'Loading...';
|
||||
|
||||
onMount(() => {
|
||||
TmdbApi.get('/' + type + '/' + tmdbId)
|
||||
.then((res) => res.data)
|
||||
.then((data: any) => {
|
||||
bg = TMDB_IMAGES + data.poster_path;
|
||||
title = data.title;
|
||||
});
|
||||
});
|
||||
|
||||
let streamFetching = false;
|
||||
function stream() {
|
||||
if (streamFetching || !tmdbId) return;
|
||||
streamFetching = true;
|
||||
getJellyfinItemByTmdbId(tmdbId).then((item: any) => {
|
||||
if (item.Id) streamJellyfinId(item.Id);
|
||||
streamFetching = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="group grid grid-cols-[2px_1fr_2px] grid-rows-[2px_1fr_2px]">
|
||||
<div
|
||||
style={'width: ' + progress + '%'}
|
||||
class="h-full bg-zinc-200 opacity-100 group-hover:opacity-80 transition-all col-span-3"
|
||||
/>
|
||||
<div
|
||||
style={'height: ' + progress + '%'}
|
||||
class="w-full bg-zinc-200 opacity-100 group-hover:opacity-80 transition-all"
|
||||
/>
|
||||
<div
|
||||
class="bg-center bg-cover aspect-[2/3] h-72 m-1.5"
|
||||
style={"background-image: url('" + bg + "')"}
|
||||
>
|
||||
<div class="w-full h-full hover:bg-darken transition-all flex">
|
||||
<div
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity p-2 flex flex-col justify-between flex-1 cursor-pointer"
|
||||
on:click={() => (window.location.href = '/' + type + '/' + tmdbId)}
|
||||
>
|
||||
<div>
|
||||
<h1 class="font-bold text-lg tracking-wide">
|
||||
{title}
|
||||
</h1>
|
||||
{#if type === 'movie'}
|
||||
<h2 class="text-xs uppercase text-zinc-300"><b>December</b> 2022</h2>
|
||||
{:else}
|
||||
<h2 class="text-xs uppercase text-zinc-300">S1 <b>E2</b></h2>
|
||||
{/if}
|
||||
{#if progress && length}
|
||||
<h2 class="mt-2 text-sm tracking-wide text-zinc-300">
|
||||
<b>{formatMinutesToTime(length * (1 - progress / 100))}</b> left
|
||||
</h2>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
class="bg-white border-2 border-white hover:bg-amber-400 hover:border-amber-400 transition-colors text-zinc-900 px-8 py-2.5 uppercase tracking-widest font-extrabold cursor-pointer text-xs"
|
||||
on:click|stopPropagation={stream}>Stream</button
|
||||
>
|
||||
<a
|
||||
on:click|stopPropagation
|
||||
href={'/' + type + '/' + tmdbId}
|
||||
class="border-2 border-white cursor-pointer transition-colors px-8 py-2.5 uppercase tracking-widest font-semibold text-xs hover:bg-amber-400 hover:text-black text-center"
|
||||
>Details</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={'height: ' + progress + '%'}
|
||||
class="w-full bg-zinc-200 opacity-100 group-hover:opacity-80 transition-all self-end"
|
||||
/>
|
||||
<div
|
||||
style={'width: ' + progress + '%'}
|
||||
class="h-full bg-zinc-200 opacity-100 group-hover:opacity-80 transition-all col-span-3 justify-self-end"
|
||||
/>
|
||||
</div>
|
||||
127
src/lib/components/RequestModal/RequestModal.svelte
Normal file
127
src/lib/components/RequestModal/RequestModal.svelte
Normal file
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import Modal from '../Modal/Modal.svelte';
|
||||
import ModalContent from '../Modal/ModalContent.svelte';
|
||||
import { formatMinutesToTime, formatSize } from '$lib/utils';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
import { DotFilled, Download, Plus } from 'radix-icons-svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import ModalHeader from '../Modal/ModalHeader.svelte';
|
||||
import { log } from '$lib/utils.js';
|
||||
import HeightHider from '../HeightHider.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let visible = true; // FIXME
|
||||
function close() {
|
||||
visible = false;
|
||||
downloadFetching = false;
|
||||
downloadingGuid = null;
|
||||
}
|
||||
|
||||
export let radarrId;
|
||||
|
||||
let releasesResponse;
|
||||
$: if (visible) {
|
||||
releasesResponse = fetch(`/movie/${radarrId}/releases`).then((res) => log(res.json()));
|
||||
}
|
||||
|
||||
let downloadFetching;
|
||||
let downloadingGuid;
|
||||
function handleDownload(guid) {
|
||||
downloadFetching = guid;
|
||||
fetch('/movie/0/releases', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ guid })
|
||||
}).then((res) => {
|
||||
dispatch('download');
|
||||
downloadFetching = false;
|
||||
if (res.ok) {
|
||||
downloadingGuid = guid;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let showAllReleases = false;
|
||||
function toggleShowAll() {
|
||||
showAllReleases = !showAllReleases;
|
||||
}
|
||||
|
||||
let showDetailsId;
|
||||
function toggleShowDetails(id) {
|
||||
if (showDetailsId === id) {
|
||||
showDetailsId = null;
|
||||
} else {
|
||||
showDetailsId = id;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal {visible} {close}>
|
||||
<ModalContent>
|
||||
<ModalHeader {close} text="Releases" />
|
||||
{#await releasesResponse}
|
||||
<div class="text-sm text-zinc-200 opacity-50 font-light p-4">Loading...</div>
|
||||
{:then data}
|
||||
{#if showAllReleases ? data?.allReleases?.length : data?.filtered?.length}
|
||||
<div class="flex flex-col py-2 divide-y divide-zinc-700 max-h-[60vh] overflow-y-scroll">
|
||||
{#each showAllReleases ? data.allReleases : data.filtered as release}
|
||||
<div>
|
||||
<div
|
||||
class="flex px-4 py-2 gap-4 hover:bg-highlight-dim items-center justify-between cursor-pointer text-sm"
|
||||
on:click={() => toggleShowDetails(release.guid)}
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="tracking-wide font-medium">{release.indexer}</div>
|
||||
<div class="text-zinc-400">{release.quality.quality.name}</div>
|
||||
<div class="text-zinc-400">{release.seeders} seeders</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="text-zinc-400">{formatSize(release.size)}</div>
|
||||
{#if release.guid !== downloadingGuid}
|
||||
<IconButton
|
||||
on:click={() => handleDownload(release.guid)}
|
||||
disabled={downloadFetching === release.guid}
|
||||
>
|
||||
<Plus size="20" />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<div class="p-1">
|
||||
<Download size="20" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<HeightHider visible={showDetailsId === release.guid}>
|
||||
<div class="flex gap-1 text-xs text-zinc-400 px-4 py-2 items-center flex-wrap">
|
||||
<div>
|
||||
{release.title}
|
||||
</div>
|
||||
<DotFilled size="15" />
|
||||
<div>{formatMinutesToTime(release.ageMinutes)} old</div>
|
||||
<DotFilled size="15" />
|
||||
<div><b>{release.seeders} seeders</b> / {release.leechers} leechers</div>
|
||||
<DotFilled size="15" />
|
||||
{#if release.seeders}
|
||||
<div>
|
||||
{formatSize(release.size / release.seeders)} per seeder
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</HeightHider>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if data?.releasesSkipped > 0}
|
||||
<div
|
||||
class="text-sm text-zinc-200 opacity-50 font-light px-4 py-2 hover:underline cursor-pointer"
|
||||
on:click={toggleShowAll}
|
||||
>
|
||||
{showAllReleases ? 'Show less' : `Show all ${data.releasesSkipped} releases`}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-sm text-zinc-200 opacity-50 font-light p-4">No releases found.</div>
|
||||
{/if}
|
||||
{/await}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
224
src/lib/components/ResourceDetails/LibraryDetails.svelte
Normal file
224
src/lib/components/ResourceDetails/LibraryDetails.svelte
Normal file
@@ -0,0 +1,224 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { formatMinutesToTime, formatSize } from '$lib/utils';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import { DotFilled, Minus, Plus, Trash, Update } from 'radix-icons-svelte';
|
||||
import RequestModal from '../RequestModal/RequestModal.svelte';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
import classNames from 'classnames';
|
||||
import { log } from '$lib/utils.js';
|
||||
|
||||
let isRequestModalVisible = false;
|
||||
export let tmdbId: string;
|
||||
export let jellyfinStreamDisabled;
|
||||
export let openJellyfinStream;
|
||||
|
||||
let response;
|
||||
|
||||
const headerStyle = 'uppercase tracking-widest font-bold';
|
||||
|
||||
let refetchTimeout;
|
||||
let isRefetching = false;
|
||||
async function refetch() {
|
||||
console.log('refetching...');
|
||||
isRefetching = true;
|
||||
const req = fetch(`/movie/${tmdbId}`)
|
||||
.then((res) => log(res.json()))
|
||||
.then((res: any) => {
|
||||
if (res?.radarrDownloads?.length) {
|
||||
clearTimeout(refetchTimeout);
|
||||
refetchTimeout = setTimeout(() => refetch(), 10000);
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.finally(() => (isRefetching = false));
|
||||
|
||||
if (!response) response = req;
|
||||
else response = await req;
|
||||
}
|
||||
|
||||
let addToRadarrLoading = false;
|
||||
function addToRadarr() {
|
||||
if (!tmdbId || addToRadarrLoading) return;
|
||||
|
||||
addToRadarrLoading = true;
|
||||
console.log('here');
|
||||
|
||||
fetch(`/movie/${tmdbId}/radarr`, {
|
||||
method: 'POST'
|
||||
})
|
||||
.then((res) => {
|
||||
console.log('res', res);
|
||||
if (res.ok) {
|
||||
refetch();
|
||||
}
|
||||
})
|
||||
.finally(() => (addToRadarrLoading = false));
|
||||
}
|
||||
|
||||
let cancelDownloadFetching = false;
|
||||
function cancelDownload(downloadId: number) {
|
||||
if (cancelDownloadFetching) return;
|
||||
cancelDownloadFetching = true;
|
||||
fetch(`/movie/${downloadId}/releases`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.ok) refetch();
|
||||
})
|
||||
.finally(() => (cancelDownloadFetching = false));
|
||||
}
|
||||
|
||||
let deleteMovieFetching = false;
|
||||
function deleteFile(movieId: number) {
|
||||
deleteMovieFetching = true;
|
||||
fetch(`/movie/${movieId}/file`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.ok) refetch();
|
||||
})
|
||||
.finally(() => (deleteMovieFetching = false));
|
||||
}
|
||||
|
||||
function openRequestModal() {
|
||||
isRequestModalVisible = true;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
refetch();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#await response then data}
|
||||
{#if data}
|
||||
<div class="flex flex-col gap-8 p-8">
|
||||
{#if !data.canStream && !data.isDownloading}
|
||||
<div>
|
||||
<h1 class="text-lg mb-1 font-medium tracking-wide">No sources found</h1>
|
||||
<p class="text-zinc-300">
|
||||
No local or remote sources found for this title. You can configure your sources on the <a
|
||||
href="/sources"
|
||||
class="text-amber-200 hover:text-amber-100">sources</a
|
||||
> page.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.isAdded && data.radarrMovie}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class={headerStyle}>Local Library</div>
|
||||
<IconButton on:click={openRequestModal}>
|
||||
<Plus size={20} />
|
||||
</IconButton>
|
||||
<IconButton>
|
||||
<Trash size={20} />
|
||||
</IconButton>
|
||||
<IconButton disabled={isRefetching} on:click={refetch}>
|
||||
<Update size={15} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{#each data.radarrDownloads || [] as downloadingFile}
|
||||
<div
|
||||
class={classNames('border-l-2 p-1 px-4 flex justify-between items-center py-1', {
|
||||
'border-purple-400': downloadingFile.status === 'downloading',
|
||||
'border-amber-400': downloadingFile.status !== 'downloading'
|
||||
})}
|
||||
>
|
||||
<div class="flex gap-1 items-center">
|
||||
<b class="">{downloadingFile.quality.quality.resolution}p</b>
|
||||
<DotFilled class="text-zinc-300" />
|
||||
<h2 class="text-zinc-200 text-sm">{formatSize(downloadingFile.size)} on disk</h2>
|
||||
<DotFilled class="text-zinc-300" />
|
||||
<h2 class="uppercase text-zinc-200 text-sm">
|
||||
{downloadingFile.quality.quality.source}
|
||||
</h2>
|
||||
<DotFilled class="text-zinc-300" />
|
||||
{#if downloadingFile.timeleft}
|
||||
<h2 class="text-zinc-200 text-sm">
|
||||
Completed in {formatMinutesToTime(
|
||||
(new Date(downloadingFile.estimatedCompletionTime).getTime() - Date.now()) /
|
||||
1000 /
|
||||
60
|
||||
)}
|
||||
</h2>
|
||||
{:else if downloadingFile.status === 'queued'}
|
||||
<h2 class="text-zinc-200 text-sm">Download starting</h2>
|
||||
{:else}
|
||||
<h2 class="text-orange-300 text-sm">Download Stalled</h2>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
type="secondary"
|
||||
disabled={downloadingFile.status === 'importing' || cancelDownloadFetching}
|
||||
on:click={() => cancelDownload(downloadingFile.id)}>Cancel Download</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if data?.radarrMovie?.movieFile}
|
||||
<div class="border-l-2 border-zinc-200 p-1 px-4 flex justify-between items-center my-1">
|
||||
<div class="flex gap-1 items-center">
|
||||
<b class="">{data.radarrMovie.movieFile.quality.quality.resolution}p</b>
|
||||
<DotFilled class="text-zinc-300" />
|
||||
<h2 class="text-zinc-200 text-sm">
|
||||
{formatSize(data.radarrMovie.movieFile.size)} on disk
|
||||
</h2>
|
||||
<DotFilled class="text-zinc-300" />
|
||||
<h2 class="uppercase text-zinc-200 text-sm">
|
||||
{data.radarrMovie.movieFile.quality.quality.source}
|
||||
</h2>
|
||||
<DotFilled class="text-zinc-300" />
|
||||
<h2 class="uppercase text-zinc-200 text-sm">
|
||||
{data.radarrMovie.movieFile.mediaInfo.videoCodec}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
type="secondary"
|
||||
on:click={() => deleteFile(data.radarrMovie.movieFile.id)}
|
||||
disabled={deleteMovieFetching}>Delete File</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
on:click={() => openJellyfinStream()}
|
||||
disabled={jellyfinStreamDisabled}>Stream</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !data?.radarrMovie?.movieFile && !data?.radarrDownloads?.length}
|
||||
<div class="text-zinc-400 text-sm font-light">Click + to add files</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 items-center bg-black p-8 py-4 empty:hidden">
|
||||
{#if !data.isAdded || data.hasLocalFiles}
|
||||
{#if !data.isAdded}
|
||||
<Button on:click={() => addToRadarr()} disabled={addToRadarrLoading}>Add to Radarr</Button
|
||||
>
|
||||
{/if}
|
||||
<!--{#if data.hasLocalFiles}-->
|
||||
<!-- <Button type="secondary">Manage Local Files</Button>-->
|
||||
<!--{/if}-->
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if data.isAdded && data.radarrMovie}
|
||||
<RequestModal
|
||||
bind:visible={isRequestModalVisible}
|
||||
radarrId={data.radarrMovie.id}
|
||||
on:download={() => setTimeout(refetch, 5000)}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
no data
|
||||
{/if}
|
||||
{:catch err}
|
||||
Could not load local movie data.
|
||||
{JSON.stringify(err)}
|
||||
{/await}
|
||||
307
src/lib/components/ResourceDetails/ResourceDetails.svelte
Normal file
307
src/lib/components/ResourceDetails/ResourceDetails.svelte
Normal file
@@ -0,0 +1,307 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, Clock } from 'radix-icons-svelte';
|
||||
import classNames from 'classnames';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import { TMDB_IMAGES } from '$lib/constants';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import type { CastMember, TmdbMovie, Video } from '$lib/apis/tmdbApi';
|
||||
import { fetchTmdbMovieCredits, fetchTmdbMovieVideos } from '$lib/apis/tmdbApi';
|
||||
import LibraryDetails from './LibraryDetails.svelte';
|
||||
import { getJellyfinItemByTmdbId } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import HeightHider from '../HeightHider.svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import type { PlayerState } from '../VideoPlayer/VideoPlayer';
|
||||
|
||||
export let movie: TmdbMovie;
|
||||
export let videos: Video[];
|
||||
export let castMembers: CastMember[];
|
||||
export let showDetails = false;
|
||||
export let trailer = true;
|
||||
|
||||
let showTrailer = false;
|
||||
let focusTrailer = false;
|
||||
let trailerStartTime = 0;
|
||||
let detailsVisible = showDetails;
|
||||
let streamButtonDisabled = true;
|
||||
let jellyfinId: string;
|
||||
|
||||
let video: Video;
|
||||
$: video = videos?.filter((v) => v.site === 'YouTube' && v.type === 'Trailer')?.[0];
|
||||
|
||||
let opacityStyle: string;
|
||||
$: opacityStyle =
|
||||
(focusTrailer ? 'opacity: 0;' : 'opacity: 100;') + 'transition: opacity 0.3s ease-in-out;';
|
||||
|
||||
// Transitions
|
||||
const duration = 200;
|
||||
const monthNames = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December'
|
||||
];
|
||||
const releaseDate = new Date(movie.release_date);
|
||||
const { playerState, close, streamJellyfinId } = getContext<PlayerState>('player');
|
||||
|
||||
function openTrailer() {
|
||||
window
|
||||
?.open(
|
||||
'https://www.youtube.com/watch?v=' +
|
||||
video.key +
|
||||
'&autoplay=1&t=' +
|
||||
(trailerStartTime === 0 ? 0 : Math.floor((Date.now() - trailerStartTime) / 1000)),
|
||||
'_blank'
|
||||
)
|
||||
?.focus();
|
||||
}
|
||||
|
||||
let fadeIndex = -1;
|
||||
const getFade = () => {
|
||||
fadeIndex += 1;
|
||||
return { duration: 200, delay: 500 + fadeIndex * 50 };
|
||||
};
|
||||
|
||||
// onMount(() => {});
|
||||
|
||||
let timeout: NodeJS.Timeout;
|
||||
$: {
|
||||
fadeIndex = 0;
|
||||
streamButtonDisabled = true;
|
||||
if (movie) {
|
||||
showTrailer = false;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
if (trailer) {
|
||||
showTrailer = true;
|
||||
trailerStartTime = Date.now();
|
||||
}
|
||||
}, 2500);
|
||||
|
||||
fetchTmdbMovieVideos(String(movie.id)).then((result) => {
|
||||
videos = result;
|
||||
});
|
||||
|
||||
fetchTmdbMovieCredits(String(movie.id)).then((result) => {
|
||||
castMembers = result;
|
||||
});
|
||||
|
||||
getJellyfinItemByTmdbId(String(movie.id)).then((r) => {
|
||||
if (!r) return;
|
||||
streamButtonDisabled = !r;
|
||||
if (r.Id) jellyfinId = r.Id;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let localDetailsTop: HTMLElement;
|
||||
</script>
|
||||
|
||||
<div class="grid">
|
||||
<div
|
||||
class="min-h-max h-screen w-screen overflow-hidden row-start-1 col-start-1 relative"
|
||||
out:fade={{ duration }}
|
||||
in:fade={{ delay: duration, duration }}
|
||||
>
|
||||
{#key video?.key + movie.id}
|
||||
<div
|
||||
class="absolute inset-0 bg-center bg-cover transition-[background-image] duration-500 delay-500"
|
||||
style={"background-image: url('" + TMDB_IMAGES + movie.backdrop_path + "');"}
|
||||
transition:fade
|
||||
/>
|
||||
<div class="youtube-container absolute h-full scale-[150%] hidden sm:block" transition:fade>
|
||||
{#if video?.key}
|
||||
<iframe
|
||||
class={classNames('transition-opacity', {
|
||||
'opacity-100': showTrailer,
|
||||
'opacity-0': !showTrailer
|
||||
})}
|
||||
src={'https://www.youtube.com/embed/' +
|
||||
video.key +
|
||||
'?autoplay=1&mute=1&loop=1&controls=0&modestbranding=1&playsinline=1&rel=0&enablejsapi=1'}
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
{#key movie.id}
|
||||
<div
|
||||
class={classNames(
|
||||
'bg-gradient-to-b from-darken via-20% via-transparent transition-opacity absolute inset-0 z-[1]',
|
||||
{
|
||||
'opacity-100': focusTrailer,
|
||||
'opacity-0': !focusTrailer
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
class={classNames(
|
||||
'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]',
|
||||
'transition-colors',
|
||||
{
|
||||
'bg-darken': !focusTrailer,
|
||||
'bg-transparent': focusTrailer
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div class="flex flex-col justify-self-start min-w-0 row-span-full">
|
||||
<div class="relative" style={opacityStyle} in:fly={{ x: -20, duration, delay: 400 }}>
|
||||
<h2 class="text-zinc-300 text-sm self-end">
|
||||
<span class="font-bold uppercase tracking-wider"
|
||||
>{monthNames[releaseDate.getMonth()]}</span
|
||||
>
|
||||
{releaseDate.getFullYear()}
|
||||
</h2>
|
||||
<h2
|
||||
class="tracking-wider font-display font-extrabold text-amber-300 absolute opacity-10 text-8xl -ml-6 mt-8"
|
||||
>
|
||||
<slot name="reason">Popular Now</slot>
|
||||
</h2>
|
||||
<h1 class="uppercase text-8xl font-bold font-display z-[1] relative">
|
||||
{movie.original_title}
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
class="mt-auto max-w-3xl flex flex-col gap-4"
|
||||
style={opacityStyle}
|
||||
in:fly={{ x: -20, duration, delay: 600 }}
|
||||
>
|
||||
<div class="text-xl font-semibold tracking-wider">{movie.tagline}</div>
|
||||
<div
|
||||
class="tracking-wider text-zinc-200 font-light leading-6 pl-4 border-l-2 border-zinc-300"
|
||||
>
|
||||
{movie.overview}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-6 mt-10" in:fly={{ x: -20, duration, delay: 600 }}>
|
||||
<div class="flex gap-1">
|
||||
<div style={opacityStyle}>
|
||||
<Button
|
||||
disabled={streamButtonDisabled}
|
||||
size="lg"
|
||||
on:click={() => jellyfinId && streamJellyfinId(jellyfinId)}>Stream</Button
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="hidden items-center justify-center border-2 border-white w-10 cursor-pointer hover:bg-white hover:text-zinc-900 transition-colors"
|
||||
>
|
||||
<ChevronDown size={20} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={opacityStyle} class:hidden={showDetails}>
|
||||
<Button
|
||||
size="lg"
|
||||
type="secondary"
|
||||
on:click={() => {
|
||||
detailsVisible = true;
|
||||
localDetailsTop?.scrollIntoView({ behavior: 'smooth' });
|
||||
}}>Details</Button
|
||||
>
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
type="secondary"
|
||||
on:mouseover={() => (focusTrailer = trailer)}
|
||||
on:mouseleave={() => (focusTrailer = false)}
|
||||
on:click={openTrailer}>Watch Trailer</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-6 max-w-[14rem] row-span-full" style={opacityStyle}>
|
||||
<h3 class="text-xs tracking-wide uppercase" in:fade={getFade()}>Details</h3>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="tracking-widest font-extralight text-sm" in:fade={getFade()}>
|
||||
{movie.genres.map((g) => g.name.charAt(0).toUpperCase() + g.name.slice(1)).join(', ')}
|
||||
</div>
|
||||
<div class="flex gap-1.5 items-center" in:fade={getFade()}>
|
||||
<Clock size={14} />
|
||||
<div class="tracking-widest font-extralight text-sm">
|
||||
{Math.floor(movie.runtime / 60)}h {movie.runtime % 60}m
|
||||
</div>
|
||||
</div>
|
||||
<div class="tracking-widest font-extralight text-sm" in:fade={getFade()}>
|
||||
Currently <b>Streaming</b>
|
||||
</div>
|
||||
<a
|
||||
href={'https://www.themoviedb.org/movie/' + movie.id}
|
||||
target="_blank"
|
||||
class="tracking-widest font-extralight text-sm"
|
||||
in:fade={getFade()}
|
||||
>
|
||||
<b>{movie.vote_average.toFixed(1)}</b> TMDB
|
||||
</a>
|
||||
<div class="flex mt-4" in:fade={getFade()}>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="text-white w-4"
|
||||
><g
|
||||
><path d="M0 0h24v24H0z" fill="none" /><path
|
||||
d="M11.29 3.814l2.02 5.707.395 1.116.007-4.81.01-4.818h4.27L18 11.871c.003 5.98-.003 10.89-.015 10.9-.012.009-.209 0-.436-.027-.989-.118-2.29-.236-3.34-.282a14.57 14.57 0 0 1-.636-.038c-.003-.004-.273-.762-.776-2.184v-.004l-2.144-6.061-.34-.954-.008 4.586c-.006 4.365-.01 4.61-.057 4.61-.163 0-1.57.09-2.04.136-.308.027-.926.09-1.37.145-.446.051-.816.085-.823.078C6.006 22.77 6 17.867 6 11.883V1.002h.005V1h4.288l.028.08c.007.016.065.176.157.437l.641 1.778.173.496-.001.023z"
|
||||
fill-rule="evenodd"
|
||||
fill="currentColor"
|
||||
/></g
|
||||
></svg
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if castMembers?.length > 0}
|
||||
<h3 class="text-xs tracking-wide uppercase" in:fade={getFade()}>Starring</h3>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each castMembers.slice(0, 5) as a}
|
||||
<a
|
||||
href={'https://www.themoviedb.org/person/' + a.id}
|
||||
target="_blank"
|
||||
class="tracking-widest font-extralight text-sm"
|
||||
in:fade={getFade()}>{a.name}</a
|
||||
>
|
||||
{/each}
|
||||
<a
|
||||
href={'https://www.themoviedb.org/movie/' + movie.id + '/cast'}
|
||||
target="_blank"
|
||||
class="tracking-widest font-extralight text-sm"
|
||||
in:fade={getFade()}>View all...</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<slot name="page-controls" />
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeightHider duration={1000} visible={detailsVisible}>
|
||||
<div bind:this={localDetailsTop} />
|
||||
{#key movie.id}
|
||||
<LibraryDetails
|
||||
openJellyfinStream={() => jellyfinId && streamJellyfinId(jellyfinId)}
|
||||
jellyfinStreamDisabled={streamButtonDisabled}
|
||||
tmdbId={String(movie.id)}
|
||||
/>
|
||||
{/key}
|
||||
</HeightHider>
|
||||
|
||||
<style>
|
||||
.youtube-container {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.youtube-container iframe {
|
||||
width: 300%;
|
||||
height: 100%;
|
||||
margin-left: -100%;
|
||||
}
|
||||
</style>
|
||||
16
src/lib/components/SetupRequired/SetupRequired.svelte
Normal file
16
src/lib/components/SetupRequired/SetupRequired.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
export let missingEnvironmentVariables: Record<string, boolean>;
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex flex-col max-w-screen-2xl mx-auto p-4 md:p-8 lg:px-32 gap-2">
|
||||
<h1 class="font-bold text-3xl">Welcome to Reiverr</h1>
|
||||
<p>
|
||||
It seems like the application is missing some environment variables that are necessary for the
|
||||
application to function. Please provide the following environment variables:
|
||||
</p>
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each Object.keys(missingEnvironmentVariables).filter((k) => missingEnvironmentVariables[k]) as variableName}
|
||||
<code class="bg-highlight-dim p-0.5 px-2 rounded self-start">{variableName}</code>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
34
src/lib/components/SourceStats/RadarrStats.svelte
Normal file
34
src/lib/components/SourceStats/RadarrStats.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { formatSize, log } from '$lib/utils.js';
|
||||
import StatsPlaceholder from './StatsPlaceholder.svelte';
|
||||
import StatsContainer from './StatsContainer.svelte';
|
||||
import RadarrIcon from '../svgs/RadarrIcon.svelte';
|
||||
|
||||
export let large = false;
|
||||
|
||||
async function fetchStats() {
|
||||
return fetch('/radarr/stats')
|
||||
.then((res) => res.json())
|
||||
.then(log)
|
||||
.then((data) => ({
|
||||
moviesAmount: data?.movies?.length
|
||||
}));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await fetchStats()}
|
||||
<StatsPlaceholder {large} />
|
||||
{:then { moviesAmount }}
|
||||
<StatsContainer
|
||||
{large}
|
||||
title="Radarr"
|
||||
subtitle="Movies Provider"
|
||||
stats={[
|
||||
{ title: 'Movies', value: String(moviesAmount) },
|
||||
{ title: 'Space Taken', value: formatSize(120_000_000_000) },
|
||||
{ title: 'Space Left', value: formatSize(50_000_000_000) }
|
||||
]}
|
||||
>
|
||||
<RadarrIcon slot="icon" class="absolute opacity-20 p-4 h-full inset-y-0 right-2" />
|
||||
</StatsContainer>
|
||||
{/await}
|
||||
37
src/lib/components/SourceStats/SonarrStats.svelte
Normal file
37
src/lib/components/SourceStats/SonarrStats.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { formatSize } from '$lib/utils.js';
|
||||
import { onMount } from 'svelte';
|
||||
import StatsPlaceholder from './StatsPlaceholder.svelte';
|
||||
import StatsContainer from './StatsContainer.svelte';
|
||||
import SonarrIcon from '../svgs/SonarrIcon.svelte';
|
||||
|
||||
export let large = false;
|
||||
|
||||
let statsRequest: Promise<{ moviesAmount: number }> = new Promise((_) => {}) as any;
|
||||
|
||||
onMount(() => {
|
||||
statsRequest = fetch('/radarr/stats')
|
||||
.then((res) => res.json())
|
||||
.then((data) => ({
|
||||
moviesAmount: data?.movies?.length
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
{#await statsRequest}
|
||||
<StatsPlaceholder {large} />
|
||||
{:then { moviesAmount }}
|
||||
<StatsContainer
|
||||
{large}
|
||||
title="Sonarr"
|
||||
subtitle="Shows Provider"
|
||||
stats={[
|
||||
{ title: 'Movies', value: String(moviesAmount) },
|
||||
{ title: 'Space Taken', value: formatSize(120_000_000_000) },
|
||||
{ title: 'Space Left', value: formatSize(50_000_000_000) }
|
||||
]}
|
||||
color="#8aacfd21"
|
||||
>
|
||||
<SonarrIcon slot="icon" class="absolute opacity-20 p-4 h-full inset-y-0 right-2" />
|
||||
</StatsContainer>
|
||||
{/await}
|
||||
48
src/lib/components/SourceStats/StatsContainer.svelte
Normal file
48
src/lib/components/SourceStats/StatsContainer.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
import RadarrIcon from '../svgs/RadarrIcon.svelte';
|
||||
|
||||
type Stat = {
|
||||
title: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export let title: string;
|
||||
export let subtitle: string;
|
||||
export let stats: Stat[] = [];
|
||||
|
||||
export let color: string = '#fde68a20';
|
||||
export let large = false;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={classNames('relative w-full mx-auto px-6 rounded-xl overflow-hidden', {
|
||||
'h-16': !large,
|
||||
'h-28': large
|
||||
})}
|
||||
style={'background-color: ' + color + ';'}
|
||||
>
|
||||
<div class="absolute left-0 inset-y-0 w-[70%] bg-[#ffffff22]" />
|
||||
{#if large}
|
||||
<slot name="icon" />
|
||||
{/if}
|
||||
<div
|
||||
class={classNames('relative z-[1] flex flex-1 h-full', {
|
||||
'justify-between items-center': !large,
|
||||
'flex-col justify-center gap-2': large
|
||||
})}
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<h3 class="text-zinc-400 font-medium text-xs tracking-wider">{subtitle}</h3>
|
||||
<a href="/static" class="text-zinc-200 font-bold text-xl tracking-wide">{title}</a>
|
||||
</div>
|
||||
<div class="flex gap-8">
|
||||
{#each stats as { title, value }}
|
||||
<div class="flex flex-col items-center gap-0.5">
|
||||
<h3 class="uppercase text-zinc-400 font-medium text-xs tracking-wider">{title}</h3>
|
||||
<div class="font-medium text-sm">{value}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
12
src/lib/components/SourceStats/StatsPlaceholder.svelte
Normal file
12
src/lib/components/SourceStats/StatsPlaceholder.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
|
||||
export let large = false;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={classNames('placeholder w-full rounded-xl', {
|
||||
'h-16': !large,
|
||||
'h-28': large
|
||||
})}
|
||||
/>
|
||||
108
src/lib/components/VideoPlayer/VideoPlayer.svelte
Normal file
108
src/lib/components/VideoPlayer/VideoPlayer.svelte
Normal file
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
getJellyfinItem,
|
||||
getJellyfinPlaybackInfo,
|
||||
reportJellyfinPlaybackProgress,
|
||||
reportJellyfinPlaybackStarted,
|
||||
reportJellyfinPlaybackStopped
|
||||
} from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import Hls from 'hls.js';
|
||||
import Modal from '../Modal/Modal.svelte';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
import { Cross2 } from 'radix-icons-svelte';
|
||||
import classNames from 'classnames';
|
||||
import { getContext, onDestroy } from 'svelte';
|
||||
import { PUBLIC_JELLYFIN_URL } from '$env/static/public';
|
||||
import getDeviceProfile from '$lib/apis/jellyfin/playback-profiles';
|
||||
import type { PlayerState, PlayerStateValue } from './VideoPlayer';
|
||||
|
||||
const { playerState, close } = getContext<PlayerState>('player');
|
||||
|
||||
let video: HTMLVideoElement;
|
||||
|
||||
let stopCallback: () => void;
|
||||
|
||||
let progressInterval: ReturnType<typeof setInterval>;
|
||||
onDestroy(() => clearInterval(progressInterval));
|
||||
|
||||
const fetchPlaybackInfo = (itemId: string) =>
|
||||
getJellyfinPlaybackInfo(itemId, getDeviceProfile()).then(
|
||||
async ({ playbackUrl: uri, playSessionId: sessionId, mediaSourceId }) => {
|
||||
if (!uri || !sessionId) return;
|
||||
|
||||
const item = await getJellyfinItem(itemId);
|
||||
|
||||
const hls = new Hls();
|
||||
|
||||
hls.loadSource(PUBLIC_JELLYFIN_URL + uri);
|
||||
hls.attachMedia(video);
|
||||
video
|
||||
.play()
|
||||
.then(() => video.requestFullscreen())
|
||||
.then(() => {
|
||||
if (item?.UserData?.PlaybackPositionTicks) {
|
||||
video.currentTime = item?.UserData?.PlaybackPositionTicks / 10_000_000;
|
||||
}
|
||||
});
|
||||
if (mediaSourceId) await reportJellyfinPlaybackStarted(itemId, sessionId, mediaSourceId);
|
||||
progressInterval = setInterval(() => {
|
||||
reportJellyfinPlaybackProgress(
|
||||
itemId,
|
||||
sessionId,
|
||||
video?.paused == true,
|
||||
video?.currentTime * 10_000_000
|
||||
);
|
||||
}, 5000);
|
||||
stopCallback = () => {
|
||||
reportJellyfinPlaybackStopped(itemId, sessionId, video?.currentTime * 10_000_000);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
function handleClose() {
|
||||
close();
|
||||
video?.pause();
|
||||
clearInterval(progressInterval);
|
||||
stopCallback?.();
|
||||
playerState.set({ visible: false, jellyfinId: '' });
|
||||
}
|
||||
|
||||
let uiVisible = false;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
function handleMouseMove() {
|
||||
uiVisible = true;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
uiVisible = false;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
let state: PlayerStateValue;
|
||||
playerState.subscribe((s) => (state = s));
|
||||
|
||||
$: {
|
||||
if (video && state.jellyfinId) {
|
||||
if (!Hls.isSupported()) {
|
||||
throw new Error('HLS is not supported');
|
||||
}
|
||||
|
||||
if (video.src === '') fetchPlaybackInfo(state.jellyfinId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal visible={$playerState.visible} close={handleClose}>
|
||||
<div class="bg-black w-screen h-screen relative" on:mousemove={handleMouseMove}>
|
||||
<video controls bind:this={video} class="w-full h-full inset-0" />
|
||||
<div
|
||||
class={classNames('absolute top-4 right-8 transition-opacity', {
|
||||
'opacity-0': !uiVisible,
|
||||
'opacity-100': uiVisible
|
||||
})}
|
||||
>
|
||||
<IconButton on:click={handleClose}>
|
||||
<Cross2 />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
17
src/lib/components/VideoPlayer/VideoPlayer.ts
Normal file
17
src/lib/components/VideoPlayer/VideoPlayer.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
const initialValue = { visible: false, jellyfinId: '' };
|
||||
export const playerState = writable(initialValue);
|
||||
|
||||
export const initialPlayerState = {
|
||||
playerState,
|
||||
close: () => {
|
||||
playerState.set({ visible: false, jellyfinId: '' });
|
||||
},
|
||||
streamJellyfinId: (id: string) => {
|
||||
playerState.set({ visible: true, jellyfinId: id });
|
||||
}
|
||||
};
|
||||
|
||||
export type PlayerState = typeof initialPlayerState;
|
||||
export type PlayerStateValue = typeof initialValue;
|
||||
9
src/lib/components/svgs/RadarrIcon.svelte
Normal file
9
src/lib/components/svgs/RadarrIcon.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg
|
||||
viewBox="0 0 1000 1115.2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={$$restProps.class || 'h-10 w-10 flex-shrink-0'}
|
||||
><path
|
||||
d="m120.015 174.916-1.433 810.916C50.198 993.53-.595 958.758.245 890.481L0 216.126C2.624 2.76 199.555-46.036 317.994 40.776l601.67 357.383c84.615 60.794 100.32 171.956 56.7 248.25-7.799-59.85-32.984-94.304-83.773-129.073l-678.066-392.46c-50.79-34.768-93.568-26.758-94.513 50.056zm-61.707 852.847c51 17.7 102.314 9.794 145.3-15.285L908.5 611.405c41.94 60.268 32.671 119.903-18.958 153.414L296.44 1098.972c-85.873 41.624-196.297-2.414-238.138-71.217z"
|
||||
fill="#fff"
|
||||
/><path d="m272.941 797.285 414.225-245.888L273.7 327.762z" fill="#ffc230" /></svg
|
||||
>
|
||||
|
After Width: | Height: | Size: 684 B |
34
src/lib/components/svgs/SonarrIcon.svelte
Normal file
34
src/lib/components/svgs/SonarrIcon.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<svg
|
||||
viewBox="0 0 216.7 216.9"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={$$restProps.class || 'h-10 w-10 flex-shrink-0'}
|
||||
><path
|
||||
d="M216.7 108.45c0 29.833-10.533 55.4-31.6 76.7-.7.833-1.483 1.6-2.35 2.3a92.767 92.767 0 0 1-11 9.25c-18.267 13.467-39.367 20.2-63.3 20.2-23.967 0-45.033-6.733-63.2-20.2-4.8-3.4-9.3-7.25-13.5-11.55-16.367-16.266-26.417-35.167-30.15-56.7-.733-4.2-1.217-8.467-1.45-12.8-.1-2.4-.15-4.8-.15-7.2 0-2.533.05-4.95.15-7.25 0-.233.066-.467.2-.7 1.567-26.6 12.033-49.583 31.4-68.95C53.05 10.517 78.617 0 108.45 0c29.933 0 55.484 10.517 76.65 31.55 21.067 21.433 31.6 47.067 31.6 76.9z"
|
||||
clip-rule="evenodd"
|
||||
fill="#EEE"
|
||||
fill-rule="evenodd"
|
||||
/><path
|
||||
d="m194.65 42.5-22.4 22.4C159.152 77.998 158 89.4 158 109.5c0 17.934 2.852 34.352 16.2 47.7 9.746 9.746 19 18.95 19 18.95-2.5 3.067-5.2 6.067-8.1 9-.7.833-1.483 1.6-2.35 2.3a90.601 90.601 0 0 1-7.9 6.95l-17.55-17.55c-15.598-15.6-27.996-17.1-48.6-17.1-19.77 0-33.223 1.822-47.7 16.3-8.647 8.647-18.55 18.6-18.55 18.6a95.782 95.782 0 0 1-10.7-9.5c-2.8-2.8-5.417-5.667-7.85-8.6 0 0 9.798-9.848 19.15-19.2 13.852-13.853 16.1-29.916 16.1-47.85 0-17.5-2.874-33.823-15.6-46.55-8.835-8.836-21.05-21-21.05-21 2.833-3.6 5.917-7.067 9.25-10.4a134.482 134.482 0 0 1 9-8.05L61.1 43.85C74.102 56.852 90.767 60.2 108.7 60.2c18.467 0 35.077-3.577 48.6-17.1 8.32-8.32 19.3-19.25 19.3-19.25 2.9 2.367 5.733 4.933 8.5 7.7a121.188 121.188 0 0 1 9.55 10.95z"
|
||||
clip-rule="evenodd"
|
||||
fill="#3A3F51"
|
||||
fill-rule="evenodd"
|
||||
/><g clip-rule="evenodd"
|
||||
><path
|
||||
d="M78.7 114c-.2-1.167-.332-2.35-.4-3.55a39.613 39.613 0 0 1 0-4c0-.067.018-.133.05-.2.435-7.367 3.334-13.733 8.7-19.1 5.9-5.833 12.984-8.75 21.25-8.75 8.3 0 15.384 2.917 21.25 8.75 5.834 5.934 8.75 13.033 8.75 21.3 0 8.267-2.916 15.35-8.75 21.25-.2.233-.416.45-.65.65a27.364 27.364 0 0 1-3.05 2.55c-5.065 3.733-10.916 5.6-17.55 5.6s-12.466-1.866-17.5-5.6a26.29 26.29 0 0 1-3.75-3.2c-4.532-4.5-7.316-9.734-8.35-15.7z"
|
||||
fill="#0CF"
|
||||
fill-rule="evenodd"
|
||||
/><path
|
||||
d="m157.8 59.75-15 14.65M30.785 32.526 71.65 73.25m84.6 84.25 27.808 28.78m1.855-153.894L157.8 59.75m-125.45 126 27.35-27.4"
|
||||
fill="none"
|
||||
stroke="#0CF"
|
||||
stroke-miterlimit="1"
|
||||
stroke-width="2"
|
||||
/><path
|
||||
d="m157.8 59.75-16.95 17.2M58.97 60.604l17.2 17.15M59.623 158.43l16.75-17.4m61.928-1.396 18.028 17.945"
|
||||
fill="none"
|
||||
stroke="#0CF"
|
||||
stroke-miterlimit="1"
|
||||
stroke-width="7"
|
||||
/></g
|
||||
></svg
|
||||
>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
3
src/lib/components/utils/WidthLimited.svelte
Normal file
3
src/lib/components/utils/WidthLimited.svelte
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class={$$restProps.class + ' mx-auto max-w-screen-2xl'}>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -1,16 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-3 -3 30 30">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5229 6.47715 22 12 22C17.5229 22 22 17.5229 22 12C22 6.47715 17.5229 2 12 2ZM0 12C0 5.3726 5.3726 0 12 0C18.6274 0 24 5.3726 24 12C24 18.6274 18.6274 24 12 24C5.3726 24 0 18.6274 0 12Z"
|
||||
fill="rgba(0,0,0,0.7)"
|
||||
stroke="none"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M9.59162 22.7357C9.49492 22.6109 9.49492 21.4986 9.59162 19.399C8.55572 19.4347 7.90122 19.3628 7.62812 19.1833C7.21852 18.9139 6.80842 18.0833 6.44457 17.4979C6.08072 16.9125 5.27312 16.8199 4.94702 16.6891C4.62091 16.5582 4.53905 16.0247 5.84562 16.4282C7.15222 16.8316 7.21592 17.9303 7.62812 18.1872C8.04032 18.4441 9.02572 18.3317 9.47242 18.1259C9.91907 17.9201 9.88622 17.1538 9.96587 16.8503C10.0666 16.5669 9.71162 16.5041 9.70382 16.5018C9.26777 16.5018 6.97697 16.0036 6.34772 13.7852C5.71852 11.5669 6.52907 10.117 6.96147 9.49369C7.24972 9.07814 7.22422 8.19254 6.88497 6.83679C8.11677 6.67939 9.06732 7.06709 9.73672 7.99999C9.73737 8.00534 10.6143 7.47854 12.0001 7.47854C13.386 7.47854 13.8777 7.90764 14.2571 7.99999C14.6365 8.09234 14.94 6.36699 17.2834 6.83679C16.7942 7.79839 16.3844 8.99999 16.6972 9.49369C17.0099 9.98739 18.2372 11.5573 17.4833 13.7852C16.9807 15.2706 15.9927 16.1761 14.5192 16.5018C14.3502 16.5557 14.2658 16.6427 14.2658 16.7627C14.2658 16.9427 14.4942 16.9624 14.8233 17.8058C15.0426 18.368 15.0585 19.9739 14.8708 22.6234C14.3953 22.7445 14.0254 22.8257 13.7611 22.8673C13.2924 22.9409 12.7835 22.9822 12.2834 22.9982C11.7834 23.0141 11.6098 23.0123 10.9185 22.948C10.4577 22.9051 10.0154 22.8343 9.59162 22.7357Z"
|
||||
fill="rgba(0,0,0,0.7)"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.1566,22.8189c-10.4-14.8851-30.94-19.2971-45.7914-9.8348L22.2825,29.6078A29.9234,29.9234,0,0,0,8.7639,49.6506a31.5136,31.5136,0,0,0,3.1076,20.2318A30.0061,30.0061,0,0,0,7.3953,81.0653a31.8886,31.8886,0,0,0,5.4473,24.1157c10.4022,14.8865,30.9423,19.2966,45.7914,9.8348L84.7167,98.3921A29.9177,29.9177,0,0,0,98.2353,78.3493,31.5263,31.5263,0,0,0,95.13,58.117a30,30,0,0,0,4.4743-11.1824,31.88,31.88,0,0,0-5.4473-24.1157" style="fill:#ff3e00"/><path d="M45.8171,106.5815A20.7182,20.7182,0,0,1,23.58,98.3389a19.1739,19.1739,0,0,1-3.2766-14.5025,18.1886,18.1886,0,0,1,.6233-2.4357l.4912-1.4978,1.3363.9815a33.6443,33.6443,0,0,0,10.203,5.0978l.9694.2941-.0893.9675a5.8474,5.8474,0,0,0,1.052,3.8781,6.2389,6.2389,0,0,0,6.6952,2.485,5.7449,5.7449,0,0,0,1.6021-.7041L69.27,76.281a5.4306,5.4306,0,0,0,2.4506-3.631,5.7948,5.7948,0,0,0-.9875-4.3712,6.2436,6.2436,0,0,0-6.6978-2.4864,5.7427,5.7427,0,0,0-1.6.7036l-9.9532,6.3449a19.0329,19.0329,0,0,1-5.2965,2.3259,20.7181,20.7181,0,0,1-22.2368-8.2427,19.1725,19.1725,0,0,1-3.2766-14.5024,17.9885,17.9885,0,0,1,8.13-12.0513L55.8833,23.7472a19.0038,19.0038,0,0,1,5.3-2.3287A20.7182,20.7182,0,0,1,83.42,29.6611a19.1739,19.1739,0,0,1,3.2766,14.5025,18.4,18.4,0,0,1-.6233,2.4357l-.4912,1.4978-1.3356-.98a33.6175,33.6175,0,0,0-10.2037-5.1l-.9694-.2942.0893-.9675a5.8588,5.8588,0,0,0-1.052-3.878,6.2389,6.2389,0,0,0-6.6952-2.485,5.7449,5.7449,0,0,0-1.6021.7041L37.73,51.719a5.4218,5.4218,0,0,0-2.4487,3.63,5.7862,5.7862,0,0,0,.9856,4.3717,6.2437,6.2437,0,0,0,6.6978,2.4864,5.7652,5.7652,0,0,0,1.602-.7041l9.9519-6.3425a18.978,18.978,0,0,1,5.2959-2.3278,20.7181,20.7181,0,0,1,22.2368,8.2427,19.1725,19.1725,0,0,1,3.2766,14.5024,17.9977,17.9977,0,0,1-8.13,12.0532L51.1167,104.2528a19.0038,19.0038,0,0,1-5.3,2.3287" style="fill:#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 352 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 113 KiB |
@@ -1,9 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
async function fetchJellyfinState() {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => resolve('true'), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
export const jellyfinState = writable(fetchJellyfinState());
|
||||
@@ -1,10 +0,0 @@
|
||||
import createClient from 'openapi-fetch';
|
||||
import type { paths } from '$lib/sonarr/sonarr-types';
|
||||
import { SONARR_API_KEY, SONARR_BASE_URL } from '$env/static/private';
|
||||
|
||||
export const SonarrApi = createClient<paths>({
|
||||
baseUrl: SONARR_BASE_URL,
|
||||
headers: {
|
||||
'X-Api-Key': SONARR_API_KEY
|
||||
}
|
||||
});
|
||||
68
src/lib/stores/libraryStore.ts
Normal file
68
src/lib/stores/libraryStore.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
getRadarrDownloads,
|
||||
getRadarrMovies,
|
||||
RadarrApi,
|
||||
type RadarrMovie
|
||||
} from '$lib/apis/radarr/radarrApi';
|
||||
import type { CardProps } from '$lib/components/Card/card';
|
||||
import { writable } from 'svelte/store';
|
||||
import { fetchCardProps } from '$lib/components/Card/card';
|
||||
import { fetchTmdbMovieImages } from '$lib/apis/tmdbApi';
|
||||
|
||||
interface PlayableRadarrMovie extends RadarrMovie {
|
||||
cardBackdropUrl: string;
|
||||
download?: {
|
||||
progress: number;
|
||||
completionTime: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Library {
|
||||
movies: PlayableRadarrMovie[];
|
||||
totalMovies: number;
|
||||
}
|
||||
|
||||
interface DownloadingCardProps extends CardProps {
|
||||
progress: number;
|
||||
completionTime: string;
|
||||
}
|
||||
|
||||
async function getLibrary(): Promise<Library> {
|
||||
const radarrMoviesPromise = getRadarrMovies();
|
||||
const radarrDownloadsPromise = getRadarrDownloads();
|
||||
|
||||
const movies: PlayableRadarrMovie[] = await radarrMoviesPromise.then(async (radarrMovies) => {
|
||||
const radarrDownloads = await radarrDownloadsPromise;
|
||||
|
||||
const playableMoviePromises = radarrMovies.map(async (m) => {
|
||||
const radarrDownload = radarrDownloads.find((d) => d.movie.tmdbId === m.tmdbId);
|
||||
|
||||
const progress = radarrDownload
|
||||
? radarrDownload.sizeleft && radarrDownload.size
|
||||
? ((radarrDownload.size - radarrDownload.sizeleft) / radarrDownload.size) * 100
|
||||
: 0
|
||||
: undefined;
|
||||
const completionTime = radarrDownload ? radarrDownload.estimatedCompletionTime : undefined;
|
||||
const download = progress && completionTime ? { progress, completionTime } : undefined;
|
||||
|
||||
const backdropUrl = await fetchTmdbMovieImages(String(m.tmdbId)).then(
|
||||
(r) => r.backdrops.filter((b) => b.iso_639_1 === 'en')[0].file_path
|
||||
);
|
||||
|
||||
return {
|
||||
...m,
|
||||
cardBackdropUrl: backdropUrl,
|
||||
download
|
||||
};
|
||||
});
|
||||
|
||||
return await Promise.all(playableMoviePromises);
|
||||
});
|
||||
|
||||
return {
|
||||
movies,
|
||||
totalMovies: movies?.length || 0
|
||||
};
|
||||
}
|
||||
|
||||
export const library = writable<Promise<Library>>(getLibrary());
|
||||
@@ -1,3 +1,3 @@
|
||||
import type { components as sonarrComponents } from '$lib/sonarr/sonarr-types';
|
||||
import type { components as sonarrComponents } from '$lib/apis/sonarr/sonarr-types';
|
||||
|
||||
export type SeriesResource = sonarrComponents['schemas']['SeriesResource'];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Genre } from '$lib/tmdb-api';
|
||||
import type { Genre } from '$lib/apis/tmdbApi';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export function formatMinutesToTime(minutes: number) {
|
||||
|
||||
Reference in New Issue
Block a user