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;
}
.header1 {
@apply font-medium text-lg text-secondary-300;
}
.header2 {
@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>>;
}
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>>;

View File

@@ -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"];
};
}

View File

@@ -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 (

View File

@@ -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>

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">
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)}

View File

@@ -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>

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">
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">

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 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>

View File

@@ -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>

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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 });

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 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}

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 { 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>

View File

@@ -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 =
{

View File

@@ -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) {

View File

@@ -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 />

View File

@@ -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
}
)}
>

View File

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

View File

@@ -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
};
}

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-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%)',