feat: Series requesting and local file management

This commit is contained in:
Aleksi Lassila
2024-04-07 16:54:04 +03:00
parent 3afafb573a
commit 8ed688d816
23 changed files with 410 additions and 173 deletions

View File

@@ -16,7 +16,7 @@
{
'bg-highlight-foreground text-stone-900': $hasFoucus,
'hover:bg-highlight-foreground hover:text-stone-900': true,
'bg-stone-800/50': !$hasFoucus,
'bg-stone-800/90': !$hasFoucus,
'cursor-pointer': !inactive,
'cursor-not-allowed pointer-events-none opacity-40': inactive
},
@@ -25,6 +25,7 @@
on:click
on:select
on:clickOrSelect
on:enter
let:hasFocus
{focusOnMount}
>

View File

@@ -4,8 +4,9 @@
import Button from '../Button.svelte';
import { formatSize } from '../../utils';
import { ChevronRight } from 'radix-icons-svelte';
import type { Download } from '../../apis/combined-types';
export let downloads: Promise<MovieDownload[]>;
export let downloads: Promise<Download[]>;
export let cancelDownload: (downloadId: number) => Promise<any>;
</script>

View File

@@ -5,9 +5,10 @@
import Button from '../../Button.svelte';
import FullScreenModal from '../../Modal/FullScreenModal.svelte';
import FullScreenModalContainer from '../ManageMediaMenuLayout.svelte';
import type { FileResource } from '../../../apis/combined-types';
export let modalId: symbol;
export let file: MovieFileResource;
export let file: FileResource;
export let handleDeleteFile: (fileId: number) => Promise<any>;
</script>

View File

