feat: Add to sonarr dialog, reworked requests

This commit is contained in:
Aleksi Lassila
2024-05-20 00:27:55 +03:00
parent a95d91f90c
commit 2d652ae9ba
34 changed files with 1113 additions and 497 deletions

View File

@@ -84,7 +84,9 @@ html[data-useragent*="Tizen"] .selectable-secondary {
@apply focus-within:outline outline-2 outline-[#f0cd6dc2] outline-offset-2; @apply focus-within:outline outline-2 outline-[#f0cd6dc2] outline-offset-2;
} }
.header1 {
@apply font-medium text-lg text-secondary-300;
}
.header2 { .header2 {
@apply font-semibold text-2xl text-secondary-100; @apply font-semibold text-2xl text-secondary-100;

View File

@@ -4,6 +4,10 @@ export interface Api<Paths extends NonNullable<unknown>> {
getClient(): ReturnType<typeof createClient<Paths>>; getClient(): ReturnType<typeof createClient<Paths>>;
} }
export interface ApiAsync<Paths extends NonNullable<unknown>> {
getClient(): Promise<ReturnType<typeof createClient<Paths>>>;
}
// export abstract class Api<Paths extends NonNullable<unknown>> { // export abstract class Api<Paths extends NonNullable<unknown>> {
// protected abstract baseUrl: string; // protected abstract baseUrl: string;
// protected abstract client: ReturnType<typeof createClient<Paths>>; // protected abstract client: ReturnType<typeof createClient<Paths>>;

View File

@@ -5,17 +5,17 @@
export interface paths { export interface paths {
"/user": { "/api/user": {
get: operations["UserController_getProfile"]; get: operations["UserController_getProfile"];
post: operations["UserController_create"]; post: operations["UserController_create"];
}; };
"/user/{id}": { "/api/user/{id}": {
get: operations["UserController_findById"]; get: operations["UserController_findById"];
}; };
"/auth": { "/api/auth": {
post: operations["AuthController_signIn"]; post: operations["AuthController_signIn"];
}; };
"/": { "/api": {
get: operations["AppController_getHello"]; get: operations["AppController_getHello"];
}; };
} }

View File

@@ -4,10 +4,24 @@ import { get } from 'svelte/store';
import { getTmdbSeries, tmdbApi } from '../tmdb/tmdb-api'; import { getTmdbSeries, tmdbApi } from '../tmdb/tmdb-api';
import type { components, paths } from './sonarr.generated'; import type { components, paths } from './sonarr.generated';
import { log } from '../../utils'; 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 { appState } from '../../stores/app-state.store';
import { createLocalStorageStore } from '../../stores/localstorage.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 SonarrSeries = components['schemas']['SeriesResource'];
export type SonarrSeason = components['schemas']['SeasonResource']; export type SonarrSeason = components['schemas']['SeasonResource'];
export type SonarrRelease = components['schemas']['ReleaseResource']; 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 DiskSpaceInfo = components['schemas']['DiskSpaceResource'];
export type SonarrEpisode = components['schemas']['EpisodeResource']; export type SonarrEpisode = components['schemas']['EpisodeResource'];
export type EpisodeFileResource = components['schemas']['EpisodeFileResource']; 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 { export interface SonarrSeriesOptions {
title: string; title: string;
@@ -25,18 +42,7 @@ export interface SonarrSeriesOptions {
tvdbId: number; tvdbId: number;
rootFolderPath: string; rootFolderPath: string;
addOptions: { addOptions: {
monitor: monitor: SonarrMonitorOptions;
| 'unknown'
| 'all'
| 'future'
| 'missing'
| 'existing'
| 'firstSeason'
| 'latestSeason'
| 'pilot'
| 'monitorSpecials'
| 'unmonitorSpecials'
| 'none';
searchForMissingEpisodes: boolean; searchForMissingEpisodes: boolean;
searchForCutoffUnmetEpisodes: boolean; searchForCutoffUnmetEpisodes: boolean;
}; };
@@ -44,8 +50,9 @@ export interface SonarrSeriesOptions {
const tmdbToTvdbCache = createLocalStorageStore<Record<number, number>>('tmdb-to-tvdb-cache', {}); const tmdbToTvdbCache = createLocalStorageStore<Record<number, number>>('tmdb-to-tvdb-cache', {});
export class SonarrApi implements Api<paths> { export class SonarrApi implements ApiAsync<paths> {
getClient() { async getClient() {
await appState.ready;
const sonarrSettings = this.getSettings(); const sonarrSettings = this.getSettings();
const baseUrl = this.getBaseUrl(); const baseUrl = this.getBaseUrl();
const apiKey = sonarrSettings?.apiKey; const apiKey = sonarrSettings?.apiKey;
@@ -83,33 +90,42 @@ export class SonarrApi implements Api<paths> {
}; };
getSeriesById = (id: number): Promise<SonarrSeries | undefined> => getSeriesById = (id: number): Promise<SonarrSeries | undefined> =>
this.getClient() this.getClient().then(
?.GET('/api/v3/series/{id}', { (client) =>
params: { client
path: { ?.GET('/api/v3/series/{id}', {
id params: {
} path: {
} id
}) }
.then((r) => r.data) || Promise.resolve(undefined); }
})
.then((r) => r.data) || Promise.resolve(undefined)
);
getAllSeries = (): Promise<SonarrSeries[]> => getAllSeries = (): Promise<SonarrSeries[]> =>
this.getClient() this.getClient().then(
?.GET('/api/v3/series', { (client) =>
params: {} client
}) ?.GET('/api/v3/series', {
.then((r) => r.data || []) || Promise.resolve([]); params: {}
})
.then((r) => r.data || []) || Promise.resolve([])
);
getSonarrSeriesByTvdbId = (tvdbId: number): Promise<SonarrSeries | undefined> => getSonarrSeriesByTvdbId = (tvdbId: number): Promise<SonarrSeries | undefined> =>
this.getClient() this.getClient().then(
?.GET('/api/v3/series', { (client) =>
params: { client
query: { ?.GET('/api/v3/series', {
tvdbId: tvdbId params: {
} query: {
} tvdbId: tvdbId
}) }
.then((r) => r.data?.find((m) => m.tvdbId === tvdbId)) || Promise.resolve(undefined); }
})
.then((r) => r.data?.find((m) => m.tvdbId === tvdbId)) || Promise.resolve(undefined)
);
getSeriesByTmdbId = async (tmdbId: number) => getSeriesByTmdbId = async (tmdbId: number) =>
this.tmdbToTvdb(tmdbId).then((tvdbId) => this.tmdbToTvdb(tmdbId).then((tvdbId) =>
@@ -117,11 +133,19 @@ export class SonarrApi implements Api<paths> {
); );
getDiskSpace = (): Promise<DiskSpaceInfo[]> => getDiskSpace = (): Promise<DiskSpaceInfo[]> =>
this.getClient() this.getClient().then(
?.GET('/api/v3/diskspace', {}) (client) =>
.then((d) => d.data || []) || Promise.resolve([]); 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<SonarrSeries | undefined> => {
const tmdbSeries = await getTmdbSeries(tmdbId); const tmdbSeries = await getTmdbSeries(tmdbId);
if (!tmdbSeries || !tmdbSeries.external_ids.tvdb_id || !tmdbSeries.name) if (!tmdbSeries || !tmdbSeries.external_ids.tvdb_id || !tmdbSeries.name)
@@ -130,100 +154,119 @@ export class SonarrApi implements Api<paths> {
const options: SonarrSeriesOptions = { const options: SonarrSeriesOptions = {
title: tmdbSeries.name, title: tmdbSeries.name,
tvdbId: tmdbSeries.external_ids.tvdb_id, tvdbId: tmdbSeries.external_ids.tvdb_id,
qualityProfileId: this.getSettings()?.qualityProfileId || 0, qualityProfileId: _options.qualityProfileId || this.getSettings()?.qualityProfileId || 0,
monitored: false, monitored: false,
addOptions: { addOptions: {
monitor: 'none', monitor: _options.monitorOptions || 'none',
searchForMissingEpisodes: false, searchForMissingEpisodes: false,
searchForCutoffUnmetEpisodes: false searchForCutoffUnmetEpisodes: false
}, },
rootFolderPath: this.getSettings()?.rootFolderPath || '', rootFolderPath: _options.rootFolderPath || this.getSettings()?.rootFolderPath || '',
languageProfileId: this.getSettings()?.languageProfileId || 0, languageProfileId: this.getSettings()?.languageProfileId || 0,
seasonFolder: true seasonFolder: true
}; };
return this.getClient() return this.getClient().then((client) =>
?.POST('/api/v3/series', { client
params: {}, ?.POST('/api/v3/series', {
body: options params: {},
}) body: options
.then((r) => r.data); })
.then((r) => r.data)
);
}; };
cancelDownload = async (downloadId: number) => { cancelDownload = async (downloadId: number) => {
const deleteResponse = await this.getClient() const deleteResponse = await this.getClient().then((client) =>
?.DELETE('/api/v3/queue/{id}', { client
params: { ?.DELETE('/api/v3/queue/{id}', {
path: { params: {
id: downloadId path: {
}, id: downloadId
query: { },
blocklist: false, query: {
removeFromClient: true blocklist: false,
removeFromClient: true
}
} }
} })
}) .then((r) => log(r))
.then((r) => log(r)); );
return !!deleteResponse?.response.ok; return !!deleteResponse?.response.ok;
}; };
cancelDownloads = async (downloadIds: number[]) => cancelDownloads = async (downloadIds: number[]) =>
this.getClient() this.getClient().then(
?.DELETE('/api/v3/queue/bulk', { (client) =>
body: { client
ids: downloadIds ?.DELETE('/api/v3/queue/bulk', {
} body: {
}) ids: downloadIds
.then((r) => r.response.ok) || Promise.resolve(false); }
})
.then((r) => r.response.ok) || Promise.resolve(false)
);
downloadSonarrRelease = (guid: string, indexerId: number) => downloadSonarrRelease = (guid: string, indexerId: number) =>
this.getClient() this.getClient().then(
?.POST('/api/v3/release', { (client) =>
params: {}, client
body: { ?.POST('/api/v3/release', {
indexerId, params: {},
guid body: {
} indexerId,
}) guid
.then((res) => res.response.ok) || Promise.resolve(false); }
})
.then((res) => res.response.ok) || Promise.resolve(false)
);
deleteSonarrEpisode = (id: number) => deleteSonarrEpisode = (id: number) =>
this.getClient() this.getClient().then(
?.DELETE('/api/v3/episodefile/{id}', { (client) =>
params: { client
path: { ?.DELETE('/api/v3/episodefile/{id}', {
id params: {
} path: {
} id
}) }
.then((res) => res.response.ok) || Promise.resolve(false); }
})
.then((res) => res.response.ok) || Promise.resolve(false)
);
deleteSonarrEpisodes = (ids: number[]) => deleteSonarrEpisodes = (ids: number[]) =>
this.getClient() this.getClient().then(
?.DELETE('/api/v3/episodefile/bulk', { (client) =>
body: { client
episodeFileIds: ids ?.DELETE('/api/v3/episodefile/bulk', {
} body: {
}) episodeFileIds: ids
.then((res) => res.response.ok) || Promise.resolve(false); }
})
.then((res) => res.response.ok) || Promise.resolve(false)
);
getSonarrDownloads = (): Promise<EpisodeDownload[]> => getSonarrDownloads = (): Promise<EpisodeDownload[]> =>
this.getClient() this.getClient().then(
?.GET('/api/v3/queue', { (client) =>
params: { client
query: { ?.GET('/api/v3/queue', {
includeEpisode: true, params: {
includeSeries: true query: {
} includeEpisode: true,
} includeSeries: true
}) }
.then( }
(r) => })
(r.data?.records?.filter( .then(
(record) => record.episode && record.series (r) =>
) as EpisodeDownload[]) || [] (r.data?.records?.filter(
) || Promise.resolve([]); (record) => record.episode && record.series
) as EpisodeDownload[]) || []
) || Promise.resolve([])
);
getDownloadsBySeriesId = (sonarrId: number) => getDownloadsBySeriesId = (sonarrId: number) =>
this.getSonarrDownloads().then((downloads) => this.getSonarrDownloads().then((downloads) =>
@@ -231,26 +274,90 @@ export class SonarrApi implements Api<paths> {
) || Promise.resolve([]); ) || Promise.resolve([]);
removeFromSonarr = (id: number): Promise<boolean> => removeFromSonarr = (id: number): Promise<boolean> =>
this.getClient() this.getClient().then(
?.DELETE('/api/v3/series/{id}', { (client) =>
params: { client
path: { ?.DELETE('/api/v3/series/{id}', {
id params: {
} path: {
} id
}) }
.then((res) => res.response.ok) || Promise.resolve(false); }
})
.then((res) => res.response.ok) || Promise.resolve(false)
);
getFilesBySeriesId = (seriesId: number): Promise<EpisodeFileResource[]> => getFilesBySeriesId = (seriesId: number): Promise<EpisodeFileResource[]> =>
this.getClient() this.getClient().then(
?.GET('/api/v3/episodefile', { (client) =>
params: { client
query: { ?.GET('/api/v3/episodefile', {
seriesId params: {
} query: {
} seriesId
}) }
.then((r) => r.data || []) || Promise.resolve([]); }
})
.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<SonarrEpisode[]> => {
return this.getClient().then(
(client) =>
client
?.GET('/api/v3/episode', {
params: {
query: {
seriesId,
seasonNumber,
includeImages: true
}
}
})
.then((r) => r.data || []) || Promise.resolve([])
);
};
getRootFolders = (): Promise<SonarrRootFolder[]> =>
this.getClient().then(
(client) =>
client?.GET('/api/v3/rootfolder', {}).then((r) => r.data || []) || Promise.resolve([])
);
getQualityProfiles = (): Promise<SonarrQualityProfile[]> =>
this.getClient().then(
(client) =>
client?.GET('/api/v3/qualityprofile', {}).then((r) => r.data || []) || Promise.resolve([])
);
// getSonarrEpisodes = async (seriesId: number) => { // getSonarrEpisodes = async (seriesId: number) => {
// const episodesPromise = // const episodesPromise =
@@ -284,44 +391,6 @@ export class SonarrApi implements Api<paths> {
// })); // }));
// }; // };
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<SonarrEpisode[]> => {
return (
this.getClient()
?.GET('/api/v3/episode', {
params: {
query: {
seriesId,
seasonNumber
}
}
})
.then((r) => r.data || []) || Promise.resolve([])
);
};
getSonarrHealth = async ( getSonarrHealth = async (
baseUrl: string | undefined = undefined, baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined apiKey: string | undefined = undefined
@@ -335,19 +404,16 @@ export class SonarrApi implements Api<paths> {
.then((res) => res.status === 200) .then((res) => res.status === 200)
.catch(() => false); .catch(() => false);
getSonarrRootFolders = async ( _getSonarrRootFolders = async (
baseUrl: string | undefined = undefined, baseUrl: string | undefined = undefined,
apiKey: string | undefined = undefined apiKey: string | undefined = undefined
) => ) =>
axios axios
.get<components['schemas']['RootFolderResource'][]>( .get<SonarrRootFolder[]>((baseUrl || this.getBaseUrl()) + '/api/v3/rootFolder', {
(baseUrl || this.getBaseUrl()) + '/api/v3/rootFolder', headers: {
{ 'X-Api-Key': apiKey || this.getApiKey()
headers: {
'X-Api-Key': apiKey || this.getApiKey()
}
} }
) })
.then((res) => res.data || []); .then((res) => res.data || []);
getSonarrQualityProfiles = async ( getSonarrQualityProfiles = async (

View File

@@ -3,12 +3,28 @@
import type { Readable } from 'svelte/store'; import type { Readable } from 'svelte/store';
import classNames from 'classnames'; import classNames from 'classnames';
import AnimatedSelection from './AnimateScale.svelte'; import AnimatedSelection from './AnimateScale.svelte';
import { createEventDispatcher } from 'svelte';
export let disabled: boolean = false; export let disabled: boolean = false;
export let focusOnMount: 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<any>) | null = null;
let actionIsFetching = false;
$: _disabled = disabled || actionIsFetching;
let hasFocus: Readable<boolean>; let hasFocus: Readable<boolean>;
const dispatch = createEventDispatcher<{ clickOrSelect: null }>();
function handleClickOrSelect() {
if (action) {
actionIsFetching = true;
action().then(() => (actionIsFetching = false));
}
dispatch('clickOrSelect');
}
</script> </script>
<AnimatedSelection hasFocus={$hasFocus}> <AnimatedSelection hasFocus={$hasFocus}>
@@ -17,42 +33,44 @@
class={classNames( class={classNames(
'h-12 rounded-lg font-medium tracking-wide flex items-center group', '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-2 p-1 hover:border-primary-500': type === 'secondary',
'border-primary-500': type === 'secondary' && $hasFocus, 'border-primary-500': type === 'secondary' && $hasFocus,
'cursor-pointer': !disabled, 'cursor-pointer': !_disabled,
'cursor-not-allowed pointer-events-none opacity-40': disabled 'cursor-not-allowed pointer-events-none opacity-40': _disabled
}, },
$$restProps.class $$restProps.class
)} )}
on:click on:click
on:select on:select
on:clickOrSelect on:clickOrSelect={handleClickOrSelect}
on:enter on:enter
{focusOnMount} {focusOnMount}
> >
<div <div
class={classNames({ class={classNames({
contents: type === 'primary', contents: type === 'primary' || type === 'primary-dark',
'border-2 border-transparent h-full w-full rounded-md flex items-center px-6': 'border-2 border-transparent h-full w-full rounded-md flex items-center px-6':
type === 'secondary', type === 'secondary',
'bg-primary-500 text-secondary-950': type === 'secondary' && $hasFocus, 'bg-primary-500 text-secondary-950': type === 'secondary' && $hasFocus,
'group-hover:bg-primary-500 group-hover:text-secondary-950': type === 'secondary' 'group-hover:bg-primary-500 group-hover:text-secondary-950': type === 'secondary'
})} })}
> >
{#if $$slots.icon} <div class="flex-1 text-center text-nowrap flex items-center justify-center">
<div class="mr-2"> {#if $$slots.icon}
<slot name="icon" /> <div class="mr-2">
</div> <slot name="icon" />
{/if} </div>
<div class="flex-1 text-center text-nowrap"> {/if}
<slot {hasFocus} /> <slot {hasFocus} />
{#if $$slots['icon-after']}
<div class="ml-2">
<slot name="icon-after" />
</div>
{/if}
</div> </div>
{#if $$slots['icon-after']}
<div class="ml-2">
<slot name="icon-after" />
</div>
{/if}
</div> </div>
</Container> </Container>
</AnimatedSelection> </AnimatedSelection>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import Container from '../../Container.svelte';
import { createEventDispatcher } from 'svelte';
import { Check } from 'radix-icons-svelte';
import classNames from 'classnames';
import AnimateScale from './AnimateScale.svelte';
import type { Readable } from 'svelte/store';
export type CheckboxChangeEvent = CustomEvent<boolean>;
const dispatch = createEventDispatcher<{
change: boolean;
}>();
export let checked: boolean;
let hasFocus: Readable<boolean>;
let input: HTMLInputElement;
const handleChange = (e: Event) => {
checked = e.target?.checked;
dispatch('change', e.target?.checked);
};
</script>
<AnimateScale hasFocus={$hasFocus}>
<Container
on:enter={(e) => {
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
}
)}
>
<div
class={classNames('flex items-center justify-center w-full h-full rounded-lg', {
'text-secondary-200 focus-within:bg-primary-500 focus-within:text-secondary-800': checked
})}
>
<input
type="checkbox"
bind:checked
class="sr-only peer"
bind:this={input}
on:input={handleChange}
/>
<Check class="opacity-0 peer-checked:opacity-100" size={24} />
<!-- <div-->
<!-- class="w-11 h-6 rounded-full peer bg-zinc-600 bg-opacity-20 peer-checked:bg-amber-200 peer-checked:bg-opacity-30 peer-selectable-->
<!-- after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px]"-->
<!-- />-->
</div>
</Container>
</AnimateScale>

View File

@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import Container from '../../../Container.svelte'; import Container from '../../../Container.svelte';
import Button from '../Button.svelte'; import Button from '../Button.svelte';
import Modal from '../Modal/Modal.svelte';
import { modalStack } from '../Modal/modal.store'; import { modalStack } from '../Modal/modal.store';
import Dialog from './Dialog.svelte'; import Dialog from './Dialog.svelte';
@@ -27,20 +26,15 @@
} }
</script> </script>
<Dialog {modalId}> <Dialog>
<div class="text-2xl font-semibold tracking-wide mb-2 text-secondary-100"> <div class="header2 mb-4">
<slot name="header" /> <slot name="header" />
</div> </div>
<div class="font-medium text-secondary-300 mb-8"> <div class="font-medium text-secondary-300 mb-8">
<slot /> <slot />
</div> </div>
<Container direction="horizontal" class="flex"> <Container class="flex flex-col space-y-4">
<Button <Button type="secondary" disabled={fetching} on:clickOrSelect={() => handleAction(confirm)}>
type="secondary"
disabled={fetching}
on:clickOrSelect={() => handleAction(confirm)}
class="mr-4"
>
Confirm Confirm
</Button> </Button>
<Button type="secondary" disabled={fetching} on:clickOrSelect={() => handleAction(cancel)} <Button type="secondary" disabled={fetching} on:clickOrSelect={() => handleAction(cancel)}

View File

@@ -1,11 +1,25 @@
<script lang="ts"> <script lang="ts">
import Modal from '../Modal/Modal.svelte'; import Modal from '../Modal/Modal.svelte';
import classNames from 'classnames';
import { fade } from 'svelte/transition';
export let size: 'sm' | 'full' = 'sm';
</script> </script>
<Modal> <Modal on:back>
<div class="h-full flex items-center justify-center bg-secondary-950/75 py-20"> <div
class="h-full flex items-center justify-center bg-primary-900/75 py-20 px-32"
transition:fade={{ duration: 100 }}
>
<div <div
class="flex-1 bg-secondary-900 rounded-2xl max-w-lg p-10 overflow-y-auto min-h-0 max-h-full scrollbar-hide" class={classNames(
'flex-1 bg-primary-800 rounded-2xl p-10 relative shadow-xl flex flex-col',
{
'max-w-lg min-h-0 overflow-y-auto scrollbar-hide': size === 'sm',
'h-full overflow-hidden': size === 'full'
},
$$restProps.class
)}
> >
<slot /> <slot />
</div> </div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import Modal from '../Modal/Modal.svelte';
import classNames from 'classnames';
import { fade } from 'svelte/transition';
</script>
<Modal on:back>
<div
class="h-full flex items-center justify-center bg-primary-900/75 py-20 px-32"
transition:fade={{ duration: 100 }}
>
<div
class={classNames(
'flex-1 bg-primary-800 rounded-2xl p-10 overflow-y-auto min-h-0 max-h-full scrollbar-hide relative shadow-xl',
$$restProps.class
)}
>
<slot />
</div>
</div>
</Modal>

View File

@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { sonarrApi } from '../../apis/sonarr/sonarr-api'; import { sonarrApi } from '../../apis/sonarr/sonarr-api';
import MMMainLayout from './MMMainLayout.svelte'; import MMMainLayout from './MMMainLayout.svelte';
import MMAddToSonarr from './MMAddToSonarr.svelte';
import MMModal from './MMModal.svelte'; import MMModal from './MMModal.svelte';
import ReleaseList from './Releases/MMReleasesTab.svelte'; import ReleaseList from './Releases/MMReleasesTab.svelte';
import DownloadList from '../MediaManager/DownloadList.svelte'; import DownloadList from '../MediaManager/DownloadList.svelte';
@@ -93,7 +92,7 @@
<MMModal {modalId} {hidden}> <MMModal {modalId} {hidden}>
{#await sonarrEpisode then sonarrEpisode} {#await sonarrEpisode then sonarrEpisode}
{#if !sonarrEpisode} {#if !sonarrEpisode}
<MMAddToSonarr /> <!-- <MMAddToSonarr />-->
{:else} {:else}
<div class="pt-20 h-screen flex flex-col"> <div class="pt-20 h-screen flex flex-col">
<MMTitle class="mb-32 mx-32"> <MMTitle class="mb-32 mx-32">

View File

@@ -0,0 +1,276 @@
<script lang="ts">
import Dialog from '../Dialog/Dialog.svelte';
import Container from '../../../Container.svelte';
import Button from '../Button.svelte';
import { ArrowRight, Check, Plus, Trash } from 'radix-icons-svelte';
import { modalStack } from '../Modal/modal.store';
import {
sonarrApi,
type SonarrMonitorOptions,
sonarrMonitorOptions,
type SonarrQualityProfile,
type SonarrRootFolder
} from '../../apis/sonarr/sonarr-api';
import type { TmdbSeries2 } from '../../apis/tmdb/tmdb-api';
import { TMDB_BACKDROP_SMALL } from '../../constants';
import classNames from 'classnames';
import { type BackEvent, scrollIntoView, Selectable } from '../../selectable';
import { fade } from 'svelte/transition';
import { sonarrService } from '../../stores/sonarr-service.store';
import { createLocalStorageStore } from '../../stores/localstorage.store';
import { formatSize } from '../../utils';
import { capitalize } from '../../utils.js';
type AddOptionsStore = {
rootFolderPath: string | null;
qualityProfileId: number | null;
monitorOptions: SonarrMonitorOptions | null;
};
export let modalId: symbol;
export let series: TmdbSeries2;
export let onComplete: () => void = () => {};
$: backgroundUrl = TMDB_BACKDROP_SMALL + series.backdrop_path;
let tab: 'add-to-sonarr' | 'root-folders' | 'quality-profiles' | 'monitor-settings' =
'add-to-sonarr';
let addToSonarrTab: Selectable;
let rootFoldersTab: Selectable;
let qualityProfilesTab: Selectable;
let monitorSettingsTab: Selectable;
$: {
if (tab === 'add-to-sonarr' && addToSonarrTab) addToSonarrTab.focus();
if (tab === 'root-folders' && rootFoldersTab) rootFoldersTab.focus();
if (tab === 'quality-profiles' && qualityProfilesTab) qualityProfilesTab.focus();
if (tab === 'monitor-settings' && monitorSettingsTab) monitorSettingsTab.focus();
}
const addOptionsStore = createLocalStorageStore<AddOptionsStore>('add-to-sonarr-options', {
rootFolderPath: null,
qualityProfileId: null,
monitorOptions: null
});
$sonarrService.then((s) => {
addOptionsStore.update((prev) => ({
rootFolderPath: prev.rootFolderPath || s.rootFolders[0]?.path || null,
qualityProfileId: prev.qualityProfileId || s.qualityProfiles[0]?.id || null,
monitorOptions: prev.monitorOptions || 'none'
}));
});
addOptionsStore.subscribe(() => (tab = 'add-to-sonarr'));
function handleAddToSonarr() {
return sonarrApi
.addToSonarr(series.id as number, {
rootFolderPath: $addOptionsStore.rootFolderPath || undefined,
monitorOptions: $addOptionsStore.monitorOptions || undefined,
qualityProfileId: $addOptionsStore.qualityProfileId || undefined
})
.then((success) => {
if (success) {
modalStack.close(modalId);
onComplete();
}
});
}
function handleBack(e: BackEvent) {
if (tab !== 'add-to-sonarr') {
tab = 'add-to-sonarr';
e.detail.stopPropagation();
}
}
const tabClasses = (active: boolean, secondary: boolean = false) =>
classNames('flex flex-col transition-all', {
'opacity-0 pointer-events-none': !active,
'-translate-x-10': !active && !secondary,
'translate-x-10': !active && secondary,
'absolute inset-0': secondary
});
const listItemClass = `flex items-center justify-between bg-primary-900 rounded-xl px-6 py-2.5 mb-4 font-medium
border-2 border-transparent focus:border-primary-500 hover:border-primary-500 cursor-pointer group`;
const scaledArrowClas = (hasFocus: boolean) =>
classNames('transition-transform', {
'text-primary-500 translate-x-0.5 scale-110': hasFocus,
'group-hover:text-primary-500 group-hover:translate-x-0.5 group-hover:scale-110': true
});
</script>
<Dialog>
{#if backgroundUrl && tab === 'add-to-sonarr'}
<div
transition:fade={{ duration: 200 }}
class="absolute inset-0 bg-cover bg-center h-52"
style="background-image: url({backgroundUrl}); -webkit-mask-image: radial-gradient(at 90% 10%, hsla(0,0%,0%,1) 0px, transparent 70%);"
/>
{/if}
{#await $sonarrService then { qualityProfiles, rootFolders }}
{@const selectedRootFolder = rootFolders.find(
(f) => f.path === $addOptionsStore.rootFolderPath
)}
{@const selectedQualityProfile = qualityProfiles.find(
(f) => f.id === $addOptionsStore.qualityProfileId
)}
<Container on:back={handleBack} class="relative">
<Container
trapFocus
bind:selectable={addToSonarrTab}
class={tabClasses(tab === 'add-to-sonarr')}
>
<div class="z-10 mb-8">
<div class="h-24" />
<h1 class="header2">Add {series?.name} to Sonarr?</h1>
<div class="font-medium text-secondary-300 mb-8">
Before you can fetch episodes, you need to add this series to Sonarr.
</div>
<Container
class={listItemClass}
on:clickOrSelect={() => (tab = 'root-folders')}
let:hasFocus
>
<div>
<h1 class="text-secondary-300 font-semibold tracking-wide text-sm">Root Folder</h1>
{selectedRootFolder?.path}
({formatSize(selectedRootFolder?.freeSpace || 0)} left)
</div>
<ArrowRight class={scaledArrowClas(hasFocus)} size={24} />
</Container>
<Container
class={listItemClass}
on:clickOrSelect={() => (tab = 'quality-profiles')}
let:hasFocus
>
<div>
<h1 class="text-secondary-300 font-semibold tracking-wide text-sm">
Quality Profile
</h1>
<span>
{selectedQualityProfile?.name}
</span>
</div>
<ArrowRight class={scaledArrowClas(hasFocus)} size={24} />
</Container>
<Container
class={listItemClass}
on:clickOrSelect={() => (tab = 'monitor-settings')}
let:hasFocus
>
<div>
<h1 class="text-secondary-300 font-semibold tracking-wide text-sm">
Monitor Strategy
</h1>
<span>
{capitalize($addOptionsStore.monitorOptions || 'none')}
</span>
</div>
<ArrowRight class={scaledArrowClas(hasFocus)} size={24} />
</Container>
<!-- <Container class="flex items-center" on:clickOrSelect={() => (tab = 'quality-profiles')}>-->
<!-- {qualityProfile?.name}-->
<!-- <ArrowRight size={19} />-->
<!-- </Container>-->
<!-- <Container class="flex items-center" on:clickOrSelect={() => (tab = 'monitor-settings')}>-->
<!-- Monitor {$addOptionsStore.monitorSettings}-->
<!-- <ArrowRight size={19} />-->
<!-- </Container>-->
</div>
<Container class="flex flex-col space-y-4">
<Button type="primary-dark" action={handleAddToSonarr} focusOnMount>
<Plus size={19} slot="icon" />
Add to Sonarr
</Button>
<Button type="primary-dark" on:clickOrSelect={() => modalStack.close(modalId)}
>Cancel</Button
>
</Container>
</Container>
<Container
trapFocus
class={tabClasses(tab === 'root-folders', true)}
bind:selectable={rootFoldersTab}
>
<h1 class="text-xl text-secondary-100 font-medium mb-4">Root Folder</h1>
<div class="min-h-0 overflow-y-auto scrollbar-hide">
{#each rootFolders as rootFolder}
<Container
class={listItemClass}
on:enter={scrollIntoView({ vertical: 64 })}
on:clickOrSelect={() =>
addOptionsStore.update((prev) => ({ ...prev, rootFolderId: rootFolder.id || 0 }))}
focusOnClick
focusOnMount={$addOptionsStore.rootFolderPath === rootFolder.path}
>
<div>
{rootFolder.path} ({formatSize(rootFolder.freeSpace || 0)} left)
</div>
{#if selectedRootFolder?.id === rootFolder.id}
<Check size={24} />
{/if}
</Container>
{/each}
</div>
</Container>
<Container
trapFocus
class={tabClasses(tab === 'quality-profiles', true)}
bind:selectable={qualityProfilesTab}
>
<h1 class="text-xl text-secondary-100 font-medium mb-4">Quality Profile</h1>
<div class="min-h-0 overflow-y-auto scrollbar-hide">
{#each qualityProfiles as qualityProfile}
<Container
class={listItemClass}
on:enter={scrollIntoView({ vertical: 64 })}
on:clickOrSelect={() =>
addOptionsStore.update((prev) => ({
...prev,
qualityProfileId: qualityProfile.id || 0
}))}
focusOnClick
focusOnMount={$addOptionsStore.qualityProfileId === qualityProfile.id}
>
<div>{qualityProfile.name}</div>
{#if selectedQualityProfile?.id === qualityProfile.id}
<Check size={24} />
{/if}
</Container>
{/each}
</div>
</Container>
<Container
trapFocus
class={tabClasses(tab === 'monitor-settings', true)}
bind:selectable={monitorSettingsTab}
>
<h1 class="text-xl text-secondary-100 font-medium mb-4">Monitor Episodes</h1>
<div class="min-h-0 overflow-y-auto scrollbar-hide">
{#each sonarrMonitorOptions as monitorOption}
<Container
class={listItemClass}
on:enter={scrollIntoView({ vertical: 64 })}
on:clickOrSelect={() =>
addOptionsStore.update((prev) => ({ ...prev, monitorOptions: monitorOption }))}
focusOnClick
focusOnMount={$addOptionsStore.monitorOptions === monitorOption}
>
<div>{capitalize(monitorOption)}</div>
{#if $addOptionsStore.monitorOptions === monitorOption}
<Check size={24} />
{/if}
</Container>
{/each}
</div>
</Container>
</Container>
{/await}
</Dialog>

View File

@@ -3,7 +3,7 @@
import classNames from 'classnames'; import classNames from 'classnames';
import MMTitle from './MMTitle.svelte'; import MMTitle from './MMTitle.svelte';
let activeTab: 'releases' | 'local-files' = 'releases'; // let activeTab: 'releases' | 'local-files' = 'releases';
</script> </script>
<div class="flex flex-col h-screen"> <div class="flex flex-col h-screen">
@@ -19,66 +19,7 @@
<slot name="downloads" /> <slot name="downloads" />
</div> </div>
</div> </div>
<div class="flex mb-8 mx-32">
<button
class={classNames('text-2xl font-semibold mr-8 transition-opacity cursor-pointer', {
'opacity-40': activeTab !== 'releases'
})}
on:click={() => (activeTab = 'releases')}
>
Releases
</button>
<button
class={classNames('text-2xl font-semibold mr-8 transition-opacity cursor-pointer', {
'opacity-40': activeTab !== 'local-files'
})}
on:click={() => (activeTab = 'local-files')}
>
Local Files
</button>
</div>
<Container focusOnMount direction="horizontal" class="flex-1 grid grid-cols-1 min-h-0"> <Container focusOnMount direction="horizontal" class="flex-1 grid grid-cols-1 min-h-0">
<Container <slot />
focusOnMount
on:enter={() => (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'
}
)}
>
<slot name="releases" />
</Container>
<Container
on:enter={() => (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'
}
)}
>
<slot name="local-files" />
</Container>
</Container> </Container>
<!-- <Container direction="horizontal" class="grid grid-cols-1 gap-16">-->
<!-- <div class="flex flex-col">-->
<!-- <h1 class="text-2xl font-semibold mb-4">Releases</h1>-->
<!-- <slot name="releases" />-->
<!-- </div>-->
<!-- <div class="flex flex-col">-->
<!-- <div class="flex flex-col mb-8">-->
<!-- <h1 class="text-2xl font-semibold mb-4">Local Files</h1>-->
<!-- <slot name="local-files" />-->
<!-- </div>-->
<!-- <div class="flex flex-col mb-8">-->
<!-- <h1 class="text-2xl font-semibold mb-4">Downloads</h1>-->
<!-- <slot name="downloads" />-->
<!-- </div>-->
<!-- </div>-->
<!-- </Container>-->
</div> </div>

View File

@@ -2,33 +2,27 @@
import classNames from 'classnames'; import classNames from 'classnames';
import Container from '../../../Container.svelte'; import Container from '../../../Container.svelte';
import { modalStack } from '../Modal/modal.store'; import { modalStack } from '../Modal/modal.store';
import Modal from '../Modal/Modal.svelte';
export let modalId: symbol; export let modalId: symbol;
export let hidden: boolean = false; export let hidden: boolean = false;
</script> </script>
<Container <Modal>
on:navigate={({ detail }) => {
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
>
<div <div
class="absolute top-0 inset-x-0 h-screen -z-10 bg-[radial-gradient(150%_50%_at_50%_-25%,_#DFAA2Bcc_0%,_#00000000_100%)]" class={classNames(
/> 'fixed inset-0 overflow-hidden',
<slot /> {
</Container> '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'
)}
>
<div
class="absolute top-0 inset-x-0 h-screen -z-10 bg-[radial-gradient(150%_50%_at_50%_-25%,_#DFAA2Bcc_0%,_#00000000_100%)]"
/>
<slot />
</div>
</Modal>

View File

@@ -0,0 +1 @@
<div>select season</div>

View File

@@ -1,8 +1,8 @@
<div {...$$restProps}> <div {...$$restProps}>
<div class="text-4xl font-semibold mb-2"> <div class="header4">
<slot name="title" /> <slot name="title" />
</div> </div>
<div class="text-zinc-300 font-medium text-xl"> <div class="header1">
<slot name="subtitle" /> <slot name="subtitle" />
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import MMMainLayout from './MMMainLayout.svelte'; import MMMainLayout from './MMMainLayout.svelte';
import MMAddToSonarr from './MMAddToSonarr.svelte'; // import MMAddToSonarr from './MMAddToSonarr.svelte';
import MMModal from './MMModal.svelte'; import MMModal from './MMModal.svelte';
import ReleaseList from './Releases/MMReleasesTab.svelte'; import ReleaseList from './Releases/MMReleasesTab.svelte';
import DownloadList from '../MediaManager/DownloadList.svelte'; import DownloadList from '../MediaManager/DownloadList.svelte';
@@ -25,7 +25,7 @@
<MMModal {modalId} {hidden}> <MMModal {modalId} {hidden}>
{#await radarrItem then movie} {#await radarrItem then movie}
{#if !movie} {#if !movie}
<MMAddToSonarr /> <!-- <MMAddToSonarr />-->
{:else} {:else}
<MMMainLayout> <MMMainLayout>
<h1 slot="title">{movie?.title}</h1> <h1 slot="title">{movie?.title}</h1>

View File

@@ -8,6 +8,7 @@
import type { GrabReleaseFn } from '../MediaManagerModal'; import type { GrabReleaseFn } from '../MediaManagerModal';
import Container from '../../../../Container.svelte'; import Container from '../../../../Container.svelte';
import TableHeaderCell from '../../Table/TableHeaderCell.svelte'; import TableHeaderCell from '../../Table/TableHeaderCell.svelte';
import MMTitle from '../MMTitle.svelte';
type Release = RadarrRelease | SonarrRelease; type Release = RadarrRelease | SonarrRelease;
@@ -62,46 +63,57 @@
} }
</script> </script>
{#await releases} <Container trapFocus class="py-8 h-full flex flex-col">
{#each new Array(5) as _, index} <h1 class="header4 mx-12">
<div class="flex-1 my-2"> <slot name="title" />
<ButtonGhost /> </h1>
</div> <h2 class="header1 mx-12 mb-8">
{/each} <slot name="subtitle" />
{:then releases} </h2>
<div class="grid grid-cols-[1fr_max-content_max-content_max-content_max-content] gap-y-4">
<TableHeaderRow>
<TableHeaderSortBy
icon={sortBy === 'age' ? sortDirection : undefined}
on:clickOrSelect={toggleSortBy('age')}>Age</TableHeaderSortBy
>
<TableHeaderSortBy
icon={sortBy === 'size' ? sortDirection : undefined}
on:clickOrSelect={toggleSortBy('size')}>Size</TableHeaderSortBy
>
<TableHeaderSortBy
icon={sortBy === 'seeders' ? sortDirection : undefined}
on:clickOrSelect={toggleSortBy('seeders')}>Peers</TableHeaderSortBy
>
<TableHeaderSortBy
icon={sortBy === 'quality' ? sortDirection : undefined}
on:clickOrSelect={toggleSortBy('quality')}>Quality</TableHeaderSortBy
>
<TableHeaderCell />
</TableHeaderRow>
<Container class="contents" focusedChild> <div class="flex-1 min-h-0 overflow-y-auto scrollbar-hide">
{#each getRecommendedReleases(releases).sort(getSortFn(sortBy, sortDirection)) as release, index} {#await releases}
<MMReleaseListRow {release} {grabRelease} /> {#each new Array(5) as _, index}
<div class="flex-1 my-2">
<ButtonGhost />
</div>
{/each} {/each}
</Container> {:then releases}
<div class="grid grid-cols-[1fr_max-content_max-content_max-content_max-content] gap-y-4">
<TableHeaderRow>
<TableHeaderSortBy
icon={sortBy === 'age' ? sortDirection : undefined}
on:clickOrSelect={toggleSortBy('age')}>Age</TableHeaderSortBy
>
<TableHeaderSortBy
icon={sortBy === 'size' ? sortDirection : undefined}
on:clickOrSelect={toggleSortBy('size')}>Size</TableHeaderSortBy
>
<TableHeaderSortBy
icon={sortBy === 'seeders' ? sortDirection : undefined}
on:clickOrSelect={toggleSortBy('seeders')}>Peers</TableHeaderSortBy
>
<TableHeaderSortBy
icon={sortBy === 'quality' ? sortDirection : undefined}
on:clickOrSelect={toggleSortBy('quality')}>Quality</TableHeaderSortBy
>
<TableHeaderCell />
</TableHeaderRow>
<h1 class="text-2xl font-semibold mb-4 mt-8 col-span-5 mx-12">All Releases</h1> <Container class="contents" focusOnMount>
{#each getRecommendedReleases(releases).sort(getSortFn(sortBy, sortDirection)) as release, index}
<MMReleaseListRow {release} {grabRelease} />
{/each}
</Container>
{#each releases <h1 class="text-2xl font-semibold mb-4 mt-8 col-span-5 mx-12">All Releases</h1>
.filter((r) => r.guid && r.indexerId)
.sort(getSortFn(sortBy, sortDirection)) as release, index} {#each releases
<MMReleaseListRow {release} {grabRelease} /> .filter((r) => r.guid && r.indexerId)
{/each} .sort(getSortFn(sortBy, sortDirection)) as release, index}
<MMReleaseListRow {release} {grabRelease} />
{/each}
</div>
{/await}
</div> </div>
{/await} </Container>

View File

@@ -1,73 +1,36 @@
<script lang="ts"> <script lang="ts">
import { sonarrApi } from '../../apis/sonarr/sonarr-api'; import { sonarrApi, type SonarrRelease, type SonarrSeries } from '../../apis/sonarr/sonarr-api';
import MMMainLayout from './MMMainLayout.svelte'; import MMMainLayout from './MMMainLayout.svelte';
import MMAddToSonarr from './MMAddToSonarr.svelte';
import MMModal from './MMModal.svelte';
import MMReleasesTab from './Releases/MMReleasesTab.svelte'; import MMReleasesTab from './Releases/MMReleasesTab.svelte';
import MMLocalFilesTab from './LocalFiles/MMLocalFilesTab.svelte'; import type { GrabReleaseFn } from './MediaManagerModal';
import type {
CancelDownloadFn,
CancelDownloadsFn,
DeleteFileFn,
DeleteFilesFn,
GrabReleaseFn
} from './MediaManagerModal';
import type { Release } from '../../apis/combined-types';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import Dialog from '../Dialog/Dialog.svelte';
import type { Release } from '../../apis/combined-types';
import MMSeasonSelectTab from './MMSeasonSelectTab.svelte';
export let id: number; // Tmdb ID export let season: number | null = null;
export let season: number; export let sonarrItem: SonarrSeries;
export let modalId: symbol; export let modalId: symbol;
export let hidden: boolean; export let hidden: boolean;
export let onGrabRelease: (release: Release) => void = () => {};
const sonarrItem = sonarrApi.getSeriesByTmdbId(id); $: releases = season !== null ? getReleases(season) : null;
let releases: Promise<Release[]> = getReleases();
let files = getLocalFiles();
let downloads = getDownloads();
let refreshDownloadsTimeout: ReturnType<typeof setTimeout>; let refreshDownloadsTimeout: ReturnType<typeof setTimeout>;
const grabRelease: GrabReleaseFn = (release) => const grabRelease: GrabReleaseFn = (release) =>
sonarrApi.downloadSonarrRelease(release.guid || '', release.indexerId || -1).then((r) => { sonarrApi.downloadSonarrRelease(release.guid || '', release.indexerId || -1).then((r) => {
refreshDownloadsTimeout = setTimeout(() => { onGrabRelease(release);
downloads = getDownloads();
}, 8000);
return r; return r;
}); });
const deleteFile: DeleteFileFn = (...args) => function getReleases(season: number) {
sonarrApi.deleteSonarrEpisode(...args).then((r) => { return sonarrApi.getSeasonReleases(sonarrItem.id || -1, season);
files = getLocalFiles();
return r;
});
const deleteFiles: DeleteFilesFn = (...args) =>
sonarrApi.deleteSonarrEpisodes(...args).then((r) => {
files = getLocalFiles();
return r;
});
const cancelDownload: CancelDownloadFn = (...args) =>
sonarrApi.cancelDownload(...args).then((r) => {
downloads = getDownloads();
return r;
});
const cancelDownloads: CancelDownloadsFn = (...args) =>
sonarrApi.cancelDownloads(...args).then((r) => {
downloads = getDownloads();
return r;
});
function getReleases() {
return sonarrItem.then((si) => sonarrApi.getSeasonReleases(si?.id || -1, season));
} }
function getLocalFiles() { function getDownloads(season: number) {
return sonarrItem.then((si) => sonarrApi.getFilesBySeriesId(si?.id || -1)); return sonarrApi
} .getDownloadsBySeriesId(sonarrItem.id || -1)
function getDownloads() {
return sonarrItem
.then((si) => sonarrApi.getDownloadsBySeriesId(si?.id || -1))
.then((ds) => ds.filter((d) => d.episode?.seasonNumber === season)); .then((ds) => ds.filter((d) => d.episode?.seasonNumber === season));
} }
@@ -76,26 +39,13 @@
}); });
</script> </script>
<MMModal {modalId} {hidden}> <Dialog size="full" {modalId} {hidden}>
{#await sonarrItem then series} {#if !season}
{#if !series} <MMSeasonSelectTab />
<MMAddToSonarr /> {:else if releases}
{:else} <MMReleasesTab {releases} {grabRelease}>
<MMMainLayout> <h1 slot="title">{sonarrItem?.title}</h1>
<h1 slot="title">{series?.title}</h1> <h2 slot="subtitle">Season {season} Releases</h2>
<h2 slot="subtitle">Season {season} Packs</h2> </MMReleasesTab>
<MMReleasesTab slot="releases" {releases} {grabRelease} /> {/if}
<MMLocalFilesTab </Dialog>
slot="local-files"
{files}
{deleteFile}
{deleteFiles}
{downloads}
{cancelDownload}
{cancelDownloads}
/>
<!-- <DownloadList slot="downloads" {downloads} {cancelDownload} />-->
</MMMainLayout>
{/if}
{/await}
</MMModal>

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import Container from '../../../Container.svelte'; import Container from '../../../Container.svelte';
import { modalStack } from './modal.store';
</script> </script>
<Container focusOnMount trapFocus class="fixed inset-0"> <Container focusOnMount trapFocus class="fixed inset-0" on:back={() => modalStack.closeTopmost()}>
<slot /> <slot />
</Container> </Container>

View File

@@ -2,19 +2,19 @@
import { modalStack, modalStackTop } from './modal.store'; import { modalStack, modalStackTop } from './modal.store';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
function handleShortcuts(event: KeyboardEvent) { // function handleShortcuts(event: KeyboardEvent) {
const top = $modalStackTop; // const top = $modalStackTop;
if ((event.key === 'Escape' || event.key === 'Back' || event.key === 'XF86Back') && top) { // if ((event.key === 'Escape' || event.key === 'Back' || event.key === 'XF86Back') && top) {
modalStack.close(top.id); // modalStack.close(top.id);
} // }
} // }
onDestroy(() => { onDestroy(() => {
modalStack.reset(); modalStack.reset();
}); });
</script> </script>
<svelte:window on:keydown={handleShortcuts} /> <!--<svelte:window on:keydown={handleShortcuts} />-->
<svelte:head> <svelte:head>
{#if $modalStackTop} {#if $modalStackTop}

View File

@@ -1,8 +1,10 @@
import type { ComponentType, SvelteComponentTyped } from 'svelte'; 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 SeasonMediaManagerModal from '../MediaManagerModal/SeasonMediaManagerModal.svelte';
import EpisodeMediaManagerModal from '../MediaManagerModal/EpisodeMediaManagerModal.svelte'; import EpisodeMediaManagerModal from '../MediaManagerModal/EpisodeMediaManagerModal.svelte';
import MovieMediaManagerModal from '../MediaManagerModal/MovieMediaManagerModal.svelte'; import MovieMediaManagerModal from '../MediaManagerModal/MovieMediaManagerModal.svelte';
import ConfirmDialog from '../Dialog/ConfirmDialog.svelte';
import { sonarrApi, type SonarrSeries } from '../../apis/sonarr/sonarr-api';
type ModalItem = { type ModalItem = {
id: symbol; id: symbol;
@@ -37,6 +39,13 @@ function createModalStack() {
items.set([]); items.set([]);
} }
function closeTopmost() {
const t = get(top);
if (t) {
close(t.id);
}
}
return { return {
subscribe: items.subscribe, subscribe: items.subscribe,
top: { top: {
@@ -45,15 +54,17 @@ function createModalStack() {
create, create,
close, close,
closeGroup, closeGroup,
closeTopmost,
reset reset
}; };
} }
export const modalStack = createModalStack(); export const modalStack = createModalStack();
export const modalStackTop = modalStack.top; export const modalStackTop = modalStack.top;
export const createModal = modalStack.create;
export const openSeasonMediaManager = (tmdbId: number, season: number) => export const openSeasonMediaManager = (sonarrItem: SonarrSeries, season: number) =>
modalStack.create(SeasonMediaManagerModal, { id: tmdbId, season }); modalStack.create(SeasonMediaManagerModal, { sonarrItem, season });
export const openEpisodeMediaManager = (tmdbId: number, season: number, episode: number) => export const openEpisodeMediaManager = (tmdbId: number, season: number, episode: number) =>
modalStack.create(EpisodeMediaManagerModal, { id: tmdbId, season, episode }); modalStack.create(EpisodeMediaManagerModal, { id: tmdbId, season, episode });

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { type EpisodeFileResource, sonarrApi } from '../../apis/sonarr/sonarr-api';
import ConfirmDialog from '../Dialog/ConfirmDialog.svelte';
export let modalId: symbol;
export let files: EpisodeFileResource[];
export let onComplete: () => void = () => {};
function handleDeleteSeason() {
return sonarrApi.deleteSonarrEpisodes(files.map((f) => f.id || -1)).then(() => onComplete());
}
</script>
<ConfirmDialog {modalId} confirm={handleDeleteSeason}>
<h1 slot="header">Delete Season Files?</h1>
<div>
Are you sure you want to delete all {files.length} file(s) from season {files[0]?.seasonNumber}?
</div>
</ConfirmDialog>

View File

@@ -18,13 +18,17 @@
import ScrollHelper from '../ScrollHelper.svelte'; import ScrollHelper from '../ScrollHelper.svelte';
import ManageSeasonCard from './ManageSeasonCard.svelte'; import ManageSeasonCard from './ManageSeasonCard.svelte';
import { TMDB_BACKDROP_SMALL } from '../../constants'; 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 { 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 id: number;
export let tmdbSeries: Readable<TmdbSeriesFull2 | undefined>; export let tmdbSeries: Readable<TmdbSeriesFull2 | undefined>;
export let jellyfinEpisodes: Promise<JellyfinItem[]>; export let jellyfinEpisodes: Promise<JellyfinItem[]>;
export let currentJellyfinEpisode: Promise<JellyfinItem | undefined>; export let currentJellyfinEpisode: Promise<JellyfinItem | undefined>;
export let handleRequestSeason: (season: number) => Promise<any>;
console.log('ID IS: ', id); console.log('ID IS: ', id);
@@ -60,14 +64,14 @@
} }
function handleMountCard(s: Selectable, episode: TmdbEpisode) { function handleMountCard(s: Selectable, episode: TmdbEpisode) {
currentJellyfinEpisode.then((currentEpisode) => { // currentJellyfinEpisode.then((currentEpisode) => {
if ( // if (
currentEpisode?.IndexNumber === episode.episode_number && // currentEpisode?.IndexNumber === episode.episode_number &&
currentEpisode?.ParentIndexNumber === episode.season_number // currentEpisode?.ParentIndexNumber === episode.season_number
) { // ) {
s.focus({ setFocusedElement: false, propagate: false }); // s.focus({ setFocusedElement: false, propagate: false });
} // }
}); // });
} }
</script> </script>
@@ -137,7 +141,7 @@
{/each} {/each}
<ManageSeasonCard <ManageSeasonCard
backdropUrl={TMDB_BACKDROP_SMALL + $tmdbSeries?.backdrop_path} backdropUrl={TMDB_BACKDROP_SMALL + $tmdbSeries?.backdrop_path}
on:clickOrSelect={() => openSeasonMediaManager(id, seasonIndex + 1)} on:clickOrSelect={() => handleRequestSeason(seasonIndex + 1)}
on:enter={scrollIntoView({ top: 92, bottom: 128 })} on:enter={scrollIntoView({ top: 92, bottom: 128 })}
/> />
{/if} {/if}

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import Dialog from '../Dialog/Dialog.svelte';
import type { EpisodeFileResource, SonarrEpisode } from '../../apis/sonarr/sonarr-api';
import Button from '../Button.svelte';
import Container from '../../../Container.svelte';
import { formatSize } from '../../utils';
import { Trash } from 'radix-icons-svelte';
export let file: EpisodeFileResource;
export let episode: SonarrEpisode | undefined;
$: backgroundUrl = episode?.images?.[0]?.remoteUrl;
</script>
<Dialog class="flex flex-col relative">
{#if backgroundUrl}
<div
class="absolute inset-0 bg-cover bg-center h-52"
style="background-image: url({backgroundUrl}); -webkit-mask-image: radial-gradient(at 90% 10%, hsla(0,0%,0%,1) 0px, transparent 70%);"
/>
{/if}
<div class="z-10">
{#if backgroundUrl}
<div class="h-24" />
{/if}
<h1 class="header2">{episode?.title}</h1>
<h2 class="header1 mb-4">Season {episode?.seasonNumber} Episode {episode?.episodeNumber}</h2>
<div
class="grid grid-cols-[1fr_min-content] font-medium mb-16
[&>*:nth-child(odd)]:text-secondary-300 [&>*:nth-child(even)]:text-right [&>*:nth-child(even)]:text-secondary-100 *:py-1"
>
<span class="border-b border-secondary-600">Runtime</span>
<span class="border-b border-secondary-600">{file.mediaInfo?.runTime}</span>
<span class="border-b border-secondary-600">Size on Disk</span>
<span class="border-b border-secondary-600">{formatSize(file.size || 0)}</span>
<span>Quality</span>
<span>{file.quality?.quality?.name}</span>
<!-- <span>Asd</span>-->
<!-- <span>Asd</span>-->
</div>
<Container class="flex flex-col space-y-4">
<Button type="secondary">
<Trash size={19} slot="icon" />
Delete
</Button>
</Container>
</div>
</Dialog>

View File

@@ -6,21 +6,24 @@
import { tmdbApi, type TmdbSeasonEpisode } from '../../apis/tmdb/tmdb-api'; import { tmdbApi, type TmdbSeasonEpisode } from '../../apis/tmdb/tmdb-api';
import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../../constants'; import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../../constants';
import classNames from 'classnames'; 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 { 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 Button from '../Button.svelte';
import { playerState } from '../VideoPlayer/VideoPlayer'; import { playerState } from '../VideoPlayer/VideoPlayer';
import { modalStack } from '../Modal/modal.store'; import { createModal, modalStack } from '../Modal/modal.store';
import { derived } from 'svelte/store'; import { derived, get, writable } from 'svelte/store';
import { scrollIntoView, useRegistrar } from '../../selectable'; import { scrollIntoView, useRegistrar } from '../../selectable';
import ScrollHelper from '../ScrollHelper.svelte'; import ScrollHelper from '../ScrollHelper.svelte';
import Carousel from '../Carousel/Carousel.svelte'; import Carousel from '../Carousel/Carousel.svelte';
import TmdbPersonCard from '../PersonCard/TmdbPersonCard.svelte'; import TmdbPersonCard from '../PersonCard/TmdbPersonCard.svelte';
import TmdbCard from '../Card/TmdbCard.svelte'; import TmdbCard from '../Card/TmdbCard.svelte';
import EpisodeGrid from './EpisodeGrid.svelte'; import EpisodeGrid from './EpisodeGrid.svelte';
import EpisodePage from '../../pages/EpisodePage.svelte'; import { formatSize } from '../../utils';
import SeriesMediaManagerModal from '../MediaManagerModal/SeasonMediaManagerModal.svelte'; 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; export let id: string;
@@ -28,11 +31,26 @@
tmdbApi.getTmdbSeries, tmdbApi.getTmdbSeries,
Number(id) Number(id)
); );
const { promise: sonarrItem } = useRequest(sonarrApi.getSeriesByTmdbId, Number(id)); let sonarrItem = sonarrApi.getSeriesByTmdbId(Number(id));
const jellyfinSeries = getJellyfinSeries(id);
const { promise: recommendations } = useRequest(tmdbApi.getSeriesRecommendations, 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( const jellyfinEpisodes = jellyfinSeries.then(
(s) => (s && jellyfinApi.getJellyfinEpisodes(s.Id)) || [] (s) => (s && jellyfinApi.getJellyfinEpisodes(s.Id)) || []
); );
@@ -41,17 +59,54 @@
items.find((i) => i.UserData?.Played === false) items.find((i) => i.UserData?.Played === false)
); );
let hideInterface = false;
const episodeCards = useRegistrar(); const episodeCards = useRegistrar();
let scrollTop: number; let scrollTop: number;
modalStack.top.subscribe((modal) => { // let hideInterface = false;
hideInterface = !!modal; // modalStack.top.subscribe((modal) => {
}); // hideInterface = !!modal;
// });
function getJellyfinSeries(id: string) { function getJellyfinSeries(id: string) {
return jellyfinApi.getLibraryItemFromTmdbId(id); 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');
}
});
}
</script> </script>
<DetachedPage let:handleGoBack let:registrar> <DetachedPage let:handleGoBack let:registrar>
@@ -75,7 +130,6 @@
?.map((i) => TMDB_IMAGES_ORIGINAL + i.file_path) ?.map((i) => TMDB_IMAGES_ORIGINAL + i.file_path)
.slice(0, 5) || [] .slice(0, 5) || []
)} )}
{hideInterface}
> >
<Container /> <Container />
<div class="h-full flex-1 flex flex-col justify-end"> <div class="h-full flex-1 flex flex-col justify-end">
@@ -118,7 +172,7 @@
</div> </div>
{/if} {/if}
{/await} {/await}
{#await Promise.all( [$sonarrItem, jellyfinSeries, jellyfinEpisodes, nextJellyfinEpisode] ) then [sonarrItem, jellyfinItem, jellyfinEpisodes, nextJellyfinEpisode]} {#await nextJellyfinEpisode then nextJellyfinEpisode}
<Container <Container
direction="horizontal" direction="horizontal"
class="flex mt-8" class="flex mt-8"
@@ -136,19 +190,13 @@
{nextJellyfinEpisode?.IndexNumber} {nextJellyfinEpisode?.IndexNumber}
<Play size={19} slot="icon" /> <Play size={19} slot="icon" />
</Button> </Button>
{/if} {:else}
<Button <Button class="mr-4" action={() => handleRequestSeason(1)}>
class="mr-4"
on:clickOrSelect={() =>
modalStack.create(SeriesMediaManagerModal, { id: Number(id) })}
>
{#if jellyfinItem}
Manage Media
{:else}
Request Request
{/if} <Plus size={19} slot="icon" />
<svelte:component this={jellyfinItem ? File : Download} size={19} slot="icon" /> </Button>
</Button> {/if}
{#if PLATFORM_WEB} {#if PLATFORM_WEB}
<Button class="mr-4"> <Button class="mr-4">
Open In TMDB Open In TMDB
@@ -166,16 +214,17 @@
</Container> </Container>
<div <div
class={classNames('transition-opacity', { class={classNames('transition-opacity', {
'opacity-0': hideInterface // 'opacity-0': hideInterface
})} })}
> >
<EpisodeGrid <EpisodeGrid
on:enter={scrollIntoView({ top: -32, bottom: 128 })} on:enter={scrollIntoView({ top: -32, bottom: 128 })}
on:mount={episodeCards.registrar}
id={Number(id)} id={Number(id)}
tmdbSeries={tmdbSeriesData} tmdbSeries={tmdbSeriesData}
{jellyfinEpisodes} {jellyfinEpisodes}
currentJellyfinEpisode={nextJellyfinEpisode} currentJellyfinEpisode={nextJellyfinEpisode}
on:mount={episodeCards.registrar} {handleRequestSeason}
/> />
<Container on:enter={scrollIntoView({ top: 0 })} class="pt-8"> <Container on:enter={scrollIntoView({ top: 0 })} class="pt-8">
{#await $tmdbSeries then series} {#await $tmdbSeries then series}
@@ -200,23 +249,23 @@
<h1 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">More Information</h1> <h1 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">More Information</h1>
<div class="text-zinc-300 font-medium text-lg flex flex-wrap"> <div class="text-zinc-300 font-medium text-lg flex flex-wrap">
<div class="flex-1"> <div class="flex-1">
<div class="border-l-2 border-zinc-300 pl-4 mb-8"> <div class="mb-8">
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Created By</h2> <h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Created By</h2>
{#each series?.created_by || [] as creator} {#each series?.created_by || [] as creator}
<div>{creator.name}</div> <div>{creator.name}</div>
{/each} {/each}
</div> </div>
<div class="border-l-2 border-zinc-300 pl-4 mb-8"> <div class="mb-8">
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Network</h2> <h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Network</h2>
<div>{series?.networks?.[0]?.name}</div> <div>{series?.networks?.[0]?.name}</div>
</div> </div>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<div class="border-l-2 border-zinc-300 pl-4 mb-8"> <div class="mb-8">
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Language</h2> <h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Language</h2>
<div>{series?.spoken_languages?.[0]?.name}</div> <div>{series?.spoken_languages?.[0]?.name}</div>
</div> </div>
<div class="border-l-2 border-zinc-300 pl-4 mb-8"> <div class="mb-8">
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Last Air Date</h2> <h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Last Air Date</h2>
<div>{series?.last_air_date}</div> <div>{series?.last_air_date}</div>
</div> </div>
@@ -224,6 +273,96 @@
</div> </div>
</Container> </Container>
{/await} {/await}
{#await Promise.all( [localFilesP, localFileSeasons, sonarrEpisodes] ) then [localFiles, seasons, episodes]}
{#if localFiles?.length}
<Container
class="flex-1 bg-secondary-950 pt-8 pb-16 px-32 flex flex-col"
on:enter={scrollIntoView({ top: 0 })}
>
<!-- <h1 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">Local Files</h1>-->
<div class="space-y-16">
{#each seasons as season}
{@const files = localFiles.filter((f) => f.seasonNumber === season)}
<div class="">
<div class="flex justify-between">
<h2 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">
Season {season} Files
</h2>
<!-- <Checkbox-->
<!-- checked={Object.keys(selectedFiles).length-->
<!-- ? Object.values(selectedFiles).every(({ file, selected }) => {-->
<!-- return file.seasonNumber === season ? selected : true;-->
<!-- })-->
<!-- : false}-->
<!-- on:change={({ detail }) => {-->
<!-- selectedFiles = Object.fromEntries(-->
<!-- Object.entries(selectedFiles).map(([key, value]) => {-->
<!-- if (value.file.seasonNumber === season) {-->
<!-- value.selected = detail;-->
<!-- }-->
<!-- return [key, value];-->
<!-- })-->
<!-- );-->
<!-- }}-->
<!-- />-->
</div>
<div class="grid grid-cols-2 gap-8">
{#each files as file}
{@const episode = episodes.find(
(e) => e.episodeFileId !== undefined && e.episodeFileId === file.id
)}
<Container
class={classNames(
'flex space-x-8 items-center text-zinc-300 font-medium',
'px-8 py-4 border-2 border-transparent rounded-xl',
{
'bg-secondary-800 focus-within:bg-primary-700 focus-within:border-primary-500': true,
'hover:bg-primary-700 hover:border-primary-500 cursor-pointer': true
// 'bg-primary-700 focus-within:border-primary-500': selected,
// 'bg-secondary-800 focus-within:border-zinc-300': !selected
}
)}
on:clickOrSelect={() =>
modalStack.create(FileDetailsDialog, { file, episode })}
focusOnClick
>
<div class="flex-1">
<h1 class="text-lg">
{episode?.episodeNumber}. {episode?.title}
</h1>
</div>
<div>
{file.mediaInfo?.runTime}
</div>
<div>
{formatSize(file.size || 0)}
</div>
<div>
{file.quality?.quality?.name}
</div>
</Container>
{/each}
</div>
<Container direction="horizontal" class="flex mt-8">
<Button
on:clickOrSelect={() =>
createModal(ConfirmDeleteSeasonDialog, {
files: files,
onComplete: () => (localFilesP = getLocalFiles())
})}
>
<Trash size={19} slot="icon" />
Delete Season Files
</Button>
</Container>
</div>
{/each}
</div>
</Container>
{/if}
{/await}
</div> </div>
</div> </div>
</DetachedPage> </DetachedPage>

View File

@@ -34,10 +34,9 @@
}); });
const selectIndex = (index: number) => () => { const selectIndex = (index: number) => () => {
if (index === activeIndex) { // if (index === activeIndex) {
if (get(selectable.hasFocusWithin)) Selectable.giveFocus('right'); // if (get(selectable.hasFocusWithin)) Selectable.giveFocus('right');
return; // }
}
selectable.focusChild(index); selectable.focusChild(index);
const path = const path =
{ {

View File

@@ -12,7 +12,7 @@
} }
:global(.row-wrapper-selected > ._table-cell) { :global(.row-wrapper-selected > ._table-cell) {
@apply bg-secondary-800; @apply bg-primary-900;
} }
:global(.row-wrapper > ._table-cell:first-child) { :global(.row-wrapper > ._table-cell:first-child) {

View File

@@ -5,7 +5,7 @@
<Container <Container
direction="horizontal" direction="horizontal"
on:enter on:enter
class="*:sticky *:top-0 *:bg-secondary-900 row-wrapper contents" class="*:sticky *:top-0 *:bg-primary-800 row-wrapper contents"
> >
<!-- <div class="absolute -inset-y-2 -inset-x-8 -z-10 bg-secondary-900" />--> <!-- <div class="absolute -inset-y-2 -inset-x-8 -z-10 bg-secondary-900" />-->
<slot /> <slot />

View File

@@ -15,9 +15,9 @@
on:clickOrSelect on:clickOrSelect
focusOnClick focusOnClick
class={classNames( 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
} }
)} )}
> >

View File

@@ -59,7 +59,6 @@
<!-- <HeroCarousel /> --> <!-- <HeroCarousel /> -->
<Container <Container
on:navigate={handleGoBack}
on:back={handleGoBack} on:back={handleGoBack}
on:mount={registrar} on:mount={registrar}
focusOnMount focusOnMount

View File

@@ -6,6 +6,15 @@ interface AuthenticationStoreData {
token?: string; token?: string;
serverBaseUrl?: string; serverBaseUrl?: string;
} }
interface UserStoreData {
user: ReiverrUser | null;
}
interface AppStateData extends AuthenticationStoreData {
user: ReiverrUser | null;
}
const authenticationStore = createLocalStorageStore<AuthenticationStoreData>( const authenticationStore = createLocalStorageStore<AuthenticationStoreData>(
'authentication-token', 'authentication-token',
{ {
@@ -15,15 +24,17 @@ const authenticationStore = createLocalStorageStore<AuthenticationStoreData>(
); );
function createAppState() { function createAppState() {
const userStore = writable<ReiverrUser | null>(undefined); const userStore = writable<UserStoreData>(undefined);
const combinedStore = derived([userStore, authenticationStore], ([$user, $auth]) => { const combinedStore = derived<[typeof userStore, typeof authenticationStore], AppStateData>(
return { [userStore, authenticationStore],
user: $user, ([user, auth]) => {
token: $auth.token, return {
serverBaseUrl: $auth.serverBaseUrl ...user,
}; ...auth
}); };
}
);
function setBaseUrl(serverBaseUrl: string | undefined = undefined) { function setBaseUrl(serverBaseUrl: string | undefined = undefined) {
authenticationStore.update((p) => ({ ...p, serverBaseUrl })); authenticationStore.update((p) => ({ ...p, serverBaseUrl }));
@@ -34,7 +45,7 @@ function createAppState() {
} }
function setUser(user: ReiverrUser | null) { function setUser(user: ReiverrUser | null) {
userStore.set(user); userStore.set({ user });
} }
function logOut() { function logOut() {
@@ -42,12 +53,21 @@ function createAppState() {
setToken(undefined); setToken(undefined);
} }
const ready = new Promise<AppStateData>((resolve) => {
combinedStore.subscribe((state) => {
if (state.token && state.serverBaseUrl && state.user !== undefined) {
resolve(state);
}
});
});
return { return {
subscribe: combinedStore.subscribe, subscribe: combinedStore.subscribe,
setBaseUrl, setBaseUrl,
setToken, setToken,
setUser, setUser,
logOut logOut,
ready
}; };
} }

View File

@@ -0,0 +1,24 @@
import { writable } from 'svelte/store';
import { sonarrApi, type SonarrRootFolder } from '../apis/sonarr/sonarr-api';
type SonarrServiceStore = ReturnType<typeof fetchSonarrService>;
async function fetchSonarrService() {
const rootFolders = sonarrApi.getRootFolders();
const qualityProfiles = sonarrApi.getQualityProfiles();
return {
rootFolders: await rootFolders,
qualityProfiles: await qualityProfiles
};
}
function useSonarrService() {
const sonarrService = writable<SonarrServiceStore>(fetchSonarrService());
return {
subscribe: sonarrService.subscribe
};
}
export const sonarrService = useSonarrService();

View File

@@ -20,17 +20,17 @@ export default {
'highlight-foreground': '#f6c304', 'highlight-foreground': '#f6c304',
'highlight-background': '#161517', 'highlight-background': '#161517',
primary: { primary: {
50: 'hsl(40, 80%, 95%)', //''#fcf9ea', 50: 'hsl(40, 60%, 95%)', //''#fcf9ea',
100: 'hsl(40, 80%, 90%)', //''#faefc7', 100: 'hsl(40, 60%, 90%)', //''#faefc7',
200: 'hsl(40, 80%, 80%)', //''#f6dc92', 200: 'hsl(40, 60%, 80%)', //''#f6dc92',
300: 'hsl(40, 80%, 70%)', //''#f0c254', 300: 'hsl(40, 60%, 70%)', //''#f0c254',
400: 'hsl(40, 80%, 65%)', //''#ebab2e', 400: 'hsl(40, 60%, 65%)', //''#ebab2e',
500: 'hsl(40, 80%, 55%)', //'#da9018', 500: 'hsl(40, 60%, 55%)', //'#da9018',
600: 'hsl(40, 80%, 24%)', //'#bc6e12', 600: 'hsl(40, 30%, 24%)', //'#bc6e12',
700: 'hsl(40, 80%, 18%)', //'#964e12', 700: 'hsl(40, 30%, 18%)', //'#964e12',
800: 'hsl(40, 80%, 12%)', //'#7d3f16', 800: 'hsl(40, 20%, 12%)', //'#7d3f16',
900: 'hsl(40, 80%, 7%)', //'#6a3419', 900: 'hsl(40, 20%, 8%)', //'#6a3419',
950: 'hsl(40, 80%, 4%)' //'#3e1a0a' 950: 'hsl(40, 20%, 4%)' //'#3e1a0a'
}, },
secondary: { secondary: {
50: 'hsl(40, 12%, 95%)', 50: 'hsl(40, 12%, 95%)',