feat: Add to sonarr dialog, reworked requests
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -4,6 +4,10 @@ export interface Api<Paths extends NonNullable<unknown>> {
|
||||
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>> {
|
||||
// protected abstract baseUrl: string;
|
||||
// protected abstract client: ReturnType<typeof createClient<Paths>>;
|
||||
|
||||
8
src/lib/apis/reiverr/reiverr.generated.d.ts
vendored
8
src/lib/apis/reiverr/reiverr.generated.d.ts
vendored
@@ -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"];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<Record<number, number>>('tmdb-to-tvdb-cache', {});
|
||||
|
||||
export class SonarrApi implements Api<paths> {
|
||||
getClient() {
|
||||
export class SonarrApi implements ApiAsync<paths> {
|
||||
async getClient() {
|
||||
await appState.ready;
|
||||
const sonarrSettings = this.getSettings();
|
||||
const baseUrl = this.getBaseUrl();
|
||||
const apiKey = sonarrSettings?.apiKey;
|
||||
@@ -83,7 +90,9 @@ export class SonarrApi implements Api<paths> {
|
||||
};
|
||||
|
||||
getSeriesById = (id: number): Promise<SonarrSeries | undefined> =>
|
||||
this.getClient()
|
||||
this.getClient().then(
|
||||
(client) =>
|
||||
client
|
||||
?.GET('/api/v3/series/{id}', {
|
||||
params: {
|
||||
path: {
|
||||
@@ -91,17 +100,23 @@ export class SonarrApi implements Api<paths> {
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data) || Promise.resolve(undefined);
|
||||
.then((r) => r.data) || Promise.resolve(undefined)
|
||||
);
|
||||
|
||||
getAllSeries = (): Promise<SonarrSeries[]> =>
|
||||
this.getClient()
|
||||
this.getClient().then(
|
||||
(client) =>
|
||||
client
|
||||
?.GET('/api/v3/series', {
|
||||
params: {}
|
||||
})
|
||||
.then((r) => r.data || []) || Promise.resolve([]);
|
||||
.then((r) => r.data || []) || Promise.resolve([])
|
||||
);
|
||||
|
||||
getSonarrSeriesByTvdbId = (tvdbId: number): Promise<SonarrSeries | undefined> =>
|
||||
this.getClient()
|
||||
this.getClient().then(
|
||||
(client) =>
|
||||
client
|
||||
?.GET('/api/v3/series', {
|
||||
params: {
|
||||
query: {
|
||||
@@ -109,7 +124,8 @@ export class SonarrApi implements Api<paths> {
|
||||
}
|
||||
}
|
||||
})
|
||||
.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) =>
|
||||
this.tmdbToTvdb(tmdbId).then((tvdbId) =>
|
||||
@@ -117,11 +133,19 @@ export class SonarrApi implements Api<paths> {
|
||||
);
|
||||
|
||||
getDiskSpace = (): Promise<DiskSpaceInfo[]> =>
|
||||
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<SonarrSeries | undefined> => {
|
||||
const tmdbSeries = await getTmdbSeries(tmdbId);
|
||||
|
||||
if (!tmdbSeries || !tmdbSeries.external_ids.tvdb_id || !tmdbSeries.name)
|
||||
@@ -130,28 +154,31 @@ export class SonarrApi implements Api<paths> {
|
||||
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()
|
||||
return this.getClient().then((client) =>
|
||||
client
|
||||
?.POST('/api/v3/series', {
|
||||
params: {},
|
||||
body: options
|
||||
})
|
||||
.then((r) => r.data);
|
||||
.then((r) => r.data)
|
||||
);
|
||||
};
|
||||
|
||||
cancelDownload = async (downloadId: number) => {
|
||||
const deleteResponse = await this.getClient()
|
||||
const deleteResponse = await this.getClient().then((client) =>
|
||||
client
|
||||
?.DELETE('/api/v3/queue/{id}', {
|
||||
params: {
|
||||
path: {
|
||||
@@ -163,22 +190,28 @@ export class SonarrApi implements Api<paths> {
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => log(r));
|
||||
.then((r) => log(r))
|
||||
);
|
||||
|
||||
return !!deleteResponse?.response.ok;
|
||||
};
|
||||
|
||||
cancelDownloads = async (downloadIds: number[]) =>
|
||||
this.getClient()
|
||||
this.getClient().then(
|
||||
(client) =>
|
||||
client
|
||||
?.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) =>
|
||||
this.getClient()
|
||||
this.getClient().then(
|
||||
(client) =>
|
||||
client
|
||||
?.POST('/api/v3/release', {
|
||||
params: {},
|
||||
body: {
|
||||
@@ -186,10 +219,13 @@ export class SonarrApi implements Api<paths> {
|
||||
guid
|
||||
}
|
||||
})
|
||||
.then((res) => res.response.ok) || Promise.resolve(false);
|
||||
.then((res) => res.response.ok) || Promise.resolve(false)
|
||||
);
|
||||
|
||||
deleteSonarrEpisode = (id: number) =>
|
||||
this.getClient()
|
||||
this.getClient().then(
|
||||
(client) =>
|
||||
client
|
||||
?.DELETE('/api/v3/episodefile/{id}', {
|
||||
params: {
|
||||
path: {
|
||||
@@ -197,19 +233,25 @@ export class SonarrApi implements Api<paths> {
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((res) => res.response.ok) || Promise.resolve(false);
|
||||
.then((res) => res.response.ok) || Promise.resolve(false)
|
||||
);
|
||||
|
||||
deleteSonarrEpisodes = (ids: number[]) =>
|
||||
this.getClient()
|
||||
this.getClient().then(
|
||||
(client) =>
|
||||
client
|
||||
?.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[]> =>
|
||||
this.getClient()
|
||||
this.getClient().then(
|
||||
(client) =>
|
||||
client
|
||||
?.GET('/api/v3/queue', {
|
||||
params: {
|
||||
query: {
|
||||
@@ -223,7 +265,8 @@ export class SonarrApi implements Api<paths> {
|
||||
(r.data?.records?.filter(
|
||||
(record) => record.episode && record.series
|
||||
) as EpisodeDownload[]) || []
|
||||
) || Promise.resolve([]);
|
||||
) || Promise.resolve([])
|
||||
);
|
||||
|
||||
getDownloadsBySeriesId = (sonarrId: number) =>
|
||||
this.getSonarrDownloads().then((downloads) =>
|
||||
@@ -231,7 +274,9 @@ export class SonarrApi implements Api<paths> {
|
||||
) || Promise.resolve([]);
|
||||
|
||||
removeFromSonarr = (id: number): Promise<boolean> =>
|
||||
this.getClient()
|
||||
this.getClient().then(
|
||||
(client) =>
|
||||
client
|
||||
?.DELETE('/api/v3/series/{id}', {
|
||||
params: {
|
||||
path: {
|
||||
@@ -239,10 +284,13 @@ export class SonarrApi implements Api<paths> {
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((res) => res.response.ok) || Promise.resolve(false);
|
||||
.then((res) => res.response.ok) || Promise.resolve(false)
|
||||
);
|
||||
|
||||
getFilesBySeriesId = (seriesId: number): Promise<EpisodeFileResource[]> =>
|
||||
this.getClient()
|
||||
this.getClient().then(
|
||||
(client) =>
|
||||
client
|
||||
?.GET('/api/v3/episodefile', {
|
||||
params: {
|
||||
query: {
|
||||
@@ -250,7 +298,66 @@ export class SonarrApi implements Api<paths> {
|
||||
}
|
||||
}
|
||||
})
|
||||
.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) => {
|
||||
// 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 (
|
||||
baseUrl: string | undefined = undefined,
|
||||
apiKey: string | undefined = undefined
|
||||
@@ -335,19 +404,16 @@ export class SonarrApi implements Api<paths> {
|
||||
.then((res) => res.status === 200)
|
||||
.catch(() => false);
|
||||
|
||||
getSonarrRootFolders = async (
|
||||
_getSonarrRootFolders = async (
|
||||
baseUrl: string | undefined = undefined,
|
||||
apiKey: string | undefined = undefined
|
||||
) =>
|
||||
axios
|
||||
.get<components['schemas']['RootFolderResource'][]>(
|
||||
(baseUrl || this.getBaseUrl()) + '/api/v3/rootFolder',
|
||||
{
|
||||
.get<SonarrRootFolder[]>((baseUrl || this.getBaseUrl()) + '/api/v3/rootFolder', {
|
||||
headers: {
|
||||
'X-Api-Key': apiKey || this.getApiKey()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
.then((res) => res.data || []);
|
||||
|
||||
getSonarrQualityProfiles = async (
|
||||
|
||||
@@ -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<any>) | null = null;
|
||||
let actionIsFetching = false;
|
||||
$: _disabled = disabled || actionIsFetching;
|
||||
|
||||
let hasFocus: Readable<boolean>;
|
||||
|
||||
const dispatch = createEventDispatcher<{ clickOrSelect: null }>();
|
||||
|
||||
function handleClickOrSelect() {
|
||||
if (action) {
|
||||
actionIsFetching = true;
|
||||
action().then(() => (actionIsFetching = false));
|
||||
}
|
||||
|
||||
dispatch('clickOrSelect');
|
||||
}
|
||||
</script>
|
||||
|
||||
<AnimatedSelection hasFocus={$hasFocus}>
|
||||
@@ -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}
|
||||
>
|
||||
<div
|
||||
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':
|
||||
type === 'secondary',
|
||||
'bg-primary-500 text-secondary-950': type === 'secondary' && $hasFocus,
|
||||
'group-hover:bg-primary-500 group-hover:text-secondary-950': type === 'secondary'
|
||||
})}
|
||||
>
|
||||
<div class="flex-1 text-center text-nowrap flex items-center justify-center">
|
||||
{#if $$slots.icon}
|
||||
<div class="mr-2">
|
||||
<slot name="icon" />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-1 text-center text-nowrap">
|
||||
<slot {hasFocus} />
|
||||
</div>
|
||||
{#if $$slots['icon-after']}
|
||||
<div class="ml-2">
|
||||
<slot name="icon-after" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</AnimatedSelection>
|
||||
|
||||
59
src/lib/components/Checkbox.svelte
Normal file
59
src/lib/components/Checkbox.svelte
Normal 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>
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Container from '../../../Container.svelte';
|
||||
import Button from '../Button.svelte';
|
||||
import Modal from '../Modal/Modal.svelte';
|
||||
import { modalStack } from '../Modal/modal.store';
|
||||
import Dialog from './Dialog.svelte';
|
||||
|
||||
@@ -27,20 +26,15 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog {modalId}>
|
||||
<div class="text-2xl font-semibold tracking-wide mb-2 text-secondary-100">
|
||||
<Dialog>
|
||||
<div class="header2 mb-4">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div class="font-medium text-secondary-300 mb-8">
|
||||
<slot />
|
||||
</div>
|
||||
<Container direction="horizontal" class="flex">
|
||||
<Button
|
||||
type="secondary"
|
||||
disabled={fetching}
|
||||
on:clickOrSelect={() => handleAction(confirm)}
|
||||
class="mr-4"
|
||||
>
|
||||
<Container class="flex flex-col space-y-4">
|
||||
<Button type="secondary" disabled={fetching} on:clickOrSelect={() => handleAction(confirm)}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button type="secondary" disabled={fetching} on:clickOrSelect={() => handleAction(cancel)}
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
<script lang="ts">
|
||||
import Modal from '../Modal/Modal.svelte';
|
||||
import classNames from 'classnames';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
export let size: 'sm' | 'full' = 'sm';
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
<div class="h-full flex items-center justify-center bg-secondary-950/75 py-20">
|
||||
<Modal on:back>
|
||||
<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="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 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 />
|
||||
</div>
|
||||
|
||||
21
src/lib/components/Dialog/FullScreenDialog.svelte
Normal file
21
src/lib/components/Dialog/FullScreenDialog.svelte
Normal 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>
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { sonarrApi } from '../../apis/sonarr/sonarr-api';
|
||||
import MMMainLayout from './MMMainLayout.svelte';
|
||||
import MMAddToSonarr from './MMAddToSonarr.svelte';
|
||||
import MMModal from './MMModal.svelte';
|
||||
import ReleaseList from './Releases/MMReleasesTab.svelte';
|
||||
import DownloadList from '../MediaManager/DownloadList.svelte';
|
||||
@@ -93,7 +92,7 @@
|
||||
<MMModal {modalId} {hidden}>
|
||||
{#await sonarrEpisode then sonarrEpisode}
|
||||
{#if !sonarrEpisode}
|
||||
<MMAddToSonarr />
|
||||
<!-- <MMAddToSonarr />-->
|
||||
{:else}
|
||||
<div class="pt-20 h-screen flex flex-col">
|
||||
<MMTitle class="mb-32 mx-32">
|
||||
|
||||
276
src/lib/components/MediaManagerModal/MMAddToSonarrDialog.svelte
Normal file
276
src/lib/components/MediaManagerModal/MMAddToSonarrDialog.svelte
Normal 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>
|
||||
@@ -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';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-screen">
|
||||
@@ -19,66 +19,7 @@
|
||||
<slot name="downloads" />
|
||||
</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
|
||||
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" />
|
||||
<slot />
|
||||
</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 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>
|
||||
|
||||
@@ -2,20 +2,14 @@
|
||||
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;
|
||||
</script>
|
||||
|
||||
<Container
|
||||
on:navigate={({ detail }) => {
|
||||
if (detail.direction === 'left' && detail.willLeaveContainer) {
|
||||
modalStack.close(modalId);
|
||||
detail.preventNavigation();
|
||||
}
|
||||
}}
|
||||
focusOnMount
|
||||
trapFocus
|
||||
<Modal>
|
||||
<div
|
||||
class={classNames(
|
||||
'fixed inset-0 overflow-hidden',
|
||||
{
|
||||
@@ -25,10 +19,10 @@
|
||||
// 'bg-[radial-gradient(150%_50%_at_50%_-25%,_#DFAA2BF0_0%,_#073AFF00_100%),linear-gradient(0deg,_#1A1914FF_0%,_#1A1914FF_0%)]'
|
||||
'bg-secondary-900'
|
||||
)}
|
||||
canFocusEmpty
|
||||
>
|
||||
<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 />
|
||||
</Container>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<div>select season</div>
|
||||
@@ -1,8 +1,8 @@
|
||||
<div {...$$restProps}>
|
||||
<div class="text-4xl font-semibold mb-2">
|
||||
<div class="header4">
|
||||
<slot name="title" />
|
||||
</div>
|
||||
<div class="text-zinc-300 font-medium text-xl">
|
||||
<div class="header1">
|
||||
<slot name="subtitle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import MMMainLayout from './MMMainLayout.svelte';
|
||||
import MMAddToSonarr from './MMAddToSonarr.svelte';
|
||||
// import MMAddToSonarr from './MMAddToSonarr.svelte';
|
||||
import MMModal from './MMModal.svelte';
|
||||
import ReleaseList from './Releases/MMReleasesTab.svelte';
|
||||
import DownloadList from '../MediaManager/DownloadList.svelte';
|
||||
@@ -25,7 +25,7 @@
|
||||
<MMModal {modalId} {hidden}>
|
||||
{#await radarrItem then movie}
|
||||
{#if !movie}
|
||||
<MMAddToSonarr />
|
||||
<!-- <MMAddToSonarr />-->
|
||||
{:else}
|
||||
<MMMainLayout>
|
||||
<h1 slot="title">{movie?.title}</h1>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import type { GrabReleaseFn } from '../MediaManagerModal';
|
||||
import Container from '../../../../Container.svelte';
|
||||
import TableHeaderCell from '../../Table/TableHeaderCell.svelte';
|
||||
import MMTitle from '../MMTitle.svelte';
|
||||
|
||||
type Release = RadarrRelease | SonarrRelease;
|
||||
|
||||
@@ -62,6 +63,15 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Container trapFocus class="py-8 h-full flex flex-col">
|
||||
<h1 class="header4 mx-12">
|
||||
<slot name="title" />
|
||||
</h1>
|
||||
<h2 class="header1 mx-12 mb-8">
|
||||
<slot name="subtitle" />
|
||||
</h2>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hide">
|
||||
{#await releases}
|
||||
{#each new Array(5) as _, index}
|
||||
<div class="flex-1 my-2">
|
||||
@@ -90,7 +100,7 @@
|
||||
<TableHeaderCell />
|
||||
</TableHeaderRow>
|
||||
|
||||
<Container class="contents" focusedChild>
|
||||
<Container class="contents" focusOnMount>
|
||||
{#each getRecommendedReleases(releases).sort(getSortFn(sortBy, sortDirection)) as release, index}
|
||||
<MMReleaseListRow {release} {grabRelease} />
|
||||
{/each}
|
||||
@@ -105,3 +115,5 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -1,73 +1,36 @@
|
||||
<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 MMAddToSonarr from './MMAddToSonarr.svelte';
|
||||
import MMModal from './MMModal.svelte';
|
||||
import MMReleasesTab from './Releases/MMReleasesTab.svelte';
|
||||
import MMLocalFilesTab from './LocalFiles/MMLocalFilesTab.svelte';
|
||||
import type {
|
||||
CancelDownloadFn,
|
||||
CancelDownloadsFn,
|
||||
DeleteFileFn,
|
||||
DeleteFilesFn,
|
||||
GrabReleaseFn
|
||||
} from './MediaManagerModal';
|
||||
import type { Release } from '../../apis/combined-types';
|
||||
import type { GrabReleaseFn } from './MediaManagerModal';
|
||||
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;
|
||||
export let season: number | null = null;
|
||||
export let sonarrItem: SonarrSeries;
|
||||
export let modalId: symbol;
|
||||
export let hidden: boolean;
|
||||
export let onGrabRelease: (release: Release) => void = () => {};
|
||||
|
||||
const sonarrItem = sonarrApi.getSeriesByTmdbId(id);
|
||||
|
||||
let releases: Promise<Release[]> = getReleases();
|
||||
let files = getLocalFiles();
|
||||
let downloads = getDownloads();
|
||||
$: releases = season !== null ? getReleases(season) : null;
|
||||
|
||||
let refreshDownloadsTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
const grabRelease: GrabReleaseFn = (release) =>
|
||||
sonarrApi.downloadSonarrRelease(release.guid || '', release.indexerId || -1).then((r) => {
|
||||
refreshDownloadsTimeout = setTimeout(() => {
|
||||
downloads = getDownloads();
|
||||
}, 8000);
|
||||
onGrabRelease(release);
|
||||
return r;
|
||||
});
|
||||
|
||||
const deleteFile: DeleteFileFn = (...args) =>
|
||||
sonarrApi.deleteSonarrEpisode(...args).then((r) => {
|
||||
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 getReleases(season: number) {
|
||||
return sonarrApi.getSeasonReleases(sonarrItem.id || -1, season);
|
||||
}
|
||||
|
||||
function getLocalFiles() {
|
||||
return sonarrItem.then((si) => sonarrApi.getFilesBySeriesId(si?.id || -1));
|
||||
}
|
||||
|
||||
function getDownloads() {
|
||||
return sonarrItem
|
||||
.then((si) => sonarrApi.getDownloadsBySeriesId(si?.id || -1))
|
||||
function getDownloads(season: number) {
|
||||
return sonarrApi
|
||||
.getDownloadsBySeriesId(sonarrItem.id || -1)
|
||||
.then((ds) => ds.filter((d) => d.episode?.seasonNumber === season));
|
||||
}
|
||||
|
||||
@@ -76,26 +39,13 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<MMModal {modalId} {hidden}>
|
||||
{#await sonarrItem then series}
|
||||
{#if !series}
|
||||
<MMAddToSonarr />
|
||||
{:else}
|
||||
<MMMainLayout>
|
||||
<h1 slot="title">{series?.title}</h1>
|
||||
<h2 slot="subtitle">Season {season} Packs</h2>
|
||||
<MMReleasesTab slot="releases" {releases} {grabRelease} />
|
||||
<MMLocalFilesTab
|
||||
slot="local-files"
|
||||
{files}
|
||||
{deleteFile}
|
||||
{deleteFiles}
|
||||
{downloads}
|
||||
{cancelDownload}
|
||||
{cancelDownloads}
|
||||
/>
|
||||
<!-- <DownloadList slot="downloads" {downloads} {cancelDownload} />-->
|
||||
</MMMainLayout>
|
||||
<Dialog size="full" {modalId} {hidden}>
|
||||
{#if !season}
|
||||
<MMSeasonSelectTab />
|
||||
{:else if releases}
|
||||
<MMReleasesTab {releases} {grabRelease}>
|
||||
<h1 slot="title">{sonarrItem?.title}</h1>
|
||||
<h2 slot="subtitle">Season {season} Releases</h2>
|
||||
</MMReleasesTab>
|
||||
{/if}
|
||||
{/await}
|
||||
</MMModal>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Container from '../../../Container.svelte';
|
||||
import { modalStack } from './modal.store';
|
||||
</script>
|
||||
|
||||
<Container focusOnMount trapFocus class="fixed inset-0">
|
||||
<Container focusOnMount trapFocus class="fixed inset-0" on:back={() => modalStack.closeTopmost()}>
|
||||
<slot />
|
||||
</Container>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleShortcuts} />
|
||||
<!--<svelte:window on:keydown={handleShortcuts} />-->
|
||||
|
||||
<svelte:head>
|
||||
{#if $modalStackTop}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
@@ -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<TmdbSeriesFull2 | undefined>;
|
||||
export let jellyfinEpisodes: Promise<JellyfinItem[]>;
|
||||
export let currentJellyfinEpisode: Promise<JellyfinItem | undefined>;
|
||||
export let handleRequestSeason: (season: number) => Promise<any>;
|
||||
|
||||
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 });
|
||||
// }
|
||||
// });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -137,7 +141,7 @@
|
||||
{/each}
|
||||
<ManageSeasonCard
|
||||
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 })}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
48
src/lib/components/SeriesPage/FileDetailsDialog.svelte
Normal file
48
src/lib/components/SeriesPage/FileDetailsDialog.svelte
Normal 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>
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<DetachedPage let:handleGoBack let:registrar>
|
||||
@@ -75,7 +130,6 @@
|
||||
?.map((i) => TMDB_IMAGES_ORIGINAL + i.file_path)
|
||||
.slice(0, 5) || []
|
||||
)}
|
||||
{hideInterface}
|
||||
>
|
||||
<Container />
|
||||
<div class="h-full flex-1 flex flex-col justify-end">
|
||||
@@ -118,7 +172,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
{#await Promise.all( [$sonarrItem, jellyfinSeries, jellyfinEpisodes, nextJellyfinEpisode] ) then [sonarrItem, jellyfinItem, jellyfinEpisodes, nextJellyfinEpisode]}
|
||||
{#await nextJellyfinEpisode then nextJellyfinEpisode}
|
||||
<Container
|
||||
direction="horizontal"
|
||||
class="flex mt-8"
|
||||
@@ -136,19 +190,13 @@
|
||||
{nextJellyfinEpisode?.IndexNumber}
|
||||
<Play size={19} slot="icon" />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
class="mr-4"
|
||||
on:clickOrSelect={() =>
|
||||
modalStack.create(SeriesMediaManagerModal, { id: Number(id) })}
|
||||
>
|
||||
{#if jellyfinItem}
|
||||
Manage Media
|
||||
{:else}
|
||||
<Button class="mr-4" action={() => handleRequestSeason(1)}>
|
||||
Request
|
||||
{/if}
|
||||
<svelte:component this={jellyfinItem ? File : Download} size={19} slot="icon" />
|
||||
<Plus size={19} slot="icon" />
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if PLATFORM_WEB}
|
||||
<Button class="mr-4">
|
||||
Open In TMDB
|
||||
@@ -166,16 +214,17 @@
|
||||
</Container>
|
||||
<div
|
||||
class={classNames('transition-opacity', {
|
||||
'opacity-0': hideInterface
|
||||
// 'opacity-0': hideInterface
|
||||
})}
|
||||
>
|
||||
<EpisodeGrid
|
||||
on:enter={scrollIntoView({ top: -32, bottom: 128 })}
|
||||
on:mount={episodeCards.registrar}
|
||||
id={Number(id)}
|
||||
tmdbSeries={tmdbSeriesData}
|
||||
{jellyfinEpisodes}
|
||||
currentJellyfinEpisode={nextJellyfinEpisode}
|
||||
on:mount={episodeCards.registrar}
|
||||
{handleRequestSeason}
|
||||
/>
|
||||
<Container on:enter={scrollIntoView({ top: 0 })} class="pt-8">
|
||||
{#await $tmdbSeries then series}
|
||||
@@ -200,23 +249,23 @@
|
||||
<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="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>
|
||||
{#each series?.created_by || [] as creator}
|
||||
<div>{creator.name}</div>
|
||||
{/each}
|
||||
</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>
|
||||
<div>{series?.networks?.[0]?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div>{series?.spoken_languages?.[0]?.name}</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>
|
||||
<div>{series?.last_air_date}</div>
|
||||
</div>
|
||||
@@ -224,6 +273,96 @@
|
||||
</div>
|
||||
</Container>
|
||||
{/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>
|
||||
</DetachedPage>
|
||||
|
||||
@@ -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 =
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<Container
|
||||
direction="horizontal"
|
||||
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" />-->
|
||||
<slot />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
<!-- <HeroCarousel /> -->
|
||||
|
||||
<Container
|
||||
on:navigate={handleGoBack}
|
||||
on:back={handleGoBack}
|
||||
on:mount={registrar}
|
||||
focusOnMount
|
||||
|
||||
@@ -6,6 +6,15 @@ interface AuthenticationStoreData {
|
||||
token?: string;
|
||||
serverBaseUrl?: string;
|
||||
}
|
||||
|
||||
interface UserStoreData {
|
||||
user: ReiverrUser | null;
|
||||
}
|
||||
|
||||
interface AppStateData extends AuthenticationStoreData {
|
||||
user: ReiverrUser | null;
|
||||
}
|
||||
|
||||
const authenticationStore = createLocalStorageStore<AuthenticationStoreData>(
|
||||
'authentication-token',
|
||||
{
|
||||
@@ -15,15 +24,17 @@ const authenticationStore = createLocalStorageStore<AuthenticationStoreData>(
|
||||
);
|
||||
|
||||
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>(
|
||||
[userStore, authenticationStore],
|
||||
([user, auth]) => {
|
||||
return {
|
||||
user: $user,
|
||||
token: $auth.token,
|
||||
serverBaseUrl: $auth.serverBaseUrl
|
||||
...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<AppStateData>((resolve) => {
|
||||
combinedStore.subscribe((state) => {
|
||||
if (state.token && state.serverBaseUrl && state.user !== undefined) {
|
||||
resolve(state);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe: combinedStore.subscribe,
|
||||
setBaseUrl,
|
||||
setToken,
|
||||
setUser,
|
||||
logOut
|
||||
logOut,
|
||||
ready
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
24
src/lib/stores/sonarr-service.store.ts
Normal file
24
src/lib/stores/sonarr-service.store.ts
Normal 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();
|
||||
@@ -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%)',
|
||||
|
||||
Reference in New Issue
Block a user