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;
|
@apply focus-within:outline outline-2 outline-[#f0cd6dc2] outline-offset-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header1 {
|
||||||
|
@apply font-medium text-lg text-secondary-300;
|
||||||
|
}
|
||||||
|
|
||||||
.header2 {
|
.header2 {
|
||||||
@apply font-semibold text-2xl text-secondary-100;
|
@apply font-semibold text-2xl text-secondary-100;
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ export interface Api<Paths extends NonNullable<unknown>> {
|
|||||||
getClient(): ReturnType<typeof createClient<Paths>>;
|
getClient(): ReturnType<typeof createClient<Paths>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiAsync<Paths extends NonNullable<unknown>> {
|
||||||
|
getClient(): Promise<ReturnType<typeof createClient<Paths>>>;
|
||||||
|
}
|
||||||
|
|
||||||
// export abstract class Api<Paths extends NonNullable<unknown>> {
|
// export abstract class Api<Paths extends NonNullable<unknown>> {
|
||||||
// protected abstract baseUrl: string;
|
// protected abstract baseUrl: string;
|
||||||
// protected abstract client: ReturnType<typeof createClient<Paths>>;
|
// protected abstract client: ReturnType<typeof createClient<Paths>>;
|
||||||
|
|||||||
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 {
|
export interface paths {
|
||||||
"/user": {
|
"/api/user": {
|
||||||
get: operations["UserController_getProfile"];
|
get: operations["UserController_getProfile"];
|
||||||
post: operations["UserController_create"];
|
post: operations["UserController_create"];
|
||||||
};
|
};
|
||||||
"/user/{id}": {
|
"/api/user/{id}": {
|
||||||
get: operations["UserController_findById"];
|
get: operations["UserController_findById"];
|
||||||
};
|
};
|
||||||
"/auth": {
|
"/api/auth": {
|
||||||
post: operations["AuthController_signIn"];
|
post: operations["AuthController_signIn"];
|
||||||
};
|
};
|
||||||
"/": {
|
"/api": {
|
||||||
get: operations["AppController_getHello"];
|
get: operations["AppController_getHello"];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,24 @@ import { get } from 'svelte/store';
|
|||||||
import { getTmdbSeries, tmdbApi } from '../tmdb/tmdb-api';
|
import { getTmdbSeries, tmdbApi } from '../tmdb/tmdb-api';
|
||||||
import type { components, paths } from './sonarr.generated';
|
import type { components, paths } from './sonarr.generated';
|
||||||
import { log } from '../../utils';
|
import { log } from '../../utils';
|
||||||
import type { Api } from '../api.interface';
|
import type { Api, ApiAsync } from '../api.interface';
|
||||||
import { appState } from '../../stores/app-state.store';
|
import { appState } from '../../stores/app-state.store';
|
||||||
import { createLocalStorageStore } from '../../stores/localstorage.store';
|
import { createLocalStorageStore } from '../../stores/localstorage.store';
|
||||||
|
|
||||||
|
export const sonarrMonitorOptions = [
|
||||||
|
'unknown',
|
||||||
|
'all',
|
||||||
|
'future',
|
||||||
|
'missing',
|
||||||
|
'existing',
|
||||||
|
'firstSeason',
|
||||||
|
'latestSeason',
|
||||||
|
'pilot',
|
||||||
|
'monitorSpecials',
|
||||||
|
'unmonitorSpecials',
|
||||||
|
'none'
|
||||||
|
] as const;
|
||||||
|
|
||||||
export type SonarrSeries = components['schemas']['SeriesResource'];
|
export type SonarrSeries = components['schemas']['SeriesResource'];
|
||||||
export type SonarrSeason = components['schemas']['SeasonResource'];
|
export type SonarrSeason = components['schemas']['SeasonResource'];
|
||||||
export type SonarrRelease = components['schemas']['ReleaseResource'];
|
export type SonarrRelease = components['schemas']['ReleaseResource'];
|
||||||
@@ -15,6 +29,9 @@ export type EpisodeDownload = components['schemas']['QueueResource'] & { series:
|
|||||||
export type DiskSpaceInfo = components['schemas']['DiskSpaceResource'];
|
export type DiskSpaceInfo = components['schemas']['DiskSpaceResource'];
|
||||||
export type SonarrEpisode = components['schemas']['EpisodeResource'];
|
export type SonarrEpisode = components['schemas']['EpisodeResource'];
|
||||||
export type EpisodeFileResource = components['schemas']['EpisodeFileResource'];
|
export type EpisodeFileResource = components['schemas']['EpisodeFileResource'];
|
||||||
|
export type SonarrRootFolder = components['schemas']['RootFolderResource'];
|
||||||
|
export type SonarrQualityProfile = components['schemas']['QualityProfileResource'];
|
||||||
|
export type SonarrMonitorOptions = (typeof sonarrMonitorOptions)[number];
|
||||||
|
|
||||||
export interface SonarrSeriesOptions {
|
export interface SonarrSeriesOptions {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -25,18 +42,7 @@ export interface SonarrSeriesOptions {
|
|||||||
tvdbId: number;
|
tvdbId: number;
|
||||||
rootFolderPath: string;
|
rootFolderPath: string;
|
||||||
addOptions: {
|
addOptions: {
|
||||||
monitor:
|
monitor: SonarrMonitorOptions;
|
||||||
| 'unknown'
|
|
||||||
| 'all'
|
|
||||||
| 'future'
|
|
||||||
| 'missing'
|
|
||||||
| 'existing'
|
|
||||||
| 'firstSeason'
|
|
||||||
| 'latestSeason'
|
|
||||||
| 'pilot'
|
|
||||||
| 'monitorSpecials'
|
|
||||||
| 'unmonitorSpecials'
|
|
||||||
| 'none';
|
|
||||||
searchForMissingEpisodes: boolean;
|
searchForMissingEpisodes: boolean;
|
||||||
searchForCutoffUnmetEpisodes: boolean;
|
searchForCutoffUnmetEpisodes: boolean;
|
||||||
};
|
};
|
||||||
@@ -44,8 +50,9 @@ export interface SonarrSeriesOptions {
|
|||||||
|
|
||||||
const tmdbToTvdbCache = createLocalStorageStore<Record<number, number>>('tmdb-to-tvdb-cache', {});
|
const tmdbToTvdbCache = createLocalStorageStore<Record<number, number>>('tmdb-to-tvdb-cache', {});
|
||||||
|
|
||||||
export class SonarrApi implements Api<paths> {
|
export class SonarrApi implements ApiAsync<paths> {
|
||||||
getClient() {
|
async getClient() {
|
||||||
|
await appState.ready;
|
||||||
const sonarrSettings = this.getSettings();
|
const sonarrSettings = this.getSettings();
|
||||||
const baseUrl = this.getBaseUrl();
|
const baseUrl = this.getBaseUrl();
|
||||||
const apiKey = sonarrSettings?.apiKey;
|
const apiKey = sonarrSettings?.apiKey;
|
||||||
@@ -83,33 +90,42 @@ export class SonarrApi implements Api<paths> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
getSeriesById = (id: number): Promise<SonarrSeries | undefined> =>
|
getSeriesById = (id: number): Promise<SonarrSeries | undefined> =>
|
||||||
this.getClient()
|
this.getClient().then(
|
||||||
?.GET('/api/v3/series/{id}', {
|
(client) =>
|
||||||
params: {
|
client
|
||||||
path: {
|
?.GET('/api/v3/series/{id}', {
|
||||||
id
|
params: {
|
||||||
}
|
path: {
|
||||||
}
|
id
|
||||||
})
|
}
|
||||||
.then((r) => r.data) || Promise.resolve(undefined);
|
}
|
||||||
|
})
|
||||||
|
.then((r) => r.data) || Promise.resolve(undefined)
|
||||||
|
);
|
||||||
|
|
||||||
getAllSeries = (): Promise<SonarrSeries[]> =>
|
getAllSeries = (): Promise<SonarrSeries[]> =>
|
||||||
this.getClient()
|
this.getClient().then(
|
||||||
?.GET('/api/v3/series', {
|
(client) =>
|
||||||
params: {}
|
client
|
||||||
})
|
?.GET('/api/v3/series', {
|
||||||
.then((r) => r.data || []) || Promise.resolve([]);
|
params: {}
|
||||||
|
})
|
||||||
|
.then((r) => r.data || []) || Promise.resolve([])
|
||||||
|
);
|
||||||
|
|
||||||
getSonarrSeriesByTvdbId = (tvdbId: number): Promise<SonarrSeries | undefined> =>
|
getSonarrSeriesByTvdbId = (tvdbId: number): Promise<SonarrSeries | undefined> =>
|
||||||
this.getClient()
|
this.getClient().then(
|
||||||
?.GET('/api/v3/series', {
|
(client) =>
|
||||||
params: {
|
client
|
||||||
query: {
|
?.GET('/api/v3/series', {
|
||||||
tvdbId: tvdbId
|
params: {
|
||||||
}
|
query: {
|
||||||
}
|
tvdbId: tvdbId
|
||||||
})
|
}
|
||||||
.then((r) => r.data?.find((m) => m.tvdbId === tvdbId)) || Promise.resolve(undefined);
|
}
|
||||||
|
})
|
||||||
|
.then((r) => r.data?.find((m) => m.tvdbId === tvdbId)) || Promise.resolve(undefined)
|
||||||
|
);
|
||||||
|
|
||||||
getSeriesByTmdbId = async (tmdbId: number) =>
|
getSeriesByTmdbId = async (tmdbId: number) =>
|
||||||
this.tmdbToTvdb(tmdbId).then((tvdbId) =>
|
this.tmdbToTvdb(tmdbId).then((tvdbId) =>
|
||||||
@@ -117,11 +133,19 @@ export class SonarrApi implements Api<paths> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
getDiskSpace = (): Promise<DiskSpaceInfo[]> =>
|
getDiskSpace = (): Promise<DiskSpaceInfo[]> =>
|
||||||
this.getClient()
|
this.getClient().then(
|
||||||
?.GET('/api/v3/diskspace', {})
|
(client) =>
|
||||||
.then((d) => d.data || []) || Promise.resolve([]);
|
client?.GET('/api/v3/diskspace', {}).then((d) => d.data || []) || Promise.resolve([])
|
||||||
|
);
|
||||||
|
|
||||||
addSeriesToSonarr = async (tmdbId: number) => {
|
addToSonarr = async (
|
||||||
|
tmdbId: number,
|
||||||
|
_options: {
|
||||||
|
qualityProfileId?: number;
|
||||||
|
rootFolderPath?: string;
|
||||||
|
monitorOptions?: SonarrMonitorOptions;
|
||||||
|
} = {}
|
||||||
|
): Promise<SonarrSeries | undefined> => {
|
||||||
const tmdbSeries = await getTmdbSeries(tmdbId);
|
const tmdbSeries = await getTmdbSeries(tmdbId);
|
||||||
|
|
||||||
if (!tmdbSeries || !tmdbSeries.external_ids.tvdb_id || !tmdbSeries.name)
|
if (!tmdbSeries || !tmdbSeries.external_ids.tvdb_id || !tmdbSeries.name)
|
||||||
@@ -130,100 +154,119 @@ export class SonarrApi implements Api<paths> {
|
|||||||
const options: SonarrSeriesOptions = {
|
const options: SonarrSeriesOptions = {
|
||||||
title: tmdbSeries.name,
|
title: tmdbSeries.name,
|
||||||
tvdbId: tmdbSeries.external_ids.tvdb_id,
|
tvdbId: tmdbSeries.external_ids.tvdb_id,
|
||||||
qualityProfileId: this.getSettings()?.qualityProfileId || 0,
|
qualityProfileId: _options.qualityProfileId || this.getSettings()?.qualityProfileId || 0,
|
||||||
monitored: false,
|
monitored: false,
|
||||||
addOptions: {
|
addOptions: {
|
||||||
monitor: 'none',
|
monitor: _options.monitorOptions || 'none',
|
||||||
searchForMissingEpisodes: false,
|
searchForMissingEpisodes: false,
|
||||||
searchForCutoffUnmetEpisodes: false
|
searchForCutoffUnmetEpisodes: false
|
||||||
},
|
},
|
||||||
rootFolderPath: this.getSettings()?.rootFolderPath || '',
|
rootFolderPath: _options.rootFolderPath || this.getSettings()?.rootFolderPath || '',
|
||||||
languageProfileId: this.getSettings()?.languageProfileId || 0,
|
languageProfileId: this.getSettings()?.languageProfileId || 0,
|
||||||
seasonFolder: true
|
seasonFolder: true
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.getClient()
|
return this.getClient().then((client) =>
|
||||||
?.POST('/api/v3/series', {
|
client
|
||||||
params: {},
|
?.POST('/api/v3/series', {
|
||||||
body: options
|
params: {},
|
||||||
})
|
body: options
|
||||||
.then((r) => r.data);
|
})
|
||||||
|
.then((r) => r.data)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
cancelDownload = async (downloadId: number) => {
|
cancelDownload = async (downloadId: number) => {
|
||||||
const deleteResponse = await this.getClient()
|
const deleteResponse = await this.getClient().then((client) =>
|
||||||
?.DELETE('/api/v3/queue/{id}', {
|
client
|
||||||
params: {
|
?.DELETE('/api/v3/queue/{id}', {
|
||||||
path: {
|
params: {
|
||||||
id: downloadId
|
path: {
|
||||||
},
|
id: downloadId
|
||||||
query: {
|
},
|
||||||
blocklist: false,
|
query: {
|
||||||
removeFromClient: true
|
blocklist: false,
|
||||||
|
removeFromClient: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
})
|
.then((r) => log(r))
|
||||||
.then((r) => log(r));
|
);
|
||||||
|
|
||||||
return !!deleteResponse?.response.ok;
|
return !!deleteResponse?.response.ok;
|
||||||
};
|
};
|
||||||
|
|
||||||
cancelDownloads = async (downloadIds: number[]) =>
|
cancelDownloads = async (downloadIds: number[]) =>
|
||||||
this.getClient()
|
this.getClient().then(
|
||||||
?.DELETE('/api/v3/queue/bulk', {
|
(client) =>
|
||||||
body: {
|
client
|
||||||
ids: downloadIds
|
?.DELETE('/api/v3/queue/bulk', {
|
||||||
}
|
body: {
|
||||||
})
|
ids: downloadIds
|
||||||
.then((r) => r.response.ok) || Promise.resolve(false);
|
}
|
||||||
|
})
|
||||||
|
.then((r) => r.response.ok) || Promise.resolve(false)
|
||||||
|
);
|
||||||
|
|
||||||
downloadSonarrRelease = (guid: string, indexerId: number) =>
|
downloadSonarrRelease = (guid: string, indexerId: number) =>
|
||||||
this.getClient()
|
this.getClient().then(
|
||||||
?.POST('/api/v3/release', {
|
(client) =>
|
||||||
params: {},
|
client
|
||||||
body: {
|
?.POST('/api/v3/release', {
|
||||||
indexerId,
|
params: {},
|
||||||
guid
|
body: {
|
||||||
}
|
indexerId,
|
||||||
})
|
guid
|
||||||
.then((res) => res.response.ok) || Promise.resolve(false);
|
}
|
||||||
|
})
|
||||||
|
.then((res) => res.response.ok) || Promise.resolve(false)
|
||||||
|
);
|
||||||
|
|
||||||
deleteSonarrEpisode = (id: number) =>
|
deleteSonarrEpisode = (id: number) =>
|
||||||
this.getClient()
|
this.getClient().then(
|
||||||
?.DELETE('/api/v3/episodefile/{id}', {
|
(client) =>
|
||||||
params: {
|
client
|
||||||
path: {
|
?.DELETE('/api/v3/episodefile/{id}', {
|
||||||
id
|
params: {
|
||||||
}
|
path: {
|
||||||
}
|
id
|
||||||
})
|
}
|
||||||
.then((res) => res.response.ok) || Promise.resolve(false);
|
}
|
||||||
|
})
|
||||||
|
.then((res) => res.response.ok) || Promise.resolve(false)
|
||||||
|
);
|
||||||
|
|
||||||
deleteSonarrEpisodes = (ids: number[]) =>
|
deleteSonarrEpisodes = (ids: number[]) =>
|
||||||
this.getClient()
|
this.getClient().then(
|
||||||
?.DELETE('/api/v3/episodefile/bulk', {
|
(client) =>
|
||||||
body: {
|
client
|
||||||
episodeFileIds: ids
|
?.DELETE('/api/v3/episodefile/bulk', {
|
||||||
}
|
body: {
|
||||||
})
|
episodeFileIds: ids
|
||||||
.then((res) => res.response.ok) || Promise.resolve(false);
|
}
|
||||||
|
})
|
||||||
|
.then((res) => res.response.ok) || Promise.resolve(false)
|
||||||
|
);
|
||||||
|
|
||||||
getSonarrDownloads = (): Promise<EpisodeDownload[]> =>
|
getSonarrDownloads = (): Promise<EpisodeDownload[]> =>
|
||||||
this.getClient()
|
this.getClient().then(
|
||||||
?.GET('/api/v3/queue', {
|
(client) =>
|
||||||
params: {
|
client
|
||||||
query: {
|
?.GET('/api/v3/queue', {
|
||||||
includeEpisode: true,
|
params: {
|
||||||
includeSeries: true
|
query: {
|
||||||
}
|
includeEpisode: true,
|
||||||
}
|
includeSeries: true
|
||||||
})
|
}
|
||||||
.then(
|
}
|
||||||
(r) =>
|
})
|
||||||
(r.data?.records?.filter(
|
.then(
|
||||||
(record) => record.episode && record.series
|
(r) =>
|
||||||
) as EpisodeDownload[]) || []
|
(r.data?.records?.filter(
|
||||||
) || Promise.resolve([]);
|
(record) => record.episode && record.series
|
||||||
|
) as EpisodeDownload[]) || []
|
||||||
|
) || Promise.resolve([])
|
||||||
|
);
|
||||||
|
|
||||||
getDownloadsBySeriesId = (sonarrId: number) =>
|
getDownloadsBySeriesId = (sonarrId: number) =>
|
||||||
this.getSonarrDownloads().then((downloads) =>
|
this.getSonarrDownloads().then((downloads) =>
|
||||||
@@ -231,26 +274,90 @@ export class SonarrApi implements Api<paths> {
|
|||||||
) || Promise.resolve([]);
|
) || Promise.resolve([]);
|
||||||
|
|
||||||
removeFromSonarr = (id: number): Promise<boolean> =>
|
removeFromSonarr = (id: number): Promise<boolean> =>
|
||||||
this.getClient()
|
this.getClient().then(
|
||||||
?.DELETE('/api/v3/series/{id}', {
|
(client) =>
|
||||||
params: {
|
client
|
||||||
path: {
|
?.DELETE('/api/v3/series/{id}', {
|
||||||
id
|
params: {
|
||||||
}
|
path: {
|
||||||
}
|
id
|
||||||
})
|
}
|
||||||
.then((res) => res.response.ok) || Promise.resolve(false);
|
}
|
||||||
|
})
|
||||||
|
.then((res) => res.response.ok) || Promise.resolve(false)
|
||||||
|
);
|
||||||
|
|
||||||
getFilesBySeriesId = (seriesId: number): Promise<EpisodeFileResource[]> =>
|
getFilesBySeriesId = (seriesId: number): Promise<EpisodeFileResource[]> =>
|
||||||
this.getClient()
|
this.getClient().then(
|
||||||
?.GET('/api/v3/episodefile', {
|
(client) =>
|
||||||
params: {
|
client
|
||||||
query: {
|
?.GET('/api/v3/episodefile', {
|
||||||
seriesId
|
params: {
|
||||||
}
|
query: {
|
||||||
}
|
seriesId
|
||||||
})
|
}
|
||||||
.then((r) => r.data || []) || Promise.resolve([]);
|
}
|
||||||
|
})
|
||||||
|
.then((r) => r.data || []) || Promise.resolve([])
|
||||||
|
);
|
||||||
|
|
||||||
|
getEpisodeReleases = async (episodeId: number) =>
|
||||||
|
this.getClient().then(
|
||||||
|
(client) =>
|
||||||
|
client
|
||||||
|
?.GET('/api/v3/release', {
|
||||||
|
params: {
|
||||||
|
query: {
|
||||||
|
episodeId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((r) => r.data || []) || Promise.resolve([])
|
||||||
|
);
|
||||||
|
|
||||||
|
getSeasonReleases = async (seriesId: number, seasonNumber: number) =>
|
||||||
|
this.getClient().then(
|
||||||
|
(client) =>
|
||||||
|
client
|
||||||
|
?.GET('/api/v3/release', {
|
||||||
|
params: {
|
||||||
|
query: {
|
||||||
|
seriesId,
|
||||||
|
seasonNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((r) => r.data || []) || Promise.resolve([])
|
||||||
|
);
|
||||||
|
|
||||||
|
getEpisodes = async (seriesId: number, seasonNumber?: number): Promise<SonarrEpisode[]> => {
|
||||||
|
return this.getClient().then(
|
||||||
|
(client) =>
|
||||||
|
client
|
||||||
|
?.GET('/api/v3/episode', {
|
||||||
|
params: {
|
||||||
|
query: {
|
||||||
|
seriesId,
|
||||||
|
seasonNumber,
|
||||||
|
includeImages: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((r) => r.data || []) || Promise.resolve([])
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
getRootFolders = (): Promise<SonarrRootFolder[]> =>
|
||||||
|
this.getClient().then(
|
||||||
|
(client) =>
|
||||||
|
client?.GET('/api/v3/rootfolder', {}).then((r) => r.data || []) || Promise.resolve([])
|
||||||
|
);
|
||||||
|
|
||||||
|
getQualityProfiles = (): Promise<SonarrQualityProfile[]> =>
|
||||||
|
this.getClient().then(
|
||||||
|
(client) =>
|
||||||
|
client?.GET('/api/v3/qualityprofile', {}).then((r) => r.data || []) || Promise.resolve([])
|
||||||
|
);
|
||||||
|
|
||||||
// getSonarrEpisodes = async (seriesId: number) => {
|
// getSonarrEpisodes = async (seriesId: number) => {
|
||||||
// const episodesPromise =
|
// const episodesPromise =
|
||||||
@@ -284,44 +391,6 @@ export class SonarrApi implements Api<paths> {
|
|||||||
// }));
|
// }));
|
||||||
// };
|
// };
|
||||||
|
|
||||||
getEpisodeReleases = async (episodeId: number) =>
|
|
||||||
this.getClient()
|
|
||||||
?.GET('/api/v3/release', {
|
|
||||||
params: {
|
|
||||||
query: {
|
|
||||||
episodeId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then((r) => r.data || []) || Promise.resolve([]);
|
|
||||||
|
|
||||||
getSeasonReleases = async (seriesId: number, seasonNumber: number) =>
|
|
||||||
this.getClient()
|
|
||||||
?.GET('/api/v3/release', {
|
|
||||||
params: {
|
|
||||||
query: {
|
|
||||||
seriesId,
|
|
||||||
seasonNumber
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then((r) => r.data || []) || Promise.resolve([]);
|
|
||||||
|
|
||||||
getEpisodes = async (seriesId: number, seasonNumber?: number): Promise<SonarrEpisode[]> => {
|
|
||||||
return (
|
|
||||||
this.getClient()
|
|
||||||
?.GET('/api/v3/episode', {
|
|
||||||
params: {
|
|
||||||
query: {
|
|
||||||
seriesId,
|
|
||||||
seasonNumber
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then((r) => r.data || []) || Promise.resolve([])
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
getSonarrHealth = async (
|
getSonarrHealth = async (
|
||||||
baseUrl: string | undefined = undefined,
|
baseUrl: string | undefined = undefined,
|
||||||
apiKey: string | undefined = undefined
|
apiKey: string | undefined = undefined
|
||||||
@@ -335,19 +404,16 @@ export class SonarrApi implements Api<paths> {
|
|||||||
.then((res) => res.status === 200)
|
.then((res) => res.status === 200)
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
|
|
||||||
getSonarrRootFolders = async (
|
_getSonarrRootFolders = async (
|
||||||
baseUrl: string | undefined = undefined,
|
baseUrl: string | undefined = undefined,
|
||||||
apiKey: string | undefined = undefined
|
apiKey: string | undefined = undefined
|
||||||
) =>
|
) =>
|
||||||
axios
|
axios
|
||||||
.get<components['schemas']['RootFolderResource'][]>(
|
.get<SonarrRootFolder[]>((baseUrl || this.getBaseUrl()) + '/api/v3/rootFolder', {
|
||||||
(baseUrl || this.getBaseUrl()) + '/api/v3/rootFolder',
|
headers: {
|
||||||
{
|
'X-Api-Key': apiKey || this.getApiKey()
|
||||||
headers: {
|
|
||||||
'X-Api-Key': apiKey || this.getApiKey()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
.then((res) => res.data || []);
|
.then((res) => res.data || []);
|
||||||
|
|
||||||
getSonarrQualityProfiles = async (
|
getSonarrQualityProfiles = async (
|
||||||
|
|||||||
@@ -3,12 +3,28 @@
|
|||||||
import type { Readable } from 'svelte/store';
|
import type { Readable } from 'svelte/store';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import AnimatedSelection from './AnimateScale.svelte';
|
import AnimatedSelection from './AnimateScale.svelte';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
export let disabled: boolean = false;
|
export let disabled: boolean = false;
|
||||||
export let focusOnMount: boolean = false;
|
export let focusOnMount: boolean = false;
|
||||||
export let type: 'primary' | 'secondary' = 'primary';
|
export let type: 'primary' | 'secondary' | 'primary-dark' = 'primary';
|
||||||
|
|
||||||
|
export let action: (() => Promise<any>) | null = null;
|
||||||
|
let actionIsFetching = false;
|
||||||
|
$: _disabled = disabled || actionIsFetching;
|
||||||
|
|
||||||
let hasFocus: Readable<boolean>;
|
let hasFocus: Readable<boolean>;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{ clickOrSelect: null }>();
|
||||||
|
|
||||||
|
function handleClickOrSelect() {
|
||||||
|
if (action) {
|
||||||
|
actionIsFetching = true;
|
||||||
|
action().then(() => (actionIsFetching = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch('clickOrSelect');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AnimatedSelection hasFocus={$hasFocus}>
|
<AnimatedSelection hasFocus={$hasFocus}>
|
||||||
@@ -17,42 +33,44 @@
|
|||||||
class={classNames(
|
class={classNames(
|
||||||
'h-12 rounded-lg font-medium tracking-wide flex items-center group',
|
'h-12 rounded-lg font-medium tracking-wide flex items-center group',
|
||||||
{
|
{
|
||||||
'selectable bg-secondary-800 px-6': type === 'primary',
|
'bg-secondary-800': type === 'primary',
|
||||||
|
'bg-primary-900': type === 'primary-dark',
|
||||||
|
'selectable px-6': type === 'primary' || type === 'primary-dark',
|
||||||
'border-2 p-1 hover:border-primary-500': type === 'secondary',
|
'border-2 p-1 hover:border-primary-500': type === 'secondary',
|
||||||
'border-primary-500': type === 'secondary' && $hasFocus,
|
'border-primary-500': type === 'secondary' && $hasFocus,
|
||||||
'cursor-pointer': !disabled,
|
'cursor-pointer': !_disabled,
|
||||||
'cursor-not-allowed pointer-events-none opacity-40': disabled
|
'cursor-not-allowed pointer-events-none opacity-40': _disabled
|
||||||
},
|
},
|
||||||
$$restProps.class
|
$$restProps.class
|
||||||
)}
|
)}
|
||||||
on:click
|
on:click
|
||||||
on:select
|
on:select
|
||||||
on:clickOrSelect
|
on:clickOrSelect={handleClickOrSelect}
|
||||||
on:enter
|
on:enter
|
||||||
{focusOnMount}
|
{focusOnMount}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class={classNames({
|
class={classNames({
|
||||||
contents: type === 'primary',
|
contents: type === 'primary' || type === 'primary-dark',
|
||||||
'border-2 border-transparent h-full w-full rounded-md flex items-center px-6':
|
'border-2 border-transparent h-full w-full rounded-md flex items-center px-6':
|
||||||
type === 'secondary',
|
type === 'secondary',
|
||||||
'bg-primary-500 text-secondary-950': type === 'secondary' && $hasFocus,
|
'bg-primary-500 text-secondary-950': type === 'secondary' && $hasFocus,
|
||||||
'group-hover:bg-primary-500 group-hover:text-secondary-950': type === 'secondary'
|
'group-hover:bg-primary-500 group-hover:text-secondary-950': type === 'secondary'
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{#if $$slots.icon}
|
<div class="flex-1 text-center text-nowrap flex items-center justify-center">
|
||||||
<div class="mr-2">
|
{#if $$slots.icon}
|
||||||
<slot name="icon" />
|
<div class="mr-2">
|
||||||
</div>
|
<slot name="icon" />
|
||||||
{/if}
|
</div>
|
||||||
<div class="flex-1 text-center text-nowrap">
|
{/if}
|
||||||
<slot {hasFocus} />
|
<slot {hasFocus} />
|
||||||
|
{#if $$slots['icon-after']}
|
||||||
|
<div class="ml-2">
|
||||||
|
<slot name="icon-after" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if $$slots['icon-after']}
|
|
||||||
<div class="ml-2">
|
|
||||||
<slot name="icon-after" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</AnimatedSelection>
|
</AnimatedSelection>
|
||||||
|
|||||||
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">
|
<script lang="ts">
|
||||||
import Container from '../../../Container.svelte';
|
import Container from '../../../Container.svelte';
|
||||||
import Button from '../Button.svelte';
|
import Button from '../Button.svelte';
|
||||||
import Modal from '../Modal/Modal.svelte';
|
|
||||||
import { modalStack } from '../Modal/modal.store';
|
import { modalStack } from '../Modal/modal.store';
|
||||||
import Dialog from './Dialog.svelte';
|
import Dialog from './Dialog.svelte';
|
||||||
|
|
||||||
@@ -27,20 +26,15 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog {modalId}>
|
<Dialog>
|
||||||
<div class="text-2xl font-semibold tracking-wide mb-2 text-secondary-100">
|
<div class="header2 mb-4">
|
||||||
<slot name="header" />
|
<slot name="header" />
|
||||||
</div>
|
</div>
|
||||||
<div class="font-medium text-secondary-300 mb-8">
|
<div class="font-medium text-secondary-300 mb-8">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<Container direction="horizontal" class="flex">
|
<Container class="flex flex-col space-y-4">
|
||||||
<Button
|
<Button type="secondary" disabled={fetching} on:clickOrSelect={() => handleAction(confirm)}>
|
||||||
type="secondary"
|
|
||||||
disabled={fetching}
|
|
||||||
on:clickOrSelect={() => handleAction(confirm)}
|
|
||||||
class="mr-4"
|
|
||||||
>
|
|
||||||
Confirm
|
Confirm
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="secondary" disabled={fetching} on:clickOrSelect={() => handleAction(cancel)}
|
<Button type="secondary" disabled={fetching} on:clickOrSelect={() => handleAction(cancel)}
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from '../Modal/Modal.svelte';
|
import Modal from '../Modal/Modal.svelte';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
export let size: 'sm' | 'full' = 'sm';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal>
|
<Modal on:back>
|
||||||
<div class="h-full flex items-center justify-center bg-secondary-950/75 py-20">
|
<div
|
||||||
|
class="h-full flex items-center justify-center bg-primary-900/75 py-20 px-32"
|
||||||
|
transition:fade={{ duration: 100 }}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="flex-1 bg-secondary-900 rounded-2xl max-w-lg p-10 overflow-y-auto min-h-0 max-h-full scrollbar-hide"
|
class={classNames(
|
||||||
|
'flex-1 bg-primary-800 rounded-2xl p-10 relative shadow-xl flex flex-col',
|
||||||
|
{
|
||||||
|
'max-w-lg min-h-0 overflow-y-auto scrollbar-hide': size === 'sm',
|
||||||
|
'h-full overflow-hidden': size === 'full'
|
||||||
|
},
|
||||||
|
$$restProps.class
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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">
|
<script lang="ts">
|
||||||
import { sonarrApi } from '../../apis/sonarr/sonarr-api';
|
import { sonarrApi } from '../../apis/sonarr/sonarr-api';
|
||||||
import MMMainLayout from './MMMainLayout.svelte';
|
import MMMainLayout from './MMMainLayout.svelte';
|
||||||
import MMAddToSonarr from './MMAddToSonarr.svelte';
|
|
||||||
import MMModal from './MMModal.svelte';
|
import MMModal from './MMModal.svelte';
|
||||||
import ReleaseList from './Releases/MMReleasesTab.svelte';
|
import ReleaseList from './Releases/MMReleasesTab.svelte';
|
||||||
import DownloadList from '../MediaManager/DownloadList.svelte';
|
import DownloadList from '../MediaManager/DownloadList.svelte';
|
||||||
@@ -93,7 +92,7 @@
|
|||||||
<MMModal {modalId} {hidden}>
|
<MMModal {modalId} {hidden}>
|
||||||
{#await sonarrEpisode then sonarrEpisode}
|
{#await sonarrEpisode then sonarrEpisode}
|
||||||
{#if !sonarrEpisode}
|
{#if !sonarrEpisode}
|
||||||
<MMAddToSonarr />
|
<!-- <MMAddToSonarr />-->
|
||||||
{:else}
|
{:else}
|
||||||
<div class="pt-20 h-screen flex flex-col">
|
<div class="pt-20 h-screen flex flex-col">
|
||||||
<MMTitle class="mb-32 mx-32">
|
<MMTitle class="mb-32 mx-32">
|
||||||
|
|||||||
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 classNames from 'classnames';
|
||||||
import MMTitle from './MMTitle.svelte';
|
import MMTitle from './MMTitle.svelte';
|
||||||
|
|
||||||
let activeTab: 'releases' | 'local-files' = 'releases';
|
// let activeTab: 'releases' | 'local-files' = 'releases';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col h-screen">
|
<div class="flex flex-col h-screen">
|
||||||
@@ -19,66 +19,7 @@
|
|||||||
<slot name="downloads" />
|
<slot name="downloads" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex mb-8 mx-32">
|
|
||||||
<button
|
|
||||||
class={classNames('text-2xl font-semibold mr-8 transition-opacity cursor-pointer', {
|
|
||||||
'opacity-40': activeTab !== 'releases'
|
|
||||||
})}
|
|
||||||
on:click={() => (activeTab = 'releases')}
|
|
||||||
>
|
|
||||||
Releases
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class={classNames('text-2xl font-semibold mr-8 transition-opacity cursor-pointer', {
|
|
||||||
'opacity-40': activeTab !== 'local-files'
|
|
||||||
})}
|
|
||||||
on:click={() => (activeTab = 'local-files')}
|
|
||||||
>
|
|
||||||
Local Files
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Container focusOnMount direction="horizontal" class="flex-1 grid grid-cols-1 min-h-0">
|
<Container focusOnMount direction="horizontal" class="flex-1 grid grid-cols-1 min-h-0">
|
||||||
<Container
|
<slot />
|
||||||
focusOnMount
|
|
||||||
on:enter={() => (activeTab = 'releases')}
|
|
||||||
class={classNames(
|
|
||||||
'row-start-1 col-start-1 pb-16 mx-20',
|
|
||||||
'transition-all overflow-y-auto overflow-x-hidden scrollbar-hide',
|
|
||||||
{
|
|
||||||
'opacity-30 -translate-x-full': activeTab !== 'releases'
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<slot name="releases" />
|
|
||||||
</Container>
|
|
||||||
<Container
|
|
||||||
on:enter={() => (activeTab = 'local-files')}
|
|
||||||
class={classNames(
|
|
||||||
'row-start-1 col-start-1 pb-16 mx-20',
|
|
||||||
'transition-all overflow-y-auto overflow-x-hidden scrollbar-hide',
|
|
||||||
{
|
|
||||||
'opacity-30 translate-x-full': activeTab !== 'local-files'
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<slot name="local-files" />
|
|
||||||
</Container>
|
|
||||||
</Container>
|
</Container>
|
||||||
<!-- <Container direction="horizontal" class="grid grid-cols-1 gap-16">-->
|
|
||||||
<!-- <div class="flex flex-col">-->
|
|
||||||
<!-- <h1 class="text-2xl font-semibold mb-4">Releases</h1>-->
|
|
||||||
<!-- <slot name="releases" />-->
|
|
||||||
<!-- </div>-->
|
|
||||||
<!-- <div class="flex flex-col">-->
|
|
||||||
<!-- <div class="flex flex-col mb-8">-->
|
|
||||||
<!-- <h1 class="text-2xl font-semibold mb-4">Local Files</h1>-->
|
|
||||||
<!-- <slot name="local-files" />-->
|
|
||||||
<!-- </div>-->
|
|
||||||
|
|
||||||
<!-- <div class="flex flex-col mb-8">-->
|
|
||||||
<!-- <h1 class="text-2xl font-semibold mb-4">Downloads</h1>-->
|
|
||||||
<!-- <slot name="downloads" />-->
|
|
||||||
<!-- </div>-->
|
|
||||||
<!-- </div>-->
|
|
||||||
<!-- </Container>-->
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,33 +2,27 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import Container from '../../../Container.svelte';
|
import Container from '../../../Container.svelte';
|
||||||
import { modalStack } from '../Modal/modal.store';
|
import { modalStack } from '../Modal/modal.store';
|
||||||
|
import Modal from '../Modal/Modal.svelte';
|
||||||
|
|
||||||
export let modalId: symbol;
|
export let modalId: symbol;
|
||||||
export let hidden: boolean = false;
|
export let hidden: boolean = false;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Container
|
<Modal>
|
||||||
on:navigate={({ detail }) => {
|
|
||||||
if (detail.direction === 'left' && detail.willLeaveContainer) {
|
|
||||||
modalStack.close(modalId);
|
|
||||||
detail.preventNavigation();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
focusOnMount
|
|
||||||
trapFocus
|
|
||||||
class={classNames(
|
|
||||||
'fixed inset-0 overflow-hidden',
|
|
||||||
{
|
|
||||||
'opacity-0': hidden
|
|
||||||
},
|
|
||||||
// 'bg-[radial-gradient(169.40%_89.55%_at_94.76%_6.29%,rgba(0,0,0,0.40)_0%,rgba(255,255,255,0.00)_100%)]'
|
|
||||||
// 'bg-[radial-gradient(150%_50%_at_50%_-25%,_#DFAA2BF0_0%,_#073AFF00_100%),linear-gradient(0deg,_#1A1914FF_0%,_#1A1914FF_0%)]'
|
|
||||||
'bg-secondary-900'
|
|
||||||
)}
|
|
||||||
canFocusEmpty
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 inset-x-0 h-screen -z-10 bg-[radial-gradient(150%_50%_at_50%_-25%,_#DFAA2Bcc_0%,_#00000000_100%)]"
|
class={classNames(
|
||||||
/>
|
'fixed inset-0 overflow-hidden',
|
||||||
<slot />
|
{
|
||||||
</Container>
|
'opacity-0': hidden
|
||||||
|
},
|
||||||
|
// 'bg-[radial-gradient(169.40%_89.55%_at_94.76%_6.29%,rgba(0,0,0,0.40)_0%,rgba(255,255,255,0.00)_100%)]'
|
||||||
|
// 'bg-[radial-gradient(150%_50%_at_50%_-25%,_#DFAA2BF0_0%,_#073AFF00_100%),linear-gradient(0deg,_#1A1914FF_0%,_#1A1914FF_0%)]'
|
||||||
|
'bg-secondary-900'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute top-0 inset-x-0 h-screen -z-10 bg-[radial-gradient(150%_50%_at_50%_-25%,_#DFAA2Bcc_0%,_#00000000_100%)]"
|
||||||
|
/>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<div>select season</div>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<div {...$$restProps}>
|
<div {...$$restProps}>
|
||||||
<div class="text-4xl font-semibold mb-2">
|
<div class="header4">
|
||||||
<slot name="title" />
|
<slot name="title" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-zinc-300 font-medium text-xl">
|
<div class="header1">
|
||||||
<slot name="subtitle" />
|
<slot name="subtitle" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MMMainLayout from './MMMainLayout.svelte';
|
import MMMainLayout from './MMMainLayout.svelte';
|
||||||
import MMAddToSonarr from './MMAddToSonarr.svelte';
|
// import MMAddToSonarr from './MMAddToSonarr.svelte';
|
||||||
import MMModal from './MMModal.svelte';
|
import MMModal from './MMModal.svelte';
|
||||||
import ReleaseList from './Releases/MMReleasesTab.svelte';
|
import ReleaseList from './Releases/MMReleasesTab.svelte';
|
||||||
import DownloadList from '../MediaManager/DownloadList.svelte';
|
import DownloadList from '../MediaManager/DownloadList.svelte';
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<MMModal {modalId} {hidden}>
|
<MMModal {modalId} {hidden}>
|
||||||
{#await radarrItem then movie}
|
{#await radarrItem then movie}
|
||||||
{#if !movie}
|
{#if !movie}
|
||||||
<MMAddToSonarr />
|
<!-- <MMAddToSonarr />-->
|
||||||
{:else}
|
{:else}
|
||||||
<MMMainLayout>
|
<MMMainLayout>
|
||||||
<h1 slot="title">{movie?.title}</h1>
|
<h1 slot="title">{movie?.title}</h1>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import type { GrabReleaseFn } from '../MediaManagerModal';
|
import type { GrabReleaseFn } from '../MediaManagerModal';
|
||||||
import Container from '../../../../Container.svelte';
|
import Container from '../../../../Container.svelte';
|
||||||
import TableHeaderCell from '../../Table/TableHeaderCell.svelte';
|
import TableHeaderCell from '../../Table/TableHeaderCell.svelte';
|
||||||
|
import MMTitle from '../MMTitle.svelte';
|
||||||
|
|
||||||
type Release = RadarrRelease | SonarrRelease;
|
type Release = RadarrRelease | SonarrRelease;
|
||||||
|
|
||||||
@@ -62,46 +63,57 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#await releases}
|
<Container trapFocus class="py-8 h-full flex flex-col">
|
||||||
{#each new Array(5) as _, index}
|
<h1 class="header4 mx-12">
|
||||||
<div class="flex-1 my-2">
|
<slot name="title" />
|
||||||
<ButtonGhost />
|
</h1>
|
||||||
</div>
|
<h2 class="header1 mx-12 mb-8">
|
||||||
{/each}
|
<slot name="subtitle" />
|
||||||
{:then releases}
|
</h2>
|
||||||
<div class="grid grid-cols-[1fr_max-content_max-content_max-content_max-content] gap-y-4">
|
|
||||||
<TableHeaderRow>
|
|
||||||
<TableHeaderSortBy
|
|
||||||
icon={sortBy === 'age' ? sortDirection : undefined}
|
|
||||||
on:clickOrSelect={toggleSortBy('age')}>Age</TableHeaderSortBy
|
|
||||||
>
|
|
||||||
<TableHeaderSortBy
|
|
||||||
icon={sortBy === 'size' ? sortDirection : undefined}
|
|
||||||
on:clickOrSelect={toggleSortBy('size')}>Size</TableHeaderSortBy
|
|
||||||
>
|
|
||||||
<TableHeaderSortBy
|
|
||||||
icon={sortBy === 'seeders' ? sortDirection : undefined}
|
|
||||||
on:clickOrSelect={toggleSortBy('seeders')}>Peers</TableHeaderSortBy
|
|
||||||
>
|
|
||||||
<TableHeaderSortBy
|
|
||||||
icon={sortBy === 'quality' ? sortDirection : undefined}
|
|
||||||
on:clickOrSelect={toggleSortBy('quality')}>Quality</TableHeaderSortBy
|
|
||||||
>
|
|
||||||
<TableHeaderCell />
|
|
||||||
</TableHeaderRow>
|
|
||||||
|
|
||||||
<Container class="contents" focusedChild>
|
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hide">
|
||||||
{#each getRecommendedReleases(releases).sort(getSortFn(sortBy, sortDirection)) as release, index}
|
{#await releases}
|
||||||
<MMReleaseListRow {release} {grabRelease} />
|
{#each new Array(5) as _, index}
|
||||||
|
<div class="flex-1 my-2">
|
||||||
|
<ButtonGhost />
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</Container>
|
{:then releases}
|
||||||
|
<div class="grid grid-cols-[1fr_max-content_max-content_max-content_max-content] gap-y-4">
|
||||||
|
<TableHeaderRow>
|
||||||
|
<TableHeaderSortBy
|
||||||
|
icon={sortBy === 'age' ? sortDirection : undefined}
|
||||||
|
on:clickOrSelect={toggleSortBy('age')}>Age</TableHeaderSortBy
|
||||||
|
>
|
||||||
|
<TableHeaderSortBy
|
||||||
|
icon={sortBy === 'size' ? sortDirection : undefined}
|
||||||
|
on:clickOrSelect={toggleSortBy('size')}>Size</TableHeaderSortBy
|
||||||
|
>
|
||||||
|
<TableHeaderSortBy
|
||||||
|
icon={sortBy === 'seeders' ? sortDirection : undefined}
|
||||||
|
on:clickOrSelect={toggleSortBy('seeders')}>Peers</TableHeaderSortBy
|
||||||
|
>
|
||||||
|
<TableHeaderSortBy
|
||||||
|
icon={sortBy === 'quality' ? sortDirection : undefined}
|
||||||
|
on:clickOrSelect={toggleSortBy('quality')}>Quality</TableHeaderSortBy
|
||||||
|
>
|
||||||
|
<TableHeaderCell />
|
||||||
|
</TableHeaderRow>
|
||||||
|
|
||||||
<h1 class="text-2xl font-semibold mb-4 mt-8 col-span-5 mx-12">All Releases</h1>
|
<Container class="contents" focusOnMount>
|
||||||
|
{#each getRecommendedReleases(releases).sort(getSortFn(sortBy, sortDirection)) as release, index}
|
||||||
|
<MMReleaseListRow {release} {grabRelease} />
|
||||||
|
{/each}
|
||||||
|
</Container>
|
||||||
|
|
||||||
{#each releases
|
<h1 class="text-2xl font-semibold mb-4 mt-8 col-span-5 mx-12">All Releases</h1>
|
||||||
.filter((r) => r.guid && r.indexerId)
|
|
||||||
.sort(getSortFn(sortBy, sortDirection)) as release, index}
|
{#each releases
|
||||||
<MMReleaseListRow {release} {grabRelease} />
|
.filter((r) => r.guid && r.indexerId)
|
||||||
{/each}
|
.sort(getSortFn(sortBy, sortDirection)) as release, index}
|
||||||
|
<MMReleaseListRow {release} {grabRelease} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
{/await}
|
</Container>
|
||||||
|
|||||||
@@ -1,73 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { sonarrApi } from '../../apis/sonarr/sonarr-api';
|
import { sonarrApi, type SonarrRelease, type SonarrSeries } from '../../apis/sonarr/sonarr-api';
|
||||||
import MMMainLayout from './MMMainLayout.svelte';
|
import MMMainLayout from './MMMainLayout.svelte';
|
||||||
import MMAddToSonarr from './MMAddToSonarr.svelte';
|
|
||||||
import MMModal from './MMModal.svelte';
|
|
||||||
import MMReleasesTab from './Releases/MMReleasesTab.svelte';
|
import MMReleasesTab from './Releases/MMReleasesTab.svelte';
|
||||||
import MMLocalFilesTab from './LocalFiles/MMLocalFilesTab.svelte';
|
import type { GrabReleaseFn } from './MediaManagerModal';
|
||||||
import type {
|
|
||||||
CancelDownloadFn,
|
|
||||||
CancelDownloadsFn,
|
|
||||||
DeleteFileFn,
|
|
||||||
DeleteFilesFn,
|
|
||||||
GrabReleaseFn
|
|
||||||
} from './MediaManagerModal';
|
|
||||||
import type { Release } from '../../apis/combined-types';
|
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
|
import Dialog from '../Dialog/Dialog.svelte';
|
||||||
|
import type { Release } from '../../apis/combined-types';
|
||||||
|
import MMSeasonSelectTab from './MMSeasonSelectTab.svelte';
|
||||||
|
|
||||||
export let id: number; // Tmdb ID
|
export let season: number | null = null;
|
||||||
export let season: number;
|
export let sonarrItem: SonarrSeries;
|
||||||
export let modalId: symbol;
|
export let modalId: symbol;
|
||||||
export let hidden: boolean;
|
export let hidden: boolean;
|
||||||
|
export let onGrabRelease: (release: Release) => void = () => {};
|
||||||
|
|
||||||
const sonarrItem = sonarrApi.getSeriesByTmdbId(id);
|
$: releases = season !== null ? getReleases(season) : null;
|
||||||
|
|
||||||
let releases: Promise<Release[]> = getReleases();
|
|
||||||
let files = getLocalFiles();
|
|
||||||
let downloads = getDownloads();
|
|
||||||
|
|
||||||
let refreshDownloadsTimeout: ReturnType<typeof setTimeout>;
|
let refreshDownloadsTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
const grabRelease: GrabReleaseFn = (release) =>
|
const grabRelease: GrabReleaseFn = (release) =>
|
||||||
sonarrApi.downloadSonarrRelease(release.guid || '', release.indexerId || -1).then((r) => {
|
sonarrApi.downloadSonarrRelease(release.guid || '', release.indexerId || -1).then((r) => {
|
||||||
refreshDownloadsTimeout = setTimeout(() => {
|
onGrabRelease(release);
|
||||||
downloads = getDownloads();
|
|
||||||
}, 8000);
|
|
||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteFile: DeleteFileFn = (...args) =>
|
function getReleases(season: number) {
|
||||||
sonarrApi.deleteSonarrEpisode(...args).then((r) => {
|
return sonarrApi.getSeasonReleases(sonarrItem.id || -1, season);
|
||||||
files = getLocalFiles();
|
|
||||||
return r;
|
|
||||||
});
|
|
||||||
const deleteFiles: DeleteFilesFn = (...args) =>
|
|
||||||
sonarrApi.deleteSonarrEpisodes(...args).then((r) => {
|
|
||||||
files = getLocalFiles();
|
|
||||||
return r;
|
|
||||||
});
|
|
||||||
const cancelDownload: CancelDownloadFn = (...args) =>
|
|
||||||
sonarrApi.cancelDownload(...args).then((r) => {
|
|
||||||
downloads = getDownloads();
|
|
||||||
return r;
|
|
||||||
});
|
|
||||||
const cancelDownloads: CancelDownloadsFn = (...args) =>
|
|
||||||
sonarrApi.cancelDownloads(...args).then((r) => {
|
|
||||||
downloads = getDownloads();
|
|
||||||
return r;
|
|
||||||
});
|
|
||||||
|
|
||||||
function getReleases() {
|
|
||||||
return sonarrItem.then((si) => sonarrApi.getSeasonReleases(si?.id || -1, season));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLocalFiles() {
|
function getDownloads(season: number) {
|
||||||
return sonarrItem.then((si) => sonarrApi.getFilesBySeriesId(si?.id || -1));
|
return sonarrApi
|
||||||
}
|
.getDownloadsBySeriesId(sonarrItem.id || -1)
|
||||||
|
|
||||||
function getDownloads() {
|
|
||||||
return sonarrItem
|
|
||||||
.then((si) => sonarrApi.getDownloadsBySeriesId(si?.id || -1))
|
|
||||||
.then((ds) => ds.filter((d) => d.episode?.seasonNumber === season));
|
.then((ds) => ds.filter((d) => d.episode?.seasonNumber === season));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,26 +39,13 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<MMModal {modalId} {hidden}>
|
<Dialog size="full" {modalId} {hidden}>
|
||||||
{#await sonarrItem then series}
|
{#if !season}
|
||||||
{#if !series}
|
<MMSeasonSelectTab />
|
||||||
<MMAddToSonarr />
|
{:else if releases}
|
||||||
{:else}
|
<MMReleasesTab {releases} {grabRelease}>
|
||||||
<MMMainLayout>
|
<h1 slot="title">{sonarrItem?.title}</h1>
|
||||||
<h1 slot="title">{series?.title}</h1>
|
<h2 slot="subtitle">Season {season} Releases</h2>
|
||||||
<h2 slot="subtitle">Season {season} Packs</h2>
|
</MMReleasesTab>
|
||||||
<MMReleasesTab slot="releases" {releases} {grabRelease} />
|
{/if}
|
||||||
<MMLocalFilesTab
|
</Dialog>
|
||||||
slot="local-files"
|
|
||||||
{files}
|
|
||||||
{deleteFile}
|
|
||||||
{deleteFiles}
|
|
||||||
{downloads}
|
|
||||||
{cancelDownload}
|
|
||||||
{cancelDownloads}
|
|
||||||
/>
|
|
||||||
<!-- <DownloadList slot="downloads" {downloads} {cancelDownload} />-->
|
|
||||||
</MMMainLayout>
|
|
||||||
{/if}
|
|
||||||
{/await}
|
|
||||||
</MMModal>
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Container from '../../../Container.svelte';
|
import Container from '../../../Container.svelte';
|
||||||
|
import { modalStack } from './modal.store';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Container focusOnMount trapFocus class="fixed inset-0">
|
<Container focusOnMount trapFocus class="fixed inset-0" on:back={() => modalStack.closeTopmost()}>
|
||||||
<slot />
|
<slot />
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
import { modalStack, modalStackTop } from './modal.store';
|
import { modalStack, modalStackTop } from './modal.store';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
function handleShortcuts(event: KeyboardEvent) {
|
// function handleShortcuts(event: KeyboardEvent) {
|
||||||
const top = $modalStackTop;
|
// const top = $modalStackTop;
|
||||||
if ((event.key === 'Escape' || event.key === 'Back' || event.key === 'XF86Back') && top) {
|
// if ((event.key === 'Escape' || event.key === 'Back' || event.key === 'XF86Back') && top) {
|
||||||
modalStack.close(top.id);
|
// modalStack.close(top.id);
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
modalStack.reset();
|
modalStack.reset();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={handleShortcuts} />
|
<!--<svelte:window on:keydown={handleShortcuts} />-->
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
{#if $modalStackTop}
|
{#if $modalStackTop}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { ComponentType, SvelteComponentTyped } from 'svelte';
|
import type { ComponentType, SvelteComponentTyped } from 'svelte';
|
||||||
import { derived, writable } from 'svelte/store';
|
import { derived, get, writable } from 'svelte/store';
|
||||||
import SeasonMediaManagerModal from '../MediaManagerModal/SeasonMediaManagerModal.svelte';
|
import SeasonMediaManagerModal from '../MediaManagerModal/SeasonMediaManagerModal.svelte';
|
||||||
import EpisodeMediaManagerModal from '../MediaManagerModal/EpisodeMediaManagerModal.svelte';
|
import EpisodeMediaManagerModal from '../MediaManagerModal/EpisodeMediaManagerModal.svelte';
|
||||||
import MovieMediaManagerModal from '../MediaManagerModal/MovieMediaManagerModal.svelte';
|
import MovieMediaManagerModal from '../MediaManagerModal/MovieMediaManagerModal.svelte';
|
||||||
|
import ConfirmDialog from '../Dialog/ConfirmDialog.svelte';
|
||||||
|
import { sonarrApi, type SonarrSeries } from '../../apis/sonarr/sonarr-api';
|
||||||
|
|
||||||
type ModalItem = {
|
type ModalItem = {
|
||||||
id: symbol;
|
id: symbol;
|
||||||
@@ -37,6 +39,13 @@ function createModalStack() {
|
|||||||
items.set([]);
|
items.set([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeTopmost() {
|
||||||
|
const t = get(top);
|
||||||
|
if (t) {
|
||||||
|
close(t.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe: items.subscribe,
|
subscribe: items.subscribe,
|
||||||
top: {
|
top: {
|
||||||
@@ -45,15 +54,17 @@ function createModalStack() {
|
|||||||
create,
|
create,
|
||||||
close,
|
close,
|
||||||
closeGroup,
|
closeGroup,
|
||||||
|
closeTopmost,
|
||||||
reset
|
reset
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const modalStack = createModalStack();
|
export const modalStack = createModalStack();
|
||||||
export const modalStackTop = modalStack.top;
|
export const modalStackTop = modalStack.top;
|
||||||
|
export const createModal = modalStack.create;
|
||||||
|
|
||||||
export const openSeasonMediaManager = (tmdbId: number, season: number) =>
|
export const openSeasonMediaManager = (sonarrItem: SonarrSeries, season: number) =>
|
||||||
modalStack.create(SeasonMediaManagerModal, { id: tmdbId, season });
|
modalStack.create(SeasonMediaManagerModal, { sonarrItem, season });
|
||||||
|
|
||||||
export const openEpisodeMediaManager = (tmdbId: number, season: number, episode: number) =>
|
export const openEpisodeMediaManager = (tmdbId: number, season: number, episode: number) =>
|
||||||
modalStack.create(EpisodeMediaManagerModal, { id: tmdbId, season, episode });
|
modalStack.create(EpisodeMediaManagerModal, { id: tmdbId, season, episode });
|
||||||
|
|||||||
@@ -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 ScrollHelper from '../ScrollHelper.svelte';
|
||||||
import ManageSeasonCard from './ManageSeasonCard.svelte';
|
import ManageSeasonCard from './ManageSeasonCard.svelte';
|
||||||
import { TMDB_BACKDROP_SMALL } from '../../constants';
|
import { TMDB_BACKDROP_SMALL } from '../../constants';
|
||||||
import { modalStack, openSeasonMediaManager } from '../Modal/modal.store';
|
import { createModal, modalStack, openSeasonMediaManager } from '../Modal/modal.store';
|
||||||
import { navigate } from '../StackRouter/StackRouter';
|
import { navigate } from '../StackRouter/StackRouter';
|
||||||
|
import SeasonMediaManagerModal from '../MediaManagerModal/SeasonMediaManagerModal.svelte';
|
||||||
|
import type { SonarrSeries } from '../../apis/sonarr/sonarr-api';
|
||||||
|
import MMAddToSonarrDialog from '../MediaManagerModal/MMAddToSonarrDialog.svelte';
|
||||||
|
|
||||||
export let id: number;
|
export let id: number;
|
||||||
export let tmdbSeries: Readable<TmdbSeriesFull2 | undefined>;
|
export let tmdbSeries: Readable<TmdbSeriesFull2 | undefined>;
|
||||||
export let jellyfinEpisodes: Promise<JellyfinItem[]>;
|
export let jellyfinEpisodes: Promise<JellyfinItem[]>;
|
||||||
export let currentJellyfinEpisode: Promise<JellyfinItem | undefined>;
|
export let currentJellyfinEpisode: Promise<JellyfinItem | undefined>;
|
||||||
|
export let handleRequestSeason: (season: number) => Promise<any>;
|
||||||
|
|
||||||
console.log('ID IS: ', id);
|
console.log('ID IS: ', id);
|
||||||
|
|
||||||
@@ -60,14 +64,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleMountCard(s: Selectable, episode: TmdbEpisode) {
|
function handleMountCard(s: Selectable, episode: TmdbEpisode) {
|
||||||
currentJellyfinEpisode.then((currentEpisode) => {
|
// currentJellyfinEpisode.then((currentEpisode) => {
|
||||||
if (
|
// if (
|
||||||
currentEpisode?.IndexNumber === episode.episode_number &&
|
// currentEpisode?.IndexNumber === episode.episode_number &&
|
||||||
currentEpisode?.ParentIndexNumber === episode.season_number
|
// currentEpisode?.ParentIndexNumber === episode.season_number
|
||||||
) {
|
// ) {
|
||||||
s.focus({ setFocusedElement: false, propagate: false });
|
// s.focus({ setFocusedElement: false, propagate: false });
|
||||||
}
|
// }
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -137,7 +141,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
<ManageSeasonCard
|
<ManageSeasonCard
|
||||||
backdropUrl={TMDB_BACKDROP_SMALL + $tmdbSeries?.backdrop_path}
|
backdropUrl={TMDB_BACKDROP_SMALL + $tmdbSeries?.backdrop_path}
|
||||||
on:clickOrSelect={() => openSeasonMediaManager(id, seasonIndex + 1)}
|
on:clickOrSelect={() => handleRequestSeason(seasonIndex + 1)}
|
||||||
on:enter={scrollIntoView({ top: 92, bottom: 128 })}
|
on:enter={scrollIntoView({ top: 92, bottom: 128 })}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
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 { tmdbApi, type TmdbSeasonEpisode } from '../../apis/tmdb/tmdb-api';
|
||||||
import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../../constants';
|
import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../../constants';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { DotFilled, Download, ExternalLink, File, Play, Plus } from 'radix-icons-svelte';
|
import { DotFilled, Download, ExternalLink, File, Play, Plus, Trash } from 'radix-icons-svelte';
|
||||||
import { jellyfinApi } from '../../apis/jellyfin/jellyfin-api';
|
import { jellyfinApi } from '../../apis/jellyfin/jellyfin-api';
|
||||||
import { sonarrApi } from '../../apis/sonarr/sonarr-api';
|
import { type EpisodeFileResource, sonarrApi } from '../../apis/sonarr/sonarr-api';
|
||||||
import Button from '../Button.svelte';
|
import Button from '../Button.svelte';
|
||||||
import { playerState } from '../VideoPlayer/VideoPlayer';
|
import { playerState } from '../VideoPlayer/VideoPlayer';
|
||||||
import { modalStack } from '../Modal/modal.store';
|
import { createModal, modalStack } from '../Modal/modal.store';
|
||||||
import { derived } from 'svelte/store';
|
import { derived, get, writable } from 'svelte/store';
|
||||||
import { scrollIntoView, useRegistrar } from '../../selectable';
|
import { scrollIntoView, useRegistrar } from '../../selectable';
|
||||||
import ScrollHelper from '../ScrollHelper.svelte';
|
import ScrollHelper from '../ScrollHelper.svelte';
|
||||||
import Carousel from '../Carousel/Carousel.svelte';
|
import Carousel from '../Carousel/Carousel.svelte';
|
||||||
import TmdbPersonCard from '../PersonCard/TmdbPersonCard.svelte';
|
import TmdbPersonCard from '../PersonCard/TmdbPersonCard.svelte';
|
||||||
import TmdbCard from '../Card/TmdbCard.svelte';
|
import TmdbCard from '../Card/TmdbCard.svelte';
|
||||||
import EpisodeGrid from './EpisodeGrid.svelte';
|
import EpisodeGrid from './EpisodeGrid.svelte';
|
||||||
import EpisodePage from '../../pages/EpisodePage.svelte';
|
import { formatSize } from '../../utils';
|
||||||
import SeriesMediaManagerModal from '../MediaManagerModal/SeasonMediaManagerModal.svelte';
|
import FileDetailsDialog from './FileDetailsDialog.svelte';
|
||||||
|
import ConfirmDeleteSeasonDialog from './ConfirmDeleteSeasonDialog.svelte';
|
||||||
|
import SeasonMediaManagerModal from '../MediaManagerModal/SeasonMediaManagerModal.svelte';
|
||||||
|
import MMAddToSonarrDialog from '../MediaManagerModal/MMAddToSonarrDialog.svelte';
|
||||||
|
|
||||||
export let id: string;
|
export let id: string;
|
||||||
|
|
||||||
@@ -28,11 +31,26 @@
|
|||||||
tmdbApi.getTmdbSeries,
|
tmdbApi.getTmdbSeries,
|
||||||
Number(id)
|
Number(id)
|
||||||
);
|
);
|
||||||
const { promise: sonarrItem } = useRequest(sonarrApi.getSeriesByTmdbId, Number(id));
|
let sonarrItem = sonarrApi.getSeriesByTmdbId(Number(id));
|
||||||
const jellyfinSeries = getJellyfinSeries(id);
|
|
||||||
|
|
||||||
const { promise: recommendations } = useRequest(tmdbApi.getSeriesRecommendations, Number(id));
|
const { promise: recommendations } = useRequest(tmdbApi.getSeriesRecommendations, Number(id));
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
$: localFilesP = sonarrItem && getLocalFiles();
|
||||||
|
$: localFileSeasons = localFilesP.then((files) => [
|
||||||
|
...new Set(files.map((item) => item.seasonNumber || -1))
|
||||||
|
]);
|
||||||
|
$: sonarrEpisodes = Promise.all([sonarrItem, localFileSeasons])
|
||||||
|
.then(([item, seasons]) =>
|
||||||
|
Promise.all(seasons.map((s) => sonarrApi.getEpisodes(item?.id || -1, s)))
|
||||||
|
)
|
||||||
|
.then((items) => items.flat());
|
||||||
|
$: localFilesP.then(console.log);
|
||||||
|
$: sonarrEpisodes.then(console.log);
|
||||||
|
$: sonarrItem.then(console.log);
|
||||||
|
$: localFileSeasons.then(console.log);
|
||||||
|
|
||||||
|
const jellyfinSeries = getJellyfinSeries(id);
|
||||||
|
|
||||||
const jellyfinEpisodes = jellyfinSeries.then(
|
const jellyfinEpisodes = jellyfinSeries.then(
|
||||||
(s) => (s && jellyfinApi.getJellyfinEpisodes(s.Id)) || []
|
(s) => (s && jellyfinApi.getJellyfinEpisodes(s.Id)) || []
|
||||||
);
|
);
|
||||||
@@ -41,17 +59,54 @@
|
|||||||
items.find((i) => i.UserData?.Played === false)
|
items.find((i) => i.UserData?.Played === false)
|
||||||
);
|
);
|
||||||
|
|
||||||
let hideInterface = false;
|
|
||||||
const episodeCards = useRegistrar();
|
const episodeCards = useRegistrar();
|
||||||
let scrollTop: number;
|
let scrollTop: number;
|
||||||
|
|
||||||
modalStack.top.subscribe((modal) => {
|
// let hideInterface = false;
|
||||||
hideInterface = !!modal;
|
// modalStack.top.subscribe((modal) => {
|
||||||
});
|
// hideInterface = !!modal;
|
||||||
|
// });
|
||||||
|
|
||||||
function getJellyfinSeries(id: string) {
|
function getJellyfinSeries(id: string) {
|
||||||
return jellyfinApi.getLibraryItemFromTmdbId(id);
|
return jellyfinApi.getLibraryItemFromTmdbId(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLocalFiles() {
|
||||||
|
return sonarrItem.then((item) =>
|
||||||
|
item ? sonarrApi.getFilesBySeriesId(item?.id || -1) : Promise.resolve([])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddedToSonarr() {
|
||||||
|
sonarrItem = sonarrApi.getSeriesByTmdbId(Number(id));
|
||||||
|
sonarrItem.then(
|
||||||
|
(sonarrItem) =>
|
||||||
|
sonarrItem &&
|
||||||
|
createModal(SeasonMediaManagerModal, {
|
||||||
|
season: 1,
|
||||||
|
sonarrItem
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRequestSeason(season: number) {
|
||||||
|
return sonarrItem.then((sonarrItem) => {
|
||||||
|
const tmdbSeries = get(tmdbSeriesData);
|
||||||
|
if (sonarrItem) {
|
||||||
|
createModal(SeasonMediaManagerModal, {
|
||||||
|
season,
|
||||||
|
sonarrItem
|
||||||
|
});
|
||||||
|
} else if (tmdbSeries) {
|
||||||
|
createModal(MMAddToSonarrDialog, {
|
||||||
|
series: tmdbSeries,
|
||||||
|
onComplete: handleAddedToSonarr
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('No series found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DetachedPage let:handleGoBack let:registrar>
|
<DetachedPage let:handleGoBack let:registrar>
|
||||||
@@ -75,7 +130,6 @@
|
|||||||
?.map((i) => TMDB_IMAGES_ORIGINAL + i.file_path)
|
?.map((i) => TMDB_IMAGES_ORIGINAL + i.file_path)
|
||||||
.slice(0, 5) || []
|
.slice(0, 5) || []
|
||||||
)}
|
)}
|
||||||
{hideInterface}
|
|
||||||
>
|
>
|
||||||
<Container />
|
<Container />
|
||||||
<div class="h-full flex-1 flex flex-col justify-end">
|
<div class="h-full flex-1 flex flex-col justify-end">
|
||||||
@@ -118,7 +172,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/await}
|
{/await}
|
||||||
{#await Promise.all( [$sonarrItem, jellyfinSeries, jellyfinEpisodes, nextJellyfinEpisode] ) then [sonarrItem, jellyfinItem, jellyfinEpisodes, nextJellyfinEpisode]}
|
{#await nextJellyfinEpisode then nextJellyfinEpisode}
|
||||||
<Container
|
<Container
|
||||||
direction="horizontal"
|
direction="horizontal"
|
||||||
class="flex mt-8"
|
class="flex mt-8"
|
||||||
@@ -136,19 +190,13 @@
|
|||||||
{nextJellyfinEpisode?.IndexNumber}
|
{nextJellyfinEpisode?.IndexNumber}
|
||||||
<Play size={19} slot="icon" />
|
<Play size={19} slot="icon" />
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{:else}
|
||||||
<Button
|
<Button class="mr-4" action={() => handleRequestSeason(1)}>
|
||||||
class="mr-4"
|
|
||||||
on:clickOrSelect={() =>
|
|
||||||
modalStack.create(SeriesMediaManagerModal, { id: Number(id) })}
|
|
||||||
>
|
|
||||||
{#if jellyfinItem}
|
|
||||||
Manage Media
|
|
||||||
{:else}
|
|
||||||
Request
|
Request
|
||||||
{/if}
|
<Plus size={19} slot="icon" />
|
||||||
<svelte:component this={jellyfinItem ? File : Download} size={19} slot="icon" />
|
</Button>
|
||||||
</Button>
|
{/if}
|
||||||
|
|
||||||
{#if PLATFORM_WEB}
|
{#if PLATFORM_WEB}
|
||||||
<Button class="mr-4">
|
<Button class="mr-4">
|
||||||
Open In TMDB
|
Open In TMDB
|
||||||
@@ -166,16 +214,17 @@
|
|||||||
</Container>
|
</Container>
|
||||||
<div
|
<div
|
||||||
class={classNames('transition-opacity', {
|
class={classNames('transition-opacity', {
|
||||||
'opacity-0': hideInterface
|
// 'opacity-0': hideInterface
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<EpisodeGrid
|
<EpisodeGrid
|
||||||
on:enter={scrollIntoView({ top: -32, bottom: 128 })}
|
on:enter={scrollIntoView({ top: -32, bottom: 128 })}
|
||||||
|
on:mount={episodeCards.registrar}
|
||||||
id={Number(id)}
|
id={Number(id)}
|
||||||
tmdbSeries={tmdbSeriesData}
|
tmdbSeries={tmdbSeriesData}
|
||||||
{jellyfinEpisodes}
|
{jellyfinEpisodes}
|
||||||
currentJellyfinEpisode={nextJellyfinEpisode}
|
currentJellyfinEpisode={nextJellyfinEpisode}
|
||||||
on:mount={episodeCards.registrar}
|
{handleRequestSeason}
|
||||||
/>
|
/>
|
||||||
<Container on:enter={scrollIntoView({ top: 0 })} class="pt-8">
|
<Container on:enter={scrollIntoView({ top: 0 })} class="pt-8">
|
||||||
{#await $tmdbSeries then series}
|
{#await $tmdbSeries then series}
|
||||||
@@ -200,23 +249,23 @@
|
|||||||
<h1 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">More Information</h1>
|
<h1 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">More Information</h1>
|
||||||
<div class="text-zinc-300 font-medium text-lg flex flex-wrap">
|
<div class="text-zinc-300 font-medium text-lg flex flex-wrap">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="border-l-2 border-zinc-300 pl-4 mb-8">
|
<div class="mb-8">
|
||||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Created By</h2>
|
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Created By</h2>
|
||||||
{#each series?.created_by || [] as creator}
|
{#each series?.created_by || [] as creator}
|
||||||
<div>{creator.name}</div>
|
<div>{creator.name}</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="border-l-2 border-zinc-300 pl-4 mb-8">
|
<div class="mb-8">
|
||||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Network</h2>
|
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Network</h2>
|
||||||
<div>{series?.networks?.[0]?.name}</div>
|
<div>{series?.networks?.[0]?.name}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="border-l-2 border-zinc-300 pl-4 mb-8">
|
<div class="mb-8">
|
||||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Language</h2>
|
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Language</h2>
|
||||||
<div>{series?.spoken_languages?.[0]?.name}</div>
|
<div>{series?.spoken_languages?.[0]?.name}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-l-2 border-zinc-300 pl-4 mb-8">
|
<div class="mb-8">
|
||||||
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Last Air Date</h2>
|
<h2 class="uppercase text-sm font-semibold text-zinc-500 mb-0.5">Last Air Date</h2>
|
||||||
<div>{series?.last_air_date}</div>
|
<div>{series?.last_air_date}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,6 +273,96 @@
|
|||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
{/await}
|
{/await}
|
||||||
|
{#await Promise.all( [localFilesP, localFileSeasons, sonarrEpisodes] ) then [localFiles, seasons, episodes]}
|
||||||
|
{#if localFiles?.length}
|
||||||
|
<Container
|
||||||
|
class="flex-1 bg-secondary-950 pt-8 pb-16 px-32 flex flex-col"
|
||||||
|
on:enter={scrollIntoView({ top: 0 })}
|
||||||
|
>
|
||||||
|
<!-- <h1 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">Local Files</h1>-->
|
||||||
|
<div class="space-y-16">
|
||||||
|
{#each seasons as season}
|
||||||
|
{@const files = localFiles.filter((f) => f.seasonNumber === season)}
|
||||||
|
<div class="">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<h2 class="font-medium tracking-wide text-2xl text-zinc-300 mb-8">
|
||||||
|
Season {season} Files
|
||||||
|
</h2>
|
||||||
|
<!-- <Checkbox-->
|
||||||
|
<!-- checked={Object.keys(selectedFiles).length-->
|
||||||
|
<!-- ? Object.values(selectedFiles).every(({ file, selected }) => {-->
|
||||||
|
<!-- return file.seasonNumber === season ? selected : true;-->
|
||||||
|
<!-- })-->
|
||||||
|
<!-- : false}-->
|
||||||
|
<!-- on:change={({ detail }) => {-->
|
||||||
|
<!-- selectedFiles = Object.fromEntries(-->
|
||||||
|
<!-- Object.entries(selectedFiles).map(([key, value]) => {-->
|
||||||
|
<!-- if (value.file.seasonNumber === season) {-->
|
||||||
|
<!-- value.selected = detail;-->
|
||||||
|
<!-- }-->
|
||||||
|
<!-- return [key, value];-->
|
||||||
|
<!-- })-->
|
||||||
|
<!-- );-->
|
||||||
|
<!-- }}-->
|
||||||
|
<!-- />-->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-8">
|
||||||
|
{#each files as file}
|
||||||
|
{@const episode = episodes.find(
|
||||||
|
(e) => e.episodeFileId !== undefined && e.episodeFileId === file.id
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Container
|
||||||
|
class={classNames(
|
||||||
|
'flex space-x-8 items-center text-zinc-300 font-medium',
|
||||||
|
'px-8 py-4 border-2 border-transparent rounded-xl',
|
||||||
|
{
|
||||||
|
'bg-secondary-800 focus-within:bg-primary-700 focus-within:border-primary-500': true,
|
||||||
|
'hover:bg-primary-700 hover:border-primary-500 cursor-pointer': true
|
||||||
|
// 'bg-primary-700 focus-within:border-primary-500': selected,
|
||||||
|
// 'bg-secondary-800 focus-within:border-zinc-300': !selected
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
on:clickOrSelect={() =>
|
||||||
|
modalStack.create(FileDetailsDialog, { file, episode })}
|
||||||
|
focusOnClick
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h1 class="text-lg">
|
||||||
|
{episode?.episodeNumber}. {episode?.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{file.mediaInfo?.runTime}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{formatSize(file.size || 0)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{file.quality?.quality?.name}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<Container direction="horizontal" class="flex mt-8">
|
||||||
|
<Button
|
||||||
|
on:clickOrSelect={() =>
|
||||||
|
createModal(ConfirmDeleteSeasonDialog, {
|
||||||
|
files: files,
|
||||||
|
onComplete: () => (localFilesP = getLocalFiles())
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Trash size={19} slot="icon" />
|
||||||
|
Delete Season Files
|
||||||
|
</Button>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DetachedPage>
|
</DetachedPage>
|
||||||
|
|||||||
@@ -34,10 +34,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const selectIndex = (index: number) => () => {
|
const selectIndex = (index: number) => () => {
|
||||||
if (index === activeIndex) {
|
// if (index === activeIndex) {
|
||||||
if (get(selectable.hasFocusWithin)) Selectable.giveFocus('right');
|
// if (get(selectable.hasFocusWithin)) Selectable.giveFocus('right');
|
||||||
return;
|
// }
|
||||||
}
|
|
||||||
selectable.focusChild(index);
|
selectable.focusChild(index);
|
||||||
const path =
|
const path =
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.row-wrapper-selected > ._table-cell) {
|
:global(.row-wrapper-selected > ._table-cell) {
|
||||||
@apply bg-secondary-800;
|
@apply bg-primary-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.row-wrapper > ._table-cell:first-child) {
|
:global(.row-wrapper > ._table-cell:first-child) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<Container
|
<Container
|
||||||
direction="horizontal"
|
direction="horizontal"
|
||||||
on:enter
|
on:enter
|
||||||
class="*:sticky *:top-0 *:bg-secondary-900 row-wrapper contents"
|
class="*:sticky *:top-0 *:bg-primary-800 row-wrapper contents"
|
||||||
>
|
>
|
||||||
<!-- <div class="absolute -inset-y-2 -inset-x-8 -z-10 bg-secondary-900" />-->
|
<!-- <div class="absolute -inset-y-2 -inset-x-8 -z-10 bg-secondary-900" />-->
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
on:clickOrSelect
|
on:clickOrSelect
|
||||||
focusOnClick
|
focusOnClick
|
||||||
class={classNames(
|
class={classNames(
|
||||||
'flex items-center rounded-full py-1 px-3 -mx-3 cursor-pointer select-none font-semibold float-left',
|
'flex items-center rounded-full py-1 cursor-pointer select-none font-semibold float-left',
|
||||||
{
|
{
|
||||||
'bg-primary-500 text-secondary-800': $hasFocus
|
'bg-primary-500 text-secondary-800 px-3': $hasFocus
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -59,7 +59,6 @@
|
|||||||
<!-- <HeroCarousel /> -->
|
<!-- <HeroCarousel /> -->
|
||||||
|
|
||||||
<Container
|
<Container
|
||||||
on:navigate={handleGoBack}
|
|
||||||
on:back={handleGoBack}
|
on:back={handleGoBack}
|
||||||
on:mount={registrar}
|
on:mount={registrar}
|
||||||
focusOnMount
|
focusOnMount
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ interface AuthenticationStoreData {
|
|||||||
token?: string;
|
token?: string;
|
||||||
serverBaseUrl?: string;
|
serverBaseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UserStoreData {
|
||||||
|
user: ReiverrUser | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppStateData extends AuthenticationStoreData {
|
||||||
|
user: ReiverrUser | null;
|
||||||
|
}
|
||||||
|
|
||||||
const authenticationStore = createLocalStorageStore<AuthenticationStoreData>(
|
const authenticationStore = createLocalStorageStore<AuthenticationStoreData>(
|
||||||
'authentication-token',
|
'authentication-token',
|
||||||
{
|
{
|
||||||
@@ -15,15 +24,17 @@ const authenticationStore = createLocalStorageStore<AuthenticationStoreData>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
function createAppState() {
|
function createAppState() {
|
||||||
const userStore = writable<ReiverrUser | null>(undefined);
|
const userStore = writable<UserStoreData>(undefined);
|
||||||
|
|
||||||
const combinedStore = derived([userStore, authenticationStore], ([$user, $auth]) => {
|
const combinedStore = derived<[typeof userStore, typeof authenticationStore], AppStateData>(
|
||||||
return {
|
[userStore, authenticationStore],
|
||||||
user: $user,
|
([user, auth]) => {
|
||||||
token: $auth.token,
|
return {
|
||||||
serverBaseUrl: $auth.serverBaseUrl
|
...user,
|
||||||
};
|
...auth
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
function setBaseUrl(serverBaseUrl: string | undefined = undefined) {
|
function setBaseUrl(serverBaseUrl: string | undefined = undefined) {
|
||||||
authenticationStore.update((p) => ({ ...p, serverBaseUrl }));
|
authenticationStore.update((p) => ({ ...p, serverBaseUrl }));
|
||||||
@@ -34,7 +45,7 @@ function createAppState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setUser(user: ReiverrUser | null) {
|
function setUser(user: ReiverrUser | null) {
|
||||||
userStore.set(user);
|
userStore.set({ user });
|
||||||
}
|
}
|
||||||
|
|
||||||
function logOut() {
|
function logOut() {
|
||||||
@@ -42,12 +53,21 @@ function createAppState() {
|
|||||||
setToken(undefined);
|
setToken(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ready = new Promise<AppStateData>((resolve) => {
|
||||||
|
combinedStore.subscribe((state) => {
|
||||||
|
if (state.token && state.serverBaseUrl && state.user !== undefined) {
|
||||||
|
resolve(state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe: combinedStore.subscribe,
|
subscribe: combinedStore.subscribe,
|
||||||
setBaseUrl,
|
setBaseUrl,
|
||||||
setToken,
|
setToken,
|
||||||
setUser,
|
setUser,
|
||||||
logOut
|
logOut,
|
||||||
|
ready
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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-foreground': '#f6c304',
|
||||||
'highlight-background': '#161517',
|
'highlight-background': '#161517',
|
||||||
primary: {
|
primary: {
|
||||||
50: 'hsl(40, 80%, 95%)', //''#fcf9ea',
|
50: 'hsl(40, 60%, 95%)', //''#fcf9ea',
|
||||||
100: 'hsl(40, 80%, 90%)', //''#faefc7',
|
100: 'hsl(40, 60%, 90%)', //''#faefc7',
|
||||||
200: 'hsl(40, 80%, 80%)', //''#f6dc92',
|
200: 'hsl(40, 60%, 80%)', //''#f6dc92',
|
||||||
300: 'hsl(40, 80%, 70%)', //''#f0c254',
|
300: 'hsl(40, 60%, 70%)', //''#f0c254',
|
||||||
400: 'hsl(40, 80%, 65%)', //''#ebab2e',
|
400: 'hsl(40, 60%, 65%)', //''#ebab2e',
|
||||||
500: 'hsl(40, 80%, 55%)', //'#da9018',
|
500: 'hsl(40, 60%, 55%)', //'#da9018',
|
||||||
600: 'hsl(40, 80%, 24%)', //'#bc6e12',
|
600: 'hsl(40, 30%, 24%)', //'#bc6e12',
|
||||||
700: 'hsl(40, 80%, 18%)', //'#964e12',
|
700: 'hsl(40, 30%, 18%)', //'#964e12',
|
||||||
800: 'hsl(40, 80%, 12%)', //'#7d3f16',
|
800: 'hsl(40, 20%, 12%)', //'#7d3f16',
|
||||||
900: 'hsl(40, 80%, 7%)', //'#6a3419',
|
900: 'hsl(40, 20%, 8%)', //'#6a3419',
|
||||||
950: 'hsl(40, 80%, 4%)' //'#3e1a0a'
|
950: 'hsl(40, 20%, 4%)' //'#3e1a0a'
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
50: 'hsl(40, 12%, 95%)',
|
50: 'hsl(40, 12%, 95%)',
|
||||||
|
|||||||
Reference in New Issue
Block a user