feat: Series requesting and local file management
This commit is contained in:
@@ -47,7 +47,7 @@
|
||||
<Route path="movies/*">
|
||||
<MoviesPage />
|
||||
</Route>
|
||||
<Route path="library">
|
||||
<Route path="library/*">
|
||||
<LibraryPage />
|
||||
</Route>
|
||||
<Route path="manage">
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
rest.container._initializeSelectable();
|
||||
|
||||
if (focusOnMount) {
|
||||
console.log('focusing', rest.container.getHtmlElement());
|
||||
rest.container.focus();
|
||||
}
|
||||
|
||||
|
||||
6
src/lib/apis/combined-types.d.ts
vendored
Normal file
6
src/lib/apis/combined-types.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { MovieDownload, MovieFileResource, RadarrRelease } from './radarr/radarr-api';
|
||||
import type { EpisodeFileResource, EpisodeDownload, SonarrRelease } from './sonarr/sonarr-api';
|
||||
|
||||
export type Release = RadarrRelease | SonarrRelease;
|
||||
export type FileResource = MovieFileResource | EpisodeFileResource;
|
||||
export type Download = MovieDownload | EpisodeDownload;
|
||||
@@ -133,7 +133,7 @@ export class RadarrApi implements Api<paths> {
|
||||
})
|
||||
.then((r) => r.data || []) || Promise.resolve([]);
|
||||
|
||||
downloadRadarrMovie = (guid: string, indexerId: number) =>
|
||||
downloadMovie = (guid: string, indexerId: number) =>
|
||||
this.getClient()
|
||||
?.POST('/api/v3/release', {
|
||||
params: {},
|
||||
@@ -144,7 +144,7 @@ export class RadarrApi implements Api<paths> {
|
||||
})
|
||||
.then((res) => res.response.ok) || Promise.resolve(false);
|
||||
|
||||
getMovieFilesByMovieId = (movieId: number): Promise<MovieFileResource[]> =>
|
||||
getFilesByMovieId = (movieId: number): Promise<MovieFileResource[]> =>
|
||||
this.getClient()
|
||||
?.GET('/api/v3/moviefile', {
|
||||
params: {
|
||||
@@ -154,7 +154,7 @@ export class RadarrApi implements Api<paths> {
|
||||
}
|
||||
})
|
||||
.then((r) => r.data || []) || Promise.resolve([]);
|
||||
deleteRadarrMovieFile = (id: number) =>
|
||||
deleteMovieFile = (id: number) =>
|
||||
this.getClient()
|
||||
?.DELETE('/api/v3/moviefile/{id}', {
|
||||
params: {
|
||||
@@ -177,7 +177,7 @@ export class RadarrApi implements Api<paths> {
|
||||
.then((r) => (r.data?.records?.filter((record) => record.movie) as MovieDownload[]) || []) ||
|
||||
Promise.resolve([]);
|
||||
|
||||
getRadarrDownloadsById = (radarrId: number) =>
|
||||
getDownloadsById = (radarrId: number) =>
|
||||
this.getRadarrDownloads().then((downloads) => downloads.filter((d) => d.movie.id === radarrId));
|
||||
|
||||
getRadarrDownloadsByTmdbId = (tmdbId: number) =>
|
||||
|
||||
@@ -9,10 +9,12 @@ import { appState } from '../../stores/app-state.store';
|
||||
import { createLocalStorageStore } from '../../stores/localstorage.store';
|
||||
|
||||
export type SonarrSeries = components['schemas']['SeriesResource'];
|
||||
export type SonarrSeason = components['schemas']['SeasonResource'];
|
||||
export type SonarrRelease = components['schemas']['ReleaseResource'];
|
||||
export type SeriesDownload = components['schemas']['QueueResource'] & { series: SonarrSeries };
|
||||
export type EpisodeDownload = components['schemas']['QueueResource'] & { series: SonarrSeries };
|
||||
export type DiskSpaceInfo = components['schemas']['DiskSpaceResource'];
|
||||
export type SonarrEpisode = components['schemas']['EpisodeResource'];
|
||||
export type EpisodeFileResource = components['schemas']['EpisodeFileResource'];
|
||||
|
||||
export interface SonarrSeriesOptions {
|
||||
title: string;
|
||||
@@ -80,7 +82,18 @@ export class SonarrApi implements Api<paths> {
|
||||
return get(tmdbToTvdbCache)[tmdbId];
|
||||
};
|
||||
|
||||
getSonarrSeries = (): Promise<SonarrSeries[]> =>
|
||||
getSeriesById = (id: number): Promise<SonarrSeries | undefined> =>
|
||||
this.getClient()
|
||||
?.GET('/api/v3/series/{id}', {
|
||||
params: {
|
||||
path: {
|
||||
id
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data) || Promise.resolve(undefined);
|
||||
|
||||
getAllSeries = (): Promise<SonarrSeries[]> =>
|
||||
this.getClient()
|
||||
?.GET('/api/v3/series', {
|
||||
params: {}
|
||||
@@ -177,7 +190,7 @@ export class SonarrApi implements Api<paths> {
|
||||
})
|
||||
.then((res) => res.response.ok) || Promise.resolve(false);
|
||||
|
||||
getSonarrDownloads = (): Promise<SeriesDownload[]> =>
|
||||
getSonarrDownloads = (): Promise<EpisodeDownload[]> =>
|
||||
this.getClient()
|
||||
?.GET('/api/v3/queue', {
|
||||
params: {
|
||||
@@ -191,7 +204,7 @@ export class SonarrApi implements Api<paths> {
|
||||
(r) =>
|
||||
(r.data?.records?.filter(
|
||||
(record) => record.episode && record.series
|
||||
) as SeriesDownload[]) || []
|
||||
) as EpisodeDownload[]) || []
|
||||
) || Promise.resolve([]);
|
||||
|
||||
getSonarrDownloadsById = (sonarrId: number) =>
|
||||
@@ -210,37 +223,48 @@ export class SonarrApi implements Api<paths> {
|
||||
})
|
||||
.then((res) => res.response.ok) || Promise.resolve(false);
|
||||
|
||||
getSonarrEpisodes = async (seriesId: number) => {
|
||||
const episodesPromise =
|
||||
this.getClient()
|
||||
?.GET('/api/v3/episode', {
|
||||
params: {
|
||||
query: {
|
||||
seriesId
|
||||
}
|
||||
getFilesBySeriesId = (seriesId: number): Promise<EpisodeFileResource[]> =>
|
||||
this.getClient()
|
||||
?.GET('/api/v3/episodefile', {
|
||||
params: {
|
||||
query: {
|
||||
seriesId
|
||||
}
|
||||
})
|
||||
.then((r) => r.data || []) || Promise.resolve([]);
|
||||
}
|
||||
})
|
||||
.then((r) => r.data || []) || Promise.resolve([]);
|
||||
|
||||
const episodeFilesPromise =
|
||||
this.getClient()
|
||||
?.GET('/api/v3/episodefile', {
|
||||
params: {
|
||||
query: {
|
||||
seriesId
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data || []) || Promise.resolve([]);
|
||||
|
||||
const episodes = await episodesPromise;
|
||||
const episodeFiles = await episodeFilesPromise;
|
||||
|
||||
return episodes.map((episode) => ({
|
||||
episode,
|
||||
episodeFile: episodeFiles.find((file) => file.id === episode.episodeFileId)
|
||||
}));
|
||||
};
|
||||
// getSonarrEpisodes = async (seriesId: number) => {
|
||||
// const episodesPromise =
|
||||
// this.getClient()
|
||||
// ?.GET('/api/v3/episode', {
|
||||
// params: {
|
||||
// query: {
|
||||
// seriesId
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
// .then((r) => r.data || []) || Promise.resolve([]);
|
||||
//
|
||||
// const episodeFilesPromise =
|
||||
// this.getClient()
|
||||
// ?.GET('/api/v3/episodefile', {
|
||||
// params: {
|
||||
// query: {
|
||||
// seriesId
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
// .then((r) => r.data || []) || Promise.resolve([]);
|
||||
//
|
||||
// const episodes = await episodesPromise;
|
||||
// const episodeFiles = await episodeFilesPromise;
|
||||
//
|
||||
// return episodes.map((episode) => ({
|
||||
// episode,
|
||||
// episodeFile: episodeFiles.find((file) => file.id === episode.episodeFileId)
|
||||
// }));
|
||||
// };
|
||||
|
||||
fetchSonarrReleases = async (episodeId: number) =>
|
||||
this.getClient()
|
||||
@@ -265,13 +289,14 @@ export class SonarrApi implements Api<paths> {
|
||||
})
|
||||
.then((r) => r.data || []) || Promise.resolve([]);
|
||||
|
||||
fetchSonarrEpisodes = async (seriesId: number): Promise<SonarrEpisode[]> => {
|
||||
getEpisodes = async (seriesId: number, seasonNumber?: number): Promise<SonarrEpisode[]> => {
|
||||
return (
|
||||
this.getClient()
|
||||
?.GET('/api/v3/episode', {
|
||||
params: {
|
||||
query: {
|
||||
seriesId
|
||||
seriesId,
|
||||
seasonNumber
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
33
src/lib/components/ManageMedia/SeasonList.svelte
Normal file
33
src/lib/components/ManageMedia/SeasonList.svelte
Normal 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>
|
||||
104
src/lib/components/ManageMedia/SonarrMediaMangerModal.svelte
Normal file
104
src/lib/components/ManageMedia/SonarrMediaMangerModal.svelte
Normal 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>
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import DetachedPage from '../components/DetachedPage/DetachedPage.svelte';
|
||||
import { modalStack } from '../components/Modal/modal.store';
|
||||
import { playerState } from '../components/VideoPlayer/VideoPlayer';
|
||||
import ManageMediaModal from '../components/ManageMedia/ManageMediaModal.svelte';
|
||||
import ManageMediaModal from '../components/ManageMedia/RadarrMediaMangerModal.svelte';
|
||||
|
||||
export let id: string;
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ export class Selectable {
|
||||
left: undefined,
|
||||
right: undefined
|
||||
};
|
||||
private focusByDefault: boolean = false;
|
||||
private canFocusEmpty: boolean = true;
|
||||
private trapFocus: boolean = false;
|
||||
private isInitialized: boolean = false;
|
||||
@@ -115,12 +114,12 @@ export class Selectable {
|
||||
|
||||
if (this.children[focusIndex]?.isFocusable()) {
|
||||
this.children[focusIndex]?.focus(options);
|
||||
return;
|
||||
} else {
|
||||
let i = focusIndex;
|
||||
while (i < this.children.length) {
|
||||
if (this.children[i]?.isFocusable()) {
|
||||
this.children[i]?.focus(options);
|
||||
// this.onFocus?.(this);
|
||||
return;
|
||||
}
|
||||
i++;
|
||||
@@ -129,13 +128,14 @@ export class Selectable {
|
||||
while (i >= 0) {
|
||||
if (this.children[i]?.isFocusable()) {
|
||||
this.children[i]?.focus(options);
|
||||
// this.onFocus?.(this);
|
||||
return;
|
||||
}
|
||||
i--;
|
||||
}
|
||||
}
|
||||
} else if (this.htmlElement) {
|
||||
}
|
||||
|
||||
if (this.htmlElement) {
|
||||
const _options: FocusEventOptions = {
|
||||
...createFocusHandlerOptions(),
|
||||
...options
|
||||
@@ -144,6 +144,7 @@ export class Selectable {
|
||||
|
||||
if (_options.setFocusedElement) {
|
||||
this.htmlElement.focus({ preventScroll: true });
|
||||
console.log('Setting focused element to', this.htmlElement);
|
||||
Selectable.focusedObject.set(this);
|
||||
}
|
||||
}
|
||||
@@ -231,7 +232,15 @@ export class Selectable {
|
||||
return currentlyFocusedObject?.giveFocus(direction, bypassActions);
|
||||
}
|
||||
|
||||
/**
|
||||
* This runs after the regsterer has been called and the thmlElement
|
||||
* has been set. Becasue all the children get initialized before their parents,
|
||||
* we can't create the parent-child tree structure in the registerer but instead
|
||||
* have to wait until every element has htmlElement and then later (here) deduce
|
||||
* the parent-child relationships.
|
||||
*/
|
||||
_initializeSelectable() {
|
||||
console.debug('Initializing', this);
|
||||
const getParentSelectable = (htmlElement: HTMLElement): Selectable | undefined => {
|
||||
if (Selectable.objects.get(htmlElement)) return Selectable.objects.get(htmlElement);
|
||||
else if (htmlElement.parentElement) return getParentSelectable(htmlElement.parentElement);
|
||||
@@ -321,8 +330,17 @@ export class Selectable {
|
||||
console.error('No parent selectable found for', this.htmlElement);
|
||||
}
|
||||
|
||||
if (!get(Selectable.focusedObject) && this.shouldFocusByDefault()) {
|
||||
if (get(Selectable.focusedObject) === parentSelectable && this.isFocusable()) {
|
||||
console.log('Focusing on add');
|
||||
this.focus();
|
||||
} else {
|
||||
console.log(
|
||||
'Not focusing on add',
|
||||
this,
|
||||
this.isFocusable(),
|
||||
get(Selectable.focusedObject),
|
||||
parentSelectable
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,6 +360,9 @@ export class Selectable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This only sets the htmlElement. See {@link _initializeSelectable} for the rest of the initialization.
|
||||
*/
|
||||
private static createRegisterer(
|
||||
_selectable?: Selectable,
|
||||
flowDirection: FlowDirection = 'vertical'
|
||||
@@ -349,8 +370,8 @@ export class Selectable {
|
||||
const selectable = _selectable || new Selectable().setDirection(flowDirection);
|
||||
|
||||
return (htmlElement: HTMLElement) => {
|
||||
// console.log('Registering', htmlElement, selectable);
|
||||
selectable.setHtmlElement(htmlElement);
|
||||
console.debug('Registering', selectable);
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
@@ -415,10 +436,6 @@ export class Selectable {
|
||||
return this;
|
||||
}
|
||||
|
||||
private shouldFocusByDefault(): boolean {
|
||||
return this.focusByDefault || this.parent?.shouldFocusByDefault() || false;
|
||||
}
|
||||
|
||||
select() {
|
||||
this.onSelect?.();
|
||||
}
|
||||
@@ -507,7 +524,7 @@ export function handleKeyboardNavigation(event: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// Selectable.focusedObject.subscribe(console.log);
|
||||
Selectable.focusedObject.subscribe(console.log);
|
||||
|
||||
type Offsets = Partial<
|
||||
Record<
|
||||
@@ -543,15 +560,6 @@ export const scrollElementIntoView = (htmlElement: HTMLElement, offsets: Offsets
|
||||
let top = -1;
|
||||
|
||||
if (offsets.top !== undefined && offsets.bottom !== undefined) {
|
||||
console.log(htmlElement, verticalParent);
|
||||
console.log(boundingRect, parentBoundingRect);
|
||||
console.log('top', boundingRect.y - parentBoundingRect.y, '<', offsets.top);
|
||||
console.log(
|
||||
'bottom',
|
||||
boundingRect.y - parentBoundingRect.y + htmlElement.clientHeight,
|
||||
'>',
|
||||
verticalParent.clientHeight - offsets.bottom
|
||||
);
|
||||
top =
|
||||
boundingRect.y - parentBoundingRect.y < offsets.top
|
||||
? boundingRect.y - parentBoundingRect.y + verticalParent.scrollTop - offsets.top
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { derived, type Readable, writable } from 'svelte/store';
|
||||
import { settings } from './settings.store';
|
||||
import { jellyfinApi, type JellyfinItem } from '../apis/jellyfin/jellyfin-api';
|
||||
import { type SeriesDownload, sonarrApi, type SonarrSeries } from '../apis/sonarr/sonarr-api';
|
||||
import { type EpisodeDownload, sonarrApi, type SonarrSeries } from '../apis/sonarr/sonarr-api';
|
||||
import { radarrApi, type MovieDownload } from '../apis/radarr/radarr-api';
|
||||
|
||||
async function waitForSettings() {
|
||||
@@ -54,7 +54,7 @@ export function _createDataFetchStore<T>(fn: () => Promise<T>) {
|
||||
};
|
||||
}
|
||||
|
||||
export const jellyfinItemsStore = _createDataFetchStore(jellyfinApi.getLibraryItems);
|
||||
export const jellyfinItemsStore = _createDataFetchStore(() => jellyfinApi.getLibraryItems());
|
||||
|
||||
export function createJellyfinItemStore(tmdbId: number | Promise<number>) {
|
||||
const store = writable<{ loading: boolean; item?: JellyfinItem }>({
|
||||
@@ -181,7 +181,7 @@ export function createRadarrDownloadStore(
|
||||
export function createSonarrDownloadStore(
|
||||
sonarrItemStore: ReturnType<typeof createSonarrSeriesStore>
|
||||
) {
|
||||
const store = writable<{ loading: boolean; downloads?: SeriesDownload[] }>({
|
||||
const store = writable<{ loading: boolean; downloads?: EpisodeDownload[] }>({
|
||||
loading: true,
|
||||
downloads: undefined
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user