feat: Manage downloads and local media

This commit is contained in:
Aleksi Lassila
2024-04-02 20:31:40 +03:00
parent 8a6cfc0669
commit 880b653e8d
23 changed files with 540 additions and 296 deletions

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import type { MovieDownload } from '../../apis/radarr/radarr-api';
import ButtonGhost from '../Ghosts/ButtonGhost.svelte';
import Button from '../Button.svelte';
import { formatSize } from '../../utils';
import { ChevronRight } from 'radix-icons-svelte';
export let downloads: Promise<MovieDownload[]>;
export let cancelDownload: (downloadId: number) => Promise<any>;
</script>
<div class="-my-1">
{#await downloads}
{#each new Array(5) as _, index}
<div class="flex-1 my-1">
<ButtonGhost />
</div>
{/each}
{:then downloads}
{#each downloads as download, index}
<Button on:click={() => cancelDownload(download.id || -1)} let:hasFocus>
<div class="flex w-full">
<h1 class="flex-1 line-clamp-1">
{download.title}
</h1>
<div>
{#if !hasFocus}
{#if download.status === 'downloading'}
{formatSize((download.size || 0) - (download.sizeleft || 0))}/{formatSize(
download.size || 0
)}
{:else}
{download.status}
{/if}
{:else}
<div class="flex items-center">
Cancel
<ChevronRight size={19} class="ml-1" />
</div>
{/if}
</div>
</div>
</Button>
{:else}
<h1 class="text-sm text-zinc-400">No downloads found</h1>
{/each}
{/await}
</div>

View File

@@ -1,20 +0,0 @@
<script lang="ts">
import Container from '../../../Container.svelte';
import { modalStack } from '../Modal/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

@@ -1,6 +0,0 @@
<div class="max-h-full mx-auto flex flex-col py-32 max-w-2xl">
<h1 class="tracking-wide text-2xl font-semibold mb-4">
<slot name="header">Header is missing</slot>
</h1>
<slot>Content is missing</slot>
</div>

View File

@@ -1,10 +0,0 @@
<script lang="ts">
import type { MovieFileResource } from '../../../apis/radarr/radarr-api';
import Button from '../../Button.svelte';
export let file: MovieFileResource;
export let handleDeleteFile: (fileId: number) => Promise<boolean>;
</script>
<div class="-my-1">
<Button focusOnMount on:click={() => file.id && handleDeleteFile(file.id)}>Delete File</Button>
</div>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import Container from '../../../../Container.svelte';
import type { MovieFileResource } from '../../../apis/radarr/radarr-api';
import { formatSize } from '../../../utils';
import Button from '../../Button.svelte';
import FullScreenModal from '../../Modal/FullScreenModal.svelte';
import FullScreenModalContainer from '../ManageMediaMenuLayout.svelte';
export let modalId: symbol;
export let file: MovieFileResource;
export let handleDeleteFile: (fileId: number) => Promise<any>;
</script>
<FullScreenModal {modalId}>
<FullScreenModalContainer>
<div slot="header" class="flex">
<h1 class="line-clamp-1 flex-1 mr-4">
{file.relativePath}
</h1>
<h1 class="text-zinc-300">{formatSize(file.size || 0)}</h1>
</div>
<Container>
<div class="-my-1">
<Button focusOnMount on:click={() => file.id && handleDeleteFile(file.id)}>
Delete File
</Button>
</div>
</Container>
</FullScreenModalContainer>
</FullScreenModal>

View File

@@ -1,28 +1,42 @@
<script lang="ts">
import { useRequest } from '../../../stores/data.store';
import type { MovieFileResource } from '../../../apis/radarr/radarr-api';
import ButtonGhost from '../../Ghosts/ButtonGhost.svelte';
import Button from '../../Button.svelte';
import { ChevronRight } from 'radix-icons-svelte';
import { formatSize } from '../../../utils.js';
export let id: number;
export let getFiles: (movieId: number) => Promise<MovieFileResource[]>;
export let files: Promise<MovieFileResource[]>;
export let handleSelectFile: (file: MovieFileResource) => void;
const { data: files, isLoading } = useRequest(getFiles, id);
</script>
<div class="-my-1">
{#if $isLoading}
{#await files}
{#each new Array(5) as _, index}
<div class="flex-1 my-1">
<ButtonGhost />
</div>
{/each}
{:else if $files}
{#each $files as file, index}
<Button focusOnMount={index === 0} on:click={() => handleSelectFile(file)}>
{file.relativePath}
{: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>
{#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>
{:else}
<div class="text-sm text-zinc-400">No local files found</div>
{/each}
{/if}
{/await}
</div>

View File

@@ -1,41 +0,0 @@
<script lang="ts">
import FullScreenModal from '../FullScreenModal.svelte';
import FullScreenModalContainer from '../FullScreenModalContainer.svelte';
import Container from '../../../../Container.svelte';
import FilesList from './FilesList.svelte';
import { type MovieFileResource, radarrApi } from '../../../apis/radarr/radarr-api';
import FileActionsList from './FileActionsList.svelte';
import { formatSize } from '../../../utils';
export let id: number;
export let modalId: symbol;
let selectedFile: MovieFileResource | undefined = undefined;
function handleSelectFile(file: MovieFileResource) {
selectedFile = file;
}
</script>
<FullScreenModal {modalId}>
{#if !selectedFile}
<FullScreenModalContainer>
<h1 slot="header">Local Files</h1>
<Container>
<FilesList getFiles={radarrApi.getMovieFilesByMovieId} {handleSelectFile} {id} />
</Container>
</FullScreenModalContainer>
{:else}
<FullScreenModalContainer>
<div slot="header" class="flex">
<h1 class="line-clamp-1 flex-1 mr-4">
{selectedFile.relativePath}
</h1>
<h1 class="text-zinc-300">{formatSize(selectedFile.size || 0)}</h1>
</div>
<Container>
<FileActionsList file={selectedFile} handleDeleteFile={radarrApi.deleteRadarrMovieFile} />
</Container>
</FullScreenModalContainer>
{/if}
</FullScreenModal>

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import Container from '../../../Container.svelte';
</script>
<Container class="flex flex-col my-16" canFocusEmpty={false}>
<h1 class="tracking-wide text-2xl font-semibold mb-4">
<slot name="header">Header is missing</slot>
</h1>
<slot>Content is missing</slot>
</Container>

View File

@@ -0,0 +1,94 @@
<script lang="ts">
import FullScreenModal from '../Modal/FullScreenModal.svelte';
import ManageMediaMenuLayout from './ManageMediaMenuLayout.svelte';
import {
type MovieFileResource,
radarrApi,
type RadarrRelease
} from '../../apis/radarr/radarr-api';
import ReleaseList from './Releases/ReleaseList.svelte';
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 ReleaseActionsModal from './Releases/ReleaseActionsModal.svelte';
export let modalId: symbol;
export let hidden: boolean;
export let id: number;
const { promise: files, refresh: refreshFiles } = useRequest(
radarrApi.getMovieFilesByMovieId,
id
);
const {
promise: downloads,
data: downloadsData,
refresh: refreshDownloads
} = useRequest(radarrApi.getRadarrDownloadsById, id);
const handleGrabRelease = (guid: string, indexerId: number) =>
radarrApi
.downloadRadarrMovie(guid, indexerId)
.then((ok) => {
if (!ok) {
// TODO: Show error
}
refreshFiles(id);
return ok;
})
.finally(() => {
radarrApi.getReleaseHistory(id).then(console.log);
setTimeout(() => refreshDownloads(id), 8000);
});
const handleCancelDownload = (id: number) =>
radarrApi.cancelDownloadRadarrMovie(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 handleSelectRelease(release: RadarrRelease) {
modalStack.create(
ReleaseActionsModal,
{
release,
grabRelease: handleGrabRelease
},
modalId
);
}
function handleSelectFile(file: MovieFileResource) {
modalStack.create(
FileActionsModal,
{
file,
handleDeleteFile: (id: number) =>
radarrApi.deleteRadarrMovieFile(id).then(() => refreshFiles(id))
},
modalId
);
}
</script>
<FullScreenModal {modalId} {hidden}>
<ManageMediaMenuLayout>
<h1 slot="header">Download</h1>
<ReleaseList {id} getReleases={radarrApi.getReleases} selectRelease={handleSelectRelease} />
</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

@@ -0,0 +1,56 @@
<script lang="ts">
import Container from '../../../../Container.svelte';
import type { MovieFileResource, RadarrRelease } from '../../../apis/radarr/radarr-api';
import { formatSize } from '../../../utils';
import Button from '../../Button.svelte';
import FullScreenModal from '../../Modal/FullScreenModal.svelte';
import FullScreenModalContainer from '../ManageMediaMenuLayout.svelte';
import { useActionRequest, useRequest } from '../../../stores/data.store';
import { Download, Plus } from 'radix-icons-svelte';
export let modalId: symbol;
export let release: RadarrRelease;
export let status: undefined | 'downloading' | 'downloaded' = undefined;
export let grabRelease: (guid: string, indexerId: number) => Promise<boolean>;
const {
send: handleGrabRelease,
isFetching,
data
} = useActionRequest((guid: string, indexerId: number) => grabRelease(guid, indexerId));
</script>
<FullScreenModal {modalId}>
<FullScreenModalContainer>
<div slot="header" class="flex">
<h1 class="line-clamp-1 flex-1 mr-4">
{release.title}
</h1>
<h1 class="text-zinc-300">{formatSize(release.size || 0)}</h1>
</div>
<Container>
<div class="-my-1">
<Button
focusOnMount
on:click={() => handleGrabRelease(release.guid || '', release.indexerId || -1)}
inactive={!!($data || $isFetching || status)}
>
{#if $data || status === 'downloading'}
Downloading...
{:else if status === 'downloaded'}
Downloaded
{:else}
Download
{/if}
<svelte:component
this={$data || status ? Download : Plus}
size={19}
slot="icon"
class="mr-2"
/>
</Button>
</div>
</Container>
</FullScreenModalContainer>
</FullScreenModal>

View File

@@ -0,0 +1,178 @@
<script lang="ts">
import { type RadarrRelease } from '../../../apis/radarr/radarr-api';
import classNames from 'classnames';
import { useRequest } from '../../../stores/data.store';
import Button from '../../Button.svelte';
import { ChevronRight, DotFilled } from 'radix-icons-svelte';
import { formatMinutesToTime, formatSize } from '../../../utils';
import { derived } from 'svelte/store';
import ButtonGhost from '../../Ghosts/ButtonGhost.svelte';
export let id: number;
export let getReleases: (id: number) => Promise<RadarrRelease[]>;
export let selectRelease: (release: RadarrRelease) => void;
let showAll = false;
const { data: releases, isLoading } = 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;
});
</script>
<div class="flex flex-col -my-1">
{#if $isLoading}
{#each new Array(5) as _, index}
<div class="flex-1 my-1">
<ButtonGhost />
</div>
{/each}
{: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}>
<div class="w-full flex">
<div class="flex-1 flex flex-col mr-2">
<div class="flex-1 flex items-center">
<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>
{#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>
{#if hasFocus}
<div class="flex items-center">
<ChevronRight size={32} />
</div>
{:else}
<div
class={classNames({
'text-zinc-400': !hasFocus,
'text-zinc-700': hasFocus
})}
>
{formatSize(release?.size || 0)}
</div>
{/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}
<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}
{/if}
</div>

View File

@@ -1,27 +0,0 @@
<script lang="ts">
import FullScreenModal from '../FullScreenModal.svelte';
import { radarrApi } from '../../../apis/radarr/radarr-api';
import ReleaseList from './ReleaseList.svelte';
import Container from '../../../../Container.svelte';
import { scrollWithOffset } from '../../../selectable';
import FullScreenModalContainer from '../FullScreenModalContainer.svelte';
export let id: number;
export let modalId: symbol;
</script>
<FullScreenModal {modalId}>
<FullScreenModalContainer>
<h1 slot="header">Download</h1>
<Container
childrenRevealStrategy={scrollWithOffset('all', 10)}
class="flex-1 overflow-y-scroll"
>
<ReleaseList
{id}
grabRelease={radarrApi.downloadRadarrMovie}
getReleases={radarrApi.fetchRadarrReleases}
/>
</Container>
</FullScreenModalContainer>
</FullScreenModal>

View File

@@ -1,136 +0,0 @@
<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';
import ButtonGhost from '../../Ghosts/ButtonGhost.svelte';
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, isLoading } = 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">
{#if $isLoading}
{#each new Array(5) as _, index}
<div class="flex-1 my-1">
<ButtonGhost />
</div>
{/each}
{:else}
{#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}
{/if}
</div>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import classNames from 'classnames';
import Container from '../../../Container.svelte';
import { modalStack } from './modal.store';
export let modalId: symbol;
export let hidden: boolean = false;
</script>
<Container
navigationActions={{
left: () => {
modalStack.close(modalId);
return true;
}
}}
focusOnMount
trapFocus
class={classNames('fixed inset-0 bg-stone-950/80 overflow-auto', {
'opacity-0': hidden
})}
>
<div class="max-h-full mx-auto max-w-2xl -my-16 py-32">
<slot />
</div>
</Container>

View File

@@ -1,3 +1,4 @@
import type { ComponentType, SvelteComponentTyped } from 'svelte';
import { derived, writable } from 'svelte/store';
type ModalItem = {
@@ -18,9 +19,9 @@ function createModalStack() {
items.update((prev) => prev.filter((i) => i.group !== group));
}
function create(
component: ConstructorOfATypedSvelteComponent,
props: Record<string, any>,
function create<P extends Record<string, any>>(
component: ComponentType<SvelteComponentTyped<P>>,
props: Omit<P, 'modalId' | 'hidden'>,
group: symbol | undefined = undefined
) {
const id = Symbol();

View File

@@ -17,7 +17,7 @@ function createPlayerState() {
},
close: () => {
store.set({ visible: false, jellyfinId: '' });
jellyfinItemsStore.refresh();
jellyfinItemsStore.send();
}
};
}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import FullScreenModal from '../ManageMedia/FullScreenModal.svelte';
import FullScreenModal from '../Modal/FullScreenModal.svelte';
import VideoPlayer from './VideoPlayer.svelte';
export let modalId: symbol;
export let id: string;