mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-22 00:35:12 +02:00
Remove sveltekit dependency, create simple test project
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { settings } from '$lib/stores/settings.store';
|
||||
import { addMessages, init, locale } from 'svelte-i18n';
|
||||
|
||||
import de from '../../lang/de.json';
|
||||
@@ -7,6 +6,7 @@
|
||||
import es from '../../lang/es.json';
|
||||
import fr from '../../lang/fr.json';
|
||||
import it from '../../lang/it.json';
|
||||
import { settings } from '../../stores/settings.store';
|
||||
|
||||
addMessages('de', de);
|
||||
addMessages('en', en);
|
||||
|
||||
318
src/lib/components/LibraryItems.svelte
Normal file
318
src/lib/components/LibraryItems.svelte
Normal 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>
|
||||
316
src/lib/components/MoviePage.svelte
Normal file
316
src/lib/components/MoviePage.svelte
Normal file
@@ -0,0 +1,316 @@
|
||||
<script lang="ts">
|
||||
import { addMovieToRadarr } from '$lib/apis/radarr/radarrApi';
|
||||
import {
|
||||
getTmdbMovie,
|
||||
getTmdbMovieRecommendations,
|
||||
getTmdbMovieSimilar
|
||||
} from '$lib/apis/tmdb/tmdbApi';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
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 PersonCard from '$lib/components/PersonCard/PersonCard.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 {
|
||||
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, 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 jellyfinItemStore = createJellyfinItemStore(tmdbId);
|
||||
const radarrMovieStore = createRadarrMovieStore(tmdbId);
|
||||
const radarrDownloadStore = createRadarrDownloadStore(radarrMovieStore);
|
||||
|
||||
async function loadInitialPageData() {
|
||||
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 castPropsPromise: Promise<ComponentProps<PersonCard>[]> = 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
|
||||
};
|
||||
}
|
||||
|
||||
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(refreshRadarr)
|
||||
.finally(() => (addToRadarrLoading = false));
|
||||
}
|
||||
|
||||
function openRequestModal() {
|
||||
if (!$radarrMovieStore.item?.id) return;
|
||||
|
||||
modalStack.create(RequestModal, {
|
||||
radarrId: $radarrMovieStore.item?.id
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#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}
|
||||
{handleCloseModal}
|
||||
>
|
||||
<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}
|
||||
<DotFilled />
|
||||
<a href={tmdbUrl} target="_blank">{movie?.vote_average?.toFixed(1)} TMDB</a>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="episodes-carousel">
|
||||
{@const progress = $jellyfinItemStore.item?.UserData?.PlayedPercentage}
|
||||
{#if progress}
|
||||
<div
|
||||
class={classNames('px-2 sm:px-4 lg:px-8', {
|
||||
'2xl:px-0': !isModal
|
||||
})}
|
||||
>
|
||||
<ProgressBar {progress} />
|
||||
</div>
|
||||
{/if}
|
||||
</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 $jellyfinItemStore.loading || $radarrMovieStore.loading}
|
||||
<div class="placeholder h-10 w-48 rounded-xl" />
|
||||
{:else}
|
||||
{@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>Play</span><ChevronRight size={20} />
|
||||
</Button>
|
||||
{: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 radarrMovie}
|
||||
<Button type="primary" on:click={openRequestModal}>
|
||||
<span class="mr-2">Request Movie</span><Plus size={20} />
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="info-components">
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Directed By</p>
|
||||
<h2 class="font-medium">
|
||||
{movie?.credits.crew
|
||||
?.filter((c) => c.job == 'Director')
|
||||
.map((p) => p.name)
|
||||
.join(', ')}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Release Date</p>
|
||||
<h2 class="font-medium">
|
||||
{new Date(movie?.release_date || Date.now()).toLocaleDateString('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
{#if movie?.budget}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Budget</p>
|
||||
<h2 class="font-medium">
|
||||
{movie?.budget?.toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
{#if movie?.revenue}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Revenue</p>
|
||||
<h2 class="font-medium">
|
||||
{movie?.revenue?.toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Status</p>
|
||||
<h2 class="font-medium">
|
||||
{movie?.status}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Runtime</p>
|
||||
<h2 class="font-medium">
|
||||
{movie?.runtime} Minutes
|
||||
</h2>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="servarr-components">
|
||||
{@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">
|
||||
{radarrMovie?.movieFile?.quality.quality?.name}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
{#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(radarrMovie?.movieFile?.size || 0)}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
{#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">Downloaded In</p>
|
||||
<h2 class="font-medium">
|
||||
{download?.estimatedCompletionTime
|
||||
? formatMinutesToTime(
|
||||
(new Date(download.estimatedCompletionTime).getTime() - Date.now()) / 1000 / 60
|
||||
)
|
||||
: 'Stalled'}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-4 flex-wrap col-span-4 sm:col-span-6 mt-4">
|
||||
<Button on:click={openRequestModal}>
|
||||
<span class="mr-2">Request Movie</span><Plus size={20} />
|
||||
</Button>
|
||||
<Button>
|
||||
<span class="mr-2">Manage</span><Archive size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
{: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" />
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="carousels">
|
||||
{#await data}
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Cast & Crew</div>
|
||||
<CarouselPlaceholderItems />
|
||||
</Carousel>
|
||||
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Recommendations</div>
|
||||
<CarouselPlaceholderItems />
|
||||
</Carousel>
|
||||
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Similar Series</div>
|
||||
<CarouselPlaceholderItems />
|
||||
</Carousel>
|
||||
{:then { castProps, tmdbRecommendationProps, tmdbSimilarProps }}
|
||||
{#if castProps?.length}
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Cast & Crew</div>
|
||||
{#each castProps as prop}
|
||||
<PersonCard {...prop} />
|
||||
{/each}
|
||||
</Carousel>
|
||||
{/if}
|
||||
|
||||
{#if tmdbRecommendationProps?.length}
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Recommendations</div>
|
||||
{#each tmdbRecommendationProps as prop}
|
||||
<Card {...prop} openInModal={isModal} />
|
||||
{/each}
|
||||
</Carousel>
|
||||
{/if}
|
||||
|
||||
{#if tmdbSimilarProps?.length}
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Similar Series</div>
|
||||
{#each tmdbSimilarProps as prop}
|
||||
<Card {...prop} openInModal={isModal} />
|
||||
{/each}
|
||||
</Carousel>
|
||||
{/if}
|
||||
{/await}
|
||||
</svelte:fragment>
|
||||
</TitlePageLayout>
|
||||
{/await}
|
||||
230
src/lib/components/PersonPage.svelte
Normal file
230
src/lib/components/PersonPage.svelte
Normal file
@@ -0,0 +1,230 @@
|
||||
<script lang="ts">
|
||||
import { getTmdbPerson } 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 TitlePageLayout from '$lib/components/TitlePageLayout/TitlePageLayout.svelte';
|
||||
import FacebookIcon from '$lib/components/svgs/FacebookIcon.svelte';
|
||||
import ImdbIcon from '$lib/components/svgs/ImdbIcon.svelte';
|
||||
import TiktokIcon from '$lib/components/svgs/TiktokIcon.svelte';
|
||||
import TmdbIcon from '$lib/components/svgs/TmdbIcon.svelte';
|
||||
import TwitterIcon from '$lib/components/svgs/TwitterIcon.svelte';
|
||||
import YoutubeIcon from '$lib/components/svgs/YoutubeIcon.svelte';
|
||||
import { TMDB_POSTER_SMALL } from '$lib/constants';
|
||||
import { DotFilled, InstagramLogo } from 'radix-icons-svelte';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
const GENDER_OPTIONS = ['Not set', 'Female', 'Male', 'Non-binary'] as const;
|
||||
|
||||
export let tmdbId: number;
|
||||
export let isModal = false;
|
||||
export let handleCloseModal: () => void = () => {};
|
||||
|
||||
const tmdbUrl = 'https://www.themoviedb.org/person/' + tmdbId;
|
||||
const data = loadInitialPageData();
|
||||
|
||||
async function loadInitialPageData() {
|
||||
const tmdbPerson = await getTmdbPerson(tmdbId);
|
||||
|
||||
const tmdbSocials = [];
|
||||
|
||||
tmdbSocials.push({
|
||||
url: `https://themoviedb.org/person/${tmdbPerson.id}`,
|
||||
icon: TmdbIcon
|
||||
});
|
||||
|
||||
for (const [social, id] of Object.entries(tmdbPerson.external_ids)) {
|
||||
if (Boolean(id)) {
|
||||
switch (social) {
|
||||
case 'facebook_id':
|
||||
tmdbSocials.push({
|
||||
url: `https://facebook.com/${id}`,
|
||||
icon: FacebookIcon
|
||||
});
|
||||
break;
|
||||
case 'imdb_id':
|
||||
tmdbSocials.push({
|
||||
url: `https://imdb.com/name/${id}`,
|
||||
icon: ImdbIcon
|
||||
});
|
||||
break;
|
||||
case 'twitter_id':
|
||||
tmdbSocials.push({
|
||||
url: `https://x.com/${id}`,
|
||||
icon: TwitterIcon
|
||||
});
|
||||
break;
|
||||
case 'youtube_id':
|
||||
tmdbSocials.push({
|
||||
url: `https://youtube.com/@${id}`,
|
||||
icon: YoutubeIcon
|
||||
});
|
||||
break;
|
||||
case 'instagram_id':
|
||||
tmdbSocials.push({
|
||||
url: `https://instagram.com/${id}`,
|
||||
icon: InstagramLogo
|
||||
});
|
||||
break;
|
||||
case 'tiktok_id':
|
||||
tmdbSocials.push({
|
||||
url: `https://www.tiktok.com/@${id}`,
|
||||
icon: TiktokIcon
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isDirector = tmdbPerson.known_for_department == 'Directing';
|
||||
|
||||
const knownForMovies = tmdbPerson.movie_credits[isDirector ? 'crew' : 'cast']?.filter(
|
||||
(value, index, self) => index === self.findIndex((t) => t.id === value.id)
|
||||
);
|
||||
const knownForSeries = tmdbPerson.tv_credits[isDirector ? 'crew' : 'cast']?.filter(
|
||||
(value, index, self) => index === self.findIndex((t) => t.id === value.id)
|
||||
);
|
||||
|
||||
let knownForProps: ComponentProps<Poster>[] = [
|
||||
...(knownForMovies ?? []),
|
||||
...(knownForSeries ?? [])
|
||||
]
|
||||
.sort(
|
||||
(a: any, b: any) =>
|
||||
new Date(b.first_air_date || b.release_date || 0).getTime() -
|
||||
new Date(a.first_air_date || a.release_date || 0).getTime()
|
||||
)
|
||||
.map((i) => ({
|
||||
tmdbId: i.id,
|
||||
title: (i as any).title ?? (i as any).name ?? '',
|
||||
subtitle: (i as any).job ?? (i as any).character ?? '',
|
||||
backdropUrl: i.poster_path ? TMDB_POSTER_SMALL + i.poster_path : ''
|
||||
}))
|
||||
.filter((i) => i.backdropUrl);
|
||||
|
||||
const movieCredits =
|
||||
tmdbPerson.movie_credits.cast?.filter(
|
||||
(value, index, self) => index === self.findIndex((t) => t.id === value.id)
|
||||
).length || 0;
|
||||
const seriesCredits =
|
||||
tmdbPerson.tv_credits.cast?.filter(
|
||||
(value, index, self) => index === self.findIndex((t) => t.id === value.id)
|
||||
).length || 0;
|
||||
const crewCredits =
|
||||
tmdbPerson.movie_credits.crew?.filter(
|
||||
(value, index, self) => index === self.findIndex((t) => t.id === value.id)
|
||||
).length || 0;
|
||||
|
||||
return {
|
||||
tmdbPerson,
|
||||
tmdbSocials,
|
||||
knownForProps,
|
||||
movieCredits,
|
||||
seriesCredits,
|
||||
crewCredits
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await data}
|
||||
<TitlePageLayout {isModal} {handleCloseModal} />
|
||||
{:then { tmdbPerson, tmdbSocials, knownForProps, movieCredits, seriesCredits, crewCredits }}
|
||||
{@const person = tmdbPerson}
|
||||
<TitlePageLayout
|
||||
titleInformation={{
|
||||
tmdbId: Number(person?.id),
|
||||
type: 'person',
|
||||
title: person?.name || 'Person',
|
||||
backdropUriCandidates: [person?.profile_path ?? ''],
|
||||
posterPath: person?.profile_path || '',
|
||||
tagline: person?.known_for_department || person?.name || '',
|
||||
overview: person?.biography || ''
|
||||
}}
|
||||
{isModal}
|
||||
{handleCloseModal}
|
||||
>
|
||||
<svelte:fragment slot="title-info">
|
||||
{#if person?.homepage}
|
||||
<a href={person?.homepage} target="_blank">Homepage</a>
|
||||
<DotFilled />
|
||||
{/if}
|
||||
{#if movieCredits + seriesCredits + crewCredits > 0}
|
||||
<p>{movieCredits + seriesCredits + crewCredits} Credits</p>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="title-right" />
|
||||
|
||||
<svelte:fragment slot="info-components">
|
||||
{#if tmdbSocials}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">External Links</p>
|
||||
<h2 class="pt-2 text-sm">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each tmdbSocials ?? [] as Prop}
|
||||
<a href={Prop.url} target="_blank">
|
||||
<Prop.icon class="h-6 w-6 flex-shrink-0 text-white" />
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Known for</p>
|
||||
<h2 class="font-medium">
|
||||
{person?.known_for_department}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Gender</p>
|
||||
<h2 class="font-medium">
|
||||
{GENDER_OPTIONS[person?.gender ?? 0]}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Birthday</p>
|
||||
<h2 class="font-medium">
|
||||
{new Date(person?.birthday || Date.now()).toLocaleDateString('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Place of Birth</p>
|
||||
<h2 class="font-medium">
|
||||
{person?.place_of_birth}
|
||||
</h2>
|
||||
</div>
|
||||
<!-- TODO: Truncate and add show all button -->
|
||||
<!-- {#if person?.also_known_as}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Also known as</p>
|
||||
<h2 class="font-medium">
|
||||
{#each person?.also_known_as ?? [] as prop}
|
||||
<p>{prop}</p>
|
||||
{/each}
|
||||
</h2>
|
||||
</div>
|
||||
{/if} -->
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="carousels">
|
||||
<div class="max-w-screen-2xl 2xl:mx-auto w-full">
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Known For</div>
|
||||
{#await knownForProps}
|
||||
<CarouselPlaceholderItems orientation="portrait" />
|
||||
{:then props}
|
||||
{#each props as prop}
|
||||
<Poster orientation="portrait" {...prop} openInModal={isModal} />
|
||||
{/each}
|
||||
{/await}
|
||||
</Carousel>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
<div slot="servarr-components" />
|
||||
</TitlePageLayout>
|
||||
{/await}
|
||||
487
src/lib/components/SeriesPage.svelte
Normal file
487
src/lib/components/SeriesPage.svelte
Normal file
@@ -0,0 +1,487 @@
|
||||
<script lang="ts">
|
||||
import { getJellyfinEpisodes, type JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import { addSeriesToSonarr, type SonarrSeries } from '$lib/apis/sonarr/sonarrApi';
|
||||
import {
|
||||
getTmdbIdFromTvdbId,
|
||||
getTmdbSeries,
|
||||
getTmdbSeriesRecommendations,
|
||||
getTmdbSeriesSeasons,
|
||||
getTmdbSeriesSimilar
|
||||
} from '$lib/apis/tmdb/tmdbApi';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
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 UiCarousel from '$lib/components/Carousel/UICarousel.svelte';
|
||||
import EpisodeCard from '$lib/components/EpisodeCard/EpisodeCard.svelte';
|
||||
import PersonCard from '$lib/components/PersonCard/PersonCard.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 { 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, DotFilled, Plus } from 'radix-icons-svelte';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export let titleId: TitleId;
|
||||
export let isModal = false;
|
||||
export let handleCloseModal: () => void = () => {};
|
||||
|
||||
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 = 1;
|
||||
let visibleEpisodeIndex: number | undefined = undefined;
|
||||
|
||||
let jellyfinEpisodeData: {
|
||||
[key: string]: {
|
||||
jellyfinId: string | undefined;
|
||||
progress: number;
|
||||
watched: boolean;
|
||||
};
|
||||
} = {};
|
||||
let episodeComponents: HTMLDivElement[] = [];
|
||||
let nextJellyfinEpisode: JellyfinItem | undefined = undefined;
|
||||
|
||||
// Refresh jellyfin episode data
|
||||
jellyfinItemStore.subscribe(async (value) => {
|
||||
const item = value.item;
|
||||
if (!item?.Id) return;
|
||||
const episodes = await getJellyfinEpisodes(item.Id);
|
||||
|
||||
episodes?.forEach((episode) => {
|
||||
const key = `S${episode?.ParentIndexNumber}E${episode?.IndexNumber}`;
|
||||
|
||||
if (!nextJellyfinEpisode && episode?.UserData?.Played === false) {
|
||||
nextJellyfinEpisode = episode;
|
||||
}
|
||||
|
||||
jellyfinEpisodeData[key] = {
|
||||
jellyfinId: episode?.Id,
|
||||
progress: episode?.UserData?.PlayedPercentage || 0,
|
||||
watched: episode?.UserData?.Played || false
|
||||
};
|
||||
});
|
||||
|
||||
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<PersonCard>[]> = 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 refreshSonarr() {
|
||||
await sonarrSeriesStore.refreshIn();
|
||||
}
|
||||
|
||||
let addToSonarrLoading = false;
|
||||
async function addToSonarr() {
|
||||
const tmdbId = await data.then((d) => d.tmdbId);
|
||||
addToSonarrLoading = true;
|
||||
addSeriesToSonarr(tmdbId)
|
||||
.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) {
|
||||
const episodeComponent = nextJellyfinEpisode?.IndexNumber
|
||||
? episodeComponents[nextJellyfinEpisode?.IndexNumber - 1]
|
||||
: undefined;
|
||||
|
||||
if (episodeComponent && nextJellyfinEpisode?.ParentIndexNumber === visibleSeasonNumber) {
|
||||
const parent = episodeComponent.offsetParent;
|
||||
|
||||
if (parent) {
|
||||
parent.scrollTo({
|
||||
left:
|
||||
episodeComponent.offsetLeft -
|
||||
document.body.clientWidth / 2 +
|
||||
episodeComponent.clientWidth / 2,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
didFocusNextEpisode = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#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
|
||||
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}
|
||||
>
|
||||
<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 $jellyfinItemStore.loading || $sonarrSeriesStore.loading}
|
||||
<div class="placeholder h-10 w-48 rounded-xl" />
|
||||
{:else}
|
||||
<OpenInButton
|
||||
title={tmdbSeries?.name}
|
||||
jellyfinItem={$jellyfinItemStore.item}
|
||||
sonarrSeries={$sonarrSeriesStore.item}
|
||||
type="series"
|
||||
{tmdbId}
|
||||
/>
|
||||
{#if !!nextJellyfinEpisode}
|
||||
<Button type="primary" on:click={playNextEpisode}>
|
||||
<span>
|
||||
Play {`S${nextJellyfinEpisode?.ParentIndexNumber}E${nextJellyfinEpisode?.IndexNumber}`}
|
||||
</span>
|
||||
<ChevronRight size={20} />
|
||||
</Button>
|
||||
{: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 $sonarrSeriesStore.item}
|
||||
<Button type="primary" on:click={openRequestModal}>
|
||||
<span class="mr-2">Request Series</span><Plus size={20} />
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
<div slot="episodes-carousel">
|
||||
<Carousel
|
||||
gradientFromColor="from-stone-950"
|
||||
class={classNames('px-2 sm:px-4 lg:px-8', {
|
||||
'2xl:px-0': !isModal
|
||||
})}
|
||||
>
|
||||
<UiCarousel slot="title" class="flex gap-6">
|
||||
{#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',
|
||||
{
|
||||
'text-zinc-200': isSelected && seasonSelectVisible,
|
||||
'text-zinc-500 hover:text-zinc-200 cursor-pointer':
|
||||
(!isSelected || seasonSelectVisible === false) &&
|
||||
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 (tmdbSeries?.number_of_seasons === 1) return;
|
||||
|
||||
if (seasonSelectVisible) {
|
||||
visibleSeasonNumber = season?.season_number || 1;
|
||||
seasonSelectVisible = false;
|
||||
} else {
|
||||
seasonSelectVisible = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronLeft
|
||||
size={20}
|
||||
class={(seasonSelectVisible || tmdbSeries?.number_of_seasons === 1) && 'hidden'}
|
||||
/>
|
||||
Season {season?.season_number}
|
||||
</button>
|
||||
{/each}
|
||||
</UiCarousel>
|
||||
{#key visibleSeasonNumber}
|
||||
{#each data.tmdbEpisodeProps[visibleSeasonNumber - 1] || [] as props, i}
|
||||
{@const jellyfinData = jellyfinEpisodeData[`S${visibleSeasonNumber}E${i + 1}`]}
|
||||
<div bind:this={episodeComponents[i]}>
|
||||
<EpisodeCard
|
||||
{...props}
|
||||
{...jellyfinData
|
||||
? {
|
||||
watched: jellyfinData.watched,
|
||||
progress: jellyfinData.progress,
|
||||
jellyfinId: jellyfinData.jellyfinId
|
||||
}
|
||||
: {}}
|
||||
on:click={() => (visibleEpisodeIndex = i)}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<CarouselPlaceholderItems />
|
||||
{/each}
|
||||
{/key}
|
||||
</Carousel>
|
||||
</div>
|
||||
|
||||
<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">{tmdbSeries?.created_by?.map((c) => c.name).join(', ')}</h2>
|
||||
</div>
|
||||
{#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(tmdbSeries?.first_air_date).toLocaleDateString('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
{#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(tmdbSeries.next_episode_to_air?.air_date).toLocaleDateString('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
{: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(tmdbSeries.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">{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">{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">
|
||||
{tmdbSeries?.spoken_languages?.map((l) => capitalize(l.english_name || '')).join(', ')}
|
||||
</h2>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="servarr-components">
|
||||
{@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">
|
||||
{sonarrSeries?.statistics?.episodeFileCount || 0} Episodes
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
{#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(sonarrSeries?.statistics?.sizeOnDisk || 0)}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
{#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">
|
||||
{download?.estimatedCompletionTime
|
||||
? formatMinutesToTime(
|
||||
(new Date(download?.estimatedCompletionTime).getTime() - Date.now()) / 1000 / 60
|
||||
)
|
||||
: 'Stalled'}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-4 flex-wrap col-span-4 sm:col-span-6 mt-4">
|
||||
<Button on:click={openRequestModal}>
|
||||
<span class="mr-2">Request Series</span><Plus size={20} />
|
||||
</Button>
|
||||
<Button>
|
||||
<span class="mr-2">Manage</span><Archive size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
{: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" />
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="carousels">
|
||||
{#await data}
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Cast & Crew</div>
|
||||
<CarouselPlaceholderItems />
|
||||
</Carousel>
|
||||
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Recommendations</div>
|
||||
<CarouselPlaceholderItems />
|
||||
</Carousel>
|
||||
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Similar Series</div>
|
||||
<CarouselPlaceholderItems />
|
||||
</Carousel>
|
||||
{:then { castProps, tmdbRecommendationProps, tmdbSimilarProps }}
|
||||
{#if castProps?.length}
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Cast & Crew</div>
|
||||
{#each castProps as prop}
|
||||
<PersonCard {...prop} />
|
||||
{/each}
|
||||
</Carousel>
|
||||
{/if}
|
||||
|
||||
{#if tmdbRecommendationProps?.length}
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Recommendations</div>
|
||||
{#each tmdbRecommendationProps as prop}
|
||||
<Card {...prop} openInModal={isModal} />
|
||||
{/each}
|
||||
</Carousel>
|
||||
{/if}
|
||||
|
||||
{#if tmdbSimilarProps?.length}
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Similar Series</div>
|
||||
{#each tmdbSimilarProps as prop}
|
||||
<Card {...prop} openInModal={isModal} />
|
||||
{/each}
|
||||
</Carousel>
|
||||
{/if}
|
||||
{/await}
|
||||
</svelte:fragment>
|
||||
</TitlePageLayout>
|
||||
{/await}
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
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 MoviePage from '../MoviePage.svelte';
|
||||
import SeriesPage from '../SeriesPage.svelte';
|
||||
import { modalStack } from '../../stores/modal.store';
|
||||
import PersonPage from '../../../routes/person/[id]/PersonPage.svelte';
|
||||
import PersonPage from '../PersonPage.svelte';
|
||||
|
||||
export let titleId: TitleId;
|
||||
export let modalId: symbol;
|
||||
|
||||
63
src/lib/components/settings/GeneralSettingsPage.svelte
Normal file
63
src/lib/components/settings/GeneralSettingsPage.svelte
Normal file
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/forms/Input.svelte';
|
||||
import Select from '$lib/components/forms/Select.svelte';
|
||||
import Toggle from '$lib/components/forms/Toggle.svelte';
|
||||
import type { SettingsValues } from '$lib/stores/settings.store';
|
||||
import { ISO_LANGUAGES } from '$lib/utils/iso-languages';
|
||||
import { ISO_REGIONS } from '$lib/utils/iso-regions';
|
||||
import { _, dictionary } from 'svelte-i18n';
|
||||
|
||||
export let values: SettingsValues;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-[1fr_min-content] justify-items-start place-items-center gap-4 text-zinc-400"
|
||||
>
|
||||
<h1
|
||||
class="font-medium text-xl text-zinc-200 tracking-wide col-span-2 border-b border-zinc-800 justify-self-stretch pb-2 mt-8"
|
||||
>
|
||||
{$_('settings.general.userInterface.userInterface')}
|
||||
</h1>
|
||||
<h2>{$_('settings.general.userInterface.language')}</h2>
|
||||
<Select bind:value={values.language}>
|
||||
{#each Object.entries(ISO_LANGUAGES).filter( ([c, l]) => Object.keys($dictionary).includes(c) ) as [code, lang]}
|
||||
<option value={code}>{`${lang?.name} - ${lang?.nativeName}`}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
<h2>
|
||||
{$_('settings.general.userInterface.autoplayTrailers')}
|
||||
</h2>
|
||||
<Toggle bind:checked={values.autoplayTrailers} />
|
||||
|
||||
<h2>
|
||||
{$_('settings.general.userInterface.animationDuration')}
|
||||
</h2>
|
||||
<Input type="number" bind:value={values.animationDuration} />
|
||||
|
||||
<h1
|
||||
class="font-medium text-xl text-zinc-200 tracking-wide col-span-2 border-b border-zinc-800 justify-self-stretch pb-2 mt-8"
|
||||
>
|
||||
{$_('settings.general.discovery.discovery')}
|
||||
</h1>
|
||||
<h2>
|
||||
{$_('settings.general.discovery.region')}
|
||||
</h2>
|
||||
<Select bind:value={values.discover.region}>
|
||||
<option value=""> {$_('settings.general.discovery.none')} </option>
|
||||
{#each Object.entries(ISO_REGIONS) as [code, region]}
|
||||
<option value={code}>{region}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
<h2>{$_('settings.general.discovery.excludeLibraryItemsFromDiscovery')}</h2>
|
||||
<Toggle bind:checked={values.discover.excludeLibraryItems} />
|
||||
|
||||
<div>
|
||||
<h2>
|
||||
{$_('settings.general.discovery.includedLanguages')}
|
||||
</h2>
|
||||
<p class="text-sm text-zinc-500 mt-1">
|
||||
{$_('settings.general.discovery.includedLanguagesDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Input bind:value={values.discover.includedLanguages} placeholder={'en,fr,de'} />
|
||||
</div>
|
||||
26
src/lib/components/settings/IntegrationCard.svelte
Normal file
26
src/lib/components/settings/IntegrationCard.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
|
||||
export let title: string;
|
||||
export let href = '#';
|
||||
export let status: 'connected' | 'disconnected' | 'error' = 'disconnected';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={classNames('border border-zinc-800 rounded-xl p-4 flex flex-col gap-4', {
|
||||
// 'border-zinc-800': status === 'connected'
|
||||
// 'border-zinc-800': status === 'disconnected'
|
||||
})}
|
||||
>
|
||||
<div class="flex items-baseline justify-between">
|
||||
<a class="text-zinc-200 text-xl font-medium" target="_blank" {href}>{title}</a>
|
||||
<div
|
||||
class={classNames('w-3 h-3 rounded-full', {
|
||||
'bg-green-600': status === 'connected',
|
||||
'bg-zinc-600': status === 'disconnected',
|
||||
'bg-amber-500': status === 'error'
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
340
src/lib/components/settings/IntegrationSettingsPage.svelte
Normal file
340
src/lib/components/settings/IntegrationSettingsPage.svelte
Normal file
@@ -0,0 +1,340 @@
|
||||
<script lang="ts">
|
||||
import { getJellyfinUsers } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import {
|
||||
getSonarrLanguageProfiles,
|
||||
getSonarrQualityProfiles,
|
||||
getSonarrRootFolders
|
||||
} from '$lib/apis/sonarr/sonarrApi';
|
||||
import FormButton from '$lib/components/forms/FormButton.svelte';
|
||||
import Input from '$lib/components/forms/Input.svelte';
|
||||
import Select from '$lib/components/forms/Select.svelte';
|
||||
import { settings, type SettingsValues } from '$lib/stores/settings.store';
|
||||
import classNames from 'classnames';
|
||||
import { Trash } from 'radix-icons-svelte';
|
||||
import IntegrationCard from './IntegrationCard.svelte';
|
||||
import TestConnectionButton from './TestConnectionButton.svelte';
|
||||
import { getRadarrQualityProfiles, getRadarrRootFolders } from '$lib/apis/radarr/radarrApi';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let values: SettingsValues;
|
||||
|
||||
export let sonarrConnected: boolean;
|
||||
export let radarrConnected: boolean;
|
||||
export let jellyfinConnected: boolean;
|
||||
|
||||
export let updateSonarrHealth: (reset?: boolean) => Promise<boolean>;
|
||||
export let updateRadarrHealth: (reset?: boolean) => Promise<boolean>;
|
||||
export let updateJellyfinHealth: (reset?: boolean) => Promise<boolean>;
|
||||
|
||||
let sonarrRootFolders: undefined | { id: number; path: string }[] = undefined;
|
||||
let sonarrQualityProfiles: undefined | { id: number; name: string }[] = undefined;
|
||||
let sonarrLanguageProfiles: undefined | { id: number; name: string }[] = undefined;
|
||||
|
||||
let radarrRootFolders: undefined | { id: number; path: string }[] = undefined;
|
||||
let radarrQualityProfiles: undefined | { id: number; name: string }[] = undefined;
|
||||
|
||||
let jellyfinUsers: undefined | { id: string; name: string }[] = undefined;
|
||||
|
||||
function handleRemoveIntegration(service: 'sonarr' | 'radarr' | 'jellyfin') {
|
||||
if (service === 'sonarr') {
|
||||
values.sonarr.baseUrl = '';
|
||||
values.sonarr.apiKey = '';
|
||||
|
||||
updateSonarrHealth();
|
||||
} else if (service === 'radarr') {
|
||||
values.radarr.baseUrl = '';
|
||||
values.radarr.apiKey = '';
|
||||
updateRadarrHealth();
|
||||
} else if (service === 'jellyfin') {
|
||||
values.jellyfin.baseUrl = '';
|
||||
values.jellyfin.apiKey = '';
|
||||
values.jellyfin.userId = '';
|
||||
updateJellyfinHealth();
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if (sonarrConnected) {
|
||||
getSonarrRootFolders(
|
||||
values.sonarr.baseUrl || undefined,
|
||||
values.sonarr.apiKey || undefined
|
||||
).then((folders) => {
|
||||
sonarrRootFolders = folders.map((f) => ({ id: f.id || 0, path: f.path || '' }));
|
||||
});
|
||||
|
||||
getSonarrQualityProfiles(
|
||||
values.sonarr.baseUrl || undefined,
|
||||
values.sonarr.apiKey || undefined
|
||||
).then((profiles) => {
|
||||
sonarrQualityProfiles = profiles.map((p) => ({ id: p.id || 0, name: p.name || '' }));
|
||||
});
|
||||
|
||||
getSonarrLanguageProfiles(
|
||||
values.sonarr.baseUrl || undefined,
|
||||
values.sonarr.apiKey || undefined
|
||||
).then((profiles) => {
|
||||
sonarrLanguageProfiles = profiles.map((p) => ({ id: p.id || 0, name: p.name || '' }));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if (radarrConnected) {
|
||||
getRadarrRootFolders(
|
||||
values.radarr.baseUrl || undefined,
|
||||
values.radarr.apiKey || undefined
|
||||
).then((folders) => {
|
||||
radarrRootFolders = folders.map((f) => ({ id: f.id || 0, path: f.path || '' }));
|
||||
});
|
||||
|
||||
getRadarrQualityProfiles(
|
||||
values.radarr.baseUrl || undefined,
|
||||
values.radarr.apiKey || undefined
|
||||
).then((profiles) => {
|
||||
radarrQualityProfiles = profiles.map((p) => ({ id: p.id || 0, name: p.name || '' }));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if (jellyfinConnected) {
|
||||
getJellyfinUsers(
|
||||
values.jellyfin.baseUrl || undefined,
|
||||
values.jellyfin.apiKey || undefined
|
||||
).then((users) => {
|
||||
jellyfinUsers = users.map((u) => ({ id: u.Id || '', name: u.Name || '' }));
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
class="border-b border-zinc-800 pb-4 mt-8 col-span-2 justify-self-stretch flex flex-col gap-2"
|
||||
>
|
||||
<h1 class="font-medium text-2xl text-zinc-200 tracking-wide">
|
||||
{$_('settings.integrations.integrations')}
|
||||
</h1>
|
||||
<p class="text-sm text-zinc-400">
|
||||
<!--- @html to render underline class-->
|
||||
{@html $_('settings.integrations.integrationsNote')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="justify-self-stretch col-span-2">
|
||||
<IntegrationCard
|
||||
title="Sonarr"
|
||||
href={$settings.sonarr.baseUrl || '#'}
|
||||
status={sonarrConnected ? 'connected' : 'disconnected'}
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-sm text-zinc-500">
|
||||
{$_('settings.integrations.baseUrl')}
|
||||
</h2>
|
||||
<Input
|
||||
placeholder={'http://127.0.0.1:8989'}
|
||||
class="w-full"
|
||||
bind:value={values.sonarr.baseUrl}
|
||||
on:change={() => updateSonarrHealth(true)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-sm text-zinc-500">
|
||||
{$_('settings.integrations.apiKey')}
|
||||
</h2>
|
||||
<Input
|
||||
class="w-full"
|
||||
bind:value={values.sonarr.apiKey}
|
||||
on:change={() => updateSonarrHealth(true)}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-[1fr_min-content] gap-2">
|
||||
<TestConnectionButton handleHealthCheck={updateSonarrHealth} />
|
||||
<FormButton on:click={() => handleRemoveIntegration('sonarr')} type="error">
|
||||
<Trash size={20} />
|
||||
</FormButton>
|
||||
</div>
|
||||
<h1 class="border-b border-zinc-800 py-2">
|
||||
{$_('settings.integrations.options.options')}
|
||||
</h1>
|
||||
<div
|
||||
class={classNames(
|
||||
'grid grid-cols-[1fr_min-content] justify-items-start gap-4 text-zinc-400',
|
||||
{
|
||||
'opacity-50 pointer-events-none': !sonarrConnected
|
||||
}
|
||||
)}
|
||||
>
|
||||
<h2>
|
||||
{$_('settings.integrations.options.rootFolder')}
|
||||
</h2>
|
||||
{#if !sonarrRootFolders}
|
||||
<Select loading />
|
||||
{:else}
|
||||
<Select bind:value={values.sonarr.rootFolderPath}>
|
||||
{#each sonarrRootFolders as folder}
|
||||
<option value={folder.path}>{folder.path}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
{/if}
|
||||
|
||||
<h2>
|
||||
{$_('settings.integrations.options.qualityProfile')}
|
||||
</h2>
|
||||
{#if !sonarrQualityProfiles}
|
||||
<Select loading />
|
||||
{:else}
|
||||
<Select bind:value={values.sonarr.qualityProfileId}>
|
||||
{#each sonarrQualityProfiles as profile}
|
||||
<option value={profile.id}>{profile.name}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
{/if}
|
||||
|
||||
<h2>
|
||||
{$_('settings.integrations.options.languageProfile')}
|
||||
</h2>
|
||||
{#if !sonarrLanguageProfiles}
|
||||
<Select loading />
|
||||
{:else}
|
||||
<Select bind:value={values.sonarr.languageProfileId}>
|
||||
{#each sonarrLanguageProfiles as profile}
|
||||
<option value={profile.id}>{profile.name}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
{/if}
|
||||
</div>
|
||||
</IntegrationCard>
|
||||
</div>
|
||||
|
||||
<div class="justify-self-stretch col-span-2">
|
||||
<IntegrationCard
|
||||
title="Radarr"
|
||||
href={$settings.radarr.baseUrl || '#'}
|
||||
status={radarrConnected ? 'connected' : 'disconnected'}
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-sm text-zinc-500">
|
||||
{$_('settings.integrations.baseUrl')}
|
||||
</h2>
|
||||
<Input
|
||||
placeholder={'http://127.0.0.1:7878'}
|
||||
class="w-full"
|
||||
bind:value={values.radarr.baseUrl}
|
||||
on:change={() => updateSonarrHealth(true)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-sm text-zinc-500">
|
||||
{$_('settings.integrations.apiKey')}
|
||||
</h2>
|
||||
<Input
|
||||
class="w-full"
|
||||
bind:value={values.radarr.apiKey}
|
||||
on:change={() => updateSonarrHealth(true)}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-[1fr_min-content] gap-2">
|
||||
<TestConnectionButton handleHealthCheck={updateRadarrHealth} />
|
||||
<FormButton on:click={() => handleRemoveIntegration('radarr')} type="error">
|
||||
<Trash size={20} />
|
||||
</FormButton>
|
||||
</div>
|
||||
<h1 class="border-b border-zinc-800 py-2">
|
||||
{$_('settings.integrations.options.options')}
|
||||
</h1>
|
||||
<div
|
||||
class={classNames(
|
||||
'grid grid-cols-[1fr_min-content] justify-items-start gap-4 text-zinc-400',
|
||||
{
|
||||
'opacity-50 pointer-events-none': !radarrConnected
|
||||
}
|
||||
)}
|
||||
>
|
||||
<h2>
|
||||
{$_('settings.integrations.options.rootFolder')}
|
||||
</h2>
|
||||
{#if !radarrRootFolders}
|
||||
<Select loading />
|
||||
{:else}
|
||||
<Select bind:value={values.radarr.rootFolderPath}>
|
||||
{#each radarrRootFolders as folder}
|
||||
<option value={folder.path}>{folder.path}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
{/if}
|
||||
|
||||
<h2>
|
||||
{$_('settings.integrations.options.qualityProfile')}
|
||||
</h2>
|
||||
{#if !radarrQualityProfiles}
|
||||
<Select loading />
|
||||
{:else}
|
||||
<Select bind:value={values.radarr.qualityProfileId}>
|
||||
{#each radarrQualityProfiles as profile}
|
||||
<option value={profile.id}>{profile.name}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
{/if}
|
||||
</div>
|
||||
</IntegrationCard>
|
||||
</div>
|
||||
|
||||
<div class="justify-self-stretch col-span-2">
|
||||
<IntegrationCard
|
||||
title="Jellyfin"
|
||||
href={$settings.jellyfin.baseUrl || '#'}
|
||||
status={jellyfinConnected ? 'connected' : 'disconnected'}
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-sm text-zinc-500">
|
||||
{$_('settings.integrations.baseUrl')}
|
||||
</h2>
|
||||
<Input
|
||||
placeholder={'http://127.0.0.1:8096'}
|
||||
class="w-full"
|
||||
bind:value={values.jellyfin.baseUrl}
|
||||
on:change={() => updateSonarrHealth(true)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-sm text-zinc-500">
|
||||
{$_('settings.integrations.apiKey')}
|
||||
</h2>
|
||||
<Input
|
||||
class="w-full"
|
||||
bind:value={values.jellyfin.apiKey}
|
||||
on:change={() => updateSonarrHealth(true)}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-[1fr_min-content] gap-2">
|
||||
<TestConnectionButton handleHealthCheck={updateJellyfinHealth} />
|
||||
<FormButton on:click={() => handleRemoveIntegration('jellyfin')} type="error">
|
||||
<Trash size={20} />
|
||||
</FormButton>
|
||||
</div>
|
||||
<h1 class="border-b border-zinc-800 py-2">{$_('settings.integrations.options.options')}</h1>
|
||||
<div
|
||||
class={classNames(
|
||||
'grid grid-cols-[1fr_min-content] justify-items-start gap-4 text-zinc-400',
|
||||
{
|
||||
'opacity-50 pointer-events-none': !jellyfinConnected
|
||||
}
|
||||
)}
|
||||
>
|
||||
<h2>
|
||||
{$_('settings.integrations.options.jellyfinUser')}
|
||||
</h2>
|
||||
{#if !jellyfinUsers}
|
||||
<Select loading />
|
||||
{:else}
|
||||
<Select bind:value={values.jellyfin.userId}>
|
||||
{#each jellyfinUsers as user}
|
||||
<option value={user.id}>{user.name}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
{/if}
|
||||
</div>
|
||||
</IntegrationCard>
|
||||
</div>
|
||||
</div>
|
||||
40
src/lib/components/settings/TestConnectionButton.svelte
Normal file
40
src/lib/components/settings/TestConnectionButton.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import FormButton from '$lib/components/forms/FormButton.svelte';
|
||||
import { onDestroy, type ComponentProps } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let handleHealthCheck: () => Promise<boolean>;
|
||||
|
||||
let type: ComponentProps<FormButton>['type'] = 'base';
|
||||
let loading = false;
|
||||
|
||||
let healthTimeout: NodeJS.Timeout;
|
||||
$: {
|
||||
if (type !== 'base') {
|
||||
clearTimeout(healthTimeout);
|
||||
healthTimeout = setTimeout(() => {
|
||||
type = 'base';
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
loading = true;
|
||||
handleHealthCheck().then((ok) => {
|
||||
if (ok) {
|
||||
type = 'success';
|
||||
} else {
|
||||
type = 'error';
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(healthTimeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
<FormButton {type} {loading} on:click={handleClick}
|
||||
>{$_('settings.integrations.testConnection')}</FormButton
|
||||
>
|
||||
Reference in New Issue
Block a user