@@ -4,9 +4,12 @@
import Button from '../../Button.svelte';
import { ChevronRight } from 'radix-icons-svelte';
import { formatSize } from '../../../utils.js';
import type { EpisodeFileResource } from '../../../apis/sonarr/sonarr-api';
import type { FileResource } from '../../../apis/combined-types';
import { scrollIntoView } from '../../../selectable';
export let files: Promise<MovieFileResource[]>;
export let handleSelectFile: (file: MovieFileResource) => void;
export let files: Promise<FileResource[]>;
export let handleSelectFile: (file: FileResource) => void;
</script>
<div class="-my-1">
@@ -18,23 +21,29 @@
{/each}
{:then files}
{#each files as file, index}
<Button on:click={() => handleSelectFile(file)} let:hasFocus>
<div class="flex items-center w-full">
<div class="flex-1">
{file.relativePath}
<div class="flex-1 my-1">
<Button
on:clickOrSelect={() => handleSelectFile(file)}
let:hasFocus
on:enter={scrollIntoView({ vertical: 64 })}
>
<div class="flex items-center w-full">
<div class="flex-1">
{file.relativePath}
</div>
{#if hasFocus}
<div class="flex items-center">
Details
<ChevronRight size={19} class="ml-1" />
</div>
{:else}
<div class="flex items-center text-zinc-400">
{formatSize(file.size || 0)}
</div>
{/if}
</div>
{#if hasFocus}
<div class="flex items-center">
Details
<ChevronRight size={19} class="ml-1" />
</div>
{:else}
<div class="flex items-center text-zinc-400">
{formatSize(file.size || 0)}
</div>
{/if}
</div>
</Button>
</Button>
</div>
{:else}
<div class="text-sm text-zinc-400">No local files found</div>
{/each}

View File

@@ -14,24 +14,22 @@
import { useRequest } from '../../stores/data.store';
import { derived, type Readable } from 'svelte/store';
import ReleaseActionsModal from './Releases/ReleaseActionsModal.svelte';
import type { SonarrRelease } from '../../apis/sonarr/sonarr-api';
export let modalId: symbol;
export let hidden: boolean;
export let id: number;
const { promise: files, refresh: refreshFiles } = useRequest(
radarrApi.getMovieFilesByMovieId,
id
);
const { promise: files, refresh: refreshFiles } = useRequest(radarrApi.getFilesByMovieId, id);
const {
promise: downloads,
data: downloadsData,
refresh: refreshDownloads
} = useRequest(radarrApi.getRadarrDownloadsById, id);
} = useRequest(radarrApi.getDownloadsById, id);
const handleGrabRelease = (guid: string, indexerId: number) =>
radarrApi
.downloadRadarrMovie(guid, indexerId)
.downloadMovie(guid, indexerId)
.then((ok) => {
if (!ok) {
// TODO: Show error
@@ -54,7 +52,7 @@
}, {})
);
function handleSelectRelease(release: RadarrRelease) {
function handleSelectRelease(release: RadarrRelease | SonarrRelease) {
modalStack.create(
ReleaseActionsModal,
{
@@ -70,8 +68,7 @@
FileActionsModal,
{
file,
handleDeleteFile: (id: number) =>
radarrApi.deleteRadarrMovieFile(id).then(() => refreshFiles(id))
handleDeleteFile: (id: number) => radarrApi.deleteMovieFile(id).then(() => refreshFiles(id))
},
modalId
);
@@ -81,7 +78,10 @@
<FullScreenModal {modalId} {hidden}>
<ManageMediaMenuLayout>
<h1 slot="header">Download</h1>
<ReleaseList {id} getReleases={radarrApi.getReleases} selectRelease={handleSelectRelease} />
<ReleaseList
getReleases={() => radarrApi.getReleases(id)}
selectRelease={handleSelectRelease}
/>
</ManageMediaMenuLayout>
<ManageMediaMenuLayout>
<h1 slot="header">Local Files</h1>

View File

@@ -7,9 +7,11 @@
import FullScreenModalContainer from '../ManageMediaMenuLayout.svelte';
import { useActionRequest, useRequest } from '../../../stores/data.store';
import { Download, Plus } from 'radix-icons-svelte';
import type { SonarrRelease } from '../../../apis/sonarr/sonarr-api';
import type { Release } from '../../../apis/combined-types';
export let modalId: symbol;
export let release: RadarrRelease;
export let release: Release;
export let status: undefined | 'downloading' | 'downloaded' = undefined;
export let grabRelease: (guid: string, indexerId: number) => Promise<boolean>;

View File

@@ -7,23 +7,25 @@
import { formatMinutesToTime, formatSize } from '../../../utils';
import { derived } from 'svelte/store';
import ButtonGhost from '../../Ghosts/ButtonGhost.svelte';
import type { SonarrRelease } from '../../../apis/sonarr/sonarr-api';
export let id: number;
export let getReleases: (id: number) => Promise<RadarrRelease[]>;
export let selectRelease: (release: RadarrRelease) => void;
type Release = RadarrRelease | SonarrRelease;
export let getReleases: () => Promise<Release[]>;
export let selectRelease: (release: Release) => void;
let showAll = false;
const { data: releases, isLoading } = useRequest(getReleases, id);
const { data: releases, isLoading } = useRequest(getReleases);
const filteredReleases = derived(releases, ($releases) => {
if (!$releases) return [];
let filtered = $releases.slice();
const releaseIsEnough = (r: Release) => r?.quality?.quality?.resolution || 0 > 720;
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) => (releaseIsEnough(b) ? 1 : 0) - (releaseIsEnough(a) ? 1 : 0));
filtered = filtered.slice(0, 5);
filtered.sort((a, b) => (b.size || 0) - (a.size || 0));
@@ -41,7 +43,11 @@
{:else}
{#each (showAll ? $releases : $filteredReleases)?.filter((r) => r.guid && r.indexerId) || [] as release, index}
<div class="flex-1 my-1">
<Button on:click={() => selectRelease(release)} let:hasFocus focusOnMount={index === 0}>
<Button
on:clickOrSelect={() => selectRelease(release)}
let:hasFocus
focusOnMount={index === 0}
>
<div class="w-full flex">
<div class="flex-1 flex flex-col mr-2">
<div class="flex-1 flex items-center">
@@ -98,71 +104,6 @@
{/if}
</div>
</Button>
<!-- <Button-->
<!-- on:click={() =>-->
<!-- !isFetching &&-->
<!-- !isGrabbed &&-->
<!-- handleGrabRelease(release.guid || '', release.indexerId || 0, release.title || '')}-->
<!-- 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}

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import FullScreenModal from '../../Modal/FullScreenModal.svelte';
import ReleaseList from './ReleaseList.svelte';
import type { Release } from '../../../apis/combined-types';
export let modalId: symbol;
export let hidden: boolean;
export let getReleases: () => Promise<Release[]>;
export let selectRelease: (release: Release) => void;
</script>
<FullScreenModal {modalId} {hidden}>
<ReleaseList {getReleases} {selectRelease} />
</FullScreenModal>

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import FullScreenModal from '../../Modal/FullScreenModal.svelte';
import ManageMediaMenuLayout from '../ManageMediaMenuLayout.svelte';
import {
sonarrApi,
type SonarrEpisode,
type SonarrRelease
} from '../../../apis/sonarr/sonarr-api';
import { useRequest } from '../../../stores/data.store';
import Button from '../../Button.svelte';
import { modalStack } from '../../Modal/modal.store';
import ReleaseListModal from './ReleaseListModal.svelte';
import type { RadarrRelease } from '../../../apis/radarr/radarr-api';
import ReleaseActionsModal from './ReleaseActionsModal.svelte';
import type { Release } from '../../../apis/combined-types';
export let modalId: symbol;
export let groupId: symbol;
export let hidden: boolean;
export let seriesId: number;
export let seasonNumber: number;
export let grabRelease: (guid: string, indexerId: number) => Promise<boolean>;
const { promise: episodes } = useRequest(sonarrApi.getEpisodes, seriesId, seasonNumber);
const handleSelectRelease = (release: Release) => {
modalStack.create(
ReleaseActionsModal,
{
release,
grabRelease: () => grabRelease(release.guid || '', release.indexerId || -1),
status: undefined
},
groupId
);
};
function handleSelectEpisode(episode: SonarrEpisode) {
const id = episode.id;
if (!id) return;
modalStack.create(
ReleaseListModal,
{
getReleases: () => sonarrApi.fetchSonarrReleases(id),
selectRelease: handleSelectRelease
},
groupId
);
}
function handleSelectSeasonPacks() {
modalStack.create(
ReleaseListModal,
{
getReleases: () => sonarrApi.fetchSonarrSeasonReleases(seriesId, seasonNumber),
selectRelease: handleSelectRelease
},
groupId
);
}
</script>
<FullScreenModal {modalId} {hidden}>
<Button on:clickOrSelect={handleSelectSeasonPacks}>Season Packs</Button>
<ManageMediaMenuLayout>
<h1 slot="header">Episodes</h1>
<div class="flex flex-col -my-1">
{#await $episodes then episodes}
{#each episodes as episode}
<div class="my-1">
<Button on:clickOrSelect={() => handleSelectEpisode(episode)}>
<div class="flex items-center font-medium">
<div class="mr-2 text-zinc-300">{episode.episodeNumber}.</div>
<div>{episode.title}</div>
</div>
</Button>
</div>
{/each}
{/await}
</div>
</ManageMediaMenuLayout>
</FullScreenModal>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { sonarrApi, type SonarrSeason } from '../../apis/sonarr/sonarr-api';
import { useRequest } from '../../stores/data.store';
import Button from '../Button.svelte';
import { scrollIntoView } from '../../selectable';
export let id: number;
export let selectSeason: (seasonNumber: number) => void;
const { promise: sonarrSeries } = useRequest(sonarrApi.getSeriesById, id);
</script>
<div class="flex flex-col -my-1">
{#await $sonarrSeries then series}
{#if series?.seasons}
{#each series.seasons.filter((s) => s.seasonNumber !== 0) as season, i}
<div class="flex-1 my-1">
<Button
on:clickOrSelect={() => selectSeason(season.seasonNumber || i + 1)}
on:enter={scrollIntoView({ vertical: 64 })}
>
<div class="mr-2">
Season {season.seasonNumber}
</div>
{#if season.statistics}
<div class="text-zinc-400">{season.statistics.totalEpisodeCount} Episodes</div>
{/if}
</Button>
</div>
{/each}
{/if}
{/await}
</div>

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import FullScreenModal from '../Modal/FullScreenModal.svelte';
import ManageMediaMenuLayout from './ManageMediaMenuLayout.svelte';
import { radarrApi } from '../../apis/radarr/radarr-api';
import FilesList from './LocalFiles/FilesList.svelte';
import { modalStack } from '../Modal/modal.store';
import FileActionsModal from './LocalFiles/FileActionsModal.svelte';
import DownloadsList from './DownloadsList.svelte';
import { useRequest } from '../../stores/data.store';
import { derived, type Readable } from 'svelte/store';
import SeasonList from './SeasonList.svelte';
import { sonarrApi } from '../../apis/sonarr/sonarr-api';
import SeasonReleasesModal from './Releases/SeasonReleasesModal.svelte';
import type { FileResource, Release } from '../../apis/combined-types';
import ReleaseActionsModal from './Releases/ReleaseActionsModal.svelte';
import Button from '../Button.svelte';
export let modalId: symbol;
export let groupId: symbol;
export let hidden: boolean;
export let id: number;
const { promise: files, refresh: refreshFiles } = useRequest(sonarrApi.getFilesBySeriesId, id);
const {
promise: downloads,
data: downloadsData,
refresh: refreshDownloads
} = useRequest(sonarrApi.getSonarrDownloadsById, id);
const handleGrabRelease = (guid: string, indexerId: number) =>
sonarrApi
.downloadSonarrEpisode(guid, indexerId)
.then((ok) => {
if (!ok) {
// TODO: Show error
}
refreshFiles(id);
return ok;
})
.finally(() => {
setTimeout(() => refreshDownloads(id), 8000);
});
const handleCancelDownload = (id: number) =>
sonarrApi.cancelDownloadSonarrEpisode(id).then(() => refreshDownloads(id));
const grabbedReleases: Readable<Record<string, boolean>> = derived(downloadsData, ($downloads) =>
($downloads || []).reduce((acc: Record<string, boolean>, download) => {
acc[`${download.title}`] = true;
return acc;
}, {})
);
function handleSelectSeason(seasonNumber: number) {
modalStack.create(
SeasonReleasesModal,
{
seriesId: id,
seasonNumber,
grabRelease: handleGrabRelease
},
groupId
);
}
const handleSelectRelease = (release: Release) => {
modalStack.create(
ReleaseActionsModal,
{
release,
grabRelease: handleGrabRelease,
status: undefined
},
groupId
);
};
function handleSelectFile(file: FileResource) {
modalStack.create(
FileActionsModal,
{
file,
handleDeleteFile: (id: number) =>
sonarrApi.deleteSonarrEpisode(id).then(() => refreshFiles(id))
},
groupId
);
}
</script>
<FullScreenModal {modalId} {hidden}>
<ManageMediaMenuLayout>
<h1 slot="header">Download</h1>
<SeasonList {id} selectSeason={handleSelectSeason} />
</ManageMediaMenuLayout>
<ManageMediaMenuLayout>
<h1 slot="header">Local Files</h1>
<FilesList files={$files} {handleSelectFile} />
</ManageMediaMenuLayout>
<ManageMediaMenuLayout>
<h1 slot="header">Downloads</h1>
<DownloadsList downloads={$downloads} cancelDownload={handleCancelDownload} />
</ManageMediaMenuLayout>
</FullScreenModal>

View File

@@ -19,6 +19,7 @@
class={classNames('fixed inset-0 bg-stone-950/80 overflow-auto', {
'opacity-0': hidden
})}
canFocusEmpty
>
<div class="max-h-full mx-auto max-w-2xl -my-16 py-32">
<slot />

View File

@@ -30,6 +30,13 @@
{@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} />
<svelte:component
this={modal.component}
{...modal.props}
modalId={modal.id}
{hidden}
groupId={modal.group}
{modal}
/>
</div>
{/each}

View File

@@ -21,7 +21,7 @@ function createModalStack() {
function create<P extends Record<string, any>>(
component: ComponentType<SvelteComponentTyped<P>>,
props: Omit<P, 'modalId' | 'hidden'>,
props: Omit<P, 'modal' | 'groupId' | 'modalId' | 'hidden'>,
group: symbol | undefined = undefined
) {
const id = Symbol();

View File

@@ -12,11 +12,12 @@
import Button from '../Button.svelte';
import { playerState } from '../VideoPlayer/VideoPlayer';
import { modalStack } from '../Modal/modal.store';
import ManageMediaModal from '../ManageMedia/ManageMediaModal.svelte';
import ManageMediaModal from '../ManageMedia/RadarrMediaMangerModal.svelte';
import { derived } from 'svelte/store';
import EpisodeCarousel from './EpisodeCarousel.svelte';
import { scrollIntoView, Selectable } from '../../selectable';
import ScrollHelper from '../ScrollHelper.svelte';
import SonarrMediaMangerModal from '../ManageMedia/SonarrMediaMangerModal.svelte';
export let id: string;
@@ -156,7 +157,7 @@
<Button
class="mr-2"
on:clickOrSelect={() =>
modalStack.create(ManageMediaModal, { id: sonarrItem.id || -1 })}
modalStack.create(SonarrMediaMangerModal, { id: sonarrItem.id || -1 })}
>
{#if jellyfinItem}
Manage Files