diff --git a/src/app.css b/src/app.css index 3c1ea9a..1dd458d 100644 --- a/src/app.css +++ b/src/app.css @@ -84,7 +84,9 @@ html[data-useragent*="Tizen"] .selectable-secondary { @apply focus-within:outline outline-2 outline-[#f0cd6dc2] outline-offset-2; } - +.header1 { + @apply font-medium text-lg text-secondary-300; +} .header2 { @apply font-semibold text-2xl text-secondary-100; diff --git a/src/lib/apis/api.interface.ts b/src/lib/apis/api.interface.ts index f196f53..16e30a7 100644 --- a/src/lib/apis/api.interface.ts +++ b/src/lib/apis/api.interface.ts @@ -4,6 +4,10 @@ export interface Api> { getClient(): ReturnType>; } +export interface ApiAsync> { + getClient(): Promise>>; +} + // export abstract class Api> { // protected abstract baseUrl: string; // protected abstract client: ReturnType>; diff --git a/src/lib/apis/reiverr/reiverr.generated.d.ts b/src/lib/apis/reiverr/reiverr.generated.d.ts index bee4ad4..629eeb0 100644 --- a/src/lib/apis/reiverr/reiverr.generated.d.ts +++ b/src/lib/apis/reiverr/reiverr.generated.d.ts @@ -5,17 +5,17 @@ export interface paths { - "/user": { + "/api/user": { get: operations["UserController_getProfile"]; post: operations["UserController_create"]; }; - "/user/{id}": { + "/api/user/{id}": { get: operations["UserController_findById"]; }; - "/auth": { + "/api/auth": { post: operations["AuthController_signIn"]; }; - "/": { + "/api": { get: operations["AppController_getHello"]; }; } diff --git a/src/lib/apis/sonarr/sonarr-api.ts b/src/lib/apis/sonarr/sonarr-api.ts index fa19cf7..01cf1d5 100644 --- a/src/lib/apis/sonarr/sonarr-api.ts +++ b/src/lib/apis/sonarr/sonarr-api.ts @@ -4,10 +4,24 @@ import { get } from 'svelte/store'; import { getTmdbSeries, tmdbApi } from '../tmdb/tmdb-api'; import type { components, paths } from './sonarr.generated'; import { log } from '../../utils'; -import type { Api } from '../api.interface'; +import type { Api, ApiAsync } from '../api.interface'; import { appState } from '../../stores/app-state.store'; import { createLocalStorageStore } from '../../stores/localstorage.store'; +export const sonarrMonitorOptions = [ + 'unknown', + 'all', + 'future', + 'missing', + 'existing', + 'firstSeason', + 'latestSeason', + 'pilot', + 'monitorSpecials', + 'unmonitorSpecials', + 'none' +] as const; + export type SonarrSeries = components['schemas']['SeriesResource']; export type SonarrSeason = components['schemas']['SeasonResource']; export type SonarrRelease = components['schemas']['ReleaseResource']; @@ -15,6 +29,9 @@ export type EpisodeDownload = components['schemas']['QueueResource'] & { series: export type DiskSpaceInfo = components['schemas']['DiskSpaceResource']; export type SonarrEpisode = components['schemas']['EpisodeResource']; export type EpisodeFileResource = components['schemas']['EpisodeFileResource']; +export type SonarrRootFolder = components['schemas']['RootFolderResource']; +export type SonarrQualityProfile = components['schemas']['QualityProfileResource']; +export type SonarrMonitorOptions = (typeof sonarrMonitorOptions)[number]; export interface SonarrSeriesOptions { title: string; @@ -25,18 +42,7 @@ export interface SonarrSeriesOptions { tvdbId: number; rootFolderPath: string; addOptions: { - monitor: - | 'unknown' - | 'all' - | 'future' - | 'missing' - | 'existing' - | 'firstSeason' - | 'latestSeason' - | 'pilot' - | 'monitorSpecials' - | 'unmonitorSpecials' - | 'none'; + monitor: SonarrMonitorOptions; searchForMissingEpisodes: boolean; searchForCutoffUnmetEpisodes: boolean; }; @@ -44,8 +50,9 @@ export interface SonarrSeriesOptions { const tmdbToTvdbCache = createLocalStorageStore>('tmdb-to-tvdb-cache', {}); -export class SonarrApi implements Api { - getClient() { +export class SonarrApi implements ApiAsync { + async getClient() { + await appState.ready; const sonarrSettings = this.getSettings(); const baseUrl = this.getBaseUrl(); const apiKey = sonarrSettings?.apiKey; @@ -83,33 +90,42 @@ export class SonarrApi implements Api { }; getSeriesById = (id: number): Promise => - this.getClient() - ?.GET('/api/v3/series/{id}', { - params: { - path: { - id - } - } - }) - .then((r) => r.data) || Promise.resolve(undefined); + this.getClient().then( + (client) => + client + ?.GET('/api/v3/series/{id}', { + params: { + path: { + id + } + } + }) + .then((r) => r.data) || Promise.resolve(undefined) + ); getAllSeries = (): Promise => - this.getClient() - ?.GET('/api/v3/series', { - params: {} - }) - .then((r) => r.data || []) || Promise.resolve([]); + this.getClient().then( + (client) => + client + ?.GET('/api/v3/series', { + params: {} + }) + .then((r) => r.data || []) || Promise.resolve([]) + ); getSonarrSeriesByTvdbId = (tvdbId: number): Promise => - this.getClient() - ?.GET('/api/v3/series', { - params: { - query: { - tvdbId: tvdbId - } - } - }) - .then((r) => r.data?.find((m) => m.tvdbId === tvdbId)) || Promise.resolve(undefined); + this.getClient().then( + (client) => + client + ?.GET('/api/v3/series', { + params: { + query: { + tvdbId: tvdbId + } + } + }) + .then((r) => r.data?.find((m) => m.tvdbId === tvdbId)) || Promise.resolve(undefined) + ); getSeriesByTmdbId = async (tmdbId: number) => this.tmdbToTvdb(tmdbId).then((tvdbId) => @@ -117,11 +133,19 @@ export class SonarrApi implements Api { ); getDiskSpace = (): Promise => - this.getClient() - ?.GET('/api/v3/diskspace', {}) - .then((d) => d.data || []) || Promise.resolve([]); + this.getClient().then( + (client) => + client?.GET('/api/v3/diskspace', {}).then((d) => d.data || []) || Promise.resolve([]) + ); - addSeriesToSonarr = async (tmdbId: number) => { + addToSonarr = async ( + tmdbId: number, + _options: { + qualityProfileId?: number; + rootFolderPath?: string; + monitorOptions?: SonarrMonitorOptions; + } = {} + ): Promise => { const tmdbSeries = await getTmdbSeries(tmdbId); if (!tmdbSeries || !tmdbSeries.external_ids.tvdb_id || !tmdbSeries.name) @@ -130,100 +154,119 @@ export class SonarrApi implements Api { const options: SonarrSeriesOptions = { title: tmdbSeries.name, tvdbId: tmdbSeries.external_ids.tvdb_id, - qualityProfileId: this.getSettings()?.qualityProfileId || 0, + qualityProfileId: _options.qualityProfileId || this.getSettings()?.qualityProfileId || 0, monitored: false, addOptions: { - monitor: 'none', + monitor: _options.monitorOptions || 'none', searchForMissingEpisodes: false, searchForCutoffUnmetEpisodes: false }, - rootFolderPath: this.getSettings()?.rootFolderPath || '', + rootFolderPath: _options.rootFolderPath || this.getSettings()?.rootFolderPath || '', languageProfileId: this.getSettings()?.languageProfileId || 0, seasonFolder: true }; - return this.getClient() - ?.POST('/api/v3/series', { - params: {}, - body: options - }) - .then((r) => r.data); + return this.getClient().then((client) => + client + ?.POST('/api/v3/series', { + params: {}, + body: options + }) + .then((r) => r.data) + ); }; cancelDownload = async (downloadId: number) => { - const deleteResponse = await this.getClient() - ?.DELETE('/api/v3/queue/{id}', { - params: { - path: { - id: downloadId - }, - query: { - blocklist: false, - removeFromClient: true + const deleteResponse = await this.getClient().then((client) => + client + ?.DELETE('/api/v3/queue/{id}', { + params: { + path: { + id: downloadId + }, + query: { + blocklist: false, + removeFromClient: true + } } - } - }) - .then((r) => log(r)); + }) + .then((r) => log(r)) + ); return !!deleteResponse?.response.ok; }; cancelDownloads = async (downloadIds: number[]) => - this.getClient() - ?.DELETE('/api/v3/queue/bulk', { - body: { - ids: downloadIds - } - }) - .then((r) => r.response.ok) || Promise.resolve(false); + this.getClient().then( + (client) => + client + ?.DELETE('/api/v3/queue/bulk', { + body: { + ids: downloadIds + } + }) + .then((r) => r.response.ok) || Promise.resolve(false) + ); downloadSonarrRelease = (guid: string, indexerId: number) => - this.getClient() - ?.POST('/api/v3/release', { - params: {}, - body: { - indexerId, - guid - } - }) - .then((res) => res.response.ok) || Promise.resolve(false); + this.getClient().then( + (client) => + client + ?.POST('/api/v3/release', { + params: {}, + body: { + indexerId, + guid + } + }) + .then((res) => res.response.ok) || Promise.resolve(false) + ); deleteSonarrEpisode = (id: number) => - this.getClient() - ?.DELETE('/api/v3/episodefile/{id}', { - params: { - path: { - id - } - } - }) - .then((res) => res.response.ok) || Promise.resolve(false); + this.getClient().then( + (client) => + client + ?.DELETE('/api/v3/episodefile/{id}', { + params: { + path: { + id + } + } + }) + .then((res) => res.response.ok) || Promise.resolve(false) + ); deleteSonarrEpisodes = (ids: number[]) => - this.getClient() - ?.DELETE('/api/v3/episodefile/bulk', { - body: { - episodeFileIds: ids - } - }) - .then((res) => res.response.ok) || Promise.resolve(false); + this.getClient().then( + (client) => + client + ?.DELETE('/api/v3/episodefile/bulk', { + body: { + episodeFileIds: ids + } + }) + .then((res) => res.response.ok) || Promise.resolve(false) + ); getSonarrDownloads = (): Promise => - this.getClient() - ?.GET('/api/v3/queue', { - params: { - query: { - includeEpisode: true, - includeSeries: true - } - } - }) - .then( - (r) => - (r.data?.records?.filter( - (record) => record.episode && record.series - ) as EpisodeDownload[]) || [] - ) || Promise.resolve([]); + this.getClient().then( + (client) => + client + ?.GET('/api/v3/queue', { + params: { + query: { + includeEpisode: true, + includeSeries: true + } + } + }) + .then( + (r) => + (r.data?.records?.filter( + (record) => record.episode && record.series + ) as EpisodeDownload[]) || [] + ) || Promise.resolve([]) + ); getDownloadsBySeriesId = (sonarrId: number) => this.getSonarrDownloads().then((downloads) => @@ -231,26 +274,90 @@ export class SonarrApi implements Api { ) || Promise.resolve([]); removeFromSonarr = (id: number): Promise => - this.getClient() - ?.DELETE('/api/v3/series/{id}', { - params: { - path: { - id - } - } - }) - .then((res) => res.response.ok) || Promise.resolve(false); + this.getClient().then( + (client) => + client + ?.DELETE('/api/v3/series/{id}', { + params: { + path: { + id + } + } + }) + .then((res) => res.response.ok) || Promise.resolve(false) + ); getFilesBySeriesId = (seriesId: number): Promise => - this.getClient() - ?.GET('/api/v3/episodefile', { - params: { - query: { - seriesId - } - } - }) - .then((r) => r.data || []) || Promise.resolve([]); + this.getClient().then( + (client) => + client + ?.GET('/api/v3/episodefile', { + params: { + query: { + seriesId + } + } + }) + .then((r) => r.data || []) || Promise.resolve([]) + ); + + getEpisodeReleases = async (episodeId: number) => + this.getClient().then( + (client) => + client + ?.GET('/api/v3/release', { + params: { + query: { + episodeId + } + } + }) + .then((r) => r.data || []) || Promise.resolve([]) + ); + + getSeasonReleases = async (seriesId: number, seasonNumber: number) => + this.getClient().then( + (client) => + client + ?.GET('/api/v3/release', { + params: { + query: { + seriesId, + seasonNumber + } + } + }) + .then((r) => r.data || []) || Promise.resolve([]) + ); + + getEpisodes = async (seriesId: number, seasonNumber?: number): Promise => { + return this.getClient().then( + (client) => + client + ?.GET('/api/v3/episode', { + params: { + query: { + seriesId, + seasonNumber, + includeImages: true + } + } + }) + .then((r) => r.data || []) || Promise.resolve([]) + ); + }; + + getRootFolders = (): Promise => + this.getClient().then( + (client) => + client?.GET('/api/v3/rootfolder', {}).then((r) => r.data || []) || Promise.resolve([]) + ); + + getQualityProfiles = (): Promise => + this.getClient().then( + (client) => + client?.GET('/api/v3/qualityprofile', {}).then((r) => r.data || []) || Promise.resolve([]) + ); // getSonarrEpisodes = async (seriesId: number) => { // const episodesPromise = @@ -284,44 +391,6 @@ export class SonarrApi implements Api { // })); // }; - getEpisodeReleases = async (episodeId: number) => - this.getClient() - ?.GET('/api/v3/release', { - params: { - query: { - episodeId - } - } - }) - .then((r) => r.data || []) || Promise.resolve([]); - - getSeasonReleases = async (seriesId: number, seasonNumber: number) => - this.getClient() - ?.GET('/api/v3/release', { - params: { - query: { - seriesId, - seasonNumber - } - } - }) - .then((r) => r.data || []) || Promise.resolve([]); - - getEpisodes = async (seriesId: number, seasonNumber?: number): Promise => { - return ( - this.getClient() - ?.GET('/api/v3/episode', { - params: { - query: { - seriesId, - seasonNumber - } - } - }) - .then((r) => r.data || []) || Promise.resolve([]) - ); - }; - getSonarrHealth = async ( baseUrl: string | undefined = undefined, apiKey: string | undefined = undefined @@ -335,19 +404,16 @@ export class SonarrApi implements Api { .then((res) => res.status === 200) .catch(() => false); - getSonarrRootFolders = async ( + _getSonarrRootFolders = async ( baseUrl: string | undefined = undefined, apiKey: string | undefined = undefined ) => axios - .get( - (baseUrl || this.getBaseUrl()) + '/api/v3/rootFolder', - { - headers: { - 'X-Api-Key': apiKey || this.getApiKey() - } + .get((baseUrl || this.getBaseUrl()) + '/api/v3/rootFolder', { + headers: { + 'X-Api-Key': apiKey || this.getApiKey() } - ) + }) .then((res) => res.data || []); getSonarrQualityProfiles = async ( diff --git a/src/lib/components/Button.svelte b/src/lib/components/Button.svelte index 022aa3c..3d46924 100644 --- a/src/lib/components/Button.svelte +++ b/src/lib/components/Button.svelte @@ -3,12 +3,28 @@ import type { Readable } from 'svelte/store'; import classNames from 'classnames'; import AnimatedSelection from './AnimateScale.svelte'; + import { createEventDispatcher } from 'svelte'; export let disabled: boolean = false; export let focusOnMount: boolean = false; - export let type: 'primary' | 'secondary' = 'primary'; + export let type: 'primary' | 'secondary' | 'primary-dark' = 'primary'; + + export let action: (() => Promise) | null = null; + let actionIsFetching = false; + $: _disabled = disabled || actionIsFetching; let hasFocus: Readable; + + const dispatch = createEventDispatcher<{ clickOrSelect: null }>(); + + function handleClickOrSelect() { + if (action) { + actionIsFetching = true; + action().then(() => (actionIsFetching = false)); + } + + dispatch('clickOrSelect'); + } @@ -17,42 +33,44 @@ class={classNames( 'h-12 rounded-lg font-medium tracking-wide flex items-center group', { - 'selectable bg-secondary-800 px-6': type === 'primary', + 'bg-secondary-800': type === 'primary', + 'bg-primary-900': type === 'primary-dark', + 'selectable px-6': type === 'primary' || type === 'primary-dark', 'border-2 p-1 hover:border-primary-500': type === 'secondary', 'border-primary-500': type === 'secondary' && $hasFocus, - 'cursor-pointer': !disabled, - 'cursor-not-allowed pointer-events-none opacity-40': disabled + 'cursor-pointer': !_disabled, + 'cursor-not-allowed pointer-events-none opacity-40': _disabled }, $$restProps.class )} on:click on:select - on:clickOrSelect + on:clickOrSelect={handleClickOrSelect} on:enter {focusOnMount} >
- {#if $$slots.icon} -
- -
- {/if} -
+
+ {#if $$slots.icon} +
+ +
+ {/if} + {#if $$slots['icon-after']} +
+ +
+ {/if}
- {#if $$slots['icon-after']} -
- -
- {/if}
diff --git a/src/lib/components/Checkbox.svelte b/src/lib/components/Checkbox.svelte new file mode 100644 index 0000000..ba03996 --- /dev/null +++ b/src/lib/components/Checkbox.svelte @@ -0,0 +1,59 @@ + + + + { + e.detail.options.setFocusedElement = input; + }} + on:clickOrSelect={() => input?.click()} + bind:hasFocus + class={classNames( + 'border-2 rounded-xl w-9 h-9 cursor-pointer flex items-center justify-center transition-colors p-[3px]', + { + 'border-secondary-200 focus-within:border-primary-500': checked, + 'focus-within:border-primary-500': !checked + } + )} + > +
+ + + + + + +
+
+
diff --git a/src/lib/components/Dialog/ConfirmDialog.svelte b/src/lib/components/Dialog/ConfirmDialog.svelte index 894287a..28fabb9 100644 --- a/src/lib/components/Dialog/ConfirmDialog.svelte +++ b/src/lib/components/Dialog/ConfirmDialog.svelte @@ -1,7 +1,6 @@ - -
+ +
- - + + + + + +

Root Folder

+
+ {#each rootFolders as rootFolder} + + addOptionsStore.update((prev) => ({ ...prev, rootFolderId: rootFolder.id || 0 }))} + focusOnClick + focusOnMount={$addOptionsStore.rootFolderPath === rootFolder.path} + > +
+ {rootFolder.path} ({formatSize(rootFolder.freeSpace || 0)} left) +
+ {#if selectedRootFolder?.id === rootFolder.id} + + {/if} +
+ {/each} +
+
+ + +

Quality Profile

+
+ {#each qualityProfiles as qualityProfile} + + addOptionsStore.update((prev) => ({ + ...prev, + qualityProfileId: qualityProfile.id || 0 + }))} + focusOnClick + focusOnMount={$addOptionsStore.qualityProfileId === qualityProfile.id} + > +
{qualityProfile.name}
+ {#if selectedQualityProfile?.id === qualityProfile.id} + + {/if} +
+ {/each} +
+
+ + +

Monitor Episodes

+
+ {#each sonarrMonitorOptions as monitorOption} + + addOptionsStore.update((prev) => ({ ...prev, monitorOptions: monitorOption }))} + focusOnClick + focusOnMount={$addOptionsStore.monitorOptions === monitorOption} + > +
{capitalize(monitorOption)}
+ {#if $addOptionsStore.monitorOptions === monitorOption} + + {/if} +
+ {/each} +
+
+ + {/await} +
diff --git a/src/lib/components/MediaManagerModal/MMMainLayout.svelte b/src/lib/components/MediaManagerModal/MMMainLayout.svelte index e82827f..e89a108 100644 --- a/src/lib/components/MediaManagerModal/MMMainLayout.svelte +++ b/src/lib/components/MediaManagerModal/MMMainLayout.svelte @@ -3,7 +3,7 @@ import classNames from 'classnames'; import MMTitle from './MMTitle.svelte'; - let activeTab: 'releases' | 'local-files' = 'releases'; + // let activeTab: 'releases' | 'local-files' = 'releases';
@@ -19,66 +19,7 @@
-
- - -
- (activeTab = 'releases')} - class={classNames( - 'row-start-1 col-start-1 pb-16 mx-20', - 'transition-all overflow-y-auto overflow-x-hidden scrollbar-hide', - { - 'opacity-30 -translate-x-full': activeTab !== 'releases' - } - )} - > - - - (activeTab = 'local-files')} - class={classNames( - 'row-start-1 col-start-1 pb-16 mx-20', - 'transition-all overflow-y-auto overflow-x-hidden scrollbar-hide', - { - 'opacity-30 translate-x-full': activeTab !== 'local-files' - } - )} - > - - + - - - - - - - - - - - - - - - - -
diff --git a/src/lib/components/MediaManagerModal/MMModal.svelte b/src/lib/components/MediaManagerModal/MMModal.svelte index 3c16776..d4e8ec6 100644 --- a/src/lib/components/MediaManagerModal/MMModal.svelte +++ b/src/lib/components/MediaManagerModal/MMModal.svelte @@ -2,33 +2,27 @@ import classNames from 'classnames'; import Container from '../../../Container.svelte'; import { modalStack } from '../Modal/modal.store'; + import Modal from '../Modal/Modal.svelte'; export let modalId: symbol; export let hidden: boolean = false; - { - if (detail.direction === 'left' && detail.willLeaveContainer) { - modalStack.close(modalId); - detail.preventNavigation(); - } - }} - focusOnMount - trapFocus - class={classNames( - 'fixed inset-0 overflow-hidden', - { - 'opacity-0': hidden - }, - // 'bg-[radial-gradient(169.40%_89.55%_at_94.76%_6.29%,rgba(0,0,0,0.40)_0%,rgba(255,255,255,0.00)_100%)]' - // 'bg-[radial-gradient(150%_50%_at_50%_-25%,_#DFAA2BF0_0%,_#073AFF00_100%),linear-gradient(0deg,_#1A1914FF_0%,_#1A1914FF_0%)]' - 'bg-secondary-900' - )} - canFocusEmpty -> +
- - + class={classNames( + 'fixed inset-0 overflow-hidden', + { + 'opacity-0': hidden + }, + // 'bg-[radial-gradient(169.40%_89.55%_at_94.76%_6.29%,rgba(0,0,0,0.40)_0%,rgba(255,255,255,0.00)_100%)]' + // 'bg-[radial-gradient(150%_50%_at_50%_-25%,_#DFAA2BF0_0%,_#073AFF00_100%),linear-gradient(0deg,_#1A1914FF_0%,_#1A1914FF_0%)]' + 'bg-secondary-900' + )} + > +
+ +
+ diff --git a/src/lib/components/MediaManagerModal/MMSeasonSelectTab.svelte b/src/lib/components/MediaManagerModal/MMSeasonSelectTab.svelte new file mode 100644 index 0000000..8fe7096 --- /dev/null +++ b/src/lib/components/MediaManagerModal/MMSeasonSelectTab.svelte @@ -0,0 +1 @@ +
select season
diff --git a/src/lib/components/MediaManagerModal/MMTitle.svelte b/src/lib/components/MediaManagerModal/MMTitle.svelte index 9f427da..41ab9f7 100644 --- a/src/lib/components/MediaManagerModal/MMTitle.svelte +++ b/src/lib/components/MediaManagerModal/MMTitle.svelte @@ -1,8 +1,8 @@
-
+
-
+
diff --git a/src/lib/components/MediaManagerModal/MovieMediaManagerModal.svelte b/src/lib/components/MediaManagerModal/MovieMediaManagerModal.svelte index 3d46d34..32cb75d 100644 --- a/src/lib/components/MediaManagerModal/MovieMediaManagerModal.svelte +++ b/src/lib/components/MediaManagerModal/MovieMediaManagerModal.svelte @@ -1,6 +1,6 @@ -{#await releases} - {#each new Array(5) as _, index} -
- -
- {/each} -{:then releases} -
- - Age - Size - Peers - Quality - - + +

+ +

+

+ +

- - {#each getRecommendedReleases(releases).sort(getSortFn(sortBy, sortDirection)) as release, index} - +
+ {#await releases} + {#each new Array(5) as _, index} +
+ +
{/each} - + {:then releases} +
+ + Age + Size + Peers + Quality + + -

All Releases

+ + {#each getRecommendedReleases(releases).sort(getSortFn(sortBy, sortDirection)) as release, index} + + {/each} + - {#each releases - .filter((r) => r.guid && r.indexerId) - .sort(getSortFn(sortBy, sortDirection)) as release, index} - - {/each} +

All Releases

+ + {#each releases + .filter((r) => r.guid && r.indexerId) + .sort(getSortFn(sortBy, sortDirection)) as release, index} + + {/each} +
+ {/await}
-{/await} +
diff --git a/src/lib/components/MediaManagerModal/SeasonMediaManagerModal.svelte b/src/lib/components/MediaManagerModal/SeasonMediaManagerModal.svelte index a5f580e..c5d7f38 100644 --- a/src/lib/components/MediaManagerModal/SeasonMediaManagerModal.svelte +++ b/src/lib/components/MediaManagerModal/SeasonMediaManagerModal.svelte @@ -1,73 +1,36 @@ - - {#await sonarrItem then series} - {#if !series} - - {:else} - -

{series?.title}

-

Season {season} Packs

- - - -
- {/if} - {/await} -
+ + {#if !season} + + {:else if releases} + +

{sonarrItem?.title}

+

Season {season} Releases

+
+ {/if} +
diff --git a/src/lib/components/Modal/Modal.svelte b/src/lib/components/Modal/Modal.svelte index 8c7c148..e17eee9 100644 --- a/src/lib/components/Modal/Modal.svelte +++ b/src/lib/components/Modal/Modal.svelte @@ -1,7 +1,8 @@ - + modalStack.closeTopmost()}> diff --git a/src/lib/components/Modal/ModalStack.svelte b/src/lib/components/Modal/ModalStack.svelte index 0784dfa..0c38182 100644 --- a/src/lib/components/Modal/ModalStack.svelte +++ b/src/lib/components/Modal/ModalStack.svelte @@ -2,19 +2,19 @@ import { modalStack, modalStackTop } from './modal.store'; import { onDestroy } from 'svelte'; - function handleShortcuts(event: KeyboardEvent) { - const top = $modalStackTop; - if ((event.key === 'Escape' || event.key === 'Back' || event.key === 'XF86Back') && top) { - modalStack.close(top.id); - } - } + // function handleShortcuts(event: KeyboardEvent) { + // const top = $modalStackTop; + // if ((event.key === 'Escape' || event.key === 'Back' || event.key === 'XF86Back') && top) { + // modalStack.close(top.id); + // } + // } onDestroy(() => { modalStack.reset(); }); - + {#if $modalStackTop} diff --git a/src/lib/components/Modal/modal.store.ts b/src/lib/components/Modal/modal.store.ts index 11704a6..02e1616 100644 --- a/src/lib/components/Modal/modal.store.ts +++ b/src/lib/components/Modal/modal.store.ts @@ -1,8 +1,10 @@ import type { ComponentType, SvelteComponentTyped } from 'svelte'; -import { derived, writable } from 'svelte/store'; +import { derived, get, writable } from 'svelte/store'; import SeasonMediaManagerModal from '../MediaManagerModal/SeasonMediaManagerModal.svelte'; import EpisodeMediaManagerModal from '../MediaManagerModal/EpisodeMediaManagerModal.svelte'; import MovieMediaManagerModal from '../MediaManagerModal/MovieMediaManagerModal.svelte'; +import ConfirmDialog from '../Dialog/ConfirmDialog.svelte'; +import { sonarrApi, type SonarrSeries } from '../../apis/sonarr/sonarr-api'; type ModalItem = { id: symbol; @@ -37,6 +39,13 @@ function createModalStack() { items.set([]); } + function closeTopmost() { + const t = get(top); + if (t) { + close(t.id); + } + } + return { subscribe: items.subscribe, top: { @@ -45,15 +54,17 @@ function createModalStack() { create, close, closeGroup, + closeTopmost, reset }; } export const modalStack = createModalStack(); export const modalStackTop = modalStack.top; +export const createModal = modalStack.create; -export const openSeasonMediaManager = (tmdbId: number, season: number) => - modalStack.create(SeasonMediaManagerModal, { id: tmdbId, season }); +export const openSeasonMediaManager = (sonarrItem: SonarrSeries, season: number) => + modalStack.create(SeasonMediaManagerModal, { sonarrItem, season }); export const openEpisodeMediaManager = (tmdbId: number, season: number, episode: number) => modalStack.create(EpisodeMediaManagerModal, { id: tmdbId, season, episode }); diff --git a/src/lib/components/SeriesPage/ConfirmDeleteSeasonDialog.svelte b/src/lib/components/SeriesPage/ConfirmDeleteSeasonDialog.svelte new file mode 100644 index 0000000..f2bef11 --- /dev/null +++ b/src/lib/components/SeriesPage/ConfirmDeleteSeasonDialog.svelte @@ -0,0 +1,20 @@ + + + +

Delete Season Files?

+
+ Are you sure you want to delete all {files.length} file(s) from season {files[0]?.seasonNumber}? +
+
diff --git a/src/lib/components/SeriesPage/EpisodeGrid.svelte b/src/lib/components/SeriesPage/EpisodeGrid.svelte index 5ad89cb..810c31a 100644 --- a/src/lib/components/SeriesPage/EpisodeGrid.svelte +++ b/src/lib/components/SeriesPage/EpisodeGrid.svelte @@ -18,13 +18,17 @@ import ScrollHelper from '../ScrollHelper.svelte'; import ManageSeasonCard from './ManageSeasonCard.svelte'; import { TMDB_BACKDROP_SMALL } from '../../constants'; - import { modalStack, openSeasonMediaManager } from '../Modal/modal.store'; + import { createModal, modalStack, openSeasonMediaManager } from '../Modal/modal.store'; import { navigate } from '../StackRouter/StackRouter'; + import SeasonMediaManagerModal from '../MediaManagerModal/SeasonMediaManagerModal.svelte'; + import type { SonarrSeries } from '../../apis/sonarr/sonarr-api'; + import MMAddToSonarrDialog from '../MediaManagerModal/MMAddToSonarrDialog.svelte'; export let id: number; export let tmdbSeries: Readable; export let jellyfinEpisodes: Promise; export let currentJellyfinEpisode: Promise; + export let handleRequestSeason: (season: number) => Promise; console.log('ID IS: ', id); @@ -60,14 +64,14 @@ } function handleMountCard(s: Selectable, episode: TmdbEpisode) { - currentJellyfinEpisode.then((currentEpisode) => { - if ( - currentEpisode?.IndexNumber === episode.episode_number && - currentEpisode?.ParentIndexNumber === episode.season_number - ) { - s.focus({ setFocusedElement: false, propagate: false }); - } - }); + // currentJellyfinEpisode.then((currentEpisode) => { + // if ( + // currentEpisode?.IndexNumber === episode.episode_number && + // currentEpisode?.ParentIndexNumber === episode.season_number + // ) { + // s.focus({ setFocusedElement: false, propagate: false }); + // } + // }); } @@ -137,7 +141,7 @@ {/each} openSeasonMediaManager(id, seasonIndex + 1)} + on:clickOrSelect={() => handleRequestSeason(seasonIndex + 1)} on:enter={scrollIntoView({ top: 92, bottom: 128 })} /> {/if} diff --git a/src/lib/components/SeriesPage/FileDetailsDialog.svelte b/src/lib/components/SeriesPage/FileDetailsDialog.svelte new file mode 100644 index 0000000..a75f1cb --- /dev/null +++ b/src/lib/components/SeriesPage/FileDetailsDialog.svelte @@ -0,0 +1,48 @@ + + + + {#if backgroundUrl} +
+ {/if} +
+ {#if backgroundUrl} +
+ {/if} +

{episode?.title}

+

Season {episode?.seasonNumber} Episode {episode?.episodeNumber}

+
+ Runtime + {file.mediaInfo?.runTime} + Size on Disk + {formatSize(file.size || 0)} + Quality + {file.quality?.quality?.name} + + +
+ + + + +
+
diff --git a/src/lib/components/SeriesPage/SeriesPage.svelte b/src/lib/components/SeriesPage/SeriesPage.svelte index 422c25d..60c5dd0 100644 --- a/src/lib/components/SeriesPage/SeriesPage.svelte +++ b/src/lib/components/SeriesPage/SeriesPage.svelte @@ -6,21 +6,24 @@ import { tmdbApi, type TmdbSeasonEpisode } from '../../apis/tmdb/tmdb-api'; import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../../constants'; import classNames from 'classnames'; - import { DotFilled, Download, ExternalLink, File, Play, Plus } from 'radix-icons-svelte'; + import { DotFilled, Download, ExternalLink, File, Play, Plus, Trash } from 'radix-icons-svelte'; import { jellyfinApi } from '../../apis/jellyfin/jellyfin-api'; - import { sonarrApi } from '../../apis/sonarr/sonarr-api'; + import { type EpisodeFileResource, sonarrApi } from '../../apis/sonarr/sonarr-api'; import Button from '../Button.svelte'; import { playerState } from '../VideoPlayer/VideoPlayer'; - import { modalStack } from '../Modal/modal.store'; - import { derived } from 'svelte/store'; + import { createModal, modalStack } from '../Modal/modal.store'; + import { derived, get, writable } from 'svelte/store'; import { scrollIntoView, useRegistrar } from '../../selectable'; import ScrollHelper from '../ScrollHelper.svelte'; import Carousel from '../Carousel/Carousel.svelte'; import TmdbPersonCard from '../PersonCard/TmdbPersonCard.svelte'; import TmdbCard from '../Card/TmdbCard.svelte'; import EpisodeGrid from './EpisodeGrid.svelte'; - import EpisodePage from '../../pages/EpisodePage.svelte'; - import SeriesMediaManagerModal from '../MediaManagerModal/SeasonMediaManagerModal.svelte'; + import { formatSize } from '../../utils'; + import FileDetailsDialog from './FileDetailsDialog.svelte'; + import ConfirmDeleteSeasonDialog from './ConfirmDeleteSeasonDialog.svelte'; + import SeasonMediaManagerModal from '../MediaManagerModal/SeasonMediaManagerModal.svelte'; + import MMAddToSonarrDialog from '../MediaManagerModal/MMAddToSonarrDialog.svelte'; export let id: string; @@ -28,11 +31,26 @@ tmdbApi.getTmdbSeries, Number(id) ); - const { promise: sonarrItem } = useRequest(sonarrApi.getSeriesByTmdbId, Number(id)); - const jellyfinSeries = getJellyfinSeries(id); - + let sonarrItem = sonarrApi.getSeriesByTmdbId(Number(id)); const { promise: recommendations } = useRequest(tmdbApi.getSeriesRecommendations, Number(id)); + // @ts-ignore + $: localFilesP = sonarrItem && getLocalFiles(); + $: localFileSeasons = localFilesP.then((files) => [ + ...new Set(files.map((item) => item.seasonNumber || -1)) + ]); + $: sonarrEpisodes = Promise.all([sonarrItem, localFileSeasons]) + .then(([item, seasons]) => + Promise.all(seasons.map((s) => sonarrApi.getEpisodes(item?.id || -1, s))) + ) + .then((items) => items.flat()); + $: localFilesP.then(console.log); + $: sonarrEpisodes.then(console.log); + $: sonarrItem.then(console.log); + $: localFileSeasons.then(console.log); + + const jellyfinSeries = getJellyfinSeries(id); + const jellyfinEpisodes = jellyfinSeries.then( (s) => (s && jellyfinApi.getJellyfinEpisodes(s.Id)) || [] ); @@ -41,17 +59,54 @@ items.find((i) => i.UserData?.Played === false) ); - let hideInterface = false; const episodeCards = useRegistrar(); let scrollTop: number; - modalStack.top.subscribe((modal) => { - hideInterface = !!modal; - }); + // let hideInterface = false; + // modalStack.top.subscribe((modal) => { + // hideInterface = !!modal; + // }); function getJellyfinSeries(id: string) { return jellyfinApi.getLibraryItemFromTmdbId(id); } + + function getLocalFiles() { + return sonarrItem.then((item) => + item ? sonarrApi.getFilesBySeriesId(item?.id || -1) : Promise.resolve([]) + ); + } + + function handleAddedToSonarr() { + sonarrItem = sonarrApi.getSeriesByTmdbId(Number(id)); + sonarrItem.then( + (sonarrItem) => + sonarrItem && + createModal(SeasonMediaManagerModal, { + season: 1, + sonarrItem + }) + ); + } + + async function handleRequestSeason(season: number) { + return sonarrItem.then((sonarrItem) => { + const tmdbSeries = get(tmdbSeriesData); + if (sonarrItem) { + createModal(SeasonMediaManagerModal, { + season, + sonarrItem + }); + } else if (tmdbSeries) { + createModal(MMAddToSonarrDialog, { + series: tmdbSeries, + onComplete: handleAddedToSonarr + }); + } else { + console.error('No series found'); + } + }); + } @@ -75,7 +130,6 @@ ?.map((i) => TMDB_IMAGES_ORIGINAL + i.file_path) .slice(0, 5) || [] )} - {hideInterface} >
@@ -118,7 +172,7 @@
{/if} {/await} - {#await Promise.all( [$sonarrItem, jellyfinSeries, jellyfinEpisodes, nextJellyfinEpisode] ) then [sonarrItem, jellyfinItem, jellyfinEpisodes, nextJellyfinEpisode]} + {#await nextJellyfinEpisode then nextJellyfinEpisode} - {/if} - + + + {/if} + {#if PLATFORM_WEB} + +
+ {/each} +
+ + {/if} + {/await}
diff --git a/src/lib/components/Sidebar/Sidebar.svelte b/src/lib/components/Sidebar/Sidebar.svelte index 4c4fa34..f176b9b 100644 --- a/src/lib/components/Sidebar/Sidebar.svelte +++ b/src/lib/components/Sidebar/Sidebar.svelte @@ -34,10 +34,9 @@ }); const selectIndex = (index: number) => () => { - if (index === activeIndex) { - if (get(selectable.hasFocusWithin)) Selectable.giveFocus('right'); - return; - } + // if (index === activeIndex) { + // if (get(selectable.hasFocusWithin)) Selectable.giveFocus('right'); + // } selectable.focusChild(index); const path = { diff --git a/src/lib/components/Table/TableCell.svelte b/src/lib/components/Table/TableCell.svelte index 9019271..701fa15 100644 --- a/src/lib/components/Table/TableCell.svelte +++ b/src/lib/components/Table/TableCell.svelte @@ -12,7 +12,7 @@ } :global(.row-wrapper-selected > ._table-cell) { - @apply bg-secondary-800; + @apply bg-primary-900; } :global(.row-wrapper > ._table-cell:first-child) { diff --git a/src/lib/components/Table/TableHeaderRow.svelte b/src/lib/components/Table/TableHeaderRow.svelte index f5a0e8c..4a98363 100644 --- a/src/lib/components/Table/TableHeaderRow.svelte +++ b/src/lib/components/Table/TableHeaderRow.svelte @@ -5,7 +5,7 @@ diff --git a/src/lib/components/Table/TableHeaderSortBy.svelte b/src/lib/components/Table/TableHeaderSortBy.svelte index 1cbcb8c..dc5f65e 100644 --- a/src/lib/components/Table/TableHeaderSortBy.svelte +++ b/src/lib/components/Table/TableHeaderSortBy.svelte @@ -15,9 +15,9 @@ on:clickOrSelect focusOnClick class={classNames( - 'flex items-center rounded-full py-1 px-3 -mx-3 cursor-pointer select-none font-semibold float-left', + 'flex items-center rounded-full py-1 cursor-pointer select-none font-semibold float-left', { - 'bg-primary-500 text-secondary-800': $hasFocus + 'bg-primary-500 text-secondary-800 px-3': $hasFocus } )} > diff --git a/src/lib/pages/EpisodePage.svelte b/src/lib/pages/EpisodePage.svelte index 07ef86e..06ec531 100644 --- a/src/lib/pages/EpisodePage.svelte +++ b/src/lib/pages/EpisodePage.svelte @@ -59,7 +59,6 @@ ( 'authentication-token', { @@ -15,15 +24,17 @@ const authenticationStore = createLocalStorageStore( ); function createAppState() { - const userStore = writable(undefined); + const userStore = writable(undefined); - const combinedStore = derived([userStore, authenticationStore], ([$user, $auth]) => { - return { - user: $user, - token: $auth.token, - serverBaseUrl: $auth.serverBaseUrl - }; - }); + const combinedStore = derived<[typeof userStore, typeof authenticationStore], AppStateData>( + [userStore, authenticationStore], + ([user, auth]) => { + return { + ...user, + ...auth + }; + } + ); function setBaseUrl(serverBaseUrl: string | undefined = undefined) { authenticationStore.update((p) => ({ ...p, serverBaseUrl })); @@ -34,7 +45,7 @@ function createAppState() { } function setUser(user: ReiverrUser | null) { - userStore.set(user); + userStore.set({ user }); } function logOut() { @@ -42,12 +53,21 @@ function createAppState() { setToken(undefined); } + const ready = new Promise((resolve) => { + combinedStore.subscribe((state) => { + if (state.token && state.serverBaseUrl && state.user !== undefined) { + resolve(state); + } + }); + }); + return { subscribe: combinedStore.subscribe, setBaseUrl, setToken, setUser, - logOut + logOut, + ready }; } diff --git a/src/lib/stores/sonarr-service.store.ts b/src/lib/stores/sonarr-service.store.ts new file mode 100644 index 0000000..8c37bf2 --- /dev/null +++ b/src/lib/stores/sonarr-service.store.ts @@ -0,0 +1,24 @@ +import { writable } from 'svelte/store'; +import { sonarrApi, type SonarrRootFolder } from '../apis/sonarr/sonarr-api'; + +type SonarrServiceStore = ReturnType; + +async function fetchSonarrService() { + const rootFolders = sonarrApi.getRootFolders(); + const qualityProfiles = sonarrApi.getQualityProfiles(); + + return { + rootFolders: await rootFolders, + qualityProfiles: await qualityProfiles + }; +} + +function useSonarrService() { + const sonarrService = writable(fetchSonarrService()); + + return { + subscribe: sonarrService.subscribe + }; +} + +export const sonarrService = useSonarrService(); diff --git a/tailwind.config.js b/tailwind.config.js index 3a3b5e4..bf7559e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -20,17 +20,17 @@ export default { 'highlight-foreground': '#f6c304', 'highlight-background': '#161517', primary: { - 50: 'hsl(40, 80%, 95%)', //''#fcf9ea', - 100: 'hsl(40, 80%, 90%)', //''#faefc7', - 200: 'hsl(40, 80%, 80%)', //''#f6dc92', - 300: 'hsl(40, 80%, 70%)', //''#f0c254', - 400: 'hsl(40, 80%, 65%)', //''#ebab2e', - 500: 'hsl(40, 80%, 55%)', //'#da9018', - 600: 'hsl(40, 80%, 24%)', //'#bc6e12', - 700: 'hsl(40, 80%, 18%)', //'#964e12', - 800: 'hsl(40, 80%, 12%)', //'#7d3f16', - 900: 'hsl(40, 80%, 7%)', //'#6a3419', - 950: 'hsl(40, 80%, 4%)' //'#3e1a0a' + 50: 'hsl(40, 60%, 95%)', //''#fcf9ea', + 100: 'hsl(40, 60%, 90%)', //''#faefc7', + 200: 'hsl(40, 60%, 80%)', //''#f6dc92', + 300: 'hsl(40, 60%, 70%)', //''#f0c254', + 400: 'hsl(40, 60%, 65%)', //''#ebab2e', + 500: 'hsl(40, 60%, 55%)', //'#da9018', + 600: 'hsl(40, 30%, 24%)', //'#bc6e12', + 700: 'hsl(40, 30%, 18%)', //'#964e12', + 800: 'hsl(40, 20%, 12%)', //'#7d3f16', + 900: 'hsl(40, 20%, 8%)', //'#6a3419', + 950: 'hsl(40, 20%, 4%)' //'#3e1a0a' }, secondary: { 50: 'hsl(40, 12%, 95%)',