Fully working sonarr integration

This commit is contained in:
Aleksi Lassila
2023-07-24 00:19:39 +03:00
parent 51a7ab630a
commit 77153a96c5
20 changed files with 355 additions and 385 deletions

View File

@@ -3,17 +3,27 @@
import IconButton from '../IconButton.svelte';
import { ChevronLeft, ChevronRight } from 'radix-icons-svelte';
export let gradientFromColor = 'from-stone-900';
let carousel: HTMLDivElement | undefined;
let scrollX: number;
let scrollX = 0;
</script>
<div class="flex justify-between items-center mx-8 gap-4">
<slot name="title" />
<div class="flex gap-2">
<IconButton>
<IconButton
on:click={() => {
carousel?.scrollTo({ left: scrollX - carousel?.clientWidth, behavior: 'smooth' });
}}
>
<ChevronLeft size={20} />
</IconButton>
<IconButton>
<IconButton
on:click={() => {
carousel?.scrollTo({ left: scrollX + carousel?.clientWidth, behavior: 'smooth' });
}}
>
<ChevronRight size={20} />
</IconButton>
</div>
@@ -30,13 +40,13 @@
{#if scrollX > 50}
<div
transition:fade={{ duration: 200 }}
class="absolute inset-y-4 left-0 w-24 bg-gradient-to-r from-darken"
class={'absolute inset-y-4 left-0 w-24 bg-gradient-to-r ' + gradientFromColor}
/>
{/if}
{#if carousel && scrollX < carousel?.scrollWidth - carousel?.clientWidth - 50}
<div
transition:fade={{ duration: 200 }}
class="absolute inset-y-4 right-0 w-24 bg-gradient-to-l from-darken"
class={'absolute inset-y-4 right-0 w-24 bg-gradient-to-l ' + gradientFromColor}
/>
{/if}
</div>

View File

@@ -1,39 +1,40 @@
<script lang="ts">
import classNames from 'classnames';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { modalStack } from './Modal';
export let visible = false;
// export let visible = false;
export let close: () => void;
export let id: Symbol;
const modalId = Symbol();
$: {
if (visible) {
modalStack.push(modalId);
} else {
modalStack.remove(modalId);
}
}
function handleShortcuts(event: KeyboardEvent) {
if (event.key === 'Escape' && visible && $modalStack.top === modalId) {
if (event.key === 'Escape' && $modalStack.top === id) {
close();
}
}
onMount(() => {
modalStack.push(id);
});
</script>
<svelte:window on:keydown={handleShortcuts} />
<svelte:head>
{#if $modalStack.top}
<style>
body {
overflow: hidden;
}
</style>
{/if}
</svelte:head>
{#if visible && $modalStack.top === modalId}
{#if $modalStack.top === id}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class={classNames('fixed inset-0 bg-stone-900 bg-opacity-50 justify-center items-center z-20', {
hidden: !visible,
'flex overflow-hidden': visible
})}
class="fixed inset-0 justify-center items-center z-20 overflow-hidden flex"
on:click|self={close}
transition:fade={{ duration: 100 }}
>
<slot />
</div>

View File

@@ -10,6 +10,10 @@ function createModalStack() {
...store,
push: (symbol: Symbol) => {
store.update((s) => {
if (s.stack.includes(symbol)) {
return s;
}
s.stack.push(symbol);
s.top = symbol;
return s;
@@ -26,3 +30,19 @@ function createModalStack() {
}
export const modalStack = createModalStack();
export type ModalProps = ReturnType<typeof createModalProps>;
export function createModalProps(onClose: () => void) {
const id = Symbol();
function close() {
onClose(); // ORDER MATTERS HERE
modalStack.remove(id);
}
return {
close,
id
};
}

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { modalStack } from './Modal';
</script>
{#if $modalStack.top}
<div
class="fixed inset-0 bg-stone-900 bg-opacity-50 z-[19] overflow-hidden"
transition:fade={{ duration: 100 }}
/>
{/if}

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import { fade, fly } from 'svelte/transition';
</script>
<div
class="max-w-3xl self-start mt-[10vh] bg-[#33333388] backdrop-blur-xl rounded overflow-hidden flex flex-col flex-1 mx-4 sm:mx-16 lg:mx-24 drop-shadow-xl"
in:fly|global={{ y: 20, duration: 200 }}
>
<slot />
</div>

View File

@@ -1,10 +1,4 @@
<script lang="ts">
import { fade, fly } from 'svelte/transition';
</script>
<div
class="max-w-3xl self-start mt-[10vh] bg-[#33333388] backdrop-blur-xl rounded overflow-hidden flex flex-col flex-1 mx-4 sm:mx-16 lg:mx-24 drop-shadow-xl"
in:fly={{ y: 20, duration: 200 }}
>
<!-- TODO: Add horizontal carousel fade effect -->
<div class="max-h-[70vh] scrollbar-hide overflow-y-scroll">
<slot />
</div>

View File

@@ -4,8 +4,9 @@
import { TMDB_IMAGES } from '$lib/constants';
import { MagnifyingGlass } from 'radix-icons-svelte';
import Modal from '../Modal/Modal.svelte';
import ModalContent from '../Modal/ModalContent.svelte';
import ModalContent from '../Modal/ModalContainer.svelte';
import ModalHeader from '../Modal/ModalHeader.svelte';
import { createModalProps } from '../Modal/Modal';
export let visible = false;
let searchInput: HTMLInputElement;
@@ -14,16 +15,16 @@
let timeout: NodeJS.Timeout;
let fetching = false;
let results: MultiSearchResponse['results'] | null = null;
export let close = (clear = true) => {
const modalProps = createModalProps(() => {
visible = false;
if (clear) {
searchValue = '';
fetching = false;
results = null;
clearTimeout(timeout);
}
};
});
function clear() {
searchValue = '';
fetching = false;
results = null;
clearTimeout(timeout);
}
const searchTimeout = () => {
clearTimeout(timeout);
@@ -52,54 +53,58 @@
event.preventDefault();
visible = true;
} else if (event.key === 'Escape' && visible) {
close(false);
clear();
modalProps.close();
}
}
</script>
<svelte:window on:keydown={handleShortcuts} />
<Modal {visible} {close}>
<ModalContent>
<ModalHeader {close}>
<MagnifyingGlass size={20} class="text-zinc-400" />
<input
bind:value={searchValue}
bind:this={searchInput}
on:input={searchTimeout}
type="text"
class="flex-1 bg-transparent font-light outline-none"
placeholder="Search for Movies and Shows..."
/>
</ModalHeader>
{#if !results || searchValue === ''}
<div class="text-sm text-zinc-200 opacity-50 font-light p-4">No recent searches</div>
{:else if results?.length === 0 && !fetching}
<div class="text-sm text-zinc-200 opacity-50 font-light p-4">No search results</div>
{:else}
<div class="py-2">
{#each results.filter((m) => m).slice(0, 5) as result}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="flex px-4 py-2 gap-4 hover:bg-lighten cursor-pointer"
on:click={() => (window.location.href = '/movie/' + result.id)}
>
{#if visible}
<Modal {...modalProps}>
<ModalContent>
<ModalHeader {...modalProps}>
<MagnifyingGlass size={20} class="text-zinc-400" />
<input
bind:value={searchValue}
bind:this={searchInput}
on:input={searchTimeout}
type="text"
class="flex-1 bg-transparent font-light outline-none"
placeholder="Search for Movies and Shows..."
/>
</ModalHeader>
{#if !results || searchValue === ''}
<div class="text-sm text-zinc-200 opacity-50 font-light p-4">No recent searches</div>
{:else if results?.length === 0 && !fetching}
<div class="text-sm text-zinc-200 opacity-50 font-light p-4">No search results</div>
{:else}
<div class="py-2">
{#each results.filter((m) => m).slice(0, 5) as result}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
style={"background-image: url('" + TMDB_IMAGES + result.poster_path + "');"}
class="bg-center bg-cover w-16 h-24 rounded-sm"
/>
<div class="flex-1 flex flex-col gap-1">
<div class="flex gap-2">
<div class="font-normal tracking-wide">{result.original_title}</div>
<div class="text-zinc-400">
{new Date(result.release_date).getFullYear()}
class="flex px-4 py-2 gap-4 hover:bg-lighten cursor-pointer"
on:click={() => (window.location.href = '/movie/' + result.id)}
>
<div
style={"background-image: url('" + TMDB_IMAGES + result.poster_path + "');"}
class="bg-center bg-cover w-16 h-24 rounded-sm"
/>
<div class="flex-1 flex flex-col gap-1">
<div class="flex gap-2">
<div class="font-normal tracking-wide">{result.original_title}</div>
<div class="text-zinc-400">
{new Date(result.release_date).getFullYear()}
</div>
</div>
<div class="text-sm text-zinc-300 line-clamp-3">{result.overview}</div>
</div>
<div class="text-sm text-zinc-300 line-clamp-3">{result.overview}</div>
</div>
</div>
{/each}
</div>
{/if}
</ModalContent>
</Modal>
{/each}
</div>
{/if}
</ModalContent>
</Modal>
{/if}

View File

@@ -1,11 +1,10 @@
<script lang="ts">
import { getJellyfinItemByTmdbId } from '$lib/apis/jellyfin/jellyfinApi';
import { TmdbApi } from '$lib/apis/tmdb/tmdbApi';
import type { TmdbMovie } from '$lib/apis/tmdb/tmdbApi';
import { getContext, onMount } from 'svelte';
import { TMDB_IMAGES } from '$lib/constants';
import { formatMinutesToTime } from '$lib/utils';
import { getJellyfinItemByTmdbId } from '$lib/apis/jellyfin/jellyfinApi';
import type { PlayerState } from '../VideoPlayer/VideoPlayer';
import { onMount } from 'svelte';
import { playerState } from '../VideoPlayer/VideoPlayer';
export let tmdbId: string;
export let progress = 0;
@@ -13,8 +12,6 @@
export let randomProgress = false;
if (randomProgress) progress = Math.random() > 0.5 ? Math.round(Math.random() * 100) : 100;
const { streamJellyfinId } = getContext<PlayerState>('player');
export let type: 'movie' | 'tv' = 'movie';
let bg = '';
@@ -34,7 +31,7 @@
if (streamFetching || !tmdbId) return;
streamFetching = true;
getJellyfinItemByTmdbId(tmdbId).then((item: any) => {
if (item.Id) streamJellyfinId(item.Id);
if (item.Id) playerState.streamJellyfinId(item.Id);
streamFetching = false;
});
}
@@ -53,7 +50,9 @@
class="bg-center bg-cover aspect-[2/3] h-72 m-1.5"
style={"background-image: url('" + bg + "')"}
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="w-full h-full hover:bg-darken transition-all flex">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="opacity-0 group-hover:opacity-100 transition-opacity p-2 flex flex-col justify-between flex-1 cursor-pointer"
on:click={() => (window.location.href = '/' + type + '/' + tmdbId)}

View File

@@ -7,13 +7,15 @@
import HeightHider from '../HeightHider.svelte';
import IconButton from '../IconButton.svelte';
import Modal from '../Modal/Modal.svelte';
import ModalContent from '../Modal/ModalContent.svelte';
import ModalContent from '../Modal/ModalContainer.svelte';
import ModalHeader from '../Modal/ModalHeader.svelte';
import type { ModalProps } from '../Modal/Modal';
const dispatch = createEventDispatcher();
// TODO: Switch to grid
export let visible = true; // FIXME
export let title = 'Releases';
export let modalProps: ModalProps;
export let radarrId: number | undefined = undefined;
export let sonarrEpisodeId: number | undefined = undefined;
@@ -22,12 +24,6 @@
let downloadFetchingGuid: string | undefined;
let downloadingGuid: string | undefined;
let releasesResponse: ReturnType<typeof fetchReleases>;
$: if (visible && !releasesResponse) {
releasesResponse = fetchReleases();
}
async function fetchReleases() {
if (!radarrId && !sonarrEpisodeId) {
return {
@@ -85,18 +81,12 @@
showDetailsId = id;
}
}
function close() {
visible = false;
downloadFetchingGuid = undefined;
downloadingGuid = undefined;
}
</script>
<Modal {visible} {close}>
<Modal {...modalProps}>
<ModalContent>
<ModalHeader {close} text="Releases" />
{#await releasesResponse}
<ModalHeader {...modalProps} text={title} />
{#await fetchReleases()}
<div class="text-sm text-zinc-200 opacity-50 font-light p-4">Loading...</div>
{:then { releases, filtered, releasesSkipped }}
{#if showAllReleases ? releases?.length : filtered?.length}
@@ -150,7 +140,9 @@
</div>
{/each}
</div>
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if releasesSkipped > 0}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="text-sm text-zinc-200 opacity-50 font-light px-4 py-2 hover:underline cursor-pointer"
on:click={toggleShowAll}

View File

@@ -1,21 +1,22 @@
<script lang="ts">
import { fetchSonarrEpisodes, type SonarrEpisode } from '$lib/apis/sonarr/sonarrApi';
import { ChevronUp } from 'radix-icons-svelte';
import IconButton from '../IconButton.svelte';
import { createModalProps, type ModalProps } from '../Modal/Modal';
import Modal from '../Modal/Modal.svelte';
import ModalContent from '../Modal/ModalContent.svelte';
import ModalContainer from '../Modal/ModalContainer.svelte';
import ModalHeader from '../Modal/ModalHeader.svelte';
import RequestModal from './RequestModal.svelte';
import ModalContent from '../Modal/ModalContent.svelte';
export let visible = false;
export let modalProps: ModalProps;
export let sonarrId: number;
let episodesPromise: ReturnType<typeof fetchEpisodes>;
$: if (visible && !episodesPromise) {
episodesPromise = fetchEpisodes(sonarrId);
}
function close() {
visible = false;
}
let selectedEpisode: SonarrEpisode | undefined;
let requestModalProps = createModalProps(() => {
modalProps.close();
selectedEpisode = undefined;
});
async function fetchEpisodes(sonarrId: number) {
return fetchSonarrEpisodes(sonarrId).then((episodes) => {
@@ -37,43 +38,64 @@
}
</script>
<Modal {visible} {close}>
<ModalContent>
<ModalHeader {close} text="Seasons" />
<div class="flex flex-col gap-2 sm:gap-4">
{#await episodesPromise then seasons}
{console.log('saesons', seasons)}
{#each seasons as episodes, i}
{#if i > 0}
<div class="border-t border-gray-200" />
{/if}
{#each episodes as episode}
<div class="flex flex-row items-center justify-between">
<div class="flex flex-row items-center gap-2">
<div class="flex flex-col">
<div class="text-sm font-medium text-gray-900">
{episode.episodeNumber}. {episode.title}
</div>
<div class="text-sm text-gray-500">
{episode.airDate}
</div>
<Modal {...modalProps}>
<ModalContainer>
<ModalHeader {...modalProps} text="Seasons" />
<ModalContent>
<div class="flex flex-col divide-y divide-zinc-700">
{#await fetchEpisodes(sonarrId)}
Loading...
{:then seasons}
{#each seasons as episodes, i}
<div class="pb-2">
<div
class="px-4 py-3 flex justify-between items-center cursor-pointer text-zinc-300 group-hover:text-zinc-300"
>
<div class="uppercase font-bold text-sm">
Season {i + 1}
</div>
<ChevronUp size={20} />
</div>
<div class="flex flex-row items-center gap-2">
<div class="flex flex-col">
<div class="text-sm font-medium text-gray-900">
{episode.episodeNumber}. {episode.title}
</div>
<div class="text-sm text-gray-500">
{episode.airDate}
</div>
</div>
<div class="flex flex-col gap-1">
{#if episodes.length === 0}
<div class="px-4 py-1 text-xs text-gray-400">No episodes</div>
{:else}
{#each episodes as episode}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="px-4 py-1 flex flex-row items-center justify-between cursor-pointer hover:bg-lighten"
on:click={() => (selectedEpisode = episode)}
>
<div class="flex flex-col gap-1">
<div class="text-sm font-medium">{episode.title}</div>
<div class="text-xs text-gray-400">
{episode.episodeNumber ? `Episode ${episode.episodeNumber}` : 'Special'}
</div>
</div>
<div class="text-xs text-gray-400">
{new Date(episode.airDate || Date.now()).toLocaleDateString('en', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</div>
</div>
{/each}
{/if}
</div>
</div>
{/each}
{/each}
{/await}
</div>
</ModalContent>
{/await}
</div>
</ModalContent>
</ModalContainer>
</Modal>
{#if selectedEpisode?.id}
<RequestModal
modalProps={requestModalProps}
sonarrEpisodeId={selectedEpisode.id}
title={selectedEpisode.title || undefined}
/>
{/if}

View File

@@ -16,21 +16,21 @@
import Button from '$lib/components/Button.svelte';
import { library } from '$lib/stores/library.store';
import { ChevronDown, Plus, Trash, Update } from 'radix-icons-svelte';
import { getContext, onMount, type ComponentProps } from 'svelte';
import { onMount, type ComponentProps } from 'svelte';
import IconButton from '../IconButton.svelte';
import { createModalProps } from '../Modal/Modal';
import RequestModal from '../RequestModal/RequestModal.svelte';
import type { PlayerState } from '../VideoPlayer/VideoPlayer';
import LibraryDetailsFile from './LibraryDetailsFile.svelte';
import SeriesRequestModal from '../RequestModal/SeriesRequestModal.svelte';
import { playerState } from '../VideoPlayer/VideoPlayer';
import LibraryDetailsFile from './LibraryDetailsFile.svelte';
export let tmdbId: number;
export let type: 'movie' | 'tv';
let { streamJellyfinId } = getContext<PlayerState>('player');
let servarrId: number | undefined = undefined;
let isAdded = false;
let isRequestModalVisible = false;
const requestModalProps = createModalProps(() => (isRequestModalVisible = false));
let downloadProps: ComponentProps<LibraryDetailsFile>[] = [];
let movieFileProps: ComponentProps<LibraryDetailsFile>[] = [];
@@ -109,7 +109,7 @@
},
jellyfinStreamDisabled: !jellyfinItem,
openJellyfinStream: () => {
if (jellyfinItem?.Id) streamJellyfinId(jellyfinItem.Id);
if (jellyfinItem?.Id) playerState.streamJellyfinId(jellyfinItem.Id);
}
}
];
@@ -138,7 +138,7 @@
},
jellyfinStreamDisabled: !jellyfinEpisode,
openJellyfinStream: () => {
if (jellyfinEpisode?.Id) streamJellyfinId(jellyfinEpisode.Id);
if (jellyfinEpisode?.Id) playerState.streamJellyfinId(jellyfinEpisode.Id);
}
}
];
@@ -326,15 +326,17 @@
{/if}
</div>
{#if isAdded && servarrId && type === 'movie'}
<RequestModal
bind:visible={isRequestModalVisible}
radarrId={servarrId}
on:download={() => setTimeout(refetch, 5000)}
/>
{:else if isAdded && servarrId && type === 'tv'}
<SeriesRequestModal bind:visible={isRequestModalVisible} sonarrId={servarrId} />
{:else}
<div>NO CONTENT</div>
{console.log('NO CONTENT')}
{#if isRequestModalVisible}
{#if isAdded && servarrId && type === 'movie'}
<RequestModal
modalProps={requestModalProps}
radarrId={servarrId}
on:download={() => setTimeout(refetch, 5000)}
/>
{:else if isAdded && servarrId && type === 'tv'}
<SeriesRequestModal modalProps={requestModalProps} sonarrId={servarrId} />
{:else}
<div>NO CONTENT</div>
{console.log('NO CONTENT')}
{/if}
{/if}

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import { getJellyfinEpisodes, getJellyfinItemByTmdbId } from '$lib/apis/jellyfin/jellyfinApi';
import { getJellyfinEpisodes } from '$lib/apis/jellyfin/jellyfinApi';
import { getTmdbSeriesSeasons, type CastMember, type Video } from '$lib/apis/tmdb/tmdbApi';
import Button from '$lib/components/Button.svelte';
import { TMDB_IMAGES } from '$lib/constants';
import { library } from '$lib/stores/library.store';
import { settings } from '$lib/stores/settings.store';
import { formatMinutesToTime } from '$lib/utils';
import classNames from 'classnames';
import { ChevronDown, Clock } from 'radix-icons-svelte';
import { getContext, type ComponentProps } from 'svelte';
import { fade, fly } from 'svelte/transition';
import EpisodeCard from '../EpisodeCard/EpisodeCard.svelte';
import HeightHider from '../HeightHider.svelte';
import type { PlayerState } from '../VideoPlayer/VideoPlayer';
import SeasonsDetails from './SeasonsDetails.svelte';
import { library } from '$lib/stores/library.store';
import { playerState } from '../VideoPlayer/VideoPlayer';
import LibraryDetails from './LibraryDetails.svelte';
import SeasonsDetails from './SeasonsDetails.svelte';
import type { ComponentProps } from 'svelte';
export let tmdbId: number;
export let type: 'movie' | 'tv';
@@ -56,7 +56,6 @@
// Transitions
const duration = 200;
const { streamJellyfinId } = getContext<PlayerState>('player');
library.subscribe(async (libraryPromise) => {
const libraryData = await libraryPromise;
@@ -422,7 +421,8 @@
<Button
disabled={streamButtonDisabled}
size="lg"
on:click={() => jellyfinId && streamJellyfinId(jellyfinId)}>Stream</Button
on:click={() => jellyfinId && playerState.streamJellyfinId(jellyfinId)}
>Stream</Button
>
</div>
<div
@@ -529,42 +529,9 @@
<HeightHider duration={1000} visible={detailsVisible}>
{#if jellyfinId !== null && type === 'tv'}
<SeasonsDetails {tmdbId} totalSeasons={seasons} {jellyfinId} />
<SeasonsDetails {tmdbId} totalSeasons={seasons} {jellyfinId} bind:nextEpisodeCardProps />
{/if}
<!-- {#await fetchPlayDetails(tmdbId, seasons)}
{#if type === 'tv' && seasons > 0}
<div class="py-4">
<Carousel>
<div slot="title" class="flex gap-4 my-1">
{#each [...Array(3).keys()] as _}
<div class={'rounded-full p-2 px-6 font-medium placeholder text-transparent'}>
Season 1
</div>
{/each}
</div>
{#each Array(10) as _, i (i)}
<div class="aspect-video h-40 lg:h-48">
<CardPlaceholder size="dynamic" />
</div>
{/each}
</Carousel>
</div>
{/if}
{:then details}
{#key tmdbId}
{#if type === 'tv' && seasons > 0}
<SeasonsDetails
jellyfinEpisodes={details.jellyfinEpisodes || []}
tmdbSeasons={details.tmdbSeasons || []}
bind:nextEpisodeCardProps
/>
{/if}
{/key}
{/await} -->
{#key tmdbId}
<div bind:this={localDetails}>
<LibraryDetails {tmdbId} {type} />

View File

@@ -1,14 +1,14 @@
<script lang="ts">
import { getJellyfinEpisodes } from '$lib/apis/jellyfin/jellyfinApi';
import { getTmdbSeriesSeasons } from '$lib/apis/tmdb/tmdbApi';
import classNames from 'classnames';
import { Check, StarFilled } from 'radix-icons-svelte';
import { getContext, onMount, type ComponentProps } from 'svelte';
import { onMount, type ComponentProps } from 'svelte';
import CardPlaceholder from '../Card/CardPlaceholder.svelte';
import Carousel from '../Carousel/Carousel.svelte';
import UiCarousel from '../Carousel/UICarousel.svelte';
import EpisodeCard from '../EpisodeCard/EpisodeCard.svelte';
import type { PlayerState } from '../VideoPlayer/VideoPlayer';
import { getTmdbSeriesSeasons, type SeasonDetails } from '$lib/apis/tmdb/tmdbApi';
import CardPlaceholder from '../Card/CardPlaceholder.svelte';
import { playerState } from '../VideoPlayer/VideoPlayer';
export let tmdbId: number;
export let totalSeasons: number;
@@ -51,7 +51,7 @@
progress: nextEpisode?.jellyfinEpisode?.UserData?.PlayedPercentage || 0,
episodeNumber: `S${tmdbEpisode.season_number}E${tmdbEpisode.episode_number}`,
handlePlay: nextEpisode?.jellyfinEpisode?.Id
? () => streamJellyfinId(nextEpisode?.jellyfinEpisode?.Id || '')
? () => playerState.streamJellyfinId(nextEpisode?.jellyfinEpisode?.Id || '')
: undefined
}
: undefined;
@@ -65,97 +65,6 @@
};
}
// export let tmdbSeasons: SeasonDetails[];
// export let jellyfinEpisodes: Awaited<ReturnType<typeof getJellyfinEpisodes>>;
const { streamJellyfinId } = getContext<PlayerState>('player');
// async function fetchSeasons(seasons: number) {
// const tmdbSeasonsPromises = getTmdbSeriesSeasons(tmdbId, seasons);
// const libraryData = await $library;
// const jellyfinSeriesId = libraryData.getSeries(tmdbId)?.jellyfinId;
// const jellyfinEpisodesPromise = jellyfinSeriesId
// ? getJellyfinEpisodes(jellyfinSeriesId)
// : undefined;
// const tmdbSeasons = await tmdbSeasonsPromises;
// const jellyfinEpisodes = await jellyfinEpisodesPromise;
// jellyfinEpisodes?.sort((a, b) => (a.IndexNumber || 99) - (b.IndexNumber || 99));
// const nextJellyfinEpisode = jellyfinEpisodes?.find((e) => e?.UserData?.Played === false);
// const nextEpisode = {
// jellyfinEpisode: nextJellyfinEpisode,
// tmdbEpisode: nextJellyfinEpisode
// ? tmdbSeasons
// .flatMap((s) => s?.episodes)
// .find(
// (e) =>
// e?.episode_number === nextJellyfinEpisode.IndexNumber &&
// e?.season_number === nextJellyfinEpisode.ParentIndexNumber
// )
// : undefined
// };
// return {
// currentSeason: nextEpisode?.tmdbEpisode?.season_number || 1,
// nextEpisode,
// tmdbSeasons,
// jellyfinEpisodes
// };
// }
// const seasonsPromise = fetchSeasons(totalSeasons);
// seasonsPromise.then(({ currentSeason }) => (visibleSeason = currentSeason));
// nextEpisodeCardPropsPromise = seasonsPromise.then(({ nextEpisode }) => {
// const tmdbEpisode = nextEpisode?.tmdbEpisode;
// if (!tmdbEpisode) return undefined;
// return {
// title: tmdbEpisode.name || '',
// subtitle: 'Next Episode',
// backdropPath: tmdbEpisode.still_path || '',
// runtime: tmdbEpisode.runtime || 0,
// progress: nextEpisode?.jellyfinEpisode?.UserData?.PlayedPercentage || 0,
// episodeNumber: `S${tmdbEpisode.season_number}E${tmdbEpisode.episode_number}`,
// handlePlay: nextEpisode?.jellyfinEpisode?.Id
// ? () => streamJellyfinId(nextEpisode?.jellyfinEpisode?.Id || '')
// : undefined
// };
// });
// async function fetchShowData(tmdbId: number, numberOfSeasons: number) {
// const tmdbSeasonsPromises = getTmdbSeriesSeasons(tmdbId, numberOfSeasons);
// const libraryData = await $library;
// const librarySeries = libraryData.getSeries(tmdbId);
// const sonarrSeriesPromise = librarySeries?.tmdbId
// ? getSonarrSeriesByTvdbId(librarySeries.tmdbId)
// : undefined;
// const sonarrDownloadsPromise = sonarrSeriesPromise?.then((series) =>
// series?.id ? getSonarrDownloadsById(series.id) : undefined
// );
// const sonarrEpisodePromises = sonarrSeriesPromise?.then((series) =>
// series ? getSonarrEpisodes(series.id) : undefined
// );
// const jellyfinEpisodesPromise = librarySeries?.jellyfinId
// ? getJellyfinEpisodes(librarySeries.jellyfinId)
// : undefined;
// return {
// playableSeries: librarySeries,
// tmdbSeasons: await tmdbSeasonsPromises,
// sonarrSeries: await sonarrSeriesPromise,
// sonarrDownloads: await sonarrDownloadsPromise,
// sonarrEpisodes: await sonarrEpisodePromises,
// jellyfinEpisodes: await jellyfinEpisodesPromise
// };
// }
const seriesPromise = fetchSeriesData();
onMount(() => {
@@ -234,7 +143,7 @@
size="dynamic"
progress={jellyfinEpisode?.UserData?.PlayedPercentage || 0}
handlePlay={jellyfinEpisode?.Id
? () => streamJellyfinId(jellyfinEpisode?.Id || '')
? () => playerState.streamJellyfinId(jellyfinEpisode?.Id || '')
: undefined}
>
<div slot="left-info" class="flex gap-1 items-center">

View File

@@ -11,18 +11,25 @@
async function fetchStats() {
const discSpacePromise = getDiskSpace();
const { movies } = await $library;
const availableMovies = movies.filter(
(movie) => !movie.download && movie.isAvailable && movie.hasFile
const { itemsArray } = await $library;
const availableMovies = itemsArray.filter(
(item) =>
!item.download &&
item.radarrMovie &&
item.radarrMovie.isAvailable &&
item.radarrMovie.movieFile
);
const diskSpaceInfo =
(await discSpacePromise).find((disk) => disk.path === '/') || (await discSpacePromise)[0];
const spaceOccupied = availableMovies.reduce((acc, movie) => acc + (movie.sizeOnDisk || 0), 0);
const spaceOccupied = availableMovies.reduce(
(acc, movie) => acc + (movie.radarrMovie?.sizeOnDisk || 0),
0
);
return {
moviesAmount: availableMovies.length,
moviesCount: availableMovies.length,
spaceLeft: diskSpaceInfo.freeSpace || 0,
spaceOccupied,
spaceTotal: diskSpaceInfo.totalSpace || 0
@@ -32,14 +39,14 @@
{#await fetchStats()}
<StatsPlaceholder {large} />
{:then { moviesAmount, spaceLeft, spaceOccupied, spaceTotal }}
{:then { moviesCount, spaceLeft, spaceOccupied, spaceTotal }}
<StatsContainer
{large}
title="Radarr"
subtitle="Movies Provider"
href={PUBLIC_RADARR_BASE_URL}
stats={[
{ title: 'Movies', value: String(moviesAmount) },
{ title: 'Movies', value: String(moviesCount) },
{ title: 'Space Taken', value: formatSize(spaceOccupied) },
{ title: 'Space Left', value: formatSize(spaceLeft) }
]}

View File

@@ -5,33 +5,54 @@
import StatsContainer from './StatsContainer.svelte';
import SonarrIcon from '../svgs/SonarrIcon.svelte';
import { PUBLIC_SONARR_BASE_URL } from '$env/static/public';
import { getDiskSpace } from '$lib/apis/sonarr/sonarrApi';
import { library } from '$lib/stores/library.store';
export let large = false;
let statsRequest: Promise<{ moviesAmount: number }> = new Promise((_) => {}) as any;
async function fetchStats() {
const discSpacePromise = getDiskSpace();
const { itemsArray } = await $library;
const availableSeries = itemsArray.filter(
(item) => item.sonarrSeries && item.sonarrSeries.statistics?.episodeFileCount
);
onMount(() => {
statsRequest = fetch('/radarr/stats')
.then((res) => res.json())
.then((data) => ({
moviesAmount: data?.movies?.length
}));
});
const diskSpaceInfo =
(await discSpacePromise).find((disk) => disk.path === '/') || (await discSpacePromise)[0];
const spaceOccupied = availableSeries.reduce(
(acc, series) => acc + (series.sonarrSeries?.statistics?.sizeOnDisk || 0),
0
);
const episodesCount = availableSeries.reduce(
(acc, series) => acc + (series.sonarrSeries?.statistics?.episodeFileCount || 0),
0
);
return {
episodesCount,
spaceLeft: diskSpaceInfo.freeSpace || 0,
spaceOccupied,
spaceTotal: diskSpaceInfo.totalSpace || 0
};
}
</script>
{#await statsRequest}
{#await fetchStats()}
<StatsPlaceholder {large} />
{:then { moviesAmount }}
{:then { episodesCount, spaceLeft, spaceOccupied, spaceTotal }}
<StatsContainer
{large}
title="Sonarr"
subtitle="Shows Provider"
href={PUBLIC_SONARR_BASE_URL}
stats={[
{ title: 'Movies', value: String(moviesAmount) },
{ title: 'Space Taken', value: formatSize(120_000_000_000) },
{ title: 'Space Left', value: formatSize(50_000_000_000) }
{ title: 'Episodes', value: String(episodesCount) },
{ title: 'Space Taken', value: formatSize(spaceOccupied) },
{ title: 'Space Left', value: formatSize(spaceLeft) }
]}
fillPercentage={((spaceTotal - spaceLeft) / spaceTotal) * 100}
color="#8aacfd21"
>
<SonarrIcon slot="icon" class="absolute opacity-20 p-4 h-full inset-y-0 right-2" />

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { PUBLIC_JELLYFIN_URL } from '$env/static/public';
import {
getJellyfinItem,
getJellyfinPlaybackInfo,
@@ -6,17 +7,22 @@
reportJellyfinPlaybackStarted,
reportJellyfinPlaybackStopped
} from '$lib/apis/jellyfin/jellyfinApi';
import Hls from 'hls.js';
import Modal from '../Modal/Modal.svelte';
import IconButton from '../IconButton.svelte';
import { Cross2 } from 'radix-icons-svelte';
import classNames from 'classnames';
import { getContext, onDestroy } from 'svelte';
import { PUBLIC_JELLYFIN_URL } from '$env/static/public';
import getDeviceProfile from '$lib/apis/jellyfin/playback-profiles';
import type { PlayerState, PlayerStateValue } from './VideoPlayer';
import classNames from 'classnames';
import Hls from 'hls.js';
import { Cross2 } from 'radix-icons-svelte';
import { onDestroy } from 'svelte';
import IconButton from '../IconButton.svelte';
import Modal from '../Modal/Modal.svelte';
import { playerState, type PlayerStateValue } from './VideoPlayer';
import { createModalProps } from '../Modal/Modal';
const { playerState, close } = getContext<PlayerState>('player');
let modalProps = createModalProps(() => {
playerState.close();
video?.pause();
clearInterval(progressInterval);
stopCallback?.();
});
let video: HTMLVideoElement;
@@ -61,14 +67,6 @@
}
);
function handleClose() {
close();
video?.pause();
clearInterval(progressInterval);
stopCallback?.();
playerState.set({ visible: false, jellyfinId: '' });
}
let uiVisible = false;
let timeout: ReturnType<typeof setTimeout>;
function handleMouseMove() {
@@ -93,18 +91,22 @@
}
</script>
<Modal visible={$playerState.visible} close={handleClose}>
<div class="bg-black w-screen h-screen relative" on:mousemove={handleMouseMove}>
<video controls bind:this={video} class="w-full h-full inset-0" />
<div
class={classNames('absolute top-4 right-8 transition-opacity', {
'opacity-0': !uiVisible,
'opacity-100': uiVisible
})}
>
<IconButton on:click={handleClose}>
<Cross2 />
</IconButton>
{#if $playerState.visible}
<Modal {...modalProps}>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="bg-black w-screen h-screen relative" on:mousemove={handleMouseMove}>
<!-- svelte-ignore a11y-media-has-caption -->
<video controls bind:this={video} class="w-full h-full inset-0" />
<div
class={classNames('absolute top-4 right-8 transition-opacity', {
'opacity-0': !uiVisible,
'opacity-100': uiVisible
})}
>
<IconButton on:click={modalProps.close}>
<Cross2 />
</IconButton>
</div>
</div>
</div>
</Modal>
</Modal>
{/if}

View File

@@ -1,17 +1,20 @@
import { writable } from 'svelte/store';
const initialValue = { visible: false, jellyfinId: '' };
export const playerState = writable(initialValue);
export const initialPlayerState = {
playerState,
close: () => {
playerState.set({ visible: false, jellyfinId: '' });
},
streamJellyfinId: (id: string) => {
playerState.set({ visible: true, jellyfinId: id });
}
};
export type PlayerState = typeof initialPlayerState;
export type PlayerStateValue = typeof initialValue;
function createPlayerState() {
const store = writable<PlayerStateValue>(initialValue);
return {
...store,
streamJellyfinId: (id: string) => {
store.set({ visible: true, jellyfinId: id });
},
close: () => {
store.set({ visible: false, jellyfinId: '' });
}
};
}
export const playerState = createPlayerState();

View File

@@ -1,16 +1,10 @@
<script lang="ts">
import '../app.css';
import ModalBackground from '$lib/components/Modal/ModalBackground.svelte';
import Navbar from '$lib/components/Navbar/Navbar.svelte';
import VideoPlayer from '$lib/components/VideoPlayer/VideoPlayer.svelte';
import { setContext } from 'svelte';
import type { LayoutData } from './$types';
import { initialPlayerState } from '$lib/components/VideoPlayer/VideoPlayer';
import SetupRequired from '$lib/components/SetupRequired/SetupRequired.svelte';
import { settings } from '$lib/stores/settings.store';
setContext('player', initialPlayerState);
settings.set({ autoplayTrailers: false });
import VideoPlayer from '$lib/components/VideoPlayer/VideoPlayer.svelte';
import '../app.css';
import type { LayoutData } from './$types';
export let data: LayoutData;
</script>
@@ -22,6 +16,7 @@
<slot />
</main>
<VideoPlayer />
<ModalBackground />
<!-- <footer>-->
<!-- <p>visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to learn SvelteKit</p>-->

View File

@@ -41,7 +41,7 @@
<!-- Does not contain any of the titles in library.-->
<div class="pt-24 bg-black">
<Carousel>
<Carousel gradientFromColor="from-black">
<div slot="title" class={headerStyle}>For You</div>
{#await discoverPromise}
<CarouselPlaceholderItems size="lg" />

View File

@@ -10,7 +10,8 @@
<div class="flex flex-col gap-4 max-w-3xl flex-1">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<RadarrStats large />
<div
<SonarrStats large />
<!-- <div
class="border-zinc-800 border-2 border-dashed relative w-full p-3 px-4 rounded-xl overflow-hidden text-zinc-500 text-center flex flex-col gap-1"
>
<h2 class="font-medium">Sonarr is not set up</h2>
@@ -18,8 +19,7 @@
To set up Sonarr, define the <code>PUBLIC_SONARR_API_KEY</code> and <code>SONARR_URL</code> environment
variables.
</p>
</div>
<SonarrStats large />
</div> -->
</div>
<!-- <div>Sources</div>-->
<!-- <div>Streaming services</div>-->