Merge branch 'dev'

This commit is contained in:
Aleksi Lassila
2023-09-01 17:36:20 +03:00
49 changed files with 3835 additions and 1222 deletions

3
.gitignore vendored
View File

@@ -10,4 +10,5 @@ node_modules
.output
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/config/*.sqlite
/config/*.sqlite
/dist

22
build.config.json Normal file
View File

@@ -0,0 +1,22 @@
{
"appId": "me.aleksilassila.reiverr",
"productName": "Reiverr",
"directories": {
"output": "dist"
},
"mac": {
"asar": false
},
"win": {
"asar": false,
"target": "msi"
},
"asar": false,
"files": [
"src/electron.cjs",
{
"from": "build",
"to": "build"
}
]
}

1994
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,17 @@
{
"name": "reiverr",
"version": "0.6.1",
"version": "0.7.0",
"repository": {
"type": "git",
"url": "https://github.com/aleksilassila/reiverr"
},
"main": "src/electron.cjs",
"scripts": {
"dev": "vite dev --host",
"build": "vite build",
"preview": "vite preview",
"deploy": "PORT=9494 NODE_ENV=production node build/",
"deploy:electron": "vite build && electron-builder -mw --x64 --config build.config.json; electron-builder -m --arm64 --config build.config.json",
"test": "playwright test",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@@ -31,6 +33,8 @@
"@typescript-eslint/parser": "^5.45.0",
"autoprefixer": "^10.4.14",
"classnames": "^2.3.2",
"electron": "^26.1.0",
"electron-builder": "^24.6.3",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.26.0",

View File

@@ -14,6 +14,10 @@ a {
@apply bg-zinc-700 bg-opacity-75;
}
.placeholder-text {
@apply bg-zinc-700 bg-opacity-40 animate-pulse text-transparent rounded-lg select-none;
}
.selectable {
@apply focus-visible:outline outline-2 outline-[#f0cd6dc2] outline-offset-2;
}

51
src/electron.cjs Normal file
View File

@@ -0,0 +1,51 @@
process.env.PORT = '9494';
const { app, BrowserWindow } = require('electron');
(async () => {
await import('../build/index.js');
// const serveURL = serve({ directory: '.' });
const port = process.env.PORT || 5173;
let mainWindow;
const createWindow = () => {
if (!mainWindow) {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: true
}
});
}
mainWindow.once('close', () => {
mainWindow = null;
});
loadSite(port);
return mainWindow;
};
function loadSite(port) {
mainWindow.loadURL(`http://localhost:${port}`).catch((e) => {
console.log('Error loading URL, retrying', e);
setTimeout(() => {
loadSite(port);
}, 500);
});
}
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (!mainWindow) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
})();

View File

@@ -33,7 +33,7 @@ export const getJellyfinContinueWatching = async (): Promise<JellyfinItem[] | un
},
query: {
mediaTypes: ['Video'],
fields: ['ProviderIds']
fields: ['ProviderIds', 'Genres']
}
}
})
@@ -45,7 +45,7 @@ export const getJellyfinNextUp = async () =>
params: {
query: {
userId: get(settings)?.jellyfin.userId || '',
fields: ['ProviderIds']
fields: ['ProviderIds', 'Genres']
}
}
})
@@ -62,11 +62,11 @@ export const getJellyfinItems = async () =>
hasTmdbId: true,
recursive: true,
includeItemTypes: ['Movie', 'Series'],
fields: ['ProviderIds']
fields: ['ProviderIds', 'Genres', 'DateLastMediaAdded', 'DateCreated']
}
}
})
.then((r) => r.data?.Items || []);
.then((r) => r.data?.Items || []) || Promise.resolve([]);
// export const getJellyfinSeries = () =>
// JellyfinApi.get('/Users/{userId}/Items', {
@@ -82,7 +82,7 @@ export const getJellyfinItems = async () =>
// }
// }).then((r) => r.data?.Items || []);
export const getJellyfinEpisodes = async () =>
export const getJellyfinEpisodes = async (parentId = '') =>
getJellyfinApi()
?.get('/Users/{userId}/Items', {
params: {
@@ -91,7 +91,8 @@ export const getJellyfinEpisodes = async () =>
},
query: {
recursive: true,
includeItemTypes: ['Episode']
includeItemTypes: ['Episode'],
parentId
}
},
headers: {
@@ -100,6 +101,23 @@ export const getJellyfinEpisodes = async () =>
})
.then((r) => r.data?.Items || []);
export const getJellyfinEpisodesInSeasons = async (seriesId: string) =>
getJellyfinEpisodes(seriesId).then((items) => {
const seasons: Record<string, JellyfinItem[]> = {};
items?.forEach((item) => {
const seasonNumber = item.ParentIndexNumber || 0;
if (!seasons[seasonNumber]) {
seasons[seasonNumber] = [];
}
seasons[seasonNumber].push(item);
});
return seasons;
});
// export const getJellyfinEpisodesBySeries = (seriesId: string) =>
// getJellyfinEpisodes().then((items) => items?.filter((i) => i.SeriesId === seriesId) || []);
@@ -266,3 +284,22 @@ export const getJellyfinUsers = async (
})
.then((res) => res.data || [])
.catch(() => []);
export const getJellyfinPosterUrl = (item: JellyfinItem, quality = 100, original = false) =>
item.ImageTags?.Primary
? `${get(settings).jellyfin.baseUrl}/Items/${item?.Id}/Images/Primary?quality=${quality}${
original ? '' : '&fillWidth=432'
}&tag=${item?.ImageTags?.Primary}`
: '';
export const getJellyfinBackdrop = (item: JellyfinItem, quality = 100) => {
if (item.BackdropImageTags?.length) {
return `${get(settings).jellyfin.baseUrl}/Items/${
item?.Id
}/Images/Backdrop?quality=${quality}&tag=${item?.BackdropImageTags?.[0]}`;
} else {
return `${get(settings).jellyfin.baseUrl}/Items/${
item?.Id
}/Images/Primary?quality=${quality}&tag=${item?.ImageTags?.Primary}`;
}
};

View File

@@ -222,3 +222,12 @@ export const getRadarrQualityProfiles = async (
}
)
.then((res) => res.data || []);
export function getRadarrPosterUrl(item: RadarrMovie, original = false) {
const url =
get(settings).radarr.baseUrl + (item.images?.find((i) => i.coverType === 'poster')?.url || '');
if (!original) return url.replace('poster.jpg', `poster-${500}.jpg`);
return url;
}

View File

@@ -306,3 +306,12 @@ export const getSonarrLanguageProfiles = async (
}
)
.then((res) => res.data || []);
export function getSonarrPosterUrl(item: SonarrSeries, original = false) {
const url =
get(settings).sonarr.baseUrl + (item.images?.find((i) => i.coverType === 'poster')?.url || '');
if (!original) return url.replace('poster.jpg', `poster-${500}.jpg`);
return url;
}

View File

@@ -1,7 +1,6 @@
import { browser } from '$app/environment';
import { TMDB_API_KEY } from '$lib/constants';
import { settings } from '$lib/stores/settings.store';
import { formatDateToYearMonthDay } from '$lib/utils';
import createClient from 'openapi-fetch';
import { get } from 'svelte/store';
import type { operations, paths } from './tmdb.generated';
@@ -75,11 +74,11 @@ export const getTmdbMovie = async (tmdbId: number) =>
}
}).then((res) => res.data as TmdbMovieFull2 | undefined);
export const getTmdbSeriesFromTvdbId = async (tvdbId: number) =>
export const getTmdbSeriesFromTvdbId = async (tvdbId: string) =>
TmdbApiOpen.get('/3/find/{external_id}', {
params: {
path: {
external_id: String(tvdbId)
external_id: tvdbId
},
query: {
external_source: 'tvdb_id'
@@ -91,7 +90,11 @@ export const getTmdbSeriesFromTvdbId = async (tvdbId: number) =>
}).then((res) => res.data?.tv_results?.[0] as TmdbSeries2 | undefined);
export const getTmdbIdFromTvdbId = async (tvdbId: number) =>
getTmdbSeriesFromTvdbId(tvdbId).then((res: any) => res?.id as number | undefined);
getTmdbSeriesFromTvdbId(String(tvdbId)).then((res: any) => {
const id = res?.id as number | undefined;
if (!id) return Promise.reject();
return id;
});
export const getTmdbSeries = async (tmdbId: number): Promise<TmdbSeriesFull2 | undefined> =>
await TmdbApiOpen.get('/3/tv/{series_id}', {
@@ -280,6 +283,16 @@ export const searchTmdbTitles = (query: string) =>
}
}).then((res) => res.data?.results || []);
export const getTmdbItemBackdrop = (item: {
images: { backdrops: { file_path: string; iso_639_1: string }[] };
}) =>
(
item?.images?.backdrops?.find((b) => b.iso_639_1 === get(settings)?.language) ||
item?.images?.backdrops?.find((b) => b.iso_639_1 === 'en') ||
item?.images?.backdrops?.find((b) => b.iso_639_1) ||
item?.images?.backdrops?.[0]
)?.file_path;
export const TMDB_MOVIE_GENRES = [
{
id: 28,

View File

@@ -1,13 +1,16 @@
<script lang="ts">
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
import { createLibraryItemStore } from '$lib/stores/library.store';
import {
createJellyfinItemStore,
createRadarrMovieStore,
createSonarrSeriesStore
} from '$lib/stores/data.store';
import type { TitleType } from '$lib/types';
import { formatMinutesToTime } from '$lib/utils';
import classNames from 'classnames';
import { Clock, Star } from 'radix-icons-svelte';
import { openTitleModal } from '../../stores/modal.store';
import ContextMenu from '../ContextMenu/ContextMenu.svelte';
import LibraryItemContextItems from '../ContextMenu/LibraryItemContextItems.svelte';
import { openTitleModal } from '../../stores/modal.store';
import ProgressBar from '../ProgressBar.svelte';
export let tmdbId: number;
@@ -25,12 +28,20 @@
export let size: 'dynamic' | 'md' | 'lg' = 'md';
export let openInModal = true;
let itemStore = createLibraryItemStore(tmdbId);
let jellyfinItemStore = createJellyfinItemStore(tmdbId);
let radarrMovieStore = createRadarrMovieStore(tmdbId);
let sonarrSeriesStore = createSonarrSeriesStore(title);
</script>
<ContextMenu heading={title}>
<svelte:fragment slot="menu">
<LibraryItemContextItems {itemStore} {type} {tmdbId} />
<LibraryItemContextItems
jellyfinItem={$jellyfinItemStore.item}
radarrMovie={$radarrMovieStore.item}
sonarrSeries={$sonarrSeriesStore.item}
{type}
{tmdbId}
/>
</svelte:fragment>
<button
class={classNames(
@@ -44,7 +55,7 @@
)}
on:click={() => {
if (openInModal) {
openTitleModal(tmdbId, type);
openTitleModal({ type, id: tmdbId, provider: 'tmdb' });
} else {
window.location.href = `/${type}/${tmdbId}`;
}

View File

@@ -4,12 +4,17 @@
export let index = 0;
export let size: 'dynamic' | 'md' | 'lg' = 'md';
export let orientation: 'portrait' | 'landscape' = 'landscape';
</script>
<div
class={classNames('rounded overflow-hidden shadow-lg placeholder shrink-0 aspect-video', {
'h-40': size === 'md',
'h-60': size === 'lg',
class={classNames('rounded-xl overflow-hidden shadow-lg placeholder shrink-0', {
'aspect-video': orientation === 'landscape',
'aspect-[2/3]': orientation === 'portrait',
'w-44': size === 'md' && orientation === 'portrait',
'h-44': size === 'md' && orientation === 'landscape',
'w-60': size === 'lg' && orientation === 'portrait',
'h-60': size === 'lg' && orientation === 'landscape',
'w-full': size === 'dynamic'
})}
style={'animation-delay: ' + ((index * 100) % 2000) + 'ms;'}

View File

@@ -6,7 +6,6 @@
export let disabled = false;
export let position: 'absolute' | 'fixed' = 'fixed';
let anchored = position === 'absolute';
export let bottom = false;
export let id = Symbol();
@@ -21,7 +20,10 @@
}
export function handleOpen(event: MouseEvent) {
if (disabled || (anchored && $contextMenu === id)) return; // Clicking button will close menu
if (disabled || (anchored && $contextMenu === id)) {
close();
return;
}
fixedPosition = { x: event.clientX, y: event.clientY };
contextMenu.show(id);
@@ -63,7 +65,15 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:contextmenu|preventDefault={handleOpen} on:click={(e) => anchored && e.stopPropagation()}>
<div
on:contextmenu|preventDefault={handleOpen}
on:click={(e) => {
if (anchored) {
e.stopPropagation();
handleOpen(e);
}
}}
>
<slot />
</div>
@@ -75,12 +85,11 @@
? `left: ${
fixedPosition.x - (fixedPosition.x > windowWidth / 2 ? menu?.clientWidth : 0)
}px; top: ${
fixedPosition.y -
(bottom ? (fixedPosition.y > windowHeight / 2 ? menu?.clientHeight : 0) : 0)
fixedPosition.y - (fixedPosition.y > windowHeight / 2 ? menu?.clientHeight : 0)
}px;`
: menu?.getBoundingClientRect()?.left > windowWidth / 2
? `right: 0;${bottom ? 'bottom: 40px;' : ''}`
: `left: 0;${bottom ? 'bottom: 40px;' : ''}`}
? `right: 0;${fixedPosition.y > windowHeight / 2 ? 'bottom: 100%;' : ''}`
: `left: 0;${fixedPosition.y > windowHeight / 2 ? 'bottom: 100%;' : ''}`}
bind:this={menu}
in:fly|global={{ y: 5, duration: 100, delay: anchored ? 0 : 100 }}
out:fly|global={{ y: 5, duration: 100 }}

View File

@@ -1,25 +1,12 @@
<script lang="ts">
import ContextMenu from './ContextMenu.svelte';
import { contextMenu } from '../ContextMenu/ContextMenu';
import Button from '../Button.svelte';
import { DotsVertical } from 'radix-icons-svelte';
export let heading = '';
export let contextMenuId = Symbol();
function handleToggleVisibility() {
if ($contextMenu === contextMenuId) contextMenu.hide();
else contextMenu.show(contextMenuId);
}
</script>
<div class="relative">
<ContextMenu position="absolute" {heading} id={contextMenuId}>
<ContextMenu position="absolute" {heading}>
<slot name="menu" slot="menu" />
<Button slim on:click={handleToggleVisibility}>
<DotsVertical size={24} />
</Button>
<slot />
</ContextMenu>
</div>

View File

@@ -1,81 +1,70 @@
<script lang="ts">
import { setJellyfinItemUnwatched, setJellyfinItemWatched } from '$lib/apis/jellyfin/jellyfinApi';
import { library, type LibraryItemStore } from '$lib/stores/library.store';
import {
setJellyfinItemUnwatched,
setJellyfinItemWatched,
type JellyfinItem
} from '$lib/apis/jellyfin/jellyfinApi';
import type { RadarrMovie } from '$lib/apis/radarr/radarrApi';
import type { SonarrSeries } from '$lib/apis/sonarr/sonarrApi';
import { jellyfinItemsStore } from '$lib/stores/data.store';
import { settings } from '$lib/stores/settings.store';
import type { TitleType } from '$lib/types';
import ContextMenuDivider from './ContextMenuDivider.svelte';
import ContextMenuItem from './ContextMenuItem.svelte';
export let itemStore: LibraryItemStore;
export let jellyfinItem: JellyfinItem | undefined = undefined;
export let sonarrSeries: SonarrSeries | undefined = undefined;
export let radarrMovie: RadarrMovie | undefined = undefined;
export let type: TitleType;
export let tmdbId: number;
let watched = false;
itemStore.subscribe((i) => {
if (i.item?.jellyfinItem) {
watched =
i.item.jellyfinItem.UserData?.Played !== undefined
? i.item.jellyfinItem.UserData?.Played
: watched;
}
});
$: watched = jellyfinItem?.UserData?.Played !== undefined ? jellyfinItem.UserData?.Played : false;
function handleSetWatched() {
if ($itemStore.item?.jellyfinId) {
if (jellyfinItem?.Id) {
watched = true;
setJellyfinItemWatched($itemStore.item?.jellyfinId).finally(() => library.refreshIn(3000));
setJellyfinItemWatched(jellyfinItem.Id).finally(() => jellyfinItemsStore.refreshIn(3000));
}
}
function handleSetUnwatched() {
if ($itemStore.item?.jellyfinId) {
if (jellyfinItem?.Id) {
watched = false;
setJellyfinItemUnwatched($itemStore.item?.jellyfinId).finally(() => library.refreshIn(3000));
setJellyfinItemUnwatched(jellyfinItem.Id).finally(() => jellyfinItemsStore.refreshIn(3000));
}
}
function handleOpenInJellyfin() {
window.open(
$settings.jellyfin.baseUrl +
'/web/index.html#!/details?id=' +
$itemStore.item?.jellyfinItem?.Id
);
window.open($settings.jellyfin.baseUrl + '/web/index.html#!/details?id=' + jellyfinItem?.Id);
}
</script>
{#if $itemStore.item}
<ContextMenuItem on:click={handleSetWatched} disabled={!$itemStore.item?.jellyfinId || watched}>
Mark as watched
</ContextMenuItem>
<ContextMenuItem on:click={handleSetWatched} disabled={!jellyfinItem?.Id || watched}>
Mark as watched
</ContextMenuItem>
<ContextMenuItem on:click={handleSetUnwatched} disabled={!jellyfinItem?.Id || !watched}>
Mark as unwatched
</ContextMenuItem>
<ContextMenuDivider />
<ContextMenuItem disabled={!jellyfinItem?.Id} on:click={handleOpenInJellyfin}>
Open in Jellyfin
</ContextMenuItem>
{#if type === 'movie'}
<ContextMenuItem
on:click={handleSetUnwatched}
disabled={!$itemStore.item?.jellyfinId || !watched}
disabled={!radarrMovie}
on:click={() => window.open($settings.radarr.baseUrl + '/movie/' + radarrMovie?.tmdbId)}
>
Mark as unwatched
Open in Radarr
</ContextMenuItem>
<ContextMenuDivider />
<ContextMenuItem disabled={!$itemStore.item.jellyfinItem} on:click={handleOpenInJellyfin}>
Open in Jellyfin
{:else}
<ContextMenuItem
disabled={!sonarrSeries}
on:click={() => window.open($settings.sonarr.baseUrl + '/series/' + sonarrSeries?.titleSlug)}
>
Open in Sonarr
</ContextMenuItem>
{#if $itemStore.item.type === 'movie'}
<ContextMenuItem
disabled={!$itemStore.item.radarrMovie}
on:click={() =>
window.open($settings.radarr.baseUrl + '/movie/' + $itemStore.item?.radarrMovie?.tmdbId)}
>
Open in Radarr
</ContextMenuItem>
{:else}
<ContextMenuItem
disabled={!$itemStore.item.sonarrSeries}
on:click={() =>
window.open(
$settings.sonarr.baseUrl + '/series/' + $itemStore.item?.sonarrSeries?.titleSlug
)}
>
Open in Sonarr
</ContextMenuItem>
{/if}
{/if}
<ContextMenuItem on:click={() => window.open(`https://www.themoviedb.org/${type}/${tmdbId}`)}>
Open in TMDB

View File

@@ -1,17 +1,16 @@
<script lang="ts">
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
import { setJellyfinItemUnwatched, setJellyfinItemWatched } from '$lib/apis/jellyfin/jellyfinApi';
import { jellyfinItemsStore } from '$lib/stores/data.store';
import classNames from 'classnames';
import { Check } from 'radix-icons-svelte';
import { fade } from 'svelte/transition';
import ContextMenu from '../ContextMenu/ContextMenu.svelte';
import ContextMenuItem from '../ContextMenu/ContextMenuItem.svelte';
import PlayButton from '../PlayButton.svelte';
import ProgressBar from '../ProgressBar.svelte';
import ContextMenuItem from '../ContextMenu/ContextMenuItem.svelte';
import { setJellyfinItemUnwatched, setJellyfinItemWatched } from '$lib/apis/jellyfin/jellyfinApi';
import { playerState } from '../VideoPlayer/VideoPlayer';
import { library } from '$lib/stores/library.store';
export let backdropPath: string;
export let backdropUrl: string;
export let title = '';
export let subtitle = '';
@@ -19,6 +18,7 @@
export let runtime = 0;
export let progress = 0;
export let watched = false;
export let airDate: Date | undefined = undefined;
export let jellyfinId: string | undefined = undefined;
@@ -28,14 +28,15 @@
if (!jellyfinId) return;
watched = true;
setJellyfinItemWatched(jellyfinId).finally(() => library.refreshIn(5000));
progress = 0;
setJellyfinItemWatched(jellyfinId).finally(() => jellyfinItemsStore.refreshIn(5000));
}
function handleSetUnwatched() {
if (!jellyfinId) return;
watched = false;
setJellyfinItemUnwatched(jellyfinId).finally(() => library.refreshIn(5000));
setJellyfinItemUnwatched(jellyfinId).finally(() => jellyfinItemsStore.refreshIn(5000));
}
function handlePlay() {
@@ -64,7 +65,7 @@
on:click
class={classNames(
'aspect-video bg-center bg-cover rounded-lg overflow-hidden transition-opacity shadow-lg selectable flex-shrink-0 placeholder-image relative',
'flex flex-col px-2 lg:px-3 py-2 gap-2',
'flex flex-col px-2 lg:px-3 py-2 gap-2 text-left',
{
'h-40': size === 'md',
'h-full': size === 'dynamic',
@@ -72,37 +73,53 @@
'cursor-default': !jellyfinId
}
)}
style={"background-image: url('" + TMDB_BACKDROP_SMALL + backdropPath + "');"}
style={"background-image: url('" + backdropUrl + "');"}
in:fade|global={{ duration: 100, delay: 100 }}
out:fade|global={{ duration: 100 }}
>
<div
class={classNames(
'absolute inset-0 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0',
'absolute inset-0 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0 bg-darken',
{
'bg-darken': !jellyfinId || watched,
'bg-gradient-to-t from-darken': !!jellyfinId
// 'bg-darken': !jellyfinId || watched,
// 'bg-gradient-to-t from-darken': !!jellyfinId
}
)}
/>
<div
class={classNames(
'flex-1 flex flex-col justify-between relative group-hover:opacity-0 group-focus-visible:opacity-0 transition-all'
'flex-1 flex flex-col justify-between relative group-hover:opacity-0 group-focus-visible:opacity-0 transition-all',
{
'opacity-8': !jellyfinId || watched
}
)}
>
<div class="flex justify-between items-center">
<div>
<slot name="left-top">
{#if episodeNumber}
{#if airDate && airDate > new Date()}
<p class="text-xs lg:text-sm font-medium text-zinc-300">
{airDate.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric'
})}
</p>
{:else if episodeNumber}
<p class="text-xs lg:text-sm font-medium text-zinc-300">{episodeNumber}</p>
{/if}
</slot>
</div>
<div>
<slot name="right-top">
{#if runtime}
{#if runtime && !progress}
<p class="text-xs lg:text-sm font-medium text-zinc-300">
{runtime} min
{runtime.toFixed(0)} min
</p>
{:else if runtime && progress}
<p class="text-xs lg:text-sm font-medium text-zinc-300">
{(runtime - (runtime / 100) * progress).toFixed(0)} min left
</p>
{/if}
</slot>

View File

@@ -13,7 +13,7 @@
},
$$restProps.class
)}
on:click|stopPropagation
on:click
>
<slot />
</button>

View File

@@ -1,11 +1,15 @@
<script lang="ts">
import { addMessages, init, locale, dictionary } from 'svelte-i18n';
import { settings } from '$lib/stores/settings.store';
import { addMessages, init, locale } from 'svelte-i18n';
import en from '../../lang/en.json';
import es from '../../lang/es.json';
import fr from '../../lang/fr.json';
import it from '../../lang/it.json';
addMessages('en', en);
addMessages('es', es);
addMessages('fr', fr);
addMessages('it', it);
settings.subscribe((value) => {
if (value.language) {

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import classNames from 'classnames';
export let src: string;
export let alt: string = '';
let loaded = false;
function handleLoad() {
loaded = true;
}
</script>
<div
class={classNames(
'transition-opacity duration-300',
{
'opacity-0': !loaded,
'opacity-100': loaded
},
$$restProps.class
)}
>
<img
{src}
{alt}
style="object-fit: cover; width: 100%; height: 100%;"
loading="lazy"
on:load={handleLoad}
/>
<slot />
</div>

View File

@@ -1,41 +1,79 @@
<script lang="ts">
import { TMDB_POSTER_SMALL } from '$lib/constants';
import type { TitleType } from '$lib/types';
import classNames from 'classnames';
import PlayButton from '../PlayButton.svelte';
import ProgressBar from '../ProgressBar.svelte';
import { playerState } from '../VideoPlayer/VideoPlayer';
import LazyImg from '../LazyImg.svelte';
import { Star } from 'radix-icons-svelte';
import { openTitleModal } from '$lib/stores/modal.store';
export let tmdbId: number;
export let tmdbId: number | undefined = undefined;
export let tvdbId: number | undefined = undefined;
export let openInModal = true;
export let jellyfinId: string = '';
export let type: TitleType = 'movie';
export let backdropUrl: string;
export let title = '';
export let subtitle = '';
export let rating: number | undefined = undefined;
export let progress = 0;
export let size: 'dynamic' | 'md' | 'lg' | 'sm' = 'md';
export let orientation: 'portrait' | 'landscape' = 'landscape';
</script>
<a
href={`/${type}/${tmdbId}`}
style={"background-image: url('" + backdropUrl + "');"}
class="relative flex shadow-lg rounded-lg aspect-[2/3] bg-center bg-cover w-44 selectable group hover:text-inherit flex-shrink-0"
<button
on:click={() => {
if (openInModal) {
if (tmdbId) {
openTitleModal({ type, id: tmdbId, provider: 'tmdb' });
} else if (tvdbId) {
openTitleModal({ type, id: tvdbId, provider: 'tvdb' });
}
} else {
window.location.href = tmdbId || tvdbId ? `/${type}/${tmdbId || tvdbId}` : '#';
}
}}
class={classNames(
'relative flex shadow-lg rounded-xl selectable group hover:text-inherit flex-shrink-0 overflow-hidden text-left',
{
'aspect-video': orientation === 'landscape',
'aspect-[2/3]': orientation === 'portrait',
'w-32': size === 'sm' && orientation === 'portrait',
'h-32': size === 'sm' && orientation === 'landscape',
'w-44': size === 'md' && orientation === 'portrait',
'h-44': size === 'md' && orientation === 'landscape',
'w-60': size === 'lg' && orientation === 'portrait',
'h-60': size === 'lg' && orientation === 'landscape',
'w-full': size === 'dynamic'
}
)}
>
<LazyImg src={backdropUrl} class="absolute inset-0 group-hover:scale-105 transition-transform" />
<div
class="absolute inset-0 opacity-0 group-hover:opacity-30 transition-opacity bg-black"
style="filter: blur(50px); transform: scale(3);"
>
<LazyImg src={backdropUrl} />
</div>
<!-- <div
style={`background-image: url(${backdropUrl}); background-size: cover; background-position: center; filter: blur(50px); transform: scale(3);`}
class="absolute inset-0 opacity-0 group-hover:opacity-30 transition-opacity bg-black"
/> -->
<div
class={classNames(
'flex-1 flex flex-col justify-between bg-darken opacity-0 group-hover:opacity-100 transition-opacity',
'flex-1 flex flex-col justify-between bg-black bg-opacity-40 opacity-0 group-hover:opacity-100 transition-opacity z-[1]',
{
'py-2 px-3': true
// 'pb-4': progress,
// 'pb-2': !progress
}
)}
>
<div class="flex justify-self-start justify-between">
<slot name="top-left">
<div>
<h1 class="font-semibold line-clamp-2">{title}</h1>
<h1 class="text-zinc-100 font-bold line-clamp-2 text-lg">{title}</h1>
<h2 class="text-zinc-300 text-sm font-medium line-clamp-2">{subtitle}</h2>
</div>
</slot>
@@ -45,30 +83,38 @@
</div>
<div class="flex justify-self-end justify-between">
<slot name="bottom-left">
<div />
<div>
{#if rating}
<h2 class="flex items-center gap-1.5 text-sm text-zinc-300 font-medium">
<Star />{rating.toFixed(1)}
</h2>
{/if}
</div>
</slot>
<slot name="bottom-right">
<div />
</slot>
</div>
</div>
<div
class="absolute inset-0 bg-gradient-to-t from-darken group-hover:opacity-0 transition-opacity"
/>
<div class="absolute inset-0 flex items-center justify-center">
<PlayButton
on:click={(e) => {
e.preventDefault();
jellyfinId && playerState.streamJellyfinId(jellyfinId);
}}
class="sm:opacity-0 group-hover:opacity-100 transition-opacity"
/>
</div>
<!-- <div
class="absolute inset-0 bg-gradient-to-t from-darken group-hover:opacity-0 transition-opacity z-[1]"
/> -->
{#if jellyfinId}
<div class="absolute inset-0 flex items-center justify-center z-[1]">
<PlayButton
on:click={(e) => {
e.preventDefault();
jellyfinId && playerState.streamJellyfinId(jellyfinId);
}}
class="sm:opacity-0 group-hover:opacity-100 transition-opacity"
/>
</div>
{/if}
{#if progress}
<div
class="absolute bottom-2 lg:bottom-3 inset-x-2 lg:inset-x-3 group-hover:opacity-0 transition-opacity bg-gradient-to-t ease-in-out"
class="absolute bottom-2 lg:bottom-3 inset-x-2 lg:inset-x-3 bg-gradient-to-t ease-in-out z-[1]"
>
<ProgressBar {progress} />
</div>
{/if}
</a>
</button>

View File

@@ -1,7 +1,16 @@
<script lang="ts">
import { onMount } from 'svelte';
export let progress = 0;
let mounted = false;
onMount(() => {
mounted = true;
});
</script>
<div class="h-1 bg-zinc-200 bg-opacity-20 rounded-full overflow-hidden">
<div style={'width: ' + progress + '%'} class="h-full bg-zinc-200 bg-opacity-80" />
<div
style={'max-width: ' + (mounted ? progress : 0) + '%'}
class="h-full bg-zinc-200 bg-opacity-80 transition-[max-width] delay-200 duration-500"
/>
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { getDiskSpace } from '$lib/apis/radarr/radarrApi';
import { library } from '$lib/stores/library.store';
import { radarrMoviesStore } from '$lib/stores/data.store';
import { settings } from '$lib/stores/settings.store';
import { formatSize } from '$lib/utils.js';
import RadarrIcon from '../svgs/RadarrIcon.svelte';
@@ -11,24 +11,15 @@
async function fetchStats() {
const discSpacePromise = getDiskSpace();
const { itemsArray } = await $library;
const availableMovies = itemsArray.filter(
(item) =>
!item.download &&
item.radarrMovie &&
item.radarrMovie.isAvailable &&
item.radarrMovie.movieFile
);
const radarrMovies = await radarrMoviesStore.promise;
const availableMovies = radarrMovies.filter((item) => item.isAvailable && item.movieFile);
const diskSpaceInfo =
(await discSpacePromise).find((disk) => disk.path === '/') ||
(await discSpacePromise)[0] ||
undefined;
const spaceOccupied = availableMovies.reduce(
(acc, movie) => acc + (movie.radarrMovie?.sizeOnDisk || 0),
0
);
const spaceOccupied = availableMovies.reduce((acc, movie) => acc + (movie?.sizeOnDisk || 0), 0);
return {
moviesCount: availableMovies.length,

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { getDiskSpace } from '$lib/apis/sonarr/sonarrApi';
import { library } from '$lib/stores/library.store';
import { sonarrSeriesStore } from '$lib/stores/data.store';
import { settings } from '$lib/stores/settings.store';
import { formatSize } from '$lib/utils.js';
import SonarrIcon from '../svgs/SonarrIcon.svelte';
@@ -11,10 +11,8 @@
async function fetchStats() {
const discSpacePromise = getDiskSpace();
const { itemsArray } = await $library;
const availableSeries = itemsArray.filter(
(item) => item.sonarrSeries && item.sonarrSeries.statistics?.episodeFileCount
);
const sonarrSeries = await sonarrSeriesStore.promise;
const availableSeries = sonarrSeries.filter((item) => item.statistics?.episodeFileCount);
const diskSpaceInfo =
(await discSpacePromise).find((disk) => disk.path === '/') ||
@@ -22,12 +20,12 @@
undefined;
const spaceOccupied = availableSeries.reduce(
(acc, series) => acc + (series.sonarrSeries?.statistics?.sizeOnDisk || 0),
(acc, series) => acc + (series?.statistics?.sizeOnDisk || 0),
0
);
const episodesCount = availableSeries.reduce(
(acc, series) => acc + (series.sonarrSeries?.statistics?.episodeFileCount || 0),
(acc, series) => acc + (series?.statistics?.episodeFileCount || 0),
0
);

View File

@@ -1,17 +1,28 @@
<script lang="ts">
import type { LibraryItemStore } from '$lib/stores/library.store';
import type { JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
import type { RadarrMovie } from '$lib/apis/radarr/radarrApi';
import type { SonarrSeries } from '$lib/apis/sonarr/sonarrApi';
import type { TitleType } from '$lib/types';
import { DotsVertical } from 'radix-icons-svelte';
import Button from '../Button.svelte';
import ContextMenuButton from '../ContextMenu/ContextMenuButton.svelte';
import LibraryItemContextItems from '../ContextMenu/LibraryItemContextItems.svelte';
export let title = '';
export let itemStore: LibraryItemStore;
export let jellyfinItem: JellyfinItem | undefined = undefined;
export let sonarrSeries: SonarrSeries | undefined = undefined;
export let radarrMovie: RadarrMovie | undefined = undefined;
export let type: TitleType;
export let tmdbId: number;
</script>
<ContextMenuButton heading={$itemStore.loading ? 'Loading...' : title}>
<ContextMenuButton heading={title}>
<svelte:fragment slot="menu">
<LibraryItemContextItems {itemStore} {type} {tmdbId} />
<LibraryItemContextItems {jellyfinItem} {sonarrSeries} {radarrMovie} {type} {tmdbId} />
</svelte:fragment>
<Button slim>
<DotsVertical size={24} />
</Button>
</ContextMenuButton>

View File

@@ -1,24 +1,27 @@
<script lang="ts">
import { TMDB_IMAGES_ORIGINAL, TMDB_POSTER_SMALL } from '$lib/constants';
import type { TitleType } from '$lib/types';
import type { TitleId, TitleType } from '$lib/types';
import classNames from 'classnames';
import { ChevronLeft, Cross2, DotFilled, ExternalLink } from 'radix-icons-svelte';
import Carousel from '../Carousel/Carousel.svelte';
import CarouselPlaceholderItems from '../Carousel/CarouselPlaceholderItems.svelte';
import IconButton from '../IconButton.svelte';
import LazyImg from '../LazyImg.svelte';
export let isModal = false;
export let handleCloseModal: () => void = () => {};
export let tmdbId: number;
export let type: TitleType;
export let backdropUriCandidates: string[];
export let posterPath: string;
export let title: string;
export let tagline: string;
export let overview: string;
export let titleInformation:
| {
tmdbId: number;
type: TitleType;
title: string;
tagline: string;
overview: string;
backdropUriCandidates: string[];
posterPath: string;
}
| undefined = undefined;
let topHeight: number;
let bottomHeight: number;
@@ -33,31 +36,34 @@
<svelte:window bind:outerHeight={windowHeight} />
<!-- Desktop -->
<div
style={"background-image: url('" +
TMDB_IMAGES_ORIGINAL +
getBackdropUri(backdropUriCandidates) +
"'); height: " +
imageHeight.toFixed() +
'px'}
style={'height: ' + imageHeight.toFixed() + 'px'}
class={classNames('hidden sm:block inset-x-0 bg-center bg-cover bg-stone-950', {
absolute: isModal,
fixed: !isModal
})}
>
<div class="absolute inset-0 bg-darken" />
{#if titleInformation}
<LazyImg
src={TMDB_IMAGES_ORIGINAL + getBackdropUri(titleInformation.backdropUriCandidates)}
class="h-full"
>
<div class="absolute inset-0 bg-darken" />
</LazyImg>
{/if}
</div>
<!-- Mobile -->
<div
style={"background-image: url('" +
TMDB_IMAGES_ORIGINAL +
posterPath +
"'); height: " +
imageHeight.toFixed() +
'px'}
style={'height: ' + imageHeight.toFixed() + 'px'}
class="sm:hidden fixed inset-x-0 bg-center bg-cover bg-stone-950"
>
<div class="absolute inset-0 bg-darken" />
{#if titleInformation}
<LazyImg src={TMDB_IMAGES_ORIGINAL + titleInformation.posterPath} class="h-full">
<div class="absolute inset-0 bg-darken" />
</LazyImg>
{/if}
</div>
<div
@@ -73,11 +79,16 @@
bind:clientHeight={topHeight}
>
{#if isModal}
<a href={`/${type}/${tmdbId}`} class="absolute top-8 right-4 sm:right-8 z-10">
<IconButton>
<ExternalLink size={20} />
</IconButton>
</a>
{#if titleInformation}
<a
href={`/${titleInformation.type}/${titleInformation.tmdbId}`}
class="absolute top-8 right-4 sm:right-8 z-10"
>
<IconButton>
<ExternalLink size={20} />
</IconButton>
</a>
{/if}
<div class="absolute top-8 left-4 sm:left-8 z-10">
<button class="flex items-center sm:hidden font-medium" on:click={handleCloseModal}>
<ChevronLeft size={20} />
@@ -92,26 +103,26 @@
{/if}
<div class="absolute inset-0 bg-gradient-to-t from-stone-950 to-30%" />
<div class="z-[1] flex-1 flex justify-end gap-8 items-end max-w-screen-2xl mx-auto">
<div
class="aspect-[2/3] w-52 bg-center bg-cover rounded-md hidden sm:block"
style={"background-image: url('" + TMDB_POSTER_SMALL + posterPath + "')"}
/>
{#if titleInformation}
<div
class="aspect-[2/3] w-52 bg-center bg-cover rounded-md hidden sm:block"
style={"background-image: url('" + TMDB_POSTER_SMALL + titleInformation.posterPath + "')"}
/>
{:else}
<div class="aspect-[2/3] w-52 bg-center bg-cover rounded-md hidden sm:block placeholder" />
{/if}
<div class="flex-1 flex gap-4 justify-between flex-col lg:flex-row lg:items-end">
<div>
<div class="text-zinc-300 text-sm uppercase font-semibold flex items-center gap-1">
<p class="flex-shrink-0">
<slot name="title-info-1" />
</p>
<DotFilled />
<p class="flex-shrink-0">
<slot name="title-info-2" />
</p>
<DotFilled />
<p class="flex-shrink-0"><slot name="title-info-3" /></p>
<!-- <DotFilled />
<p class="line-clamp-1">{series?.genres?.map((g) => g.name).join(', ')}</p> -->
<slot name="title-info">
<div class="placeholder-text">Placeholder Long</div>
</slot>
</div>
<h1 class="text-4xl sm:text-5xl md:text-6xl font-semibold">{title}</h1>
{#if titleInformation}
<h1 class="text-4xl sm:text-5xl md:text-6xl font-semibold">{titleInformation.title}</h1>
{:else}
<h1 class="text-4xl sm:text-5xl md:text-6xl placeholder-text mt-2">Placeholder</h1>
{/if}
</div>
<div class="flex-shrink-0">
<slot name="title-right" />
@@ -138,9 +149,10 @@
<div
class="flex flex-col gap-3 max-w-5xl row-span-3 col-span-4 sm:col-span-6 lg:col-span-3 mb-4 lg:mr-8"
>
<div class="flex gap-4 justify-between">
<h1 class="font-semibold text-xl sm:text-2xl">{tagline}</h1>
<!-- <div class="flex items-center gap-4">
{#if titleInformation}
<div class="flex gap-4 justify-between">
<h1 class="font-semibold text-xl sm:text-2xl">{titleInformation.tagline}</h1>
<!-- <div class="flex items-center gap-4">
<a
target="_blank"
href={'https://www.themoviedb.org/tv/' + tmdbId}
@@ -168,8 +180,22 @@
<Globe size={15} />
</a>
{/if} -->
</div>
<p class="pl-4 border-l-2 text-sm sm:text-base">{overview}</p>
</div>
<p class="pl-4 border-l-2 text-sm sm:text-base">{titleInformation.overview}</p>
{:else}
<div class="flex gap-4 justify-between">
<h1 class="font-semibold text-xl sm:text-2xl placeholder-text">Placeholder</h1>
</div>
<div class="flex">
<div class="mr-4 placeholder w-1 flex-shrink-0 rounded" />
<p class="text-sm sm:text-base placeholder-text">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sit amet sem eget
dolor lobortis mollis. Aliquam semper imperdiet mi nec viverra. Praesent ac ligula
congue, aliquam diam nec, ullamcorper libero. Nunc mattis rhoncus justo, ac pretium
urna vehicula et.
</p>
</div>
{/if}
</div>
</slot>
<slot name="info-components" />

View File

@@ -1,12 +1,11 @@
<script lang="ts">
import type { TitleType } from '$lib/types';
import type { TitleId } from '$lib/types';
import { fly } from 'svelte/transition';
import MoviePage from '../../../routes/movie/[id]/MoviePage.svelte';
import SeriesPage from '../../../routes/series/[id]/SeriesPage.svelte';
import { modalStack } from '../../stores/modal.store';
export let tmdbId: number;
export let type: TitleType;
export let titleId: TitleId;
export let modalId: symbol;
function handleCloseModal() {
@@ -22,10 +21,10 @@
in:fly|global={{ y: 20, duration: 200, delay: 200 }}
out:fly|global={{ y: 20, duration: 200 }}
>
{#if type === 'movie'}
<MoviePage {tmdbId} isModal={true} {handleCloseModal} />
{#if titleId.type === 'movie'}
<MoviePage tmdbId={titleId.id} isModal={true} {handleCloseModal} />
{:else}
<SeriesPage {tmdbId} isModal={true} {handleCloseModal} />
<SeriesPage {titleId} isModal={true} {handleCloseModal} />
{/if}
</div>
</div>

View File

@@ -123,7 +123,11 @@
out:fade|global={{ duration: ANIMATION_DURATION }}
>
<div class="flex gap-4 items-center">
<Button size="lg" type="primary" on:click={() => openTitleModal(tmdbId, type)}>
<Button
size="lg"
type="primary"
on:click={() => openTitleModal({ type, id: tmdbId, provider: 'tmdb' })}
>
<span>{$_('titleShowcase.details')}</span><ChevronRight size={20} />
</Button>
{#if trailerId}

View File

@@ -33,6 +33,8 @@
import { modalStack } from '../../stores/modal.store';
import Slider from './Slider.svelte';
import { playerState } from './VideoPlayer';
import { linear } from 'svelte/easing';
import ContextMenuButton from '../ContextMenu/ContextMenuButton.svelte';
export let modalId: symbol;
@@ -326,8 +328,37 @@
fullscreen = !!getFullscreenElement?.();
});
}
function handleRequestFullscreen() {
if (reqFullscreenFunc) {
fullscreen = !fullscreen;
// @ts-ignore
} else if (video?.webkitEnterFullScreen) {
// Edge case to allow fullscreen on iPhone
// @ts-ignore
video.webkitEnterFullScreen();
}
}
function handleShortcuts(event: KeyboardEvent) {
if (event.key === 'f') {
handleRequestFullscreen();
} else if (event.key === ' ') {
paused = !paused;
} else if (event.key === 'ArrowLeft') {
video.currentTime -= 10;
} else if (event.key === 'ArrowRight') {
video.currentTime += 10;
} else if (event.key === 'ArrowUp') {
volume = Math.min(volume + 0.1, 1);
} else if (event.key === 'ArrowDown') {
volume = Math.max(volume - 0.1, 0);
}
}
</script>
<svelte:window on:keydown={handleShortcuts} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class={classNames(
@@ -336,15 +367,16 @@
'cursor-none': !uiVisible
}
)}
in:fade|global={{ duration: 300, easing: linear }}
out:fade|global={{ duration: 200, easing: linear }}
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="bg-black w-screen h-screen flex items-center justify-center"
class="w-screen h-screen flex items-center justify-center"
bind:this={videoWrapper}
on:mousemove={() => handleUserInteraction(false)}
on:touchend|preventDefault={() => handleUserInteraction(true)}
on:dblclick|preventDefault={() => (fullscreen = !fullscreen)}
on:click={() => (paused = !paused)}
in:fade|global={{ duration: 500, delay: 1200, easing: linear }}
>
<!-- svelte-ignore a11y-media-has-caption -->
<video
@@ -365,6 +397,8 @@
bind:muted={mute}
class="sm:w-full sm:h-full"
playsinline={true}
on:dblclick|preventDefault={() => (fullscreen = !fullscreen)}
on:click={() => (paused = !paused)}
/>
{#if uiVisible}
@@ -403,29 +437,22 @@
</IconButton>
<div class="flex items-center space-x-3">
<div class="relative">
<ContextMenu
heading="Quality"
position="absolute"
bottom={true}
id={qualityContextMenuId}
>
<svelte:fragment slot="menu">
{#each getQualities(resolution) as quality}
<SelectableContextMenuItem
selected={quality.maxBitrate === currentBitrate}
on:click={() => handleSelectQuality(quality.maxBitrate)}
>
{quality.name}
</SelectableContextMenuItem>
{/each}
</svelte:fragment>
<IconButton on:click={handleQualityToggleVisibility}>
<Gear size={20} />
</IconButton>
</ContextMenu>
</div>
<ContextMenuButton heading="Quality">
<svelte:fragment slot="menu">
{#each getQualities(resolution) as quality}
<SelectableContextMenuItem
selected={quality.maxBitrate === currentBitrate}
on:click={() => handleSelectQuality(quality.maxBitrate)}
>
{quality.name}
</SelectableContextMenuItem>
{/each}
</svelte:fragment>
<IconButton>
<Gear size={20} />
</IconButton>
</ContextMenuButton>
<IconButton
on:click={() => {
mute = !mute;
@@ -446,20 +473,13 @@
<Slider bind:primaryValue={volume} secondaryValue={0} max={1} />
</div>
{#if reqFullscreenFunc}
<IconButton on:click={() => (fullscreen = !fullscreen)}>
{#if fullscreen}
<ExitFullScreen size={20} />
{:else if !fullscreen && exitFullscreen}
<EnterFullScreen size={20} />
{/if}
</IconButton>
<!-- Edge case to allow fullscreen on iPhone -->
{:else if video?.webkitEnterFullScreen}
<IconButton on:click={() => video.webkitEnterFullScreen()}>
<IconButton on:click={handleRequestFullscreen}>
{#if fullscreen}
<ExitFullScreen size={20} />
{:else if !fullscreen && exitFullscreen}
<EnterFullScreen size={20} />
</IconButton>
{/if}
{/if}
</IconButton>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { library } from '$lib/stores/library.store';
import { jellyfinItemsStore } from '$lib/stores/data.store';
import { writable } from 'svelte/store';
import { modalStack } from '../../stores/modal.store';
import VideoPlayer from './VideoPlayer.svelte';
@@ -17,7 +17,7 @@ function createPlayerState() {
},
close: () => {
store.set({ visible: false, jellyfinId: '' });
library.refresh();
jellyfinItemsStore.refresh();
}
};
}

View File

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

View File

@@ -21,7 +21,7 @@
"upcomingSeries": "Upcoming Series",
"genres": "Genres",
"newDigitalReleases": "New Digital Releases",
"streamingNow": "On Streaming Now",
"streamingNow": "Streaming Now",
"TVNetworks": "TV Networks"
},
"library": {

102
src/lib/lang/fr.json Normal file
View File

@@ -0,0 +1,102 @@
{
"appName": "Reiverr",
"setupRequiredTitle": "Bienvenue à",
"setupRequiredDescription": "Il semble que l'application manque de certaines variables d'environnement nécessaires au fonctionnement de l'application. ",
"navbar": {
"home": "Accueil",
"discover": "Découvrir",
"library": "Bibliothèque",
"sources": "Sources",
"settings": "Paramètres"
},
"search": {
"placeHolder": "Rechercher des films et des émissions de télévision",
"noRecentSearches": "Aucune recherche récente",
"noResults": "Aucun résultat trouvé"
},
"discover": {
"trending": "Tendance",
"popularPeople": "Personnes populaires",
"upcomingMovies": "Films à venir",
"upcomingSeries": "Série à venir",
"genres": "Genres",
"newDigitalReleases": "Nouvelles versions numériques",
"streamingNow": "En streaming maintenant",
"TVNetworks": "Réseaux de télévision"
},
"library": {
"missingConfiguration": "Configurez Radarr, Sonarr et Jellyfin pour surveiller et gérer votre bibliothèque",
"available": "Disponible",
"watched": "Regardé",
"unavailable": "Indisponible",
"sort": {
"byTitle": "Par titre"
},
"content": {
"movie": "Film",
"show": "Voir",
"requestContent": "Demander",
"directedBy": "Dirigé par",
"releaseDate": "Date de sortie",
"budget": "Budget",
"status": "Statut",
"runtime": "Durée",
"castAndCrew": "Casting",
"recommendations": "Recommandations",
"similarTitles": "Titres similaires"
}
},
"sources": {},
"titleShowcase": {
"details": "Détails",
"watchTrailer": "Regarde la bande-annonce",
"releaseDate": "Date de sortie",
"directedBy": "Dirigé par"
},
"settings": {
"navbar": {
"settings": "Configuration",
"general": "Général",
"integrations": "Intégrations"
},
"general": {
"userInterface": {
"userInterface": "Interface utilisateur",
"language": "Langue",
"autoplayTrailers": "Bandes-annonces à lecture automatique",
"animationDuration": "Durée de l'animation"
},
"discovery": {
"discovery": "Découverte",
"none": "Aucun",
"region": "Région",
"excludeLibraryItemsFromDiscovery": "Exclure les éléments de bibliothèque de Discovery",
"includedLanguages": "Langues incluses",
"includedLanguagesDescription": "Filtrez les résultats en fonction de la langue parlée. "
}
},
"integrations": {
"integrations": "Intégrations",
"integrationsNote": "Remarque: Les URL de base doivent être accessibles depuis le navigateur, ce qui signifie que les adresses Docker internes ne fonctionneront pas, par exemple. <span class='font-medium underline'>sera exposé</span> au navigateur.",
"baseUrl": "URL de base",
"apiKey": "clé API",
"testConnection": "Tester la connexion",
"status": {
"connected": "Connecté",
"disconnected": "Déconnecté"
},
"options": {
"options": "Options",
"rootFolder": "Dossier racine",
"qualityProfile": "Profil de qualité",
"languageProfile": "Profil linguistique",
"jellyfinUser": "Utilisateur de Jellyfin"
}
},
"misc": {
"saveChanges": "Sauvegarder les modifications",
"resetToDefaults": "Réinitialiser les paramètres par défaut ",
"changelog": "Journal des modifications"
}
}
}

102
src/lib/lang/it.json Normal file
View File

@@ -0,0 +1,102 @@
{
"appName": "Reiverr",
"setupRequiredTitle": "Benvenuti in",
"setupRequiredDescription": "Sembra che alcune variabili di ambiente necessarie per il funzionamento dell'applicazione siano mancanti. Per favore, inserisci le seguenti variabili d'ambiente:",
"navbar": {
"home": "Home",
"discover": "Esplora",
"library": "Libreria",
"sources": "Fonti",
"settings": "Impostazioni"
},
"search": {
"placeHolder": "Cerca Film o Serie TV",
"noRecentSearches": "Nessuna ricerca recente",
"noResults": "Nessun risultato"
},
"discover": {
"trending": "In tendenza",
"popularPeople": "Personaggi in tendenza",
"upcomingMovies": "Film in uscita",
"upcomingSeries": "Serie TV in uscita",
"genres": "Generi",
"newDigitalReleases": "Nuove uscite in digitale",
"streamingNow": "In streaming adesso",
"TVNetworks": "Network TV"
},
"library": {
"missingConfiguration": "Configura Radarr, Sonarr e Jellyfin per guardare e gestire la tua libreria",
"available": "Disponibile",
"watched": "Guardato",
"unavailable": "Non disponibile",
"sort": {
"byTitle": "Per Titolo"
},
"content": {
"movie": "Film",
"show": "Serie TV",
"requestContent": "Richiedi",
"directedBy": "Diretto da",
"releaseDate": "Data di uscita",
"budget": "Budget",
"status": "Stato",
"runtime": "Durata",
"castAndCrew": "Cast e Crew",
"recommendations": "Suggerimenti",
"similarTitles": "Titoli simili"
}
},
"sources": {},
"titleShowcase": {
"details": "Dettagli",
"watchTrailer": "Guarda il trailer",
"releaseDate": "Data di uscita",
"directedBy": "Diretto da"
},
"settings": {
"navbar": {
"settings": "Configurazione",
"general": "Generale",
"integrations": "Integrazioni"
},
"general": {
"userInterface": {
"userInterface": "Interfaccia utente",
"language": "Lingua",
"autoplayTrailers": "Riproduci automaticamente i trailer",
"animationDuration": "Durata dell'animazione"
},
"discovery": {
"discovery": "Esplora",
"none": "Niente",
"region": "Paese",
"excludeLibraryItemsFromDiscovery": "Escludi i media presenti nella libreria dalla sezione Esplora",
"includedLanguages": "Lingue disponibili",
"includedLanguagesDescription": "Filtra i risultati in base alla lingua dell'audio. Inserisci i codici della lingua in formato ISO 639-1 separati da virgola. Lascia vuoto per disabilitare."
}
},
"integrations": {
"integrations": "Integrazioni",
"integrationsNote": "Nota bene: L'indirizzo (URL) deve essere accessibile dal browser, questo vuol dire che indirizzi come quello interno di docker non funzioneranno. Le chiavi API <span class='font-medium underline'>saranno rese disponibili</span> al browser.",
"baseUrl": "Indirizzo (URL)",
"apiKey": "Chiave API",
"testConnection": "Testa la connessione",
"status": {
"connected": "Connesso",
"disconnected": "Disconnesso"
},
"options": {
"options": "Opzioni",
"rootFolder": "Percorso",
"qualityProfile": "Profilo di qualità",
"languageProfile": "Profilo della lingua",
"jellyfinUser": "Utente Jellyfin"
}
},
"misc": {
"saveChanges": "Salva",
"resetToDefaults": "Impostazioni predefinite",
"changelog": "Note di rilascio"
}
}
}

View File

@@ -0,0 +1,219 @@
import { getJellyfinItems, type JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
import {
getRadarrDownloads,
getRadarrMovies,
type RadarrDownload
} from '$lib/apis/radarr/radarrApi';
import {
getSonarrDownloads,
getSonarrSeries,
type SonarrDownload,
type SonarrSeries
} from '$lib/apis/sonarr/sonarrApi';
import { derived, writable } from 'svelte/store';
import { settings } from './settings.store';
async function waitForSettings() {
return new Promise((resolve) => {
let resolved = false;
settings.subscribe((settings) => {
if (settings?.initialised && !resolved) {
resolved = true;
resolve(settings);
}
});
});
}
type AwaitableStoreValue<R, T = { data?: R }> = {
loading: boolean;
} & T;
function _createDataFetchStore<T>(fn: () => Promise<T>) {
const store = writable<AwaitableStoreValue<T>>({
loading: true,
data: undefined
});
async function refresh() {
store.update((s) => ({ ...s, loading: true }));
return waitForSettings().then(() =>
fn().then((data) => {
store.set({ loading: false, data });
return data;
})
);
}
let updateTimeout: NodeJS.Timeout;
function refreshIn(ms = 1000) {
return new Promise((resolve) => {
clearTimeout(updateTimeout);
updateTimeout = setTimeout(() => {
refresh().then(resolve);
}, ms);
});
}
return {
subscribe: store.subscribe,
refresh,
refreshIn,
promise: refresh()
};
}
export const jellyfinItemsStore = _createDataFetchStore(getJellyfinItems);
export function createJellyfinItemStore(tmdbId: number | Promise<number>) {
const store = writable<{ loading: boolean; item?: JellyfinItem }>({
loading: true,
item: undefined
});
jellyfinItemsStore.subscribe(async (s) => {
const awaited = await tmdbId;
store.set({
loading: s.loading,
item: s.data?.find((i) => i.ProviderIds?.Tmdb === String(awaited))
});
});
return {
subscribe: store.subscribe,
refresh: jellyfinItemsStore.refresh,
refreshIn: jellyfinItemsStore.refreshIn,
promise: new Promise<JellyfinItem | undefined>((resolve) => {
store.subscribe((s) => {
if (!s.loading) resolve(s.item);
});
})
};
}
export const sonarrSeriesStore = _createDataFetchStore(getSonarrSeries);
export const radarrMoviesStore = _createDataFetchStore(getRadarrMovies);
export function createRadarrMovieStore(tmdbId: number) {
const store = derived(radarrMoviesStore, (s) => {
return {
loading: s.loading,
item: s.data?.find((i) => i.tmdbId === tmdbId)
};
});
return {
subscribe: store.subscribe,
refresh: radarrMoviesStore.refresh,
refreshIn: radarrMoviesStore.refreshIn
};
}
export function createSonarrSeriesStore(name: Promise<string> | string) {
function shorten(str: string) {
return str.toLowerCase().replace(/[^a-zA-Z0-9]/g, '');
}
const store = writable<{ loading: boolean; item?: SonarrSeries }>({
loading: true,
item: undefined
});
sonarrSeriesStore.subscribe(async (s) => {
const awaited = await name;
store.set({
loading: s.loading,
item: s.data?.find(
(i) =>
shorten(i.titleSlug || '') === shorten(awaited) ||
i.alternateTitles?.find((t) => shorten(t.title || '') === shorten(awaited))
)
});
});
return {
subscribe: store.subscribe,
refresh: sonarrSeriesStore.refresh,
refreshIn: sonarrSeriesStore.refreshIn
};
}
export const sonarrDownloadsStore = _createDataFetchStore(getSonarrDownloads);
export const radarrDownloadsStore = _createDataFetchStore(getRadarrDownloads);
export const servarrDownloadsStore = (() => {
const store = derived([sonarrDownloadsStore, radarrDownloadsStore], ([sonarr, radarr]) => {
return {
loading: sonarr.loading || radarr.loading,
radarrDownloads: radarr.data,
sonarrDownloads: sonarr.data
};
});
return {
subscribe: store.subscribe
};
})();
export function createRadarrDownloadStore(
radarrMovieStore: ReturnType<typeof createRadarrMovieStore>
) {
const store = writable<{ loading: boolean; downloads?: RadarrDownload[] }>({
loading: true,
downloads: undefined
});
const combinedStore = derived(
[radarrMovieStore, radarrDownloadsStore],
([movieStore, downloadsStore]) => ({ movieStore, downloadsStore })
);
combinedStore.subscribe(async (data) => {
const movie = data.movieStore.item;
const downloads = data.downloadsStore.data;
if (!movie || !downloads) return;
store.set({
loading: false,
downloads: downloads?.filter((d) => d.movie.tmdbId === movie?.tmdbId)
});
});
return {
subscribe: store.subscribe,
refresh: async () => radarrDownloadsStore.refresh()
};
}
export function createSonarrDownloadStore(
sonarrItemStore: ReturnType<typeof createSonarrSeriesStore>
) {
const store = writable<{ loading: boolean; downloads?: SonarrDownload[] }>({
loading: true,
downloads: undefined
});
const combinedStore = derived(
[sonarrItemStore, sonarrDownloadsStore],
([itemStore, downloadsStore]) => ({ itemStore, downloadsStore })
);
combinedStore.subscribe(async (data) => {
const item = data.itemStore.item;
const downloads = data.downloadsStore.data;
if (!item || !downloads) return;
store.set({
loading: false,
downloads: downloads?.filter((d) => d.series.id === item?.id)
});
});
return {
subscribe: store.subscribe,
refresh: async () => sonarrDownloadsStore.refresh()
};
}

View File

@@ -1,328 +0,0 @@
import {
getJellyfinContinueWatching,
getJellyfinEpisodes,
getJellyfinItems,
getJellyfinNextUp,
type JellyfinItem
} from '$lib/apis/jellyfin/jellyfinApi';
import {
getRadarrDownloads,
getRadarrMovies,
type RadarrDownload,
type RadarrMovie
} from '$lib/apis/radarr/radarrApi';
import {
getSonarrDownloads,
getSonarrSeries,
type SonarrDownload,
type SonarrSeries
} from '$lib/apis/sonarr/sonarrApi';
import {
getTmdbMovieBackdrop,
getTmdbMoviePoster,
getTmdbSeriesBackdrop,
getTmdbSeriesFromTvdbId
} from '$lib/apis/tmdb/tmdbApi';
import { TMDB_BACKDROP_SMALL, TMDB_POSTER_SMALL } from '$lib/constants';
import type { TitleType } from '$lib/types';
import { get, writable } from 'svelte/store';
import { settings } from './settings.store';
export interface PlayableItem {
tmdbRating: number;
backdropUrl: string;
posterUrl: string;
download?: {
progress: number;
completionTime: string | undefined;
};
continueWatching?: {
progress: number;
length: number;
};
isPlayed: boolean;
jellyfinId?: string;
type: TitleType;
tmdbId: number;
jellyfinItem?: JellyfinItem;
jellyfinEpisodes?: JellyfinItem[];
nextJellyfinEpisode?: JellyfinItem;
radarrMovie?: RadarrMovie;
radarrDownloads?: RadarrDownload[];
sonarrSeries?: SonarrSeries;
sonarrDownloads?: SonarrDownload[];
}
export interface Library {
items: Record<string, PlayableItem>;
itemsArray: PlayableItem[];
continueWatching: PlayableItem[];
}
async function getLibrary(): Promise<Library> {
const radarrMoviesPromise = getRadarrMovies();
const radarrDownloadsPromise = getRadarrDownloads();
const sonarrSeriesPromise = getSonarrSeries();
const sonarrDownloadsPromise = getSonarrDownloads();
const jellyfinContinueWatchingPromise = getJellyfinContinueWatching();
const jellyfinNextUpPromise = getJellyfinNextUp();
const jellyfinLibraryItemsPromise = getJellyfinItems();
const jellyfinEpisodesPromise = getJellyfinEpisodes();
const radarrMovies = await radarrMoviesPromise;
const radarrDownloads = await radarrDownloadsPromise;
const sonarrSeries = await sonarrSeriesPromise;
const sonarrDownloads = await sonarrDownloadsPromise;
const jellyfinContinueWatching = (await jellyfinContinueWatchingPromise) || [];
const jellyfinLibraryItems = (await jellyfinLibraryItemsPromise) || [];
const jellyfinEpisodes =
(await jellyfinEpisodesPromise.then((episodes) =>
episodes?.sort((a, b) => (a.IndexNumber || 99) - (b.IndexNumber || 99))
)) || [];
const jellyfinNextUp = await jellyfinNextUpPromise;
const items: Record<string, PlayableItem> = {};
const moviesPromise: Promise<PlayableItem>[] = radarrMovies.map(async (radarrMovie) => {
const itemRadarrDownloads = radarrDownloads.filter(
(d) => d.movie.tmdbId === radarrMovie.tmdbId
);
const radarrDownload = itemRadarrDownloads[0];
const jellyfinItem = jellyfinLibraryItems.find(
(i) => i.ProviderIds?.Tmdb === String(radarrMovie.tmdbId)
);
const downloadProgress =
radarrDownload?.sizeleft && radarrDownload?.size
? ((radarrDownload.size - radarrDownload.sizeleft) / radarrDownload.size) * 100
: undefined;
const completionTime = radarrDownload?.estimatedCompletionTime || undefined;
const download = downloadProgress ? { progress: downloadProgress, completionTime } : undefined;
const length = jellyfinItem?.RunTimeTicks
? jellyfinItem.RunTimeTicks / 10_000_000 / 60
: undefined;
const watchingProgress = jellyfinItem?.UserData?.PlayedPercentage;
const continueWatching =
length &&
watchingProgress &&
!!jellyfinContinueWatching.find((i) => i.Id === jellyfinItem?.Id)
? { length, progress: watchingProgress }
: undefined;
const backdropUri = await getTmdbMovieBackdrop(radarrMovie.tmdbId || 0);
const posterUri = await getTmdbMoviePoster(radarrMovie.tmdbId || 0);
const playableItem: PlayableItem = {
type: 'movie' as const,
tmdbId: radarrMovie.tmdbId || 0,
tmdbRating: radarrMovie.ratings?.tmdb?.value || 0,
backdropUrl: backdropUri ? TMDB_BACKDROP_SMALL + backdropUri : '',
posterUrl: posterUri ? TMDB_POSTER_SMALL + posterUri : '',
download,
continueWatching,
isPlayed: jellyfinItem?.UserData?.Played || false,
jellyfinId: jellyfinItem?.Id,
jellyfinItem,
radarrMovie,
radarrDownloads: itemRadarrDownloads
};
return playableItem;
});
const seriesPromise: Promise<PlayableItem>[] = sonarrSeries.map(async (sonarrSeries) => {
const itemSonarrDownloads = sonarrDownloads.filter(
(d) => d.series.tvdbId === sonarrSeries.tvdbId
);
const sonarrDownload = itemSonarrDownloads[0];
const jellyfinItem = jellyfinLibraryItems.find(
(i) => i.ProviderIds?.Tvdb === String(sonarrSeries.tvdbId)
);
const downloadProgress =
sonarrDownload?.sizeleft && sonarrDownload?.size
? ((sonarrDownload.size - sonarrDownload.sizeleft) / sonarrDownload.size) * 100
: undefined;
const completionTime = sonarrDownload?.estimatedCompletionTime || undefined;
const download = downloadProgress ? { progress: downloadProgress, completionTime } : undefined;
const nextJellyfinEpisode = jellyfinItem
? jellyfinContinueWatching.find((i) => i.SeriesId === jellyfinItem?.Id) ||
jellyfinNextUp?.find((i) => i.SeriesId === jellyfinItem?.Id)
: undefined;
const length = nextJellyfinEpisode?.RunTimeTicks
? nextJellyfinEpisode.RunTimeTicks / 10_000_000 / 60
: undefined;
const watchingProgress = nextJellyfinEpisode?.UserData?.PlayedPercentage;
const continueWatching =
length && watchingProgress && !!nextJellyfinEpisode
? { length, progress: watchingProgress }
: undefined;
const tmdbItem = sonarrSeries.tvdbId
? await getTmdbSeriesFromTvdbId(sonarrSeries.tvdbId)
: undefined;
const tmdbId = tmdbItem?.id || undefined;
const backdropUrl = await getTmdbSeriesBackdrop(tmdbId || 0);
const posterUri = tmdbItem?.poster_path || '';
const playableItem: PlayableItem = {
type: 'series' as const,
tmdbId: tmdbId || 0,
tmdbRating: tmdbItem?.vote_average || 0,
backdropUrl: backdropUrl ? TMDB_BACKDROP_SMALL + backdropUrl : '',
posterUrl: posterUri ? TMDB_POSTER_SMALL + posterUri : '',
download,
continueWatching,
isPlayed: jellyfinItem?.UserData?.Played || false,
jellyfinId: jellyfinItem?.Id,
jellyfinItem,
sonarrSeries,
sonarrDownloads: itemSonarrDownloads,
jellyfinEpisodes: jellyfinEpisodes.filter((i) => i.SeriesId === jellyfinItem?.Id),
nextJellyfinEpisode
};
return playableItem;
});
const jellyfinSourceItems = jellyfinLibraryItems
.filter(
(i) =>
!radarrMovies.find((m) => m.tmdbId === Number(i.ProviderIds?.Tmdb)) &&
!sonarrSeries.find((s) => s.tvdbId === Number(i.ProviderIds?.Tvdb))
)
.map((jellyfinItem) => {
const itemJellyfinEpisodes = jellyfinEpisodes.filter((e) => e.SeriesId === jellyfinItem.Id);
const jellyfinNextUpEpisode =
jellyfinNextUp?.find((e) => e.SeriesId === jellyfinItem.Id) ||
jellyfinContinueWatching.find((e) => e.SeriesId === jellyfinItem.Id);
const length = jellyfinNextUpEpisode?.RunTimeTicks
? jellyfinNextUpEpisode.RunTimeTicks / 10_000_000 / 60
: undefined;
const watchingProgress = jellyfinNextUpEpisode?.UserData?.PlayedPercentage;
const continueWatching =
length && watchingProgress && !!jellyfinNextUpEpisode
? { length, progress: watchingProgress }
: undefined;
const tmdbId = Number(jellyfinItem.ProviderIds?.Tmdb);
const backdropUrl = jellyfinItem.BackdropImageTags?.length
? `${get(settings).jellyfin.baseUrl}/Items/${
jellyfinItem?.Id
}/Images/Backdrop?quality=100&tag=${jellyfinItem?.BackdropImageTags?.[0]}`
: '';
const posterUri = jellyfinItem.ImageTags?.Primary
? `${get(settings).jellyfin.baseUrl}/Items/${
jellyfinItem?.Id
}/Images/Backdrop?quality=100&tag=${jellyfinItem?.ImageTags?.Primary}`
: '';
const type: TitleType = jellyfinItem.Type === 'Movie' ? 'movie' : 'series';
const playableItem: PlayableItem = {
type,
tmdbId,
tmdbRating: jellyfinItem.CommunityRating || 0,
backdropUrl: backdropUrl,
posterUrl: posterUri,
continueWatching,
isPlayed: jellyfinItem.UserData?.Played || false,
jellyfinId: jellyfinItem.Id,
jellyfinItem,
jellyfinEpisodes: itemJellyfinEpisodes,
nextJellyfinEpisode: jellyfinNextUpEpisode
};
return playableItem;
});
await Promise.all([...moviesPromise, ...seriesPromise, ...jellyfinSourceItems]).then((r) =>
r.forEach((item) => {
items[item.tmdbId] = item;
})
);
return {
items,
itemsArray: Object.values(items),
continueWatching: Object.values(items).filter(
(i) => i.continueWatching || i.nextJellyfinEpisode
)
};
}
async function waitForSettings() {
return new Promise((resolve) => {
let resolved = false;
settings.subscribe((settings) => {
if (settings?.initialised && !resolved) {
resolved = true;
resolve(settings);
}
});
});
}
let delayedRefreshTimeout: NodeJS.Timeout;
function createLibraryStore() {
const { update, set, ...library } = writable<Promise<Library>>(
waitForSettings().then(() => getLibrary())
); //TODO promise to undefined
async function filterNotInLibrary<T>(toFilter: T[], getTmdbId: (item: T) => number) {
const libraryData = await get(library);
return toFilter.filter((item) => !(getTmdbId(item) in libraryData.items));
}
return {
...library,
refresh: async () => getLibrary().then((r) => set(Promise.resolve(r))),
refreshIn: async (ms: number) => {
clearTimeout(delayedRefreshTimeout);
delayedRefreshTimeout = setTimeout(() => {
getLibrary().then((r) => set(Promise.resolve(r)));
}, ms);
},
filterNotInLibrary
};
}
export const library = createLibraryStore();
function _createLibraryItemStore(tmdbId: number) {
const store = writable<{ loading: boolean; item?: PlayableItem }>({
loading: true,
item: undefined
});
library.subscribe(async (library) => {
const item = (await library).items[tmdbId];
store.set({ loading: false, item });
});
return {
subscribe: store.subscribe
};
}
const itemStores: Record<string, ReturnType<typeof _createLibraryItemStore>> = {};
export type LibraryItemStore = ReturnType<typeof _createLibraryItemStore>;
export function createLibraryItemStore(tmdbId: number) {
if (!itemStores[tmdbId]) {
itemStores[tmdbId] = _createLibraryItemStore(tmdbId);
}
return itemStores[tmdbId];
}

View File

@@ -1,15 +1,15 @@
import { writable } from 'svelte/store';
export function createLocalStorageStore<T>(key: string) {
const store = writable<T | null>(JSON.parse(localStorage.getItem(key) || 'null') || null);
export function createLocalStorageStore<T>(key: string, defaultValue: T) {
const store = writable<T>(JSON.parse(localStorage.getItem(key) || 'null') || defaultValue);
return {
subscribe: store.subscribe,
set: (value: T | null) => {
set: (value: T) => {
localStorage.setItem(key, JSON.stringify(value));
store.set(value);
}
};
}
export const skippedVersion = createLocalStorageStore('skipped-version');
export const skippedVersion = createLocalStorageStore<string | null>('skipped-version', null);

View File

@@ -1,4 +1,4 @@
import type { TitleType } from '$lib/types';
import type { TitleId, TitleType } from '$lib/types';
import { writable } from 'svelte/store';
import TitlePageModal from '../components/TitlePageLayout/TitlePageModal.svelte';
@@ -61,9 +61,11 @@ function createDynamicModalStack() {
export const modalStack = createDynamicModalStack();
let lastTitleModal: symbol | undefined = undefined;
export function openTitleModal(tmdbId: number, type: TitleType) {
export function openTitleModal(titleId: TitleId) {
if (lastTitleModal) {
modalStack.close(lastTitleModal);
}
lastTitleModal = modalStack.create(TitlePageModal, { tmdbId, type });
lastTitleModal = modalStack.create(TitlePageModal, {
titleId
});
}

View File

@@ -1 +1,6 @@
export type TitleType = 'movie' | 'series';
export type TitleId = {
id: number;
provider: 'tmdb' | 'tvdb';
type: TitleType;
};

View File

@@ -1,4 +1,7 @@
import { writable } from 'svelte/store';
import { get, writable } from 'svelte/store';
import type { SonarrSeries } from './apis/sonarr/sonarrApi';
import type { RadarrMovie } from './apis/radarr/radarrApi';
import { settings } from './stores/settings.store';
export function formatMinutesToTime(minutes: number) {
const days = Math.floor(minutes / 60 / 24);

View File

@@ -1,12 +1,16 @@
<script lang="ts">
import {
getJellyfinBackdrop,
getJellyfinContinueWatching,
getJellyfinNextUp
} from '$lib/apis/jellyfin/jellyfinApi';
import { getTmdbMovie, getTmdbPopularMovies } from '$lib/apis/tmdb/tmdbApi';
import Carousel from '$lib/components/Carousel/Carousel.svelte';
import CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte';
import Poster from '$lib/components/Poster/Poster.svelte';
import EpisodeCard from '$lib/components/EpisodeCard/EpisodeCard.svelte';
import TitleShowcase from '$lib/components/TitleShowcase/TitleShowcase.svelte';
import { library } from '$lib/stores/library.store';
import type { ComponentProps } from 'svelte';
import { _ } from 'svelte-i18n';
import { jellyfinItemsStore } from '$lib/stores/data.store';
import { log } from '$lib/utils';
let continueWatchingVisible = true;
@@ -14,41 +18,46 @@
.then((movies) => Promise.all(movies.map((movie) => getTmdbMovie(movie.id || 0))))
.then((movies) => movies.filter((m) => !!m).slice(0, 10));
let continueWatchingProps: Promise<(ComponentProps<Poster> & { runtime: number })[]> = $library
.then((libraryData) => libraryData.continueWatching)
let nextUpP = getJellyfinNextUp();
let continueWatchingP = getJellyfinContinueWatching();
let nextUpProps = Promise.all([nextUpP, continueWatchingP])
.then(([nextUp, continueWatching]) => [
...(continueWatching || []),
...(nextUp?.filter((i) => !continueWatching?.find((c) => c.SeriesId === i.SeriesId)) || [])
])
.then((items) =>
items.map((item) =>
item.type === 'movie'
? {
type: 'movie',
tmdbId: item.tmdbId || 0,
jellyfinId: item.jellyfinId,
backdropUrl: item.posterUrl || '',
title: item.radarrMovie?.title || item.jellyfinItem?.Name || '',
subtitle: item.radarrMovie?.genres?.join(', ') || '',
progress: item.continueWatching?.progress,
runtime: item.radarrMovie?.runtime || 0
}
: {
tmdbId: item.tmdbId || 0,
jellyfinId: item.nextJellyfinEpisode?.Id,
type: 'series',
backdropUrl: item.posterUrl || '',
title: item.nextJellyfinEpisode?.Name || item.sonarrSeries?.title || '',
subtitle:
(item.nextJellyfinEpisode?.IndexNumber &&
'Episode ' + item.nextJellyfinEpisode?.IndexNumber) ||
item.sonarrSeries?.genres?.join(', ') ||
'',
progress: item.continueWatching?.progress,
runtime: item.nextJellyfinEpisode?.RunTimeTicks
? item.nextJellyfinEpisode?.RunTimeTicks / 10_000_000 / 60
: item.sonarrSeries?.runtime || 0
}
Promise.all(
items?.map(async (item) => {
const parentSeries = await jellyfinItemsStore.promise.then((items) =>
items.find((i) => i.Id === item.SeriesId)
);
return {
tmdbId: Number(item.ProviderIds?.Tmdb) || Number(parentSeries?.ProviderIds?.Tmdb) || 0,
jellyfinId: item.Id,
backdropUrl: getJellyfinBackdrop(item),
title: item.Name || '',
progress: item.UserData?.PlayedPercentage || undefined,
runtime: item.RunTimeTicks ? item.RunTimeTicks / 10_000_000 / 60 : 0,
...(item.Type === 'Movie'
? {
type: 'movie',
subtitle: item.Genres?.join(', ') || ''
}
: {
type: 'series',
subtitle:
(item?.IndexNumber && 'Episode ' + item.IndexNumber) ||
item.Genres?.join(', ') ||
''
})
} as const;
})
)
);
continueWatchingProps.then((props) => {
nextUpProps.then((props) => {
if (props.length === 0) {
continueWatchingVisible = false;
}
@@ -96,17 +105,14 @@
<div class="py-8" hidden={!continueWatchingVisible}>
<Carousel gradientFromColor="from-stone-950" class="px-4 lg:px-16 2xl:px-32">
<div slot="title" class="text-xl font-medium text-zinc-200">Continue Watching</div>
{#await continueWatchingProps}
{#await nextUpProps}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop}
<Poster {...prop}>
<div slot="bottom-left" class="text-sm font-medium text-zinc-300">
{#if prop.progress}
{(prop.runtime - (prop.runtime / 100) * prop.progress).toFixed()} Minutes Left
{/if}
</div>
</Poster>
<EpisodeCard
on:click={() => (window.location.href = `/${prop.type}/${prop.tmdbId}`)}
{...prop}
/>
{/each}
{/await}
</Carousel>

View File

@@ -1,32 +1,68 @@
<script lang="ts">
import { TmdbApiOpen, type TmdbMovie2, type TmdbSeries2 } from '$lib/apis/tmdb/tmdbApi';
import type { JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
import { TmdbApiOpen, getTmdbItemBackdrop, getTmdbMovieBackdrop } from '$lib/apis/tmdb/tmdbApi';
import Card from '$lib/components/Card/Card.svelte';
import { fetchCardTmdbProps } from '$lib/components/Card/card';
import Carousel from '$lib/components/Carousel/Carousel.svelte';
import CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte';
import GenreCard from '$lib/components/GenreCard.svelte';
import NetworkCard from '$lib/components/NetworkCard.svelte';
import PeopleCard from '$lib/components/PeopleCard/PeopleCard.svelte';
import Poster from '$lib/components/Poster/Poster.svelte';
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
import { genres, networks } from '$lib/discover';
import { library } from '$lib/stores/library.store';
import { jellyfinItemsStore } from '$lib/stores/data.store';
import { settings } from '$lib/stores/settings.store';
import type { TitleType } from '$lib/types';
import { formatDateToYearMonthDay } from '$lib/utils';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import type { ComponentProps } from 'svelte';
import { _ } from 'svelte-i18n';
import { fade } from 'svelte/transition';
function parseIncludedLanguages(includedLanguages: string) {
return includedLanguages.replace(' ', '').split(',').join('|');
}
const jellyfinItemsPromise = new Promise<JellyfinItem[]>((resolve) => {
jellyfinItemsStore.subscribe((data) => {
if (data.loading) return;
resolve(data.data || []);
});
});
const fetchCardProps = async (items: TmdbMovie2[] | TmdbSeries2[]) =>
Promise.all(
(
await ($settings.discover.excludeLibraryItems
? library.filterNotInLibrary(items, (t) => t.id || 0)
: items)
).map(fetchCardTmdbProps)
const fetchCardProps = async (
items: {
name?: string;
title?: string;
id?: number;
vote_average?: number;
number_of_seasons?: number;
first_air_date?: string;
}[],
type: TitleType | undefined = undefined
): Promise<ComponentProps<Poster>[]> => {
const filtered = $settings.discover.excludeLibraryItems
? items.filter(
async (item) =>
!(await jellyfinItemsPromise).find((i) => i.ProviderIds?.Tmdb === String(item.id))
)
: items;
return Promise.all(
filtered.map(async (item) => {
const backdropUri = await getTmdbMovieBackdrop(item.id || 0);
const t =
type ||
(item?.number_of_seasons === undefined && item?.first_air_date === undefined
? 'movie'
: 'series');
return {
tmdbId: item.id || 0,
title: item.title || item.name || '',
// subtitle: item.subtitle || '',
rating: item.vote_average || undefined,
size: 'md',
backdropUrl: backdropUri ? TMDB_BACKDROP_SMALL + backdropUri : '',
type: t
} as const;
})
).then((props) => props.filter((p) => p.backdropUrl));
};
const fetchTrendingProps = () =>
TmdbApiOpen.get('/3/trending/all/{time_window}', {
@@ -89,7 +125,7 @@
}
})
.then((res) => res.data?.results || [])
.then(fetchCardProps);
.then((i) => fetchCardProps(i, 'series'));
const fetchDigitalReleases = () =>
TmdbApiOpen.get('/3/discover/movie', {
@@ -120,7 +156,11 @@
}
})
.then((res) => res.data?.results || [])
.then(fetchCardProps);
.then((i) => fetchCardProps(i, 'series'));
function parseIncludedLanguages(includedLanguages: string) {
return includedLanguages.replace(' ', '').split(',').join('|');
}
</script>
<div
@@ -141,7 +181,7 @@
<CarouselPlaceholderItems size="lg" />
{:then props}
{#each props as prop (prop.tmdbId)}
<Card size="lg" {...prop} />
<Poster {...prop} size="lg" />
{/each}
{/await}
</Carousel>
@@ -149,7 +189,7 @@
</div>
<div
class="flex flex-col gap-8 max-w-screen-2xl mx-auto py-4"
class="flex flex-col gap-12 max-w-screen-2xl mx-auto py-4"
in:fade|global={{
duration: $settings.animationDuration,
delay: $settings.animationDuration
@@ -170,7 +210,7 @@
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Card {...prop} />
<Poster {...prop} />
{/each}
{/await}
</Carousel>
@@ -179,7 +219,7 @@
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Card {...prop} />
<Poster {...prop} />
{/each}
{/await}
</Carousel>
@@ -193,7 +233,7 @@
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Card {...prop} />
<Poster {...prop} />
{/each}
{/await}
</Carousel>
@@ -202,7 +242,7 @@
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Card {...prop} />
<Poster {...prop} />
{/each}
{/await}
</Carousel>

View File

@@ -1,190 +1,69 @@
<script lang="ts">
import Card from '$lib/components/Card/Card.svelte';
import CardPlaceholder from '$lib/components/Card/CardPlaceholder.svelte';
import {
getJellyfinBackdrop,
getJellyfinPosterUrl,
type JellyfinItem
} from '$lib/apis/jellyfin/jellyfinApi';
import Button from '$lib/components/Button.svelte';
import Carousel from '$lib/components/Carousel/Carousel.svelte';
import UiCarousel from '$lib/components/Carousel/UICarousel.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { TMDB_IMAGES_ORIGINAL } from '$lib/constants';
import { library, type PlayableItem } from '$lib/stores/library.store';
import Poster from '$lib/components/Poster/Poster.svelte';
import { playerState } from '$lib/components/VideoPlayer/VideoPlayer';
import { PLACEHOLDER_BACKDROP } from '$lib/constants';
import { jellyfinItemsStore, servarrDownloadsStore } from '$lib/stores/data.store';
import { settings } from '$lib/stores/settings.store';
import classNames from 'classnames';
import { CaretDown, Gear } from 'radix-icons-svelte';
import { ChevronRight } from 'radix-icons-svelte';
import type { ComponentProps } from 'svelte';
import { fade } from 'svelte/transition';
import { _ } from 'svelte-i18n';
let itemsVisible: 'all' | 'movies' | 'shows' = 'all';
let sortBy: 'added' | 'rating' | 'release' | 'size' | 'name' = 'name';
let loading = true;
let noItems = false;
let searchInput: HTMLInputElement | undefined;
let searchInputValue = '';
import { fade } from 'svelte/transition';
import LibraryItems from './LibraryItems.svelte';
import { capitalize } from '$lib/utils';
import LazyImg from '$lib/components/LazyImg.svelte';
let openNextUpTab: 'downloading' | 'nextUp' = 'downloading';
let openTab: 'available' | 'watched' | 'unavailable' = 'available';
let noItems = false;
let items: PlayableItem[] = [];
let showcasePromise: Promise<JellyfinItem | undefined> = jellyfinItemsStore.promise.then(
(items) =>
items
?.slice()
?.sort((a, b) =>
(a.DateCreated || a.DateLastMediaAdded || '') <
(b.DateCreated || b.DateLastMediaAdded || '')
? 1
: -1
)?.[0]
);
let downloadingProps: ComponentProps<Card>[] = [];
let availableProps: ComponentProps<Card>[] = [];
let watchedProps: ComponentProps<Card>[] = [];
let unavailableProps: ComponentProps<Card>[] = [];
let nextUpProps: ComponentProps<Card>[] = [];
$: if (items.length) updateComponentProps(searchInputValue);
$: if (!downloadingProps.length && nextUpProps.length) openNextUpTab = 'nextUp';
library.subscribe(async (libraryPromise) => {
const libraryData = await libraryPromise;
items = libraryData.itemsArray;
loading = false;
noItems = !items.length;
});
function getComponentProps(item: PlayableItem) {
let props: ComponentProps<Card> | undefined;
const series = item.sonarrSeries;
const movie = item.radarrMovie;
if (item.type === 'series') {
props = {
size: 'dynamic',
let downloadProps: ComponentProps<Poster>[] = [];
$: {
const sonarrProps: ComponentProps<Poster>[] =
$servarrDownloadsStore.sonarrDownloads?.map((item) => ({
tvdbId: item.series.tvdbId,
title: item.series.title || '',
subtitle:
`S${item.episode?.seasonNumber}E${item.episode?.episodeNumber} • ` +
capitalize(item.status || ''),
type: 'series',
tmdbId: item.tmdbId,
title: series?.title || item.jellyfinItem?.Name || '',
genres: series?.genres || [],
backdropUrl: item.backdropUrl,
rating: series?.ratings?.value || series?.ratings?.value || item.tmdbRating || 0,
seasons: series?.seasons?.length || 0,
progress: item.nextJellyfinEpisode?.UserData?.PlayedPercentage || undefined
};
} else if (item.type === 'movie') {
props = {
size: 'dynamic',
progress: 100 * (((item.size || 0) - (item.sizeleft || 0)) / (item.size || 1)),
backdropUrl: item.series.images?.find((i) => i.coverType === 'poster')?.url || '',
orientation: 'portrait'
})) || [];
const radarrProps: ComponentProps<Poster>[] =
$servarrDownloadsStore.radarrDownloads?.map((item) => ({
tmdbId: item.movie.tmdbId,
title: item.movie.title || '',
subtitle: capitalize(item.status || ''),
type: 'movie',
tmdbId: item.tmdbId,
title: movie?.title || item.jellyfinItem?.Name || '',
genres: movie?.genres || [],
backdropUrl: item.backdropUrl,
rating: movie?.ratings?.tmdb?.value || movie?.ratings?.imdb?.value || 0,
runtimeMinutes: movie?.runtime || 0,
progress: item.jellyfinItem?.UserData?.PlayedPercentage || undefined
};
} else props = undefined;
backdropUrl: item.movie.images?.find((i) => i.coverType === 'poster')?.url || '',
progress: 100 * (((item.size || 0) - (item.sizeleft || 0)) / (item.size || 1)),
orientation: 'portrait'
})) || [];
return props;
}
function sortItems(items: PlayableItem[], criteria: typeof sortBy) {
return items.sort((a, b) => {
switch (criteria) {
case 'added':
return (b.radarrMovie?.added || b.sonarrSeries?.added || '') <
(a.radarrMovie?.added || a.sonarrSeries?.added || '')
? -1
: 1;
case 'rating':
return (b.tmdbRating || 0) - (a.tmdbRating || 0);
case 'release':
return (b.radarrMovie?.inCinemas || b.sonarrSeries?.firstAired || '') <
(a.radarrMovie?.inCinemas || a.sonarrSeries?.firstAired || '')
? -1
: 1;
case 'size':
return (
(b.radarrMovie?.sizeOnDisk || b.sonarrSeries?.statistics?.sizeOnDisk || 0) -
(a.radarrMovie?.sizeOnDisk || a.sonarrSeries?.statistics?.sizeOnDisk || 0)
);
case 'name':
return (b.radarrMovie?.title?.toLowerCase() ||
b.sonarrSeries?.title?.toLowerCase() ||
'') >
(a.radarrMovie?.title?.toLowerCase() || a.sonarrSeries?.title?.toLowerCase() || '')
? -1
: 1;
}
return 0;
});
}
function updateComponentProps(searchInputValue: string) {
const nextUpItems = sortItems([...items], 'added')
.filter(
(item) =>
(!item.isPlayed && item?.radarrMovie?.isAvailable && item?.radarrMovie?.movieFile) ||
item?.sonarrSeries?.seasons?.find((season) => !!season?.statistics?.episodeFileCount)
)
.splice(0, 6);
const libraryItems = sortItems([...items], sortBy).filter((item) => {
if (searchInputValue) {
return (
item.radarrMovie?.title?.toLowerCase().includes(searchInputValue.toLowerCase()) ||
item.sonarrSeries?.title?.toLowerCase().includes(searchInputValue.toLowerCase())
);
} else {
return true;
}
});
downloadingProps = [];
availableProps = [];
watchedProps = [];
unavailableProps = [];
nextUpProps = [];
for (let item of libraryItems) {
let props = getComponentProps(item);
if (!props) continue;
if (item.download) {
downloadingProps.push({
...props,
progress: item.download.progress,
completionTime: item.download.completionTime
});
} else if (item.isPlayed) {
watchedProps.push({ ...props, available: false });
} else if (
(item.type === 'movie' && item.jellyfinItem?.RunTimeTicks) ||
item.jellyfinEpisodes?.length
) {
availableProps.push(props);
} else {
unavailableProps.push({ ...props, available: false });
}
}
for (let item of nextUpItems) {
let props = getComponentProps(item);
if (!props) continue;
nextUpProps.push(props);
}
downloadingProps = downloadingProps;
availableProps = availableProps;
watchedProps = watchedProps;
unavailableProps = unavailableProps;
nextUpProps = nextUpProps;
}
function handleShortcuts(event: KeyboardEvent) {
if (event.key === 'f' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
searchInput?.focus();
}
downloadProps = [...(sonarrProps || []), ...(radarrProps || [])];
}
</script>
<svelte:window on:keydown={handleShortcuts} />
{#if noItems}
<div
class="h-screen flex items-center justify-center text-zinc-500 p-8"
@@ -206,62 +85,58 @@
}}
out:fade|global={{ duration: $settings.animationDuration }}
>
<div
style={"background-image: url('" +
TMDB_IMAGES_ORIGINAL +
(downloadingProps[0]?.backdropUrl || nextUpProps[0]?.backdropUrl) +
"');"}
class="absolute inset-0 h-[50vh] bg-center bg-cover"
>
<div class="relative pt-24">
{#await showcasePromise then showcase}
<LazyImg
src={(showcase && getJellyfinBackdrop(showcase)) || PLACEHOLDER_BACKDROP}
class="absolute inset-0"
/>
{/await}
<div class="absolute inset-0 bg-gradient-to-t from-stone-950 to-80% to-darken" />
</div>
</div>
<div
class="pt-24 pb-4 px-2 md:px-8 relative bg-center bg-cover"
in:fade|global={{
duration: $settings.animationDuration,
delay: $settings.animationDuration
}}
out:fade|global={{ duration: $settings.animationDuration }}
>
<div class="relative grid grid-cols-3 grid-rows-3 z-[1] max-w-screen-2xl mx-auto">
<div class="col-start-1 row-start-2 row-span-2 col-span-3 flex justify-end flex-col">
{#if downloadingProps.length || nextUpProps.length}
<Carousel>
<div slot="title" class="flex items-center gap-6 font-medium text-xl text-zinc-400">
{#if downloadingProps.length}
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openNextUpTab === 'downloading'
})}
on:click={() => (openNextUpTab = 'downloading')}
>
Download Queue
</button>
{/if}
{#if nextUpProps.length}
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openNextUpTab === 'nextUp'
})}
on:click={() => (openNextUpTab = 'nextUp')}
>
Next Up
</button>
{/if}
<div class="max-w-screen-2xl mx-auto relative z-[1] px-2 md:px-8 pt-56 pb-12">
<div class="flex gap-4 items-end">
{#await showcasePromise}
<div class="w-32 aspect-[2/3] placeholder rounded-lg shadow-lg" />
<div class="flex flex-col gap-2">
<div class="placeholder-text w-20">Placeholder</div>
<div class="placeholder-text w-[50vw] text-3xl sm:text-4xl md:text-5xl">
Placeholder
</div>
<div class="flex gap-2 mt-2">
<div class="placeholder-text w-28 h-10" />
<div class="placeholder-text w-28 h-10" />
</div>
</div>
{#if downloadingProps.length && openNextUpTab === 'downloading'}
{#each downloadingProps as props (props.tmdbId)}
<Card {...props} size="md" />
{/each}
{:else if openNextUpTab === 'nextUp'}
{#each nextUpProps as props (props.tmdbId)}
<Card {...props} size="md" />
{/each}
{/if}
</Carousel>
{/if}
{:then showcase}
<div
style={"background-image: url('" +
(showcase ? getJellyfinPosterUrl(showcase) : '') +
"');"}
class="w-32 aspect-[2/3] rounded-lg bg-center bg-cover flex-shrink-0 shadow-lg"
/>
<div>
<p class="text-zinc-400 font-medium">Latest Addition</p>
<h1 class="text-3xl sm:text-4xl md:text-5xl font-semibold">
{showcase?.Name}
</h1>
<div class="flex gap-2 mt-4">
<Button
type="primary"
on:click={() => showcase?.Id && playerState.streamJellyfinId(showcase?.Id)}
>
Play<ChevronRight size={20} />
</Button>
<Button
href={`/${showcase?.Type === 'Movie' ? 'movie' : 'series'}/${
showcase?.ProviderIds?.Tmdb || showcase?.ProviderIds?.Tvdb
}`}
>
<span>{$_('titleShowcase.details')}</span><ChevronRight size={20} />
</Button>
</div>
</div>
{/await}
</div>
</div>
</div>
</div>
@@ -274,70 +149,18 @@
}}
out:fade|global={{ duration: $settings.animationDuration }}
>
<div class="max-w-screen-2xl m-auto flex flex-col gap-4">
<div class="flex items-center justify-between gap-2">
<UiCarousel>
<div class="flex gap-6 text-lg font-medium text-zinc-400">
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openTab === 'available'
})}
on:click={() => (openTab = 'available')}
>
{$_('library.available')}
</button>
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openTab === 'watched'
})}
on:click={() => (openTab = 'watched')}
>
{$_('library.watched')}
</button>
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openTab === 'unavailable'
})}
on:click={() => (openTab = 'unavailable')}
>
{$_('library.unavailable')}
</button>
</div>
</UiCarousel>
<div class="flex items-center gap-3 justify-end flex-shrink-0 flex-initial relative">
<IconButton>
<div class="flex gap-0.5 items-center text-sm">
<span>
{$_('library.sort.byTitle')}
</span>
<CaretDown size={20} />
</div>
</IconButton>
<IconButton>
<Gear size={20} />
</IconButton>
</div>
</div>
{#if loading}
<div
class="grid gap-x-2 sm:gap-x-4 gap-y-4 sm:gap-y-8 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
{#each [...Array(20).keys()] as index (index)}
<CardPlaceholder size="dynamic" {index} />
{/each}
</div>
{:else}
<div
class="grid gap-x-2 sm:gap-x-4 gap-y-4 sm:gap-y-8 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
{#each { available: availableProps, watched: watchedProps, unavailable: unavailableProps }[openTab] as props (props.tmdbId)}
<Card {...props} />
{:else}
<div class="flex-1 flex items-center text-zinc-500">No items.</div>
{/each}
<div class="max-w-screen-2xl m-auto flex flex-col gap-12">
{#if downloadProps?.length}
<div>
<Carousel heading="Downloading">
{#each downloadProps as props}
<Poster {...props} />
{/each}
</Carousel>
</div>
{/if}
<LibraryItems />
</div>
</div>
{/if}

View File

@@ -0,0 +1,318 @@
<script lang="ts">
import UiCarousel from '$lib/components/Carousel/UICarousel.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { settings } from '$lib/stores/settings.store';
import classNames from 'classnames';
import { fade, fly } from 'svelte/transition';
import { _ } from 'svelte-i18n';
import { CaretDown, ChevronDown, Cross2, Gear, MagnifyingGlass } from 'radix-icons-svelte';
import CardPlaceholder from '$lib/components/Card/CardPlaceholder.svelte';
import { tick, type ComponentProps } from 'svelte';
import Poster from '$lib/components/Poster/Poster.svelte';
import { getJellyfinPosterUrl, type JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
import { getRadarrPosterUrl, type RadarrMovie } from '$lib/apis/radarr/radarrApi';
import { getSonarrPosterUrl, type SonarrSeries } from '$lib/apis/sonarr/sonarrApi';
import { jellyfinItemsStore, radarrMoviesStore, sonarrSeriesStore } from '$lib/stores/data.store';
import Button from '$lib/components/Button.svelte';
import ContextMenu from '$lib/components/ContextMenu/ContextMenu.svelte';
import SelectableContextMenuItem from '$lib/components/ContextMenu/SelectableContextMenuItem.svelte';
import ContextMenuDivider from '$lib/components/ContextMenu/ContextMenuDivider.svelte';
import { createLocalStorageStore } from '$lib/stores/localstorage.store';
type SortBy = 'Date Added' | 'Rating' | 'Relase Date' | 'Size' | 'Name';
type SortOrder = 'Ascending' | 'Descending';
const PAGE_SIZE = 100;
const SORT_OPTIONS = ['Date Added', 'Rating', 'Relase Date', 'Size', 'Name'] as const;
const SORT_ORDER = ['Ascending', 'Descending'] as const;
let itemsVisible: 'all' | 'movies' | 'shows' = 'all';
const sortBy = createLocalStorageStore<SortBy>('library-sort-by', 'Date Added');
const sortOrder = createLocalStorageStore<SortOrder>('library-sort-order', 'Descending');
let searchQuery = '';
let openTab: 'available' | 'watched' | 'unavailable' = 'available';
let page = 0;
let searchVisible = false;
let searchInput: HTMLInputElement | undefined;
let libraryLoading = true;
let posterProps: ComponentProps<Poster>[] = [];
let hasMore = true;
$: loadPosterProps(openTab, page, $sortBy, $sortOrder, searchQuery);
function getPropsFromJellyfinItem(item: JellyfinItem): ComponentProps<Poster> {
return {
tmdbId: Number(item.ProviderIds?.Tmdb) || 0,
jellyfinId: item.Id,
title: item.Name || undefined,
subtitle: item.Genres?.join(', ') || undefined,
backdropUrl: getJellyfinPosterUrl(item, 80),
size: 'dynamic',
...(item.Type === 'Movie' ? { type: 'movie' } : { type: 'series' }),
orientation: 'portrait',
rating: item.CommunityRating || undefined
};
}
function getPropsfromServarrItem(item: RadarrMovie | SonarrSeries): ComponentProps<Poster> {
if ((<any>item)?.tmdbId) {
const movie = item as RadarrMovie;
return {
tmdbId: movie.tmdbId || 0,
title: movie.title || undefined,
subtitle: movie.genres?.join(', ') || undefined,
backdropUrl: getRadarrPosterUrl(movie),
size: 'dynamic',
type: 'movie',
orientation: 'portrait',
rating: movie.ratings?.tmdb?.value || undefined
};
} else {
const series = item as SonarrSeries;
return {
tvdbId: series.tvdbId || 0,
title: item.title || undefined,
subtitle: item.genres?.join(', ') || undefined,
backdropUrl: getSonarrPosterUrl(series),
size: 'dynamic',
type: 'series',
tmdbId: undefined,
orientation: 'portrait',
rating: series.ratings?.value || undefined
};
}
}
async function loadPosterProps(
tab: typeof openTab,
page: number,
sort: SortBy,
order: SortOrder,
searchQuery: string
) {
if (page === 0) posterProps = [];
const jellyfinItemsPromise = jellyfinItemsStore.promise
.then((i) => i.filter((i) => i.Name?.toLowerCase().includes(searchQuery.toLowerCase())) || [])
.then((i) => {
const sorted = i.sort((a, b) => {
if (sort === 'Date Added') {
return new Date(b.DateCreated || 0).getTime() - new Date(a.DateCreated || 0).getTime();
} else if (sort === 'Rating') {
return (b.CommunityRating || 0) - (a.CommunityRating || 0);
} else if (sort === 'Relase Date') {
return (
new Date(b.PremiereDate || 0).getTime() - new Date(a.PremiereDate || 0).getTime()
);
} else if (sort === 'Size') {
return (b.RunTimeTicks || 0) - (a.RunTimeTicks || 0);
} else if (sort === 'Name') {
return (b.Name || '').localeCompare(a.Name || '');
} else {
return 0;
}
});
if (order === 'Ascending') {
return sorted.reverse();
} else {
return sorted;
}
});
let props: ComponentProps<Poster>[] = [];
if (tab === 'available') {
props = await jellyfinItemsPromise.then((items) =>
items.filter((i) => !i.UserData?.Played).map((item) => getPropsFromJellyfinItem(item))
);
} else if (tab === 'watched') {
props = await jellyfinItemsPromise.then((items) =>
items.filter((i) => i.UserData?.Played).map((item) => getPropsFromJellyfinItem(item))
);
} else if (tab === 'unavailable') {
props = await Promise.all([
radarrMoviesStore.promise,
sonarrSeriesStore.promise,
jellyfinItemsPromise
])
.then(([radarr, sonarr, jellyfinItems]) => ({
items: [...radarr, ...sonarr],
jellyfinItems
}))
.then(({ items, jellyfinItems }) =>
items
.filter((i) => i.title?.toLowerCase().includes(searchQuery.toLowerCase()))
.filter(
(i) =>
!jellyfinItems.find((j) => j.ProviderIds?.Tmdb === String((<any>i).tmdbId || '-'))
)
.filter(
(i) =>
!jellyfinItems.find((j) => j.ProviderIds?.Tvdb === String((<any>i).tvdbId || '-'))
)
.map((i) => getPropsfromServarrItem(i))
);
}
const toAdd = props.slice(PAGE_SIZE * page, PAGE_SIZE * (page + 1));
hasMore = toAdd.length === PAGE_SIZE;
libraryLoading = false;
posterProps = [...posterProps, ...toAdd];
}
function handleTabChange(tab: typeof openTab) {
openTab = tab;
page = 0;
}
async function handleOpenSearch() {
searchVisible = true;
await tick();
searchInput?.focus();
}
async function handleCloseSearch() {
searchVisible = false;
searchQuery = '';
}
async function handleShortcuts(event: KeyboardEvent) {
if (event.key === 'f' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
handleOpenSearch();
} else if (event.key === 'Escape') {
handleCloseSearch();
}
}
</script>
<svelte:window on:keydown={handleShortcuts} />
{#if searchVisible}
<div
transition:fly={{ y: 5, duration: 200 }}
class="fixed top-20 left-1/2 w-80 -ml-40 z-10 bg-[#33333388] backdrop-blur-xl rounded-full
flex items-center text-zinc-300"
>
<div class="absolute inset-y-0 left-4 flex items-center justify-center">
<MagnifyingGlass size={20} />
</div>
<div class="absolute inset-y-0 right-4 flex items-center justify-center">
<IconButton on:click={handleCloseSearch}>
<Cross2 size={20} />
</IconButton>
</div>
<input
bind:this={searchInput}
bind:value={searchQuery}
placeholder="Seach in library"
class="appearance-none mx-2.5 my-2.5 px-10 bg-transparent outline-none placeholder:text-zinc-400 font-medium w-full"
/>
</div>
{/if}
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-4">
<UiCarousel>
<div class="flex gap-6 text-lg font-medium text-zinc-400">
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openTab === 'available'
})}
on:click={() => handleTabChange('available')}
>
{$_('library.available')}
</button>
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openTab === 'watched'
})}
on:click={() => handleTabChange('watched')}
>
{$_('library.watched')}
</button>
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openTab === 'unavailable'
})}
on:click={() => handleTabChange('unavailable')}
>
{$_('library.unavailable')}
</button>
</div>
</UiCarousel>
<div class="flex items-center gap-3 justify-end flex-shrink-0 flex-initial relative">
<ContextMenu heading="Sort By" position="absolute">
<svelte:fragment slot="menu">
{#each SORT_OPTIONS as sortOption}
<SelectableContextMenuItem
selected={$sortBy === sortOption}
on:click={() => {
sortBy.set(sortOption);
page = 0;
}}
>
{sortOption}
</SelectableContextMenuItem>
{/each}
<ContextMenuDivider />
{#each SORT_ORDER as order}
<SelectableContextMenuItem
selected={$sortOrder === order}
on:click={() => {
sortOrder.set(order);
page = 0;
}}
>
{order}
</SelectableContextMenuItem>
{/each}
</svelte:fragment>
<IconButton>
<div class="flex gap-1 items-center">
{$sortBy}
<ChevronDown size={20} />
</div>
</IconButton>
</ContextMenu>
<IconButton on:click={handleOpenSearch}>
<MagnifyingGlass size={20} />
</IconButton>
</div>
</div>
<div
class="grid gap-x-4 gap-y-4 sm:gap-y-8 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7"
>
{#if libraryLoading}
{#each [...Array(20).keys()] as index (index)}
<CardPlaceholder orientation="portrait" size="dynamic" {index} />
{/each}
{:else}
{#each posterProps.slice(0, PAGE_SIZE + page * PAGE_SIZE) as prop}
<Poster {...prop} />
{:else}
<div class="flex-1 flex font-medium text-zinc-500 col-span-full mb-64">
{openTab === 'available'
? 'Your Jellyfin library items will appear here.'
: openTab === 'watched'
? 'Your watched Jellyfin items will appear here.'
: "Your Radarr and Sonarr items that aren't available will appear here."}
</div>
{/each}
{/if}
</div>
{#if !libraryLoading && posterProps.length > 0}
<div class="mx-auto my-4">
<Button on:click={() => (page = page + 1)} disabled={!hasMore}>Load More</Button>
</div>
{/if}
</div>

View File

@@ -9,99 +9,120 @@
import Card from '$lib/components/Card/Card.svelte';
import { fetchCardTmdbProps } from '$lib/components/Card/card';
import CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte';
import { modalStack } from '$lib/stores/modal.store';
import PeopleCard from '$lib/components/PeopleCard/PeopleCard.svelte';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import RequestModal from '$lib/components/RequestModal/RequestModal.svelte';
import OpenInButton from '$lib/components/TitlePageLayout/OpenInButton.svelte';
import TitlePageLayout from '$lib/components/TitlePageLayout/TitlePageLayout.svelte';
import { playerState } from '$lib/components/VideoPlayer/VideoPlayer';
import { createLibraryItemStore, library } from '$lib/stores/library.store';
import {
createJellyfinItemStore,
createRadarrDownloadStore,
createRadarrMovieStore
} from '$lib/stores/data.store';
import { modalStack } from '$lib/stores/modal.store';
import { settings } from '$lib/stores/settings.store';
import { formatMinutesToTime, formatSize } from '$lib/utils';
import classNames from 'classnames';
import { Archive, ChevronRight, Plus } from 'radix-icons-svelte';
import { Archive, ChevronRight, DotFilled, Plus } from 'radix-icons-svelte';
import type { ComponentProps } from 'svelte';
export let tmdbId: number;
export let isModal = false;
export let handleCloseModal: () => void = () => {};
const tmdbUrl = 'https://www.themoviedb.org/movie/' + tmdbId;
const data = loadInitialPageData();
const itemStore = createLibraryItemStore(tmdbId);
const jellyfinItemStore = createJellyfinItemStore(tmdbId);
const radarrMovieStore = createRadarrMovieStore(tmdbId);
const radarrDownloadStore = createRadarrDownloadStore(radarrMovieStore);
const tmdbMoviePromise = getTmdbMovie(tmdbId);
const tmdbRecommendationProps = getTmdbMovieRecommendations(tmdbId)
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
.then((r) => r.filter((p) => p.backdropUrl));
const tmdbSimilarProps = getTmdbMovieSimilar(tmdbId)
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
.then((r) => r.filter((p) => p.backdropUrl));
const castProps: Promise<ComponentProps<PeopleCard>[]> = tmdbMoviePromise.then((m) =>
Promise.all(
m?.credits?.cast?.slice(0, 20).map((m) => ({
tmdbId: m.id || 0,
backdropUri: m.profile_path || '',
name: m.name || '',
subtitle: m.character || m.known_for_department || ''
})) || []
)
);
async function loadInitialPageData() {
const tmdbMoviePromise = getTmdbMovie(tmdbId);
function play() {
if ($itemStore.item?.jellyfinItem?.Id)
playerState.streamJellyfinId($itemStore.item?.jellyfinItem?.Id);
const tmdbRecommendationProps = getTmdbMovieRecommendations(tmdbId)
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
.then((r) => r.filter((p) => p.backdropUrl));
const tmdbSimilarProps = getTmdbMovieSimilar(tmdbId)
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
.then((r) => r.filter((p) => p.backdropUrl));
const castPropsPromise: Promise<ComponentProps<PeopleCard>[]> = tmdbMoviePromise.then((m) =>
Promise.all(
m?.credits?.cast?.slice(0, 20).map((m) => ({
tmdbId: m.id || 0,
backdropUri: m.profile_path || '',
name: m.name || '',
subtitle: m.character || m.known_for_department || ''
})) || []
)
);
return {
tmdbMovie: await tmdbMoviePromise,
tmdbRecommendationProps: await tmdbRecommendationProps,
tmdbSimilarProps: await tmdbSimilarProps,
castProps: await castPropsPromise
};
}
async function refresh() {
await library.refresh();
function play() {
if ($jellyfinItemStore.item?.Id) playerState.streamJellyfinId($jellyfinItemStore.item?.Id);
}
async function refreshRadarr() {
await radarrMovieStore.refreshIn();
}
let addToRadarrLoading = false;
function addToRadarr() {
addToRadarrLoading = true;
addMovieToRadarr(tmdbId)
.then(refresh)
.then(refreshRadarr)
.finally(() => (addToRadarrLoading = false));
}
function openRequestModal() {
if (!$itemStore.item?.radarrMovie?.id) return;
if (!$radarrMovieStore.item?.id) return;
modalStack.create(RequestModal, {
radarrId: $itemStore.item?.radarrMovie?.id
radarrId: $radarrMovieStore.item?.id
});
}
</script>
{#await tmdbMoviePromise then movie}
{#await data}
<TitlePageLayout {isModal} {handleCloseModal} />
{:then { tmdbMovie, tmdbRecommendationProps, tmdbSimilarProps, castProps }}
{@const movie = tmdbMovie}
<TitlePageLayout
titleInformation={{
tmdbId,
type: 'movie',
title: movie?.title || 'Movie',
backdropUriCandidates: movie?.images?.backdrops?.map((b) => b.file_path || '') || [],
posterPath: movie?.poster_path || '',
tagline: movie?.tagline || movie?.title || '',
overview: movie?.overview || ''
}}
{isModal}
{tmdbId}
{handleCloseModal}
type="movie"
title={movie?.title || 'Movie'}
backdropUriCandidates={movie?.images?.backdrops?.map((b) => b.file_path || '') || []}
posterPath={movie?.poster_path || ''}
tagline={movie?.tagline || movie?.title || ''}
overview={movie?.overview || ''}
>
<svelte:fragment slot="title-info-1"
>{new Date(movie?.release_date || Date.now()).getFullYear()}</svelte:fragment
>
<svelte:fragment slot="title-info-2">
{@const progress = $itemStore.item?.continueWatching?.progress}
<svelte:fragment slot="title-info">
{new Date(movie?.release_date || Date.now()).getFullYear()}
<DotFilled />
{@const progress = $jellyfinItemStore.item?.UserData?.PlayedPercentage}
{#if progress}
{progress.toFixed()} min left
{:else}
{movie?.runtime} min
{/if}
</svelte:fragment>
<svelte:fragment slot="title-info-3">
<DotFilled />
<a href={tmdbUrl} target="_blank">{movie?.vote_average?.toFixed(1)} TMDB</a>
</svelte:fragment>
<svelte:fragment slot="episodes-carousel">
{@const progress = $itemStore.item?.continueWatching?.progress}
{@const progress = $jellyfinItemStore.item?.UserData?.PlayedPercentage}
{#if progress}
<div
class={classNames('px-2 sm:px-4 lg:px-8', {
@@ -117,19 +138,21 @@
<div
class="flex gap-2 items-center flex-row-reverse justify-end lg:flex-row lg:justify-start"
>
{#if $itemStore.loading}
{#if $jellyfinItemStore.loading || $radarrMovieStore.loading}
<div class="placeholder h-10 w-48 rounded-xl" />
{:else}
<OpenInButton title={movie?.title} {itemStore} type="movie" {tmdbId} />
{#if $itemStore.item?.jellyfinItem}
{@const jellyfinItem = $jellyfinItemStore.item}
{@const radarrMovie = $radarrMovieStore.item}
<OpenInButton title={movie?.title} {jellyfinItem} {radarrMovie} type="movie" {tmdbId} />
{#if jellyfinItem}
<Button type="primary" on:click={play}>
<span>Watch</span><ChevronRight size={20} />
<span>Play</span><ChevronRight size={20} />
</Button>
{:else if !$itemStore.item?.radarrMovie && $settings.radarr.baseUrl && $settings.radarr.apiKey}
{:else if !radarrMovie && $settings.radarr.baseUrl && $settings.radarr.apiKey}
<Button type="primary" disabled={addToRadarrLoading} on:click={addToRadarr}>
<span>Add to Radarr</span><Plus size={20} />
</Button>
{:else if $itemStore.item?.radarrMovie}
{:else if radarrMovie}
<Button type="primary" on:click={openRequestModal}>
<span class="mr-2">Request Movie</span><Plus size={20} />
</Button>
@@ -192,85 +215,35 @@
{movie?.runtime} Minutes
</h2>
</div>
<!-- {#if series?.first_air_date}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">First Air Date</p>
<h2 class="font-medium">
{new Date(series?.first_air_date).toLocaleDateString('en', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</h2>
</div>
{/if}
{#if series?.next_episode_to_air}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Next Air Date</p>
<h2 class="font-medium">
{new Date(series.next_episode_to_air?.air_date).toLocaleDateString('en', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</h2>
</div>
{:else if series?.last_air_date}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Last Air Date</p>
<h2 class="font-medium">
{new Date(series.last_air_date).toLocaleDateString('en', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</h2>
</div>
{/if}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Networks</p>
<h2 class="font-medium">{series?.networks?.map((n) => n.name).join(', ')}</h2>
</div>
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Episode Run Time</p>
<h2 class="font-medium">{series?.episode_run_time} Minutes</h2>
</div>
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Spoken Languages</p>
<h2 class="font-medium">
{series?.spoken_languages?.map((l) => capitalize(l.name || '')).join(', ')}
</h2>
</div> -->
</svelte:fragment>
<svelte:fragment slot="servarr-components">
{#if !$itemStore.loading && $itemStore.item}
{@const item = $itemStore.item}
{#if item.radarrMovie?.movieFile?.quality}
{@const radarrMovie = $radarrMovieStore.item}
{#if radarrMovie}
{#if radarrMovie?.movieFile?.quality}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Video</p>
<h2 class="font-medium">
{item.radarrMovie?.movieFile?.quality.quality?.name}
{radarrMovie?.movieFile?.quality.quality?.name}
</h2>
</div>
{/if}
{#if item.radarrMovie?.movieFile?.size}
{#if radarrMovie?.movieFile?.size}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Size On Disk</p>
<h2 class="font-medium">
{formatSize(item.radarrMovie?.movieFile?.size || 0)}
{formatSize(radarrMovie?.movieFile?.size || 0)}
</h2>
</div>
{/if}
{#if $itemStore.item?.download}
{#if $radarrDownloadStore.downloads?.length}
{@const download = $radarrDownloadStore.downloads[0]}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Download Completed In</p>
<p class="text-zinc-400 text-sm">Downloaded In</p>
<h2 class="font-medium">
{$itemStore.item?.download.completionTime
{download?.estimatedCompletionTime
? formatMinutesToTime(
(new Date($itemStore.item?.download.completionTime).getTime() - Date.now()) /
1000 /
60
(new Date(download.estimatedCompletionTime).getTime() - Date.now()) / 1000 / 60
)
: 'Stalled'}
</h2>
@@ -285,16 +258,7 @@
<span class="mr-2">Manage</span><Archive size={20} />
</Button>
</div>
<!-- <div
class="flex-1 flex justify-between py-2 gap-4 items-start sm:items-center flex-col sm:flex-row"
>
<div>
<h1 class="font-medium text-lg">No sources found</h1>
<p class="text-sm text-zinc-400">Check your source settings</p>
</div>
<Button type="secondary"><span>Add to Sonarr</span><Plus size={20} /></Button>
</div> -->
{:else if $itemStore.loading}
{:else if $radarrMovieStore.loading}
<div class="flex gap-4 flex-wrap col-span-4 sm:col-span-6 mt-4">
<div class="placeholder h-10 w-40 rounded-xl" />
<div class="placeholder h-10 w-40 rounded-xl" />
@@ -336,11 +300,3 @@
</svelte:fragment>
</TitlePageLayout>
{/await}
<!-- {#if requestModalVisible} -->
<!-- {@const radarrMovie = $itemStore.item?.radarrMovie} -->
<!-- {#if radarrMovie && radarrMovie.id} -->
<!-- <RequestModal modalProps={requestModalProps} radarrId={radarrMovie.id} /> -->
<!-- {/if} -->
<!-- {/if} -->
<!-- -->

View File

@@ -1,13 +1,14 @@
<script lang="ts">
import type { TitleId } from '$lib/types';
import type { PageData } from './$types';
import SeriesPage from './SeriesPage.svelte';
export let data: PageData;
let tmdbId: number;
$: tmdbId = Number(data.tmdbId);
let titleId: TitleId;
$: titleId = { provider: 'tmdb', id: data.tmdbId, type: 'series' };
</script>
{#key tmdbId}
<SeriesPage {tmdbId} />
{#key titleId}
<SeriesPage {titleId} />
{/key}

View File

@@ -2,6 +2,6 @@ import type { PageLoad } from './$types';
export const load = (async ({ params }) => {
return {
tmdbId: params.id
tmdbId: Number(params.id)
};
}) satisfies PageLoad;

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import type { JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
import { addSeriesToSonarr } from '$lib/apis/sonarr/sonarrApi';
import { getJellyfinEpisodes, type JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
import { addSeriesToSonarr, type SonarrSeries } from '$lib/apis/sonarr/sonarrApi';
import {
getTmdbIdFromTvdbId,
getTmdbSeries,
getTmdbSeriesRecommendations,
getTmdbSeriesSeasons,
@@ -14,119 +15,162 @@
import CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte';
import UiCarousel from '$lib/components/Carousel/UICarousel.svelte';
import EpisodeCard from '$lib/components/EpisodeCard/EpisodeCard.svelte';
import { modalStack } from '$lib/stores/modal.store';
import PeopleCard from '$lib/components/PeopleCard/PeopleCard.svelte';
import SeriesRequestModal from '$lib/components/RequestModal/SeriesRequestModal.svelte';
import OpenInButton from '$lib/components/TitlePageLayout/OpenInButton.svelte';
import TitlePageLayout from '$lib/components/TitlePageLayout/TitlePageLayout.svelte';
import { playerState } from '$lib/components/VideoPlayer/VideoPlayer';
import { createLibraryItemStore, library } from '$lib/stores/library.store';
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
import {
createJellyfinItemStore,
createSonarrDownloadStore,
createSonarrSeriesStore
} from '$lib/stores/data.store';
import { modalStack } from '$lib/stores/modal.store';
import { settings } from '$lib/stores/settings.store';
import type { TitleId } from '$lib/types';
import { capitalize, formatMinutesToTime, formatSize } from '$lib/utils';
import classNames from 'classnames';
import { Archive, ChevronLeft, ChevronRight, Plus } from 'radix-icons-svelte';
import { Archive, ChevronLeft, ChevronRight, DotFilled, Plus } from 'radix-icons-svelte';
import type { ComponentProps } from 'svelte';
import { get } from 'svelte/store';
export let tmdbId: number;
export let titleId: TitleId;
export let isModal = false;
export let handleCloseModal: () => void = () => {};
const tmdbUrl = 'https://www.themoviedb.org/tv/' + tmdbId;
const itemStore = createLibraryItemStore(tmdbId);
let data = loadInitialPageData();
const jellyfinItemStore = createJellyfinItemStore(data.then((d) => d.tmdbId));
const sonarrSeriesStore = createSonarrSeriesStore(data.then((d) => d.tmdbSeries?.name || ''));
const sonarrDownloadStore = createSonarrDownloadStore(sonarrSeriesStore);
let seasonSelectVisible = false;
let visibleSeasonNumber: number | undefined = undefined;
let visibleSeasonNumber: number = 1;
let visibleEpisodeIndex: number | undefined = undefined;
function openRequestModal() {
if (
!$itemStore.item?.sonarrSeries?.id ||
!$itemStore.item?.sonarrSeries?.statistics?.seasonCount
)
return;
modalStack.create(SeriesRequestModal, {
sonarrId: $itemStore.item?.sonarrSeries?.id || 0,
seasons: $itemStore.item?.sonarrSeries?.statistics?.seasonCount || 0,
heading: $itemStore.item?.sonarrSeries?.title || 'Series'
});
}
let episodeProps: ComponentProps<EpisodeCard>[][] = [];
let jellyfinEpisodeData: {
[key: string]: {
jellyfinId: string | undefined;
progress: number;
watched: boolean;
};
} = {};
let episodeComponents: HTMLDivElement[] = [];
let nextJellyfinEpisode: JellyfinItem | undefined = undefined;
const tmdbSeriesPromise = getTmdbSeries(tmdbId);
const tmdbSeasonsPromise = tmdbSeriesPromise.then((s) =>
getTmdbSeriesSeasons(tmdbId, s?.number_of_seasons || 0)
);
// Refresh jellyfin episode data
jellyfinItemStore.subscribe(async (value) => {
const item = value.item;
if (!item?.Id) return;
const episodes = await getJellyfinEpisodes(item.Id);
const tmdbRecommendationProps = getTmdbSeriesRecommendations(tmdbId).then((r) =>
Promise.all(r.map(fetchCardTmdbProps))
);
const tmdbSimilarProps = getTmdbSeriesSimilar(tmdbId)
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
.then((r) => r.filter((p) => p.backdropUrl));
const castProps: Promise<ComponentProps<PeopleCard>[]> = tmdbSeriesPromise.then((s) =>
Promise.all(
s?.aggregate_credits?.cast?.slice(0, 20)?.map((m) => ({
tmdbId: m.id || 0,
backdropUri: m.profile_path || '',
name: m.name || '',
subtitle: m.roles?.[0]?.character || m.known_for_department || ''
})) || []
)
);
episodes?.forEach((episode) => {
const key = `S${episode?.ParentIndexNumber}E${episode?.IndexNumber}`;
itemStore.subscribe(async (libraryItem) => {
const tmdbSeasons = await tmdbSeasonsPromise;
if (!nextJellyfinEpisode && episode?.UserData?.Played === false) {
nextJellyfinEpisode = episode;
}
tmdbSeasons.forEach((season) => {
const episodes: ComponentProps<EpisodeCard>[] = [];
season?.episodes?.forEach((tmdbEpisode) => {
const jellyfinEpisode = libraryItem.item?.jellyfinEpisodes?.find(
(e) =>
e?.IndexNumber === tmdbEpisode?.episode_number &&
e?.ParentIndexNumber === tmdbEpisode?.season_number
);
const jellyfinEpisodeId = jellyfinEpisode?.Id;
if (!nextJellyfinEpisode && jellyfinEpisode?.UserData?.Played === false) {
nextJellyfinEpisode = jellyfinEpisode;
}
episodes.push({
title: tmdbEpisode?.name || '',
subtitle: `Episode ${tmdbEpisode?.episode_number}`,
backdropPath: tmdbEpisode?.still_path || '',
progress: jellyfinEpisode?.UserData?.PlayedPercentage || 0,
watched: jellyfinEpisode?.UserData?.Played || false,
jellyfinId: jellyfinEpisodeId
});
});
episodeProps[season?.season_number || 0] = episodes;
jellyfinEpisodeData[key] = {
jellyfinId: episode?.Id,
progress: episode?.UserData?.PlayedPercentage || 0,
watched: episode?.UserData?.Played || false
};
});
if (!nextJellyfinEpisode) nextJellyfinEpisode = libraryItem.item?.jellyfinEpisodes?.[0];
visibleSeasonNumber = nextJellyfinEpisode?.ParentIndexNumber || visibleSeasonNumber || 1;
if (!nextJellyfinEpisode) nextJellyfinEpisode = episodes?.[0];
visibleSeasonNumber = nextJellyfinEpisode?.ParentIndexNumber || visibleSeasonNumber;
});
async function loadInitialPageData() {
const tmdbId = await (titleId.provider === 'tvdb'
? getTmdbIdFromTvdbId(titleId.id)
: Promise.resolve(titleId.id));
const tmdbSeriesPromise = getTmdbSeries(tmdbId);
const tmdbSeasonsPromise = tmdbSeriesPromise.then((s) =>
getTmdbSeriesSeasons(s?.id || 0, s?.number_of_seasons || 0)
);
const tmdbUrl = 'https://www.themoviedb.org/tv/' + tmdbId;
const tmdbRecommendationPropsPromise = getTmdbSeriesRecommendations(tmdbId).then((r) =>
Promise.all(r.map(fetchCardTmdbProps))
);
const tmdbSimilarPropsPromise = getTmdbSeriesSimilar(tmdbId)
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
.then((r) => r.filter((p) => p.backdropUrl));
const castPropsPromise: Promise<ComponentProps<PeopleCard>[]> = tmdbSeriesPromise.then((s) =>
Promise.all(
s?.aggregate_credits?.cast?.slice(0, 20)?.map((m) => ({
tmdbId: m.id || 0,
backdropUri: m.profile_path || '',
name: m.name || '',
subtitle: m.roles?.[0]?.character || m.known_for_department || ''
})) || []
)
);
const tmdbEpisodePropsPromise: Promise<ComponentProps<EpisodeCard>[][]> =
tmdbSeasonsPromise.then((seasons) =>
seasons.map(
(season) =>
season?.episodes?.map((episode) => ({
title: episode?.name || '',
subtitle: `Episode ${episode?.episode_number}`,
backdropUrl: TMDB_BACKDROP_SMALL + episode?.still_path || '',
airDate:
episode.air_date && new Date(episode.air_date) > new Date()
? new Date(episode.air_date)
: undefined
})) || []
)
);
return {
tmdbId,
tmdbSeries: await tmdbSeriesPromise,
tmdbSeasons: await tmdbSeasonsPromise,
tmdbUrl,
tmdbRecommendationProps: await tmdbRecommendationPropsPromise,
tmdbSimilarProps: await tmdbSimilarPropsPromise,
castProps: await castPropsPromise,
tmdbEpisodeProps: await tmdbEpisodePropsPromise
};
}
function playNextEpisode() {
if (nextJellyfinEpisode?.Id) playerState.streamJellyfinId(nextJellyfinEpisode?.Id || '');
}
async function refresh() {
await library.refresh();
async function refreshSonarr() {
await sonarrSeriesStore.refreshIn();
}
let addToSonarrLoading = false;
function addToSonarr() {
async function addToSonarr() {
const tmdbId = await data.then((d) => d.tmdbId);
addToSonarrLoading = true;
addSeriesToSonarr(tmdbId)
.then(refresh)
.then(refreshSonarr)
.finally(() => (addToSonarrLoading = false));
}
async function openRequestModal() {
const sonarrSeries = get(sonarrSeriesStore).item;
if (!sonarrSeries?.id || !sonarrSeries?.statistics?.seasonCount) return;
modalStack.create(SeriesRequestModal, {
sonarrId: sonarrSeries?.id || 0,
seasons: sonarrSeries?.statistics?.seasonCount || 0,
heading: sonarrSeries?.title || 'Series'
});
}
// Focus next episode on load
let didFocusNextEpisode = false;
$: {
if (episodeComponents && !didFocusNextEpisode) {
@@ -153,46 +197,68 @@
}
</script>
{#await tmdbSeriesPromise then series}
{#await data}
<TitlePageLayout {isModal} {handleCloseModal}>
<div slot="episodes-carousel">
<Carousel
gradientFromColor="from-stone-950"
class={classNames('px-2 sm:px-4 lg:px-8', {
'2xl:px-0': !isModal
})}
heading="Episodes"
>
<CarouselPlaceholderItems />
</Carousel>
</div>
</TitlePageLayout>
{:then { tmdbSeries, tmdbId, ...data }}
<TitlePageLayout
{tmdbId}
type="series"
titleInformation={{
tmdbId,
type: 'series',
backdropUriCandidates: tmdbSeries?.images?.backdrops?.map((b) => b.file_path || '') || [],
posterPath: tmdbSeries?.poster_path || '',
title: tmdbSeries?.name || '',
tagline: tmdbSeries?.tagline || tmdbSeries?.name || '',
overview: tmdbSeries?.overview || ''
}}
{isModal}
{handleCloseModal}
backdropUriCandidates={series?.images?.backdrops?.map((b) => b.file_path || '') || []}
posterPath={series?.poster_path || ''}
title={series?.name || ''}
tagline={series?.tagline || series?.name || ''}
overview={series?.overview || ''}
>
<svelte:fragment slot="title-info-1">
{new Date(series?.first_air_date || Date.now()).getFullYear()}
</svelte:fragment>
<svelte:fragment slot="title-info-2">{series?.status}</svelte:fragment>
<svelte:fragment slot="title-info-3">
<a href={tmdbUrl} target="_blank">{series?.vote_average?.toFixed(1)} TMDB</a>
<svelte:fragment slot="title-info">
{new Date(tmdbSeries?.first_air_date || Date.now()).getFullYear()}
<DotFilled />
{tmdbSeries?.status}
<DotFilled />
<a href={data.tmdbUrl} target="_blank">{tmdbSeries?.vote_average?.toFixed(1)} TMDB</a>
</svelte:fragment>
<svelte:fragment slot="title-right">
<div
class="flex gap-2 items-center flex-row-reverse justify-end lg:flex-row lg:justify-start"
>
{#if $itemStore.loading}
{#if $jellyfinItemStore.loading || $sonarrSeriesStore.loading}
<div class="placeholder h-10 w-48 rounded-xl" />
{:else}
<OpenInButton title={series?.name} {itemStore} type="series" {tmdbId} />
{#if $itemStore.item?.jellyfinEpisodes?.length && !!nextJellyfinEpisode}
<OpenInButton
title={tmdbSeries?.name}
jellyfinItem={$jellyfinItemStore.item}
sonarrSeries={$sonarrSeriesStore.item}
type="series"
{tmdbId}
/>
{#if !!nextJellyfinEpisode}
<Button type="primary" on:click={playNextEpisode}>
<span>
Watch {`S${nextJellyfinEpisode?.ParentIndexNumber}E${nextJellyfinEpisode?.IndexNumber}`}
Play {`S${nextJellyfinEpisode?.ParentIndexNumber}E${nextJellyfinEpisode?.IndexNumber}`}
</span>
<ChevronRight size={20} />
</Button>
{:else if !$itemStore.item?.sonarrSeries && $settings.sonarr.apiKey && $settings.sonarr.baseUrl}
{:else if !$sonarrSeriesStore.item && $settings.sonarr.apiKey && $settings.sonarr.baseUrl}
<Button type="primary" disabled={addToSonarrLoading} on:click={addToSonarr}>
<span>Add to Sonarr</span><Plus size={20} />
</Button>
{:else if $itemStore.item?.sonarrSeries}
{:else if $sonarrSeriesStore.item}
<Button type="primary" on:click={openRequestModal}>
<span class="mr-2">Request Series</span><Plus size={20} />
</Button>
@@ -209,9 +275,9 @@
})}
>
<UiCarousel slot="title" class="flex gap-6">
{#each [...Array(series?.number_of_seasons || 0).keys()].map((i) => i + 1) as seasonNumber}
{@const season = series?.seasons?.find((s) => s.season_number === seasonNumber)}
{@const isSelected = season?.season_number === (visibleSeasonNumber || 1)}
{#each [...Array(tmdbSeries?.number_of_seasons || 0).keys()].map((i) => i + 1) as seasonNumber}
{@const season = tmdbSeries?.seasons?.find((s) => s.season_number === seasonNumber)}
{@const isSelected = season?.season_number === visibleSeasonNumber}
<button
class={classNames(
'font-medium tracking-wide transition-colors flex-shrink-0 flex items-center gap-1',
@@ -219,14 +285,14 @@
'text-zinc-200': isSelected && seasonSelectVisible,
'text-zinc-500 hover:text-zinc-200 cursor-pointer':
(!isSelected || seasonSelectVisible === false) &&
series?.number_of_seasons !== 1,
'text-zinc-500 cursor-default': series?.number_of_seasons === 1,
tmdbSeries?.number_of_seasons !== 1,
'text-zinc-500 cursor-default': tmdbSeries?.number_of_seasons === 1,
hidden:
!seasonSelectVisible && visibleSeasonNumber !== (season?.season_number || 1)
}
)}
on:click={() => {
if (series?.number_of_seasons === 1) return;
if (tmdbSeries?.number_of_seasons === 1) return;
if (seasonSelectVisible) {
visibleSeasonNumber = season?.season_number || 1;
@@ -238,16 +304,27 @@
>
<ChevronLeft
size={20}
class={(seasonSelectVisible || series?.number_of_seasons === 1) && 'hidden'}
class={(seasonSelectVisible || tmdbSeries?.number_of_seasons === 1) && 'hidden'}
/>
Season {season?.season_number}
</button>
{/each}
</UiCarousel>
{#key visibleSeasonNumber}
{#each episodeProps[visibleSeasonNumber || 1] || [] as props, i}
{#each data.tmdbEpisodeProps[visibleSeasonNumber - 1] || [] as props, i}
{@const jellyfinData = jellyfinEpisodeData[`S${visibleSeasonNumber}E${i + 1}`]}
<div bind:this={episodeComponents[i]}>
<EpisodeCard {...props} on:click={() => (visibleEpisodeIndex = i)} />
<EpisodeCard
{...props}
{...jellyfinData
? {
watched: jellyfinData.watched,
progress: jellyfinData.progress,
jellyfinId: jellyfinData.jellyfinId
}
: {}}
on:click={() => (visibleEpisodeIndex = i)}
/>
</div>
{:else}
<CarouselPlaceholderItems />
@@ -259,13 +336,13 @@
<svelte:fragment slot="info-components">
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Created By</p>
<h2 class="font-medium">{series?.created_by?.map((c) => c.name).join(', ')}</h2>
<h2 class="font-medium">{tmdbSeries?.created_by?.map((c) => c.name).join(', ')}</h2>
</div>
{#if series?.first_air_date}
{#if tmdbSeries?.first_air_date}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">First Air Date</p>
<h2 class="font-medium">
{new Date(series?.first_air_date).toLocaleDateString('en', {
{new Date(tmdbSeries?.first_air_date).toLocaleDateString('en', {
year: 'numeric',
month: 'short',
day: 'numeric'
@@ -273,22 +350,22 @@
</h2>
</div>
{/if}
{#if series?.next_episode_to_air}
{#if tmdbSeries?.next_episode_to_air}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Next Air Date</p>
<h2 class="font-medium">
{new Date(series.next_episode_to_air?.air_date).toLocaleDateString('en', {
{new Date(tmdbSeries.next_episode_to_air?.air_date).toLocaleDateString('en', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</h2>
</div>
{:else if series?.last_air_date}
{:else if tmdbSeries?.last_air_date}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Last Air Date</p>
<h2 class="font-medium">
{new Date(series.last_air_date).toLocaleDateString('en', {
{new Date(tmdbSeries.last_air_date).toLocaleDateString('en', {
year: 'numeric',
month: 'short',
day: 'numeric'
@@ -298,48 +375,47 @@
{/if}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Networks</p>
<h2 class="font-medium">{series?.networks?.map((n) => n.name).join(', ')}</h2>
<h2 class="font-medium">{tmdbSeries?.networks?.map((n) => n.name).join(', ')}</h2>
</div>
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Episode Run Time</p>
<h2 class="font-medium">{series?.episode_run_time} Minutes</h2>
<h2 class="font-medium">{tmdbSeries?.episode_run_time} Minutes</h2>
</div>
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Spoken Languages</p>
<h2 class="font-medium">
{series?.spoken_languages?.map((l) => capitalize(l.english_name || '')).join(', ')}
{tmdbSeries?.spoken_languages?.map((l) => capitalize(l.english_name || '')).join(', ')}
</h2>
</div>
</svelte:fragment>
<svelte:fragment slot="servarr-components">
{#if !$itemStore.loading && $itemStore.item?.sonarrSeries}
{@const item = $itemStore.item}
{#if item.sonarrSeries?.statistics?.episodeFileCount}
{@const sonarrSeries = $sonarrSeriesStore.item}
{#if sonarrSeries}
{#if sonarrSeries?.statistics?.episodeFileCount}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Available</p>
<h2 class="font-medium">
{item.sonarrSeries?.statistics?.episodeFileCount || 0} Episodes
{sonarrSeries?.statistics?.episodeFileCount || 0} Episodes
</h2>
</div>
{/if}
{#if item.sonarrSeries?.statistics?.sizeOnDisk}
{#if sonarrSeries?.statistics?.sizeOnDisk}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Size On Disk</p>
<h2 class="font-medium">
{formatSize(item.sonarrSeries?.statistics?.sizeOnDisk || 0)}
{formatSize(sonarrSeries?.statistics?.sizeOnDisk || 0)}
</h2>
</div>
{/if}
{#if $itemStore.item?.download}
{#if $sonarrDownloadStore.downloads?.length}
{@const download = $sonarrDownloadStore.downloads?.[0]}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Download Completed In</p>
<h2 class="font-medium">
{$itemStore.item?.download.completionTime
{download?.estimatedCompletionTime
? formatMinutesToTime(
(new Date($itemStore.item?.download.completionTime).getTime() - Date.now()) /
1000 /
60
(new Date(download?.estimatedCompletionTime).getTime() - Date.now()) / 1000 / 60
)
: 'Stalled'}
</h2>
@@ -354,7 +430,7 @@
<span class="mr-2">Manage</span><Archive size={20} />
</Button>
</div>
{:else if $itemStore.loading}
{:else if $sonarrSeriesStore.loading}
<div class="flex gap-4 flex-wrap col-span-4 sm:col-span-6 mt-4">
<div class="placeholder h-10 w-40 rounded-xl" />
<div class="placeholder h-10 w-40 rounded-xl" />
@@ -364,7 +440,7 @@
<div slot="cast-crew-carousel-title" class="font-medium text-lg">Cast & Crew</div>
<svelte:fragment slot="cast-crew-carousel">
{#await castProps}
{#await data.castProps}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop}
@@ -375,7 +451,7 @@
<div slot="recommendations-carousel-title" class="font-medium text-lg">Recommendations</div>
<svelte:fragment slot="recommendations-carousel">
{#await tmdbRecommendationProps}
{#await data.tmdbRecommendationProps}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop}
@@ -386,7 +462,7 @@
<div slot="similar-carousel-title" class="font-medium text-lg">Similar Series</div>
<svelte:fragment slot="similar-carousel">
{#await tmdbSimilarProps}
{#await data.tmdbSimilarProps}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop}

BIN
static/placeholder.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB