feat: Modals & release downloading

This commit is contained in:
Aleksi Lassila
2024-04-01 12:39:09 +03:00
parent ad8476d78a
commit 243ee258c1
30 changed files with 312 additions and 105 deletions

View File

@@ -14,14 +14,15 @@
import { getReiverrApiClient } from './lib/apis/reiverr/reiverr-api';
import { appState } from './lib/stores/app-state.store';
import MoviePage from './lib/pages/MoviePage.svelte';
import DetatchedPage from './lib/components/DetatchedPage/DetatchedPage.svelte';
import Button from './lib/components/Button.svelte';
import ModalStack from './lib/components/Modal/ModalStack.svelte';
getReiverrApiClient()
.GET('/user', {})
.then((res) => res.data)
.then((user) => appState.setUser(user || null))
.catch(() => appState.setUser(null));
appState.subscribe((s) => console.log('appState', s));
</script>
<I18n />
@@ -66,6 +67,8 @@
<Router>
<Route path="movies/movie/:id" component={MoviePage} />
</Router>
<ModalStack />
{/if}
</Container>

View File

@@ -57,5 +57,5 @@
})}
use:registerer
>
<slot />
<slot hasFocus={$hasFocus} hasFocusWithin={$hasFocusWithin} focusIndex={$focusIndex} />
</svelte:element>

View File

@@ -10,7 +10,7 @@ import type { Api } from '../api.interface';
export type RadarrMovie = components['schemas']['MovieResource'];
export type MovieFileResource = components['schemas']['MovieFileResource'];
export type ReleaseResource = components['schemas']['ReleaseResource'];
export type RadarrRelease = components['schemas']['ReleaseResource'];
export type RadarrDownload = components['schemas']['QueueResource'] & { movie: RadarrMovie };
export type DiskSpaceInfo = components['schemas']['DiskSpaceResource'];
@@ -116,7 +116,7 @@ export class RadarrApi implements Api<paths> {
return !!deleteResponse?.response.ok;
};
fetchRadarrReleases = (movieId: number) =>
fetchRadarrReleases = (movieId: number): Promise<RadarrRelease[]> =>
this.getClient()
?.GET('/api/v3/release', { params: { query: { movieId: movieId } } })
.then((r) => r.data || []) || Promise.resolve([]);

View File

@@ -7,7 +7,7 @@ import { settings } from '../../stores/settings.store';
import { log } from '../../utils';
export type SonarrSeries = components['schemas']['SeriesResource'];
export type SonarrReleaseResource = components['schemas']['ReleaseResource'];
export type SonarrRelease = components['schemas']['ReleaseResource'];
export type SonarrDownload = components['schemas']['QueueResource'] & { series: SonarrSeries };
export type DiskSpaceInfo = components['schemas']['DiskSpaceResource'];
export type SonarrEpisode = components['schemas']['EpisodeResource'];

View File

@@ -8,7 +8,7 @@
import { formatMinutesToTime } from '../../../lib/utils';
import classNames from 'classnames';
import { Clock, Star } from 'radix-icons-svelte';
import { openTitleModal } from '../../stores/modal.store';
import { openTitleModal } from '../../components/Modal/modal.store';
import ContextMenu from '../ContextMenu/ContextMenu.svelte';
import LibraryItemContextItems from '../ContextMenu/LibraryItemContextItems.svelte';
import ProgressBar from '../ProgressBar.svelte';

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import classNames from 'classnames';
import { modalStack } from '../../stores/modal.store';
import { modalStack } from '../../components/Modal/modal.store';
import { fade } from 'svelte/transition';
import { onDestroy } from 'svelte';

View File

@@ -5,7 +5,7 @@
import TitleSearchModal from './TitleSearchModal.svelte';
import IconButton from '../IconButton.svelte';
import { fade } from 'svelte/transition';
import { modalStack } from '../../stores/modal.store';
import { modalStack } from '../../components/Modal/modal.store';
import { _ } from 'svelte-i18n';
let y = 0;

View File

