feat: Updated movie and series pages to not use library.store, added keyboard controls to video player
This commit is contained in:
@@ -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) || []);
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
|
||||
import { createLibraryItemStore } from '$lib/stores/library.store';
|
||||
import {
|
||||
createJellyfinItemStore,
|
||||
createRadarrMovieStore,
|
||||
createSonarrItemStore
|
||||
} from '$lib/stores/library.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 = createSonarrItemStore(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(tmdbId, type, title);
|
||||
} else {
|
||||
window.location.href = `/${type}/${tmdbId}`;
|
||||
}
|
||||
|
||||
@@ -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 { library } from '$lib/stores/library.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(() => library.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(() => library.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
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
|
||||
import { setJellyfinItemUnwatched, setJellyfinItemWatched } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import { library } from '$lib/stores/library.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 backdropUrl: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -96,7 +96,16 @@
|
||||
<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>
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
<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 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>
|
||||
</ContextMenuButton>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { modalStack } from '../../stores/modal.store';
|
||||
|
||||
export let tmdbId: number;
|
||||
export let title: string = '';
|
||||
export let type: TitleType;
|
||||
export let modalId: symbol;
|
||||
|
||||
@@ -25,7 +26,7 @@
|
||||
{#if type === 'movie'}
|
||||
<MoviePage {tmdbId} isModal={true} {handleCloseModal} />
|
||||
{:else}
|
||||
<SeriesPage {tmdbId} isModal={true} {handleCloseModal} />
|
||||
<SeriesPage {tmdbId} {title} isModal={true} {handleCloseModal} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
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(tmdbId, type, title)}>
|
||||
<span>{$_('titleShowcase.details')}</span><ChevronRight size={20} />
|
||||
</Button>
|
||||
{#if trailerId}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
import { modalStack } from '../../stores/modal.store';
|
||||
import Slider from './Slider.svelte';
|
||||
import { playerState } from './VideoPlayer';
|
||||
import { linear } from 'svelte/easing';
|
||||
|
||||
export let modalId: symbol;
|
||||
|
||||
@@ -326,8 +327,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 +366,18 @@
|
||||
'cursor-none': !uiVisible
|
||||
}
|
||||
)}
|
||||
in:fade|global={{ duration: 500, easing: linear }}
|
||||
out:fade|global={{ duration: 300, 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
|
||||
@@ -446,20 +479,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>
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} 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 { derived, get, writable, type Stores, type Writable } from 'svelte/store';
|
||||
import { settings } from './settings.store';
|
||||
|
||||
export interface PlayableItem {
|
||||
@@ -280,6 +280,7 @@ function createLibraryStore() {
|
||||
); //TODO promise to undefined
|
||||
|
||||
async function filterNotInLibrary<T>(toFilter: T[], getTmdbId: (item: T) => number) {
|
||||
return toFilter;
|
||||
const libraryData = await get(library);
|
||||
|
||||
return toFilter.filter((item) => !(getTmdbId(item) in libraryData.items));
|
||||
@@ -287,7 +288,8 @@ function createLibraryStore() {
|
||||
|
||||
return {
|
||||
...library,
|
||||
refresh: async () => getLibrary().then((r) => set(Promise.resolve(r))),
|
||||
refresh: async (tmdbId: number | undefined = undefined) =>
|
||||
getLibrary().then((r) => set(Promise.resolve(r))),
|
||||
refreshIn: async (ms: number) => {
|
||||
clearTimeout(delayedRefreshTimeout);
|
||||
delayedRefreshTimeout = setTimeout(() => {
|
||||
@@ -300,19 +302,191 @@ function createLibraryStore() {
|
||||
|
||||
export const library = createLibraryStore();
|
||||
|
||||
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) {
|
||||
const store = derived(jellyfinItemsStore, (s) => {
|
||||
return {
|
||||
loading: s.loading,
|
||||
item: s.data?.find((i) => i.ProviderIds?.Tmdb === String(tmdbId))
|
||||
};
|
||||
});
|
||||
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) {
|
||||
return derived(radarrMoviesStore, (s) => {
|
||||
return {
|
||||
loading: s.loading,
|
||||
item: s.data?.find((i) => i.tmdbId === tmdbId),
|
||||
refresh: radarrMoviesStore.refresh,
|
||||
refreshIn: radarrMoviesStore.refreshIn
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function createSonarrItemStore(name: string) {
|
||||
function shorten(str: string) {
|
||||
return str.toLowerCase().replace(/[^a-zA-Z0-9]/g, '');
|
||||
}
|
||||
|
||||
const store = derived(sonarrSeriesStore, (s) => {
|
||||
return {
|
||||
loading: s.loading,
|
||||
item: s.data?.find(
|
||||
(i) =>
|
||||
shorten(i.titleSlug || '') === shorten(name) ||
|
||||
i.alternateTitles?.find((t) => shorten(t.title || '') === shorten(name))
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
refresh: sonarrSeriesStore.refresh,
|
||||
refreshIn: sonarrSeriesStore.refreshIn
|
||||
};
|
||||
}
|
||||
|
||||
export const sonarrDownloadsStore = _createDataFetchStore(getSonarrDownloads);
|
||||
export const radarrDownloadsStore = _createDataFetchStore(getRadarrDownloads);
|
||||
|
||||
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 createSonarrItemStore>
|
||||
) {
|
||||
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()
|
||||
};
|
||||
}
|
||||
|
||||
export type LibraryItem = {
|
||||
jellyfinItem?: JellyfinItem;
|
||||
};
|
||||
|
||||
function _createLibraryItemStore(tmdbId: number) {
|
||||
const store = writable<{ loading: boolean; item?: PlayableItem }>({
|
||||
function getValue(jellyfinItems: JellyfinItem[]) {
|
||||
return jellyfinItems.find((i) => i.ProviderIds?.Tmdb === String(tmdbId));
|
||||
}
|
||||
|
||||
const store = writable<{ loading: boolean; item?: LibraryItem }>({
|
||||
loading: true,
|
||||
item: undefined
|
||||
});
|
||||
|
||||
library.subscribe(async (library) => {
|
||||
const item = (await library).items[tmdbId];
|
||||
jellyfinItemsStore.subscribe(async (data) => {
|
||||
const item = {
|
||||
jellyfinItem: getValue((await data).jellyfinItems || [])
|
||||
};
|
||||
store.set({ loading: false, item });
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe
|
||||
subscribe: store.subscribe,
|
||||
refresh: async () => jellyfinItemsStore.refresh()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -61,9 +61,9 @@ function createDynamicModalStack() {
|
||||
export const modalStack = createDynamicModalStack();
|
||||
|
||||
let lastTitleModal: symbol | undefined = undefined;
|
||||
export function openTitleModal(tmdbId: number, type: TitleType) {
|
||||
export function openTitleModal(tmdbId: number, type: TitleType, title = '') {
|
||||
if (lastTitleModal) {
|
||||
modalStack.close(lastTitleModal);
|
||||
}
|
||||
lastTitleModal = modalStack.create(TitlePageModal, { tmdbId, type });
|
||||
lastTitleModal = modalStack.create(TitlePageModal, { tmdbId, type, title });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { TmdbApiOpen, type TmdbMovie2, type TmdbSeries2 } from '$lib/apis/tmdb/tmdbApi';
|
||||
import type { JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import { TmdbApiOpen } 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';
|
||||
@@ -8,24 +9,35 @@
|
||||
import NetworkCard from '$lib/components/NetworkCard.svelte';
|
||||
import PeopleCard from '$lib/components/PeopleCard/PeopleCard.svelte';
|
||||
import { genres, networks } from '$lib/discover';
|
||||
import { library } from '$lib/stores/library.store';
|
||||
import { jellyfinItemsStore } from '$lib/stores/library.store';
|
||||
import { settings } from '$lib/stores/settings.store';
|
||||
import { formatDateToYearMonthDay } from '$lib/utils';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
function parseIncludedLanguages(includedLanguages: string) {
|
||||
return includedLanguages.replace(' ', '').split(',').join('|');
|
||||
}
|
||||
|
||||
const fetchCardProps = async (items: TmdbMovie2[] | TmdbSeries2[]) =>
|
||||
Promise.all(
|
||||
(
|
||||
await ($settings.discover.excludeLibraryItems
|
||||
? library.filterNotInLibrary(items, (t) => t.id || 0)
|
||||
: items)
|
||||
).map(fetchCardTmdbProps)
|
||||
).then((props) => props.filter((p) => p.backdropUrl));
|
||||
const jellyfinItemsPromise = new Promise<JellyfinItem[]>((resolve) => {
|
||||
jellyfinItemsStore.subscribe((data) => {
|
||||
if (data.loading) return;
|
||||
resolve(data.data || []);
|
||||
});
|
||||
});
|
||||
|
||||
const fetchCardProps = async (items: { id?: number }[]) => {
|
||||
const i = $settings.discover.excludeLibraryItems
|
||||
? items.filter(
|
||||
async (item) =>
|
||||
!(await jellyfinItemsPromise).find((i) => i.ProviderIds?.Tmdb === String(item.id))
|
||||
)
|
||||
: items;
|
||||
|
||||
return Promise.all(i.map(fetchCardTmdbProps)).then((props) =>
|
||||
props.filter((p) => p.backdropUrl)
|
||||
);
|
||||
};
|
||||
|
||||
const fetchTrendingProps = () =>
|
||||
TmdbApiOpen.get('/3/trending/all/{time_window}', {
|
||||
|
||||
@@ -16,7 +16,13 @@
|
||||
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,
|
||||
createLibraryItemStore,
|
||||
createRadarrDownloadStore,
|
||||
createRadarrMovieStore,
|
||||
library
|
||||
} from '$lib/stores/library.store';
|
||||
import { settings } from '$lib/stores/settings.store';
|
||||
import { formatMinutesToTime, formatSize } from '$lib/utils';
|
||||
import classNames from 'classnames';
|
||||
@@ -28,7 +34,12 @@
|
||||
export let handleCloseModal: () => void = () => {};
|
||||
const tmdbUrl = 'https://www.themoviedb.org/movie/' + tmdbId;
|
||||
|
||||
const itemStore = createLibraryItemStore(tmdbId);
|
||||
const jellyfinItemStore = createJellyfinItemStore(tmdbId);
|
||||
const radarrMovieStore = createRadarrMovieStore(tmdbId);
|
||||
const radarrDownloadStore = createRadarrDownloadStore(radarrMovieStore);
|
||||
|
||||
const jellyfinItem = $jellyfinItemStore.item;
|
||||
const radarrMovie = $radarrMovieStore.item;
|
||||
|
||||
const tmdbMoviePromise = getTmdbMovie(tmdbId);
|
||||
const tmdbRecommendationProps = getTmdbMovieRecommendations(tmdbId)
|
||||
@@ -49,27 +60,26 @@
|
||||
);
|
||||
|
||||
function play() {
|
||||
if ($itemStore.item?.jellyfinItem?.Id)
|
||||
playerState.streamJellyfinId($itemStore.item?.jellyfinItem?.Id);
|
||||
if (jellyfinItem?.Id) playerState.streamJellyfinId(jellyfinItem?.Id);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await library.refresh();
|
||||
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 (!radarrMovie?.id) return;
|
||||
|
||||
modalStack.create(RequestModal, {
|
||||
radarrId: $itemStore.item?.radarrMovie?.id
|
||||
radarrId: radarrMovie?.id
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -90,7 +100,7 @@
|
||||
>{new Date(movie?.release_date || Date.now()).getFullYear()}</svelte:fragment
|
||||
>
|
||||
<svelte:fragment slot="title-info-2">
|
||||
{@const progress = $itemStore.item?.continueWatching?.progress}
|
||||
{@const progress = jellyfinItem?.UserData?.PlayedPercentage}
|
||||
{#if progress}
|
||||
{progress.toFixed()} min left
|
||||
{:else}
|
||||
@@ -101,7 +111,7 @@
|
||||
<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 = jellyfinItem?.UserData?.PlayedPercentage}
|
||||
{#if progress}
|
||||
<div
|
||||
class={classNames('px-2 sm:px-4 lg:px-8', {
|
||||
@@ -117,19 +127,19 @@
|
||||
<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}
|
||||
<OpenInButton title={movie?.title} {jellyfinItem} {radarrMovie} type="movie" {tmdbId} />
|
||||
{#if jellyfinItem}
|
||||
<Button type="primary" on:click={play}>
|
||||
<span>Watch</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 +202,34 @@
|
||||
{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}
|
||||
{#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 +244,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" />
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
|
||||
let tmdbId: number;
|
||||
$: tmdbId = Number(data.tmdbId);
|
||||
let name: string;
|
||||
$: name = data.name || '';
|
||||
</script>
|
||||
|
||||
{#key tmdbId}
|
||||
<SeriesPage {tmdbId} />
|
||||
<SeriesPage {tmdbId} title={name} />
|
||||
{/key}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { getTmdbSeries } from '$lib/apis/tmdb/tmdbApi';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ params }) => {
|
||||
const tmdbSeries = await getTmdbSeries(Number(params.id));
|
||||
|
||||
return {
|
||||
tmdbId: params.id
|
||||
tmdbId: params.id,
|
||||
name: tmdbSeries?.name
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import { getJellyfinEpisodes, type JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import { addSeriesToSonarr } from '$lib/apis/sonarr/sonarrApi';
|
||||
import {
|
||||
getTmdbSeries,
|
||||
@@ -14,13 +14,19 @@
|
||||
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,
|
||||
createSonarrItemStore,
|
||||
library
|
||||
} from '$lib/stores/library.store';
|
||||
import { modalStack } from '$lib/stores/modal.store';
|
||||
import { settings } from '$lib/stores/settings.store';
|
||||
import { capitalize, formatMinutesToTime, formatSize } from '$lib/utils';
|
||||
import classNames from 'classnames';
|
||||
@@ -28,27 +34,29 @@
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
export let tmdbId: number;
|
||||
export let title: string;
|
||||
export let isModal = false;
|
||||
export let handleCloseModal: () => void = () => {};
|
||||
const tmdbUrl = 'https://www.themoviedb.org/tv/' + tmdbId;
|
||||
|
||||
const itemStore = createLibraryItemStore(tmdbId);
|
||||
const jellyfinItemStore = createJellyfinItemStore(tmdbId);
|
||||
const sonarrSeriesStore = createSonarrItemStore(title);
|
||||
const sonarrDownloadStore = createSonarrDownloadStore(sonarrSeriesStore);
|
||||
|
||||
let sonarrSeries = $sonarrSeriesStore.item;
|
||||
let jellyfinItem = $jellyfinItemStore.item;
|
||||
|
||||
let seasonSelectVisible = false;
|
||||
let visibleSeasonNumber: number | undefined = undefined;
|
||||
let visibleEpisodeIndex: number | undefined = undefined;
|
||||
|
||||
function openRequestModal() {
|
||||
if (
|
||||
!$itemStore.item?.sonarrSeries?.id ||
|
||||
!$itemStore.item?.sonarrSeries?.statistics?.seasonCount
|
||||
)
|
||||
return;
|
||||
if (!sonarrSeries?.id || !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'
|
||||
sonarrId: sonarrSeries?.id || 0,
|
||||
seasons: sonarrSeries?.statistics?.seasonCount || 0,
|
||||
heading: sonarrSeries?.title || 'Series'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,18 +86,18 @@
|
||||
)
|
||||
);
|
||||
|
||||
itemStore.subscribe(async (libraryItem) => {
|
||||
jellyfinItemStore.promise.then(async (jellyfinItem) => {
|
||||
const jellyfinEpisodes = jellyfinItem?.Id ? await getJellyfinEpisodes(jellyfinItem?.Id) : [];
|
||||
const tmdbSeasons = await tmdbSeasonsPromise;
|
||||
|
||||
tmdbSeasons.forEach((season) => {
|
||||
const episodes: ComponentProps<EpisodeCard>[] = [];
|
||||
season?.episodes?.forEach((tmdbEpisode) => {
|
||||
const jellyfinEpisode = libraryItem.item?.jellyfinEpisodes?.find(
|
||||
const jellyfinEpisode = 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;
|
||||
@@ -98,16 +106,17 @@
|
||||
episodes.push({
|
||||
title: tmdbEpisode?.name || '',
|
||||
subtitle: `Episode ${tmdbEpisode?.episode_number}`,
|
||||
backdropUrl: tmdbEpisode?.still_path || '',
|
||||
backdropUrl: TMDB_BACKDROP_SMALL + tmdbEpisode?.still_path || '',
|
||||
progress: jellyfinEpisode?.UserData?.PlayedPercentage || 0,
|
||||
watched: jellyfinEpisode?.UserData?.Played || false,
|
||||
jellyfinId: jellyfinEpisodeId
|
||||
jellyfinId: jellyfinEpisode?.Id,
|
||||
airDate: tmdbEpisode.air_date ? new Date(tmdbEpisode.air_date) : undefined
|
||||
});
|
||||
});
|
||||
episodeProps[season?.season_number || 0] = episodes;
|
||||
});
|
||||
|
||||
if (!nextJellyfinEpisode) nextJellyfinEpisode = libraryItem.item?.jellyfinEpisodes?.[0];
|
||||
if (!nextJellyfinEpisode) nextJellyfinEpisode = jellyfinEpisodes?.[0];
|
||||
visibleSeasonNumber = nextJellyfinEpisode?.ParentIndexNumber || visibleSeasonNumber || 1;
|
||||
});
|
||||
|
||||
@@ -116,7 +125,7 @@
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await library.refresh();
|
||||
await library.refresh(tmdbId);
|
||||
}
|
||||
|
||||
let addToSonarrLoading = false;
|
||||
@@ -177,22 +186,22 @@
|
||||
<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={series?.name} {jellyfinItem} {sonarrSeries} type="series" {tmdbId} />
|
||||
{#if !!nextJellyfinEpisode}
|
||||
<Button type="primary" on:click={playNextEpisode}>
|
||||
<span>
|
||||
Watch {`S${nextJellyfinEpisode?.ParentIndexNumber}E${nextJellyfinEpisode?.IndexNumber}`}
|
||||
</span>
|
||||
<ChevronRight size={20} />
|
||||
</Button>
|
||||
{:else if !$itemStore.item?.sonarrSeries && $settings.sonarr.apiKey && $settings.sonarr.baseUrl}
|
||||
{:else if !sonarrSeries && $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 sonarrSeries}
|
||||
<Button type="primary" on:click={openRequestModal}>
|
||||
<span class="mr-2">Request Series</span><Plus size={20} />
|
||||
</Button>
|
||||
@@ -313,33 +322,31 @@
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="servarr-components">
|
||||
{#if !$itemStore.loading && $itemStore.item?.sonarrSeries}
|
||||
{@const item = $itemStore.item}
|
||||
{#if item.sonarrSeries?.statistics?.episodeFileCount}
|
||||
{#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 +361,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" />
|
||||
|
||||
Reference in New Issue
Block a user