Project Refactoring

This commit is contained in:
Aleksi Lassila
2023-07-09 15:50:04 +03:00
parent 56ef4ee865
commit 494a3bf85a
83 changed files with 319 additions and 276 deletions

View File

@@ -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';

View File

@@ -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', {

View 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
}
});

View 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>

View 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>

View 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;'}
/>

View 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}

View 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>

View 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
};
};

View 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>

View File

@@ -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}

View 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>

View 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>

View 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}

View 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>

View 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>

View 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} />

View 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>

View 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>

View 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>

View 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}

View 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>

View 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>

View 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}

View 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}

View 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>

View 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
})}
/>

View 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>

View 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;

View 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

View 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

View File

@@ -0,0 +1,3 @@
<div class={$$restProps.class + ' mx-auto max-w-screen-2xl'}>
<slot />
</div>

View File

@@ -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

View File

@@ -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

View File

@@ -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());

View File

@@ -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
}
});

View 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());

View File

@@ -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'];

View File

@@ -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) {