@@ -3,7 +3,7 @@
import { searchTmdbTitles } from '$lib/apis/tmdb/tmdbApi';
import { TMDB_POSTER_SMALL } from '$lib/constants';
import { MagnifyingGlass } from 'radix-icons-svelte';
import { modalStack } from '../../stores/modal.store';
import { modalStack } from '../../components/Modal/modal.store';
import ModalContent from '../Modal/ModalContainer.svelte';
import ModalHeader from '../Modal/ModalHeader.svelte';
import { onMount } from 'svelte';

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { TitleType } from '../../../lib/types';
import { TMDB_PROFILE_SMALL } from '../../../lib/constants';
import { openTitleModal } from '../../../lib/stores/modal.store';
import { openTitleModal } from '../../components/Modal/modal.store';
import classNames from 'classnames';
export let tmdbId: number;

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { fetchSonarrEpisodes, type SonarrEpisode } from '$lib/apis/sonarr/sonarrApi';
import { modalStack } from '../../stores/modal.store';
import { modalStack } from '../../components/Modal/modal.store';
import ModalContainer from '../Modal/ModalContainer.svelte';
import ModalContent from '../Modal/ModalContent.svelte';
import ModalHeader from '../Modal/ModalHeader.svelte';

View File

@@ -10,7 +10,7 @@
import { createEventDispatcher } from 'svelte';
import HeightHider from '../HeightHider.svelte';
import IconButton from '../IconButton.svelte';
import { modalStack } from '../../stores/modal.store';
import { modalStack } from '../../components/Modal/modal.store';
import ModalContent from '../Modal/ModalContainer.svelte';
import ModalHeader from '../Modal/ModalHeader.svelte';

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { ChevronRight } from 'radix-icons-svelte';
import Button from '../Button.svelte';
import { modalStack } from '../../stores/modal.store';
import { modalStack } from '../../components/Modal/modal.store';
import ModalContainer from '../Modal/ModalContainer.svelte';
import ModalContent from '../Modal/ModalContent.svelte';
import ModalHeader from '../Modal/ModalHeader.svelte';

View File

@@ -26,7 +26,7 @@
createSonarrDownloadStore,
createSonarrSeriesStore
} from '../../lib/stores/data.store';
import { modalStack } from '../../lib/stores/modal.store';
import { modalStack } from '../components/Modal/modal.store';
import { settings } from '../../lib/stores/settings.store';
import type { TitleId } from '../../lib/types';
import { capitalize, formatMinutesToTime, formatSize } from '../../lib/utils';

View File

@@ -3,7 +3,7 @@
import { fly } from 'svelte/transition';
import MoviePage from '../MoviePage.svelte';
import SeriesPage from '../SeriesPage.svelte';
import { modalStack } from '../../stores/modal.store';
import { modalStack } from '../../components/Modal/modal.store';
import PersonPage from '../PersonPage.svelte';
export let titleId: TitleId;

View File

@@ -29,7 +29,7 @@
import { contextMenu } from '../ContextMenu/ContextMenu';
import SelectableContextMenuItem from '../ContextMenu/SelectableContextMenuItem.svelte';
import IconButton from '../IconButton.svelte';
import { modalStack } from '../../stores/modal.store';
import { modalStack } from '../../components/Modal/modal.store';
import Slider from './Slider.svelte';
import { playerState } from './VideoPlayer';
import { linear } from 'svelte/easing';

View File

@@ -1,5 +1,5 @@
import { writable } from 'svelte/store';
import { modalStack } from '../../stores/modal.store';
import { modalStack } from '../../components/Modal/modal.store';
import VideoPlayer from './VideoPlayer.svelte';
import { jellyfinItemsStore } from '../../stores/data.store';

View File

@@ -4,6 +4,7 @@
import classNames from 'classnames';
export let inactive: boolean = false;
export let focusOnMount: boolean = false;
let hasFoucus: Readable<boolean>;
</script>
@@ -18,13 +19,15 @@
'cursor-not-allowed pointer-events-none opacity-40': inactive
})}
on:click
let:hasFocus
{focusOnMount}
>
{#if $$slots.icon}
<div class="mr-2">
<slot name="icon" />
</div>
{/if}
<slot />
<slot {hasFocus} />
{#if $$slots['icon-after']}
<div class="ml-2">
<slot name="icon-after" />

View File

@@ -17,8 +17,6 @@
}
};
$: console.log('cols', cols);
onMount(() => {
calculateRows();
});

View File

@@ -1,5 +1,5 @@
import type { getTmdbPopularMovies } from '../../apis/tmdb/tmdb-api';
import { formatMinutesToTime } from '../../utils';
import type { tmdbApi } from '../../apis/tmdb/tmdb-api';
export type RatingSource = 'tmdb'; // TODO: Add more rating sources & move elsewhere
@@ -20,9 +20,8 @@ export type ShowcaseItemProps = {
};
export async function getShowcasePropsFromTmdb(
response: Awaited<ReturnType<typeof getTmdbPopularMovies>>
response: Awaited<ReturnType<typeof tmdbApi.getPopularMovies>>
): Promise<ShowcaseItemProps[]> {
console.log(response);
return response.slice(0, 10).map((movie) => ({
title: movie.title || '',
posterUrl: movie.poster_path || '',

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import Container from '../../../Container.svelte';
import { modalStack } from './modal.store';
export let modalId: symbol;
</script>
<Container
navigationActions={{
left: () => {
modalStack.close(modalId);
return true;
}
}}
focusOnMount
trapFocus
class="fixed inset-0 bg-stone-950/80"
>
<slot />
</Container>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { modalStack, modalStackTop } from './modal.store';
import { onDestroy } from 'svelte';
function handleShortcuts(event: KeyboardEvent) {
const top = $modalStackTop;
if (event.key === 'Escape' && top) {
modalStack.close(top.id);
}
}
onDestroy(() => {
modalStack.reset();
});
</script>
<svelte:window on:keydown={handleShortcuts} />
<svelte:head>
{#if $modalStackTop}
<style>
body {
overflow: hidden;
}
</style>
{/if}
</svelte:head>
{#each $modalStack as modal (modal.id)}
{@const hidden = $modalStackTop?.group === modal.group && $modalStackTop?.id !== modal.id}
<div class="fixed inset-0 z-30">
<svelte:component this={modal.component} {...modal.props} modalId={modal.id} {hidden} />
</div>
{/each}

View File

@@ -0,0 +1,59 @@
import { derived, writable } from 'svelte/store';
type ModalItem = {
id: symbol;
group: symbol;
component: ConstructorOfATypedSvelteComponent;
props: Record<string, any>;
};
function createModalStack() {
const items = writable<ModalItem[]>([]);
const top = derived(items, ($items) => $items[$items.length - 1]);
function close(symbol: symbol) {
items.update((prev) => prev.filter((i) => i.id !== symbol));
}
function closeGroup(group: symbol) {
items.update((prev) => prev.filter((i) => i.group !== group));
}
function create(
component: ConstructorOfATypedSvelteComponent,
props: Record<string, any>,
group: symbol | undefined = undefined
) {
const id = Symbol();
const item = { id, component, props, group: group || id };
items.update((prev) => [...prev, item]);
return id;
}
function reset() {
items.set([]);
}
return {
subscribe: items.subscribe,
top: {
subscribe: top.subscribe
},
create,
close,
closeGroup,
reset
};
}
export const modalStack = createModalStack();
export const modalStackTop = modalStack.top;
// let lastTitleModal: symbol | undefined = undefined;
// export function openTitleModal(titleId: TitleId) {
// if (lastTitleModal) {
// modalStack.close(lastTitleModal);
// }
// lastTitleModal = modalStack.create(TitlePageModal, {
// titleId
// });
// }

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import FullScreenModal from '../Modal/FullScreenModal.svelte';
import { radarrApi } from '../../apis/radarr/radarr-api';
import ReleaseList from './ReleaseList.svelte';
export let id: number;
export let modalId: symbol;
</script>
<FullScreenModal {modalId}>
<div class="m-auto flex flex-col items-center my-16">
<div>
<h1 class="tracking-wide text-2xl font-semibold mb-4">Download</h1>
<ReleaseList
{id}
grabRelease={radarrApi.downloadRadarrMovie}
getReleases={radarrApi.fetchRadarrReleases}
/>
</div>
</div>
</FullScreenModal>

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { type RadarrRelease } from '../../apis/radarr/radarr-api';
import type { SonarrRelease } from '../../apis/sonarr/sonarrApi';
import classNames from 'classnames';
import { useRequest } from '../../stores/data.store';
import Button from '../Button.svelte';
import { DotFilled, Download, Plus } from 'radix-icons-svelte';
import { formatMinutesToTime, formatSize } from '../../utils';
import { derived } from 'svelte/store';
export let id: number;
export let getReleases: (id: number) => Promise<(RadarrRelease | SonarrRelease)[]>;
export let grabRelease: (guid: string, indexerId: number) => Promise<boolean>;
let showAll = false;
const { data: releases } = useRequest(getReleases, id);
const filteredReleases = derived(releases, ($releases) => {
if (!$releases) return [];
let filtered = $releases.slice();
filtered.sort((a, b) => (b.seeders || 0) - (a.seeders || 0));
filtered = (filtered as any)
.filter((release: any) => release?.quality?.quality?.resolution > 720)
.slice(0, 5);
filtered.sort((a, b) => (b.size || 0) - (a.size || 0));
return filtered;
});
const isFetchingGrab: Record<string, boolean> = {};
const grabbedReleases: Record<string, boolean> = {};
function handleGrabRelease(guid: string, indexerId: number) {
isFetchingGrab[guid] = true;
grabRelease(guid, indexerId).then((ok) => {
isFetchingGrab[guid] = false;
if (ok) {
grabbedReleases[guid] = true;
}
});
}
</script>
<div class="flex flex-col -my-1 max-w-2xl">
{#each (showAll ? $releases : $filteredReleases)?.filter((r) => r.guid && r.indexerId) || [] as release, index}
{@const isFetching = isFetchingGrab[release.guid || ''] || false}
{@const isGrabbed = grabbedReleases[release.guid || ''] || false}
<div class="flex-1 my-1">
<Button
on:click={() =>
!isFetching &&
!isGrabbed &&
handleGrabRelease(release.guid || '', release.indexerId || 0)}
inactive={isFetching || isGrabbed}
let:hasFocus
focusOnMount={index === 0}
>
<div class="w-full flex flex-col">
<div class="flex-1 flex items-center">
{#if !isGrabbed}
<Plus size={19} class="mr-2" />
{:else}
<Download size={19} class="mr-2" />
{/if}
<div class="flex-1 flex mr-2">
<div class="tracking-wide mr-2">{release.indexer}</div>
<div
class={classNames('mr-2', {
'text-zinc-400': !hasFocus,
'text-zinc-700': hasFocus
})}
>
{release?.quality?.quality?.name}
</div>
<div
class={classNames('mr-2', {
'text-zinc-400': !hasFocus,
'text-zinc-700': hasFocus
})}
>
{release.seeders} seeders
</div>
</div>
<div>
<div
class={classNames({
'text-zinc-400': !hasFocus,
'text-zinc-700': hasFocus
})}
>
{formatSize(release?.size || 0)}
</div>
</div>
</div>
{#if hasFocus}
<div class="flex text-xs text-zinc-700 items-center flex-wrap mt-2">
<div>
{release.title}
</div>
<DotFilled size={15} />
<div>{formatMinutesToTime(release.ageMinutes || 0)} old</div>
<DotFilled size={15} />
<div><b>{release.seeders} seeders</b> / {release.leechers} leechers</div>
<DotFilled size={15} />
{#if release.seeders}
<div>
{formatSize((release.size || 0) / release.seeders)} per seeder
</div>
{/if}
</div>
{/if}
</div>
</Button>
</div>
{/each}
{#if !showAll && $releases?.length}
<div class="my-1 w-full">
<Button on:click={() => (showAll = true)}>Show all {$releases?.length} releases</Button>
</div>
{:else if showAll}
<div class="my-1 w-full">
<Button on:click={() => (showAll = false)}>Show less</Button>
</div>
{/if}
</div>

View File

@@ -9,8 +9,6 @@
let focusIndex: Readable<number>;
const navigate = useNavigate();
const asd = '';
const asd2 = '';
const itemContainer = (index: number, _focusIndex: number) =>
classNames('h-12 flex items-center', {
'text-amber-300': _focusIndex === index,

View File

@@ -1,5 +1,5 @@
import { writable } from 'svelte/store';
import { modalStack } from '../../stores/modal.store';
import { modalStack } from '../Modal/modal.store';
import VideoPlayer from './VideoPlayer.svelte';
import { jellyfinItemsStore } from '../../stores/data.store';

View File

@@ -4,13 +4,15 @@
import { tmdbApi } from '../apis/tmdb/tmdb-api';
import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../constants';
import classNames from 'classnames';
import { DotFilled, ExternalLink, Plus } from 'radix-icons-svelte';
import { DotFilled, Download, ExternalLink, Play, Plus } from 'radix-icons-svelte';
import Button from '../components/Button.svelte';
import { jellyfinApi } from '../apis/jellyfin/jellyfin-api';
import VideoPlayer from '../components/VideoPlayer/VideoPlayer.svelte';
import { radarrApi } from '../apis/radarr/radarr-api';
import { useActionRequests, useRequest } from '../stores/data.store';
import DetatchedPage from '../components/DetatchedPage/DetatchedPage.svelte';
import DetachedPage from '../components/DetachedPage/DetachedPage.svelte';
import { modalStack } from '../components/Modal/modal.store';
import RequestModal from '../components/RequestModal/RadarrRequestModal.svelte';
export let id: string;
@@ -34,7 +36,7 @@
});
</script>
<DetatchedPage>
<DetachedPage>
<div class="h-screen flex flex-col">
<HeroCarousel
bind:index={heroIndex}
@@ -83,9 +85,15 @@
{#await Promise.all([$jellyfinItemP, $radarrItemP]) then [jellyfinItem, radarrItem]}
<Container direction="horizontal" class="flex mt-8 gap-2">
{#if jellyfinItem}
<Button on:click={() => (playbackId = jellyfinItem.Id || '')}>Play</Button>
<Button on:click={() => (playbackId = jellyfinItem.Id || '')}>
Play
<Play size={19} slot="icon" />
</Button>
{:else if radarrItem}
<Button>Request</Button>
<Button on:click={() => modalStack.create(RequestModal, { id: radarrItem.id })}>
Request
<Download size={19} slot="icon" />
</Button>
{:else}
<Button
on:click={() => requests.handleAddToRadarr(Number(id))}
@@ -95,6 +103,12 @@
<Plus slot="icon" size={19} />
</Button>
{/if}
{#if jellyfinItem && radarrItem}
<Button on:click={() => modalStack.create(RequestModal, { id: radarrItem.id })}>
Manage Files
<Download size={19} slot="icon" />
</Button>
{/if}
{#if PLATFORM_WEB}
<Button>
Open In TMDB
@@ -115,4 +129,4 @@
<VideoPlayer jellyfinId={playbackId} />
{/if}
</div>
</DetatchedPage>
</DetachedPage>

View File

@@ -265,6 +265,7 @@ export const useRequest = <P extends (...args: A) => Promise<any>, A extends any
function refresh(...args: A): ReturnType<P> {
isFetching.set(true);
// @ts-ignore
const p: ReturnType<P> = fn(...args)
.then((res) => {
data.set(res);
@@ -278,7 +279,7 @@ export const useRequest = <P extends (...args: A) => Promise<any>, A extends any
return p;
}
refresh(...initialArgs);
refresh(...initialArgs).finally(() => isLoading.set(false));
return {
promise: {

View File

@@ -1,71 +0,0 @@
import type { TitleId } from '$lib/types';
import { writable } from 'svelte/store';
import TitlePageModal from '../components/TitlePageLayout/TitlePageModal.svelte';
type ModalItem = {
id: symbol;
group: symbol;
component: ConstructorOfATypedSvelteComponent;
props: Record<string, any>;
};
function createDynamicModalStack() {
const store = writable<{ stack: ModalItem[]; top: ModalItem | undefined }>({
stack: [],
top: undefined
});
function close(symbol: symbol) {
store.update((s) => {
s.stack = s.stack.filter((i) => i.id !== symbol);
s.top = s.stack[s.stack.length - 1];
return s;
});
}
function closeGroup(group: symbol) {
store.update((s) => {
s.stack = s.stack.filter((i) => i.group !== group);
s.top = s.stack[s.stack.length - 1];
return s;
});
}
function create(
component: ConstructorOfATypedSvelteComponent,
props: Record<string, any>,
group: symbol | undefined = undefined
) {
const id = Symbol();
const item = { id, component, props, group: group || id };
store.update((s) => {
s.stack.push(item);
s.top = item;
return s;
});
return id;
}
function reset() {
store.set({ stack: [], top: undefined });
}
return {
...store,
create,
close,
closeGroup,
reset
};
}
export const modalStack = createDynamicModalStack();
let lastTitleModal: symbol | undefined = undefined;
export function openTitleModal(titleId: TitleId) {
if (lastTitleModal) {
modalStack.close(lastTitleModal);
}
lastTitleModal = modalStack.create(TitlePageModal, {
titleId
});
}