feat: Series page & derived requests
This commit is contained in:
@@ -56,7 +56,6 @@
|
||||
<Route path="search">
|
||||
<SearchPage />
|
||||
</Route>
|
||||
<Route path="series/:id" component={SeriesPage} />
|
||||
<Route path="movie/:id" component={MoviePage} />
|
||||
<Route path="*">
|
||||
<div>404</div>
|
||||
@@ -67,6 +66,8 @@
|
||||
<Router>
|
||||
<Route path="movies/movie/:id" component={MoviePage} />
|
||||
<Route path="library/movie/:id" component={MoviePage} />
|
||||
<Route path="series/:id" component={SeriesPage} />
|
||||
<Route path="library/series/:id" component={SeriesPage} />
|
||||
</Router>
|
||||
|
||||
<ModalStack />
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { components, paths } from './jellyfin.generated';
|
||||
import type { Api } from '../api.interface';
|
||||
import { appState } from '../../stores/app-state.store';
|
||||
import type { DeviceProfile } from './playback-profiles';
|
||||
import axios from 'axios';
|
||||
|
||||
export type JellyfinItem = components['schemas']['BaseItemDto'];
|
||||
|
||||
@@ -31,6 +32,10 @@ export class JellyfinApi implements Api<paths> {
|
||||
return get(appState).user?.settings.jellyfin.apiKey || '';
|
||||
}
|
||||
|
||||
getBaseUrl() {
|
||||
return get(appState).user?.settings.jellyfin.baseUrl || '';
|
||||
}
|
||||
|
||||
async getContinueWatching(): Promise<JellyfinItem[] | undefined> {
|
||||
return this.getClient()
|
||||
.GET('/Users/{userId}/Items/Resume', {
|
||||
@@ -187,6 +192,258 @@ export class JellyfinApi implements Api<paths> {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getJellyfinContinueWatching = async (): Promise<JellyfinItem[] | undefined> =>
|
||||
this.getClient()
|
||||
?.GET('/Users/{userId}/Items/Resume', {
|
||||
params: {
|
||||
path: {
|
||||
userId: this.getUserId()
|
||||
},
|
||||
query: {
|
||||
mediaTypes: ['Video'],
|
||||
fields: ['ProviderIds', 'Genres']
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data?.Items || []);
|
||||
|
||||
getJellyfinNextUp = async () =>
|
||||
this.getClient()
|
||||
?.GET('/Shows/NextUp', {
|
||||
params: {
|
||||
query: {
|
||||
userId: this.getUserId(),
|
||||
fields: ['ProviderIds', 'Genres']
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data?.Items || []);
|
||||
|
||||
// getJellyfinSeries = () =>
|
||||
// JellyfinApi.get('/Users/{userId}/Items', {
|
||||
// params: {
|
||||
// path: {
|
||||
// userId: PUBLIC_JELLYFIN_USER_ID || ''
|
||||
// },
|
||||
// query: {
|
||||
// hasTmdbId: true,
|
||||
// recursive: true,
|
||||
// includeItemTypes: ['Series']
|
||||
// }
|
||||
// }
|
||||
// }).then((r) => r.data?.Items || []);
|
||||
|
||||
getJellyfinEpisodes = async (parentId = '') =>
|
||||
this.getClient()
|
||||
?.GET('/Users/{userId}/Items', {
|
||||
params: {
|
||||
path: {
|
||||
userId: this.getUserId()
|
||||
},
|
||||
query: {
|
||||
recursive: true,
|
||||
includeItemTypes: ['Episode'],
|
||||
parentId
|
||||
}
|
||||
},
|
||||
headers: {
|
||||
'cache-control': 'max-age=10'
|
||||
}
|
||||
})
|
||||
.then((r) => r.data?.Items || []) || [];
|
||||
|
||||
getJellyfinEpisodesInSeasons = async (seriesId: string) =>
|
||||
this.getJellyfinEpisodes(seriesId).then((items) => {
|
||||
const seasons: Record<string, JellyfinItem[]> = {};
|
||||
|
||||
items?.forEach((item) => {
|
||||
const seasonNumber = item.ParentIndexNumber || 0;
|
||||
|
||||
if (!seasons[seasonNumber]) {
|
||||
seasons[seasonNumber] = [];
|
||||
}
|
||||
|
||||
seasons[seasonNumber]?.push(item);
|
||||
});
|
||||
|
||||
return seasons;
|
||||
});
|
||||
|
||||
// getJellyfinEpisodesBySeries = (seriesId: string) =>
|
||||
// getJellyfinEpisodes().then((items) => items?.filter((i) => i.SeriesId === seriesId) || []);
|
||||
|
||||
// getJellyfinItemByTmdbId = (tmdbId: string) =>
|
||||
// getJellyfinItems().then((items) => items?.find((i) => i.ProviderIds?.Tmdb == tmdbId));
|
||||
|
||||
getJellyfinItem = async (itemId: string) =>
|
||||
this.getClient()
|
||||
?.GET('/Users/{userId}/Items/{itemId}', {
|
||||
params: {
|
||||
path: {
|
||||
itemId,
|
||||
userId: this.getUserId()
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data);
|
||||
|
||||
// requestJellyfinItemByTmdbId = () =>
|
||||
// request((tmdbId: string) => getJellyfinItemByTmdbId(tmdbId));
|
||||
|
||||
getJellyfinPlaybackInfo = async (
|
||||
itemId: string,
|
||||
playbackProfile: DeviceProfile,
|
||||
startTimeTicks = 0,
|
||||
maxStreamingBitrate = 140000000
|
||||
) =>
|
||||
this.getClient()
|
||||
?.POST('/Items/{itemId}/PlaybackInfo', {
|
||||
params: {
|
||||
path: {
|
||||
itemId: itemId
|
||||
},
|
||||
query: {
|
||||
userId: this.getUserId(),
|
||||
startTimeTicks,
|
||||
autoOpenLiveStream: true,
|
||||
maxStreamingBitrate
|
||||
}
|
||||
},
|
||||
body: {
|
||||
DeviceProfile: playbackProfile
|
||||
}
|
||||
})
|
||||
.then((r) => ({
|
||||
playbackUri:
|
||||
r.data?.MediaSources?.[0]?.TranscodingUrl ||
|
||||
`/Videos/${r.data?.MediaSources?.[0]?.Id}/stream.mp4?Static=true&mediaSourceId=${
|
||||
r.data?.MediaSources?.[0]?.Id
|
||||
}&deviceId=${JELLYFIN_DEVICE_ID}&api_key=${this.getApiKey()}&Tag=${
|
||||
r.data?.MediaSources?.[0]?.ETag
|
||||
}`,
|
||||
mediaSourceId: r.data?.MediaSources?.[0]?.Id,
|
||||
playSessionId: r.data?.PlaySessionId,
|
||||
directPlay:
|
||||
!!r.data?.MediaSources?.[0]?.SupportsDirectPlay ||
|
||||
!!r.data?.MediaSources?.[0]?.SupportsDirectStream
|
||||
}));
|
||||
|
||||
reportJellyfinPlaybackStarted = (
|
||||
itemId: string,
|
||||
sessionId: string,
|
||||
mediaSourceId: string,
|
||||
audioStreamIndex?: number,
|
||||
subtitleStreamIndex?: number
|
||||
) =>
|
||||
this.getClient()?.POST('/Sessions/Playing', {
|
||||
body: {
|
||||
CanSeek: true,
|
||||
ItemId: itemId,
|
||||
PlaySessionId: sessionId,
|
||||
MediaSourceId: mediaSourceId,
|
||||
AudioStreamIndex: 1,
|
||||
SubtitleStreamIndex: -1
|
||||
}
|
||||
});
|
||||
|
||||
reportJellyfinPlaybackProgress = (
|
||||
itemId: string,
|
||||
sessionId: string,
|
||||
isPaused: boolean,
|
||||
positionTicks: number
|
||||
) =>
|
||||
this.getClient()?.POST('/Sessions/Playing/Progress', {
|
||||
body: {
|
||||
ItemId: itemId,
|
||||
PlaySessionId: sessionId,
|
||||
IsPaused: isPaused,
|
||||
PositionTicks: Math.round(positionTicks),
|
||||
CanSeek: true,
|
||||
MediaSourceId: itemId
|
||||
}
|
||||
});
|
||||
|
||||
reportJellyfinPlaybackStopped = (itemId: string, sessionId: string, positionTicks: number) =>
|
||||
this.getClient()?.POST('/Sessions/Playing/Stopped', {
|
||||
body: {
|
||||
ItemId: itemId,
|
||||
PlaySessionId: sessionId,
|
||||
PositionTicks: Math.round(positionTicks),
|
||||
MediaSourceId: itemId
|
||||
}
|
||||
});
|
||||
|
||||
delteActiveEncoding = (playSessionId: string) =>
|
||||
this.getClient()?.DELETE('/Videos/ActiveEncodings', {
|
||||
params: {
|
||||
query: {
|
||||
deviceId: JELLYFIN_DEVICE_ID,
|
||||
playSessionId: playSessionId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setJellyfinItemWatched = async (jellyfinId: string) =>
|
||||
this.getClient()?.POST('/Users/{userId}/PlayedItems/{itemId}', {
|
||||
params: {
|
||||
path: {
|
||||
userId: this.getUserId(),
|
||||
itemId: jellyfinId
|
||||
},
|
||||
query: {
|
||||
datePlayed: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setJellyfinItemUnwatched = async (jellyfinId: string) =>
|
||||
this.getClient()?.DELETE('/Users/{userId}/PlayedItems/{itemId}', {
|
||||
params: {
|
||||
path: {
|
||||
userId: this.getUserId(),
|
||||
itemId: jellyfinId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
getJellyfinHealth = async (
|
||||
baseUrl: string | undefined = undefined,
|
||||
apiKey: string | undefined = undefined
|
||||
) =>
|
||||
axios
|
||||
.get((baseUrl || this.getBaseUrl()) + '/System/Info', {
|
||||
headers: {
|
||||
'X-Emby-Token': apiKey || this.getApiKey()
|
||||
}
|
||||
})
|
||||
.then((res) => res.status === 200)
|
||||
.catch(() => false);
|
||||
|
||||
getJellyfinUsers = async (
|
||||
baseUrl: string | undefined = undefined,
|
||||
apiKey: string | undefined = undefined
|
||||
): Promise<components['schemas']['UserDto'][]> =>
|
||||
axios
|
||||
.get((baseUrl || this.getBaseUrl()) + '/Users', {
|
||||
headers: {
|
||||
'X-Emby-Token': apiKey || this.getApiKey()
|
||||
}
|
||||
})
|
||||
.then((res) => res.data || [])
|
||||
.catch(() => []);
|
||||
|
||||
getJellyfinBackdrop = (item: JellyfinItem, quality = 100) => {
|
||||
if (item.BackdropImageTags?.length) {
|
||||
return `${this.getBaseUrl()}/Items/${item?.Id}/Images/Backdrop?quality=${quality}&tag=${
|
||||
item?.BackdropImageTags?.[0]
|
||||
}`;
|
||||
} else {
|
||||
return `${this.getBaseUrl()}/Items/${item?.Id}/Images/Primary?quality=${quality}&tag=${
|
||||
item?.ImageTags?.Primary
|
||||
}`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const jellyfinApi = new JellyfinApi();
|
||||
|
||||
368
src/lib/apis/sonarr/sonarr-api.ts
Normal file
368
src/lib/apis/sonarr/sonarr-api.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import axios from 'axios';
|
||||
import createClient from 'openapi-fetch';
|
||||
import { get } from 'svelte/store';
|
||||
import { getTmdbSeries, tmdbApi } from '../tmdb/tmdb-api';
|
||||
import type { components, paths } from './sonarr.generated';
|
||||
import { log } from '../../utils';
|
||||
import type { Api } from '../api.interface';
|
||||
import { appState } from '../../stores/app-state.store';
|
||||
import { createLocalStorageStore } from '../../stores/localstorage.store';
|
||||
|
||||
export type SonarrSeries = components['schemas']['SeriesResource'];
|
||||
export type SonarrRelease = components['schemas']['ReleaseResource'];
|
||||
export type SeriesDownload = components['schemas']['QueueResource'] & { series: SonarrSeries };
|
||||
export type DiskSpaceInfo = components['schemas']['DiskSpaceResource'];
|
||||
export type SonarrEpisode = components['schemas']['EpisodeResource'];
|
||||
|
||||
export interface SonarrSeriesOptions {
|
||||
title: string;
|
||||
qualityProfileId: number;
|
||||
languageProfileId: number;
|
||||
seasonFolder: boolean;
|
||||
monitored: boolean;
|
||||
tvdbId: number;
|
||||
rootFolderPath: string;
|
||||
addOptions: {
|
||||
monitor:
|
||||
| 'unknown'
|
||||
| 'all'
|
||||
| 'future'
|
||||
| 'missing'
|
||||
| 'existing'
|
||||
| 'firstSeason'
|
||||
| 'latestSeason'
|
||||
| 'pilot'
|
||||
| 'monitorSpecials'
|
||||
| 'unmonitorSpecials'
|
||||
| 'none';
|
||||
searchForMissingEpisodes: boolean;
|
||||
searchForCutoffUnmetEpisodes: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const tmdbToTvdbCache = createLocalStorageStore<Record<number, number>>('tmdb-to-tvdb-cache', {});
|
||||
|
||||
export class SonarrApi implements Api<paths> {
|
||||
getClient() {
|
||||
const sonarrSettings = this.getSettings();
|
||||
const baseUrl = this.getBaseUrl();
|
||||
const apiKey = sonarrSettings?.apiKey;
|
||||
|
||||
return createClient<paths>({
|
||||
baseUrl,
|
||||
headers: {
|
||||
'X-Api-Key': apiKey
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getBaseUrl() {
|
||||
return get(appState)?.user?.settings?.sonarr.baseUrl || '';
|
||||
}
|
||||
|
||||
getSettings() {
|
||||
return get(appState).user?.settings.sonarr;
|
||||
}
|
||||
|
||||
getApiKey() {
|
||||
return get(appState).user?.settings.sonarr.apiKey;
|
||||
}
|
||||
|
||||
tmdbToTvdb = async (tmdbId: number) => {
|
||||
if (!get(tmdbToTvdbCache)[tmdbId]) {
|
||||
const tvdbId = await tmdbApi
|
||||
.getTmdbSeries(tmdbId)
|
||||
.then((series) => series?.external_ids.tvdb_id || 0);
|
||||
tmdbToTvdbCache.update((prev) => ({ ...prev, [tmdbId]: tvdbId }));
|
||||
return tvdbId;
|
||||
}
|
||||
|
||||
return get(tmdbToTvdbCache)[tmdbId];
|
||||
};
|
||||
|
||||
getSonarrSeries = (): Promise<SonarrSeries[]> =>
|
||||
this.getClient()
|
||||
?.GET('/api/v3/series', {
|
||||
params: {}
|
||||
})
|
||||
.then((r) => r.data || []) || Promise.resolve([]);
|
||||
|
||||
getSonarrSeriesByTvdbId = (tvdbId: number): Promise<SonarrSeries | undefined> =>
|
||||
this.getClient()
|
||||
?.GET('/api/v3/series', {
|
||||
params: {
|
||||
query: {
|
||||
tvdbId: tvdbId
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data?.find((m) => m.tvdbId === tvdbId)) || Promise.resolve(undefined);
|
||||
|
||||
getSeriesByTmdbId = async (tmdbId: number) =>
|
||||
this.tmdbToTvdb(tmdbId).then((tvdbId) =>
|
||||
tvdbId ? this.getSonarrSeriesByTvdbId(tvdbId) : undefined
|
||||
);
|
||||
|
||||
getDiskSpace = (): Promise<DiskSpaceInfo[]> =>
|
||||
this.getClient()
|
||||
?.GET('/api/v3/diskspace', {})
|
||||
.then((d) => d.data || []) || Promise.resolve([]);
|
||||
|
||||
addSeriesToSonarr = async (tmdbId: number) => {
|
||||
const tmdbSeries = await getTmdbSeries(tmdbId);
|
||||
|
||||
if (!tmdbSeries || !tmdbSeries.external_ids.tvdb_id || !tmdbSeries.name)
|
||||
throw new Error('Movie not found');
|
||||
|
||||
const options: SonarrSeriesOptions = {
|
||||
title: tmdbSeries.name,
|
||||
tvdbId: tmdbSeries.external_ids.tvdb_id,
|
||||
qualityProfileId: this.getSettings()?.qualityProfileId || 0,
|
||||
monitored: false,
|
||||
addOptions: {
|
||||
monitor: 'none',
|
||||
searchForMissingEpisodes: false,
|
||||
searchForCutoffUnmetEpisodes: false
|
||||
},
|
||||
rootFolderPath: this.getSettings()?.rootFolderPath || '',
|
||||
languageProfileId: this.getSettings()?.languageProfileId || 0,
|
||||
seasonFolder: true
|
||||
};
|
||||
|
||||
return this.getClient()
|
||||
?.POST('/api/v3/series', {
|
||||
params: {},
|
||||
body: options
|
||||
})
|
||||
.then((r) => r.data);
|
||||
};
|
||||
|
||||
cancelDownloadSonarrEpisode = async (downloadId: number) => {
|
||||
const deleteResponse = await this.getClient()
|
||||
?.DELETE('/api/v3/queue/{id}', {
|
||||
params: {
|
||||
path: {
|
||||
id: downloadId
|
||||
},
|
||||
query: {
|
||||
blocklist: false,
|
||||
removeFromClient: true
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => log(r));
|
||||
|
||||
return !!deleteResponse?.response.ok;
|
||||
};
|
||||
|
||||
downloadSonarrEpisode = (guid: string, indexerId: number) =>
|
||||
this.getClient()
|
||||
?.POST('/api/v3/release', {
|
||||
params: {},
|
||||
body: {
|
||||
indexerId,
|
||||
guid
|
||||
}
|
||||
})
|
||||
.then((res) => res.response.ok) || Promise.resolve(false);
|
||||
|
||||
deleteSonarrEpisode = (id: number) =>
|
||||
this.getClient()
|
||||
?.DELETE('/api/v3/episodefile/{id}', {
|
||||
params: {
|
||||
path: {
|
||||
id
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((res) => res.response.ok) || Promise.resolve(false);
|
||||
|
||||
getSonarrDownloads = (): Promise<SeriesDownload[]> =>
|
||||
this.getClient()
|
||||
?.GET('/api/v3/queue', {
|
||||
params: {
|
||||
query: {
|
||||
includeEpisode: true,
|
||||
includeSeries: true
|
||||
}
|
||||
}
|
||||
})
|
||||
.then(
|
||||
(r) =>
|
||||
(r.data?.records?.filter(
|
||||
(record) => record.episode && record.series
|
||||
) as SeriesDownload[]) || []
|
||||
) || Promise.resolve([]);
|
||||
|
||||
getSonarrDownloadsById = (sonarrId: number) =>
|
||||
this.getSonarrDownloads().then((downloads) =>
|
||||
downloads.filter((d) => d.seriesId === sonarrId)
|
||||
) || Promise.resolve([]);
|
||||
|
||||
removeFromSonarr = (id: number): Promise<boolean> =>
|
||||
this.getClient()
|
||||
?.DELETE('/api/v3/series/{id}', {
|
||||
params: {
|
||||
path: {
|
||||
id
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((res) => res.response.ok) || Promise.resolve(false);
|
||||
|
||||
getSonarrEpisodes = async (seriesId: number) => {
|
||||
const episodesPromise =
|
||||
this.getClient()
|
||||
?.GET('/api/v3/episode', {
|
||||
params: {
|
||||
query: {
|
||||
seriesId
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data || []) || Promise.resolve([]);
|
||||
|
||||
const episodeFilesPromise =
|
||||
this.getClient()
|
||||
?.GET('/api/v3/episodefile', {
|
||||
params: {
|
||||
query: {
|
||||
seriesId
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data || []) || Promise.resolve([]);
|
||||
|
||||
const episodes = await episodesPromise;
|
||||
const episodeFiles = await episodeFilesPromise;
|
||||
|
||||
return episodes.map((episode) => ({
|
||||
episode,
|
||||
episodeFile: episodeFiles.find((file) => file.id === episode.episodeFileId)
|
||||
}));
|
||||
};
|
||||
|
||||
fetchSonarrReleases = async (episodeId: number) =>
|
||||
this.getClient()
|
||||
?.GET('/api/v3/release', {
|
||||
params: {
|
||||
query: {
|
||||
episodeId
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data || []) || Promise.resolve([]);
|
||||
|
||||
fetchSonarrSeasonReleases = async (seriesId: number, seasonNumber: number) =>
|
||||
this.getClient()
|
||||
?.GET('/api/v3/release', {
|
||||
params: {
|
||||
query: {
|
||||
seriesId,
|
||||
seasonNumber
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data || []) || Promise.resolve([]);
|
||||
|
||||
fetchSonarrEpisodes = async (seriesId: number): Promise<SonarrEpisode[]> => {
|
||||
return (
|
||||
this.getClient()
|
||||
?.GET('/api/v3/episode', {
|
||||
params: {
|
||||
query: {
|
||||
seriesId
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data || []) || Promise.resolve([])
|
||||
);
|
||||
};
|
||||
|
||||
getSonarrHealth = async (
|
||||
baseUrl: string | undefined = undefined,
|
||||
apiKey: string | undefined = undefined
|
||||
) =>
|
||||
axios
|
||||
.get((baseUrl || this.getBaseUrl()) + '/api/v3/health', {
|
||||
headers: {
|
||||
'X-Api-Key': apiKey || this.getApiKey()
|
||||
}
|
||||
})
|
||||
.then((res) => res.status === 200)
|
||||
.catch(() => false);
|
||||
|
||||
getSonarrRootFolders = async (
|
||||
baseUrl: string | undefined = undefined,
|
||||
apiKey: string | undefined = undefined
|
||||
) =>
|
||||
axios
|
||||
.get<components['schemas']['RootFolderResource'][]>(
|
||||
(baseUrl || this.getBaseUrl()) + '/api/v3/rootFolder',
|
||||
{
|
||||
headers: {
|
||||
'X-Api-Key': apiKey || this.getApiKey()
|
||||
}
|
||||
}
|
||||
)
|
||||
.then((res) => res.data || []);
|
||||
|
||||
getSonarrQualityProfiles = async (
|
||||
baseUrl: string | undefined = undefined,
|
||||
apiKey: string | undefined = undefined
|
||||
) =>
|
||||
axios
|
||||
.get<components['schemas']['QualityProfileResource'][]>(
|
||||
(baseUrl || this.getBaseUrl()) + '/api/v3/qualityprofile',
|
||||
{
|
||||
headers: {
|
||||
'X-Api-Key': apiKey || this.getApiKey()
|
||||
}
|
||||
}
|
||||
)
|
||||
.then((res) => res.data || []);
|
||||
|
||||
getSonarrLanguageProfiles = async (
|
||||
baseUrl: string | undefined = undefined,
|
||||
apiKey: string | undefined = undefined
|
||||
) =>
|
||||
axios
|
||||
.get<components['schemas']['LanguageProfileResource'][]>(
|
||||
(baseUrl || this.getBaseUrl()) + '/api/v3/languageprofile',
|
||||
{
|
||||
headers: {
|
||||
'X-Api-Key': apiKey || this.getApiKey()
|
||||
}
|
||||
}
|
||||
)
|
||||
.then((res) => res.data || []);
|
||||
|
||||
getSonarrPosterUrl = (item: SonarrSeries, original = false) => {
|
||||
const url = this.getBaseUrl() + (item.images?.find((i) => i.coverType === 'poster')?.url || '');
|
||||
|
||||
if (!original) return url.replace('poster.jpg', `poster-${500}.jpg`);
|
||||
|
||||
return url;
|
||||
};
|
||||
}
|
||||
|
||||
export const sonarrApi = new SonarrApi();
|
||||
export const sonarrApiClient = sonarrApi.getClient;
|
||||
|
||||
// function getSonarrApi() {
|
||||
// const baseUrl = get(settings)?.sonarr.baseUrl;
|
||||
// const apiKey = get(settings)?.sonarr.apiKey;
|
||||
// const rootFolder = get(settings)?.sonarr.rootFolderPath;
|
||||
// const qualityProfileId = get(settings)?.sonarr.qualityProfileId;
|
||||
// const languageProfileId = get(settings)?.sonarr.languageProfileId;
|
||||
//
|
||||
// if (!baseUrl || !apiKey || !rootFolder || !qualityProfileId || !languageProfileId)
|
||||
// return undefined;
|
||||
//
|
||||
// return createClient<paths>({
|
||||
// baseUrl,
|
||||
// headers: {
|
||||
// 'X-Api-Key': apiKey
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
@@ -1,317 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import createClient from 'openapi-fetch';
|
||||
import { get } from 'svelte/store';
|
||||
import { getTmdbSeries } from '../tmdb/tmdb-api';
|
||||
import type { components, paths } from './sonarr.generated';
|
||||
import { settings } from '../../stores/settings.store';
|
||||
import { log } from '../../utils';
|
||||
|
||||
export type SonarrSeries = components['schemas']['SeriesResource'];
|
||||
export type SonarrRelease = components['schemas']['ReleaseResource'];
|
||||
export type SonarrDownload = components['schemas']['QueueResource'] & { series: SonarrSeries };
|
||||
export type DiskSpaceInfo = components['schemas']['DiskSpaceResource'];
|
||||
export type SonarrEpisode = components['schemas']['EpisodeResource'];
|
||||
|
||||
export interface SonarrSeriesOptions {
|
||||
title: string;
|
||||
qualityProfileId: number;
|
||||
languageProfileId: number;
|
||||
seasonFolder: boolean;
|
||||
monitored: boolean;
|
||||
tvdbId: number;
|
||||
rootFolderPath: string;
|
||||
addOptions: {
|
||||
monitor:
|
||||
| 'unknown'
|
||||
| 'all'
|
||||
| 'future'
|
||||
| 'missing'
|
||||
| 'existing'
|
||||
| 'firstSeason'
|
||||
| 'latestSeason'
|
||||
| 'pilot'
|
||||
| 'monitorSpecials'
|
||||
| 'unmonitorSpecials'
|
||||
| 'none';
|
||||
searchForMissingEpisodes: boolean;
|
||||
searchForCutoffUnmetEpisodes: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function getSonarrApi() {
|
||||
const baseUrl = get(settings)?.sonarr.baseUrl;
|
||||
const apiKey = get(settings)?.sonarr.apiKey;
|
||||
const rootFolder = get(settings)?.sonarr.rootFolderPath;
|
||||
const qualityProfileId = get(settings)?.sonarr.qualityProfileId;
|
||||
const languageProfileId = get(settings)?.sonarr.languageProfileId;
|
||||
|
||||
if (!baseUrl || !apiKey || !rootFolder || !qualityProfileId || !languageProfileId)
|
||||
return undefined;
|
||||
|
||||
return createClient<paths>({
|
||||
baseUrl,
|
||||
headers: {
|
||||
'X-Api-Key': apiKey
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const getSonarrSeries = (): Promise<SonarrSeries[]> =>
|
||||
getSonarrApi()
|
||||
?.GET('/api/v3/series', {
|
||||
params: {}
|
||||
})
|
||||
.then((r) => r.data || []) || Promise.resolve([]);
|
||||
|
||||
export const getSonarrSeriesByTvdbId = (tvdbId: number): Promise<SonarrSeries | undefined> =>
|
||||
getSonarrApi()
|
||||
?.GET('/api/v3/series', {
|
||||
params: {
|
||||
query: {
|
||||
tvdbId: tvdbId
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data?.find((m) => m.tvdbId === tvdbId)) || Promise.resolve(undefined);
|
||||
|
||||
export const getDiskSpace = (): Promise<DiskSpaceInfo[]> =>
|
||||
getSonarrApi()
|
||||
?.GET('/api/v3/diskspace', {})
|
||||
.then((d) => d.data || []) || Promise.resolve([]);
|
||||
|
||||
export const addSeriesToSonarr = async (tmdbId: number) => {
|
||||
const tmdbSeries = await getTmdbSeries(tmdbId);
|
||||
|
||||
if (!tmdbSeries || !tmdbSeries.external_ids.tvdb_id || !tmdbSeries.name)
|
||||
throw new Error('Movie not found');
|
||||
|
||||
const options: SonarrSeriesOptions = {
|
||||
title: tmdbSeries.name,
|
||||
tvdbId: tmdbSeries.external_ids.tvdb_id,
|
||||
qualityProfileId: get(settings)?.sonarr.qualityProfileId || 0,
|
||||
monitored: false,
|
||||
addOptions: {
|
||||
monitor: 'none',
|
||||
searchForMissingEpisodes: false,
|
||||
searchForCutoffUnmetEpisodes: false
|
||||
},
|
||||
rootFolderPath: get(settings)?.sonarr.rootFolderPath || '',
|
||||
languageProfileId: get(settings)?.sonarr.languageProfileId || 0,
|
||||
seasonFolder: true
|
||||
};
|
||||
|
||||
return getSonarrApi()
|
||||
?.POST('/api/v3/series', {
|
||||
params: {},
|
||||
body: options
|
||||
})
|
||||
.then((r) => r.data);
|
||||
};
|
||||
|
||||
export const cancelDownloadSonarrEpisode = async (downloadId: number) => {
|
||||
const deleteResponse = await getSonarrApi()
|
||||
?.DELETE('/api/v3/queue/{id}', {
|
||||
params: {
|
||||
path: {
|
||||
id: downloadId
|
||||
},
|
||||
query: {
|
||||
blocklist: false,
|
||||
removeFromClient: true
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => log(r));
|
||||
|
||||
return !!deleteResponse?.response.ok;
|
||||
};
|
||||
|
||||
export const downloadSonarrEpisode = (guid: string, indexerId: number) =>
|
||||
getSonarrApi()
|
||||
?.POST('/api/v3/release', {
|
||||
params: {},
|
||||
body: {
|
||||
indexerId,
|
||||
guid
|
||||
}
|
||||
})
|
||||
.then((res) => res.response.ok) || Promise.resolve(false);
|
||||
|
||||
export const deleteSonarrEpisode = (id: number) =>
|
||||
getSonarrApi()
|
||||
?.DELETE('/api/v3/episodefile/{id}', {
|
||||
params: {
|
||||
path: {
|
||||
id
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((res) => res.response.ok) || Promise.resolve(false);
|
||||
|
||||
export const getSonarrDownloads = (): Promise<SonarrDownload[]> =>
|
||||
getSonarrApi()
|
||||
?.GET('/api/v3/queue', {
|
||||
params: {
|
||||
query: {
|
||||
includeEpisode: true,
|
||||
includeSeries: true
|
||||
}
|
||||
}
|
||||
})
|
||||
.then(
|
||||
(r) =>
|
||||
(r.data?.records?.filter(
|
||||
(record) => record.episode && record.series
|
||||
) as SonarrDownload[]) || []
|
||||
) || Promise.resolve([]);
|
||||
|
||||
export const getSonarrDownloadsById = (sonarrId: number) =>
|
||||
getSonarrDownloads().then((downloads) => downloads.filter((d) => d.seriesId === sonarrId)) ||
|
||||
Promise.resolve([]);
|
||||
|
||||
export const removeFromSonarr = (id: number): Promise<boolean> =>
|
||||
getSonarrApi()
|
||||
?.DELETE('/api/v3/series/{id}', {
|
||||
params: {
|
||||
path: {
|
||||
id
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((res) => res.response.ok) || Promise.resolve(false);
|
||||
|
||||
export const getSonarrEpisodes = async (seriesId: number) => {
|
||||
const episodesPromise =
|
||||
getSonarrApi()
|
||||
?.GET('/api/v3/episode', {
|
||||
params: {
|
||||
query: {
|
||||
seriesId
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data || []) || Promise.resolve([]);
|
||||
|
||||
const episodeFilesPromise =
|
||||
getSonarrApi()
|
||||
?.GET('/api/v3/episodefile', {
|
||||
params: {
|
||||
query: {
|
||||
seriesId
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data || []) || Promise.resolve([]);
|
||||
|
||||
const episodes = await episodesPromise;
|
||||
const episodeFiles = await episodeFilesPromise;
|
||||
|
||||
return episodes.map((episode) => ({
|
||||
episode,
|
||||
episodeFile: episodeFiles.find((file) => file.id === episode.episodeFileId)
|
||||
}));
|
||||
};
|
||||
|
||||
export const fetchSonarrReleases = async (episodeId: number) =>
|
||||
getSonarrApi()
|
||||
?.GET('/api/v3/release', {
|
||||
params: {
|
||||
query: {
|
||||
episodeId
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data || []) || Promise.resolve([]);
|
||||
|
||||
export const fetchSonarrSeasonReleases = async (seriesId: number, seasonNumber: number) =>
|
||||
getSonarrApi()
|
||||
?.GET('/api/v3/release', {
|
||||
params: {
|
||||
query: {
|
||||
seriesId,
|
||||
seasonNumber
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data || []) || Promise.resolve([]);
|
||||
|
||||
export const fetchSonarrEpisodes = async (seriesId: number): Promise<SonarrEpisode[]> => {
|
||||
return (
|
||||
getSonarrApi()
|
||||
?.GET('/api/v3/episode', {
|
||||
params: {
|
||||
query: {
|
||||
seriesId
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data || []) || Promise.resolve([])
|
||||
);
|
||||
};
|
||||
|
||||
export const getSonarrHealth = async (
|
||||
baseUrl: string | undefined = undefined,
|
||||
apiKey: string | undefined = undefined
|
||||
) =>
|
||||
axios
|
||||
.get((baseUrl || get(settings)?.sonarr.baseUrl) + '/api/v3/health', {
|
||||
headers: {
|
||||
'X-Api-Key': apiKey || get(settings)?.sonarr.apiKey
|
||||
}
|
||||
})
|
||||
.then((res) => res.status === 200)
|
||||
.catch(() => false);
|
||||
|
||||
export const getSonarrRootFolders = async (
|
||||
baseUrl: string | undefined = undefined,
|
||||
apiKey: string | undefined = undefined
|
||||
) =>
|
||||
axios
|
||||
.get<components['schemas']['RootFolderResource'][]>(
|
||||
(baseUrl || get(settings)?.sonarr.baseUrl) + '/api/v3/rootFolder',
|
||||
{
|
||||
headers: {
|
||||
'X-Api-Key': apiKey || get(settings)?.sonarr.apiKey
|
||||
}
|
||||
}
|
||||
)
|
||||
.then((res) => res.data || []);
|
||||
|
||||
export const getSonarrQualityProfiles = async (
|
||||
baseUrl: string | undefined = undefined,
|
||||
apiKey: string | undefined = undefined
|
||||
) =>
|
||||
axios
|
||||
.get<components['schemas']['QualityProfileResource'][]>(
|
||||
(baseUrl || get(settings)?.sonarr.baseUrl) + '/api/v3/qualityprofile',
|
||||
{
|
||||
headers: {
|
||||
'X-Api-Key': apiKey || get(settings)?.sonarr.apiKey
|
||||
}
|
||||
}
|
||||
)
|
||||
.then((res) => res.data || []);
|
||||
|
||||
export const getSonarrLanguageProfiles = async (
|
||||
baseUrl: string | undefined = undefined,
|
||||
apiKey: string | undefined = undefined
|
||||
) =>
|
||||
axios
|
||||
.get<components['schemas']['LanguageProfileResource'][]>(
|
||||
(baseUrl || get(settings)?.sonarr.baseUrl) + '/api/v3/languageprofile',
|
||||
{
|
||||
headers: {
|
||||
'X-Api-Key': apiKey || get(settings)?.sonarr.apiKey
|
||||
}
|
||||
}
|
||||
)
|
||||
.then((res) => res.data || []);
|
||||
|
||||
export function getSonarrPosterUrl(item: SonarrSeries, original = false) {
|
||||
const url =
|
||||
get(settings).sonarr.baseUrl + (item.images?.find((i) => i.coverType === 'poster')?.url || '');
|
||||
|
||||
if (!original) return url.replace('poster.jpg', `poster-${500}.jpg`);
|
||||
|
||||
return url;
|
||||
}
|
||||
@@ -50,29 +50,112 @@ export class TmdbApi implements Api<paths> {
|
||||
});
|
||||
}
|
||||
|
||||
async getTmdbMovie(tmdbId: number) {
|
||||
return TmdbApiOpen.GET('/3/movie/{movie_id}', {
|
||||
params: {
|
||||
path: {
|
||||
movie_id: tmdbId
|
||||
},
|
||||
query: {
|
||||
append_to_response: 'videos,credits,external_ids,images',
|
||||
...({ include_image_language: get(settings)?.language + ',en,null' } as any)
|
||||
// MOVIES
|
||||
|
||||
getTmdbMovie = async (tmdbId: number) => {
|
||||
return this.getClient()
|
||||
?.GET('/3/movie/{movie_id}', {
|
||||
params: {
|
||||
path: {
|
||||
movie_id: tmdbId
|
||||
},
|
||||
query: {
|
||||
append_to_response: 'videos,credits,external_ids,images',
|
||||
...({ include_image_language: get(settings)?.language + ',en,null' } as any)
|
||||
}
|
||||
}
|
||||
}
|
||||
}).then((res) => res.data as TmdbMovieFull2 | undefined);
|
||||
}
|
||||
})
|
||||
.then((res) => res.data as TmdbMovieFull2 | undefined);
|
||||
};
|
||||
|
||||
getPopularMovies = () =>
|
||||
TmdbApiOpen.GET('/3/movie/popular', {
|
||||
params: {
|
||||
query: {
|
||||
language: get(settings)?.language,
|
||||
region: get(settings)?.discover.region
|
||||
this.getClient()
|
||||
?.GET('/3/movie/popular', {
|
||||
params: {
|
||||
query: {
|
||||
language: get(settings)?.language,
|
||||
region: get(settings)?.discover.region
|
||||
}
|
||||
}
|
||||
}
|
||||
}).then((res) => res.data?.results || []);
|
||||
})
|
||||
.then((res) => res.data?.results || []);
|
||||
|
||||
// SERIES
|
||||
|
||||
getTmdbSeriesFromTvdbId = async (tvdbId: string) =>
|
||||
this.getClient()
|
||||
?.GET('/3/find/{external_id}', {
|
||||
params: {
|
||||
path: {
|
||||
external_id: tvdbId
|
||||
},
|
||||
query: {
|
||||
external_source: 'tvdb_id'
|
||||
}
|
||||
},
|
||||
headers: {
|
||||
'Cache-Control': CACHE_ONE_DAY
|
||||
}
|
||||
})
|
||||
.then((res) => res.data?.tv_results?.[0] as TmdbSeries2 | undefined);
|
||||
|
||||
getTmdbIdFromTvdbId = async (tvdbId: number) =>
|
||||
getTmdbSeriesFromTvdbId(String(tvdbId)).then((res: any) => {
|
||||
const id = res?.id as number | undefined;
|
||||
if (!id) return Promise.reject();
|
||||
return id;
|
||||
});
|
||||
|
||||
getTmdbSeries = async (tmdbId: number): Promise<TmdbSeriesFull2 | undefined> =>
|
||||
await this.getClient()
|
||||
?.GET('/3/tv/{series_id}', {
|
||||
params: {
|
||||
path: {
|
||||
series_id: tmdbId
|
||||
},
|
||||
query: {
|
||||
append_to_response: 'videos,aggregate_credits,external_ids,images',
|
||||
...({ include_image_language: get(settings)?.language + ',en,null' } as any)
|
||||
}
|
||||
},
|
||||
headers: {
|
||||
'Cache-Control': CACHE_ONE_DAY
|
||||
}
|
||||
})
|
||||
.then((res) => res.data as TmdbSeriesFull2 | undefined);
|
||||
|
||||
getTmdbSeriesSeason = async (tmdbId: number, season: number): Promise<TmdbSeason | undefined> =>
|
||||
this.getClient()
|
||||
?.GET('/3/tv/{series_id}/season/{season_number}', {
|
||||
params: {
|
||||
path: {
|
||||
series_id: tmdbId,
|
||||
season_number: season
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((res) => res.data);
|
||||
|
||||
getTmdbSeriesSeasons = async (tmdbId: number, seasons: number) =>
|
||||
Promise.all([...Array(seasons).keys()].map((i) => getTmdbSeriesSeason(tmdbId, i + 1))).then(
|
||||
(r) => r.filter((s) => s) as TmdbSeason[]
|
||||
);
|
||||
|
||||
getTmdbSeriesImages = async (tmdbId: number) =>
|
||||
this.getClient()
|
||||
?.GET('/3/tv/{series_id}/images', {
|
||||
params: {
|
||||
path: {
|
||||
series_id: tmdbId
|
||||
}
|
||||
},
|
||||
headers: {
|
||||
'Cache-Control': CACHE_FOUR_DAYS // 4 days
|
||||
}
|
||||
})
|
||||
.then((res) => res.data);
|
||||
|
||||
// OTHER
|
||||
}
|
||||
|
||||
export const tmdbApi = new TmdbApi();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getJellyfinEpisodes, type JellyfinItem } from '../apis/jellyfin/jellyfin-api';
|
||||
import { addSeriesToSonarr, type SonarrSeries } from '../../lib/apis/sonarr/sonarrApi';
|
||||
import { addSeriesToSonarr, type SonarrSeries } from '../apis/sonarr/sonarr-api';
|
||||
import {
|
||||
getTmdbIdFromTvdbId,
|
||||
getTmdbSeries,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
type JellyfinItem
|
||||
} from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import type { RadarrMovie } from '../../apis/radarr/radarr-api';
|
||||
import type { SonarrSeries } from '../../apis/sonarr/sonarrApi';
|
||||
import type { SonarrSeries } from '../../apis/sonarr/sonarr-api';
|
||||
import { jellyfinItemsStore } from '../../stores/data.store';
|
||||
import { settings } from '../../stores/settings.store';
|
||||
import type { TitleType } from '../../types';
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
}}
|
||||
focusOnMount
|
||||
trapFocus
|
||||
class="fixed inset-0 z-20"
|
||||
class="fixed inset-0 z-20 bg-stone-950"
|
||||
>
|
||||
<slot />
|
||||
</Container>
|
||||
|
||||
@@ -15,7 +15,12 @@
|
||||
$: urls.then((urls) => (length = urls.length));
|
||||
|
||||
function onNext() {
|
||||
index = (index + 1) % length;
|
||||
if (index === length - 1) {
|
||||
return false;
|
||||
} else {
|
||||
index = (index + 1) % length;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,10 +26,6 @@
|
||||
Number(id)
|
||||
);
|
||||
|
||||
let playbackId: string = '';
|
||||
|
||||
let heroIndex: number;
|
||||
|
||||
const { requests, isFetching, data } = useActionRequests({
|
||||
handleAddToRadarr: (id: number) =>
|
||||
radarrApi.addMovieToRadarr(id).finally(() => refreshRadarrItem(Number(id)))
|
||||
@@ -39,7 +35,6 @@
|
||||
<DetachedPage>
|
||||
<div class="min-h-screen flex flex-col py-12 px-20 relative">
|
||||
<HeroCarousel
|
||||
bind:index={heroIndex}
|
||||
urls={$movieDataP.then(
|
||||
(movie) =>
|
||||
movie?.images.backdrops
|
||||
|
||||
@@ -2,17 +2,142 @@
|
||||
import Container from '../../Container.svelte';
|
||||
import SidebarMargin from '../components/SidebarMargin.svelte';
|
||||
import HeroCarousel from '../components/HeroCarousel/HeroCarousel.svelte';
|
||||
import DetachedPage from '../components/DetachedPage/DetachedPage.svelte';
|
||||
import { useActionRequest, useDependantRequest, useRequest } from '../stores/data.store';
|
||||
import { tmdbApi } from '../apis/tmdb/tmdb-api';
|
||||
import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../constants';
|
||||
import classNames from 'classnames';
|
||||
import { DotFilled, Download, ExternalLink, File, Play, Plus } from 'radix-icons-svelte';
|
||||
import { jellyfinApi } from '../apis/jellyfin/jellyfin-api';
|
||||
import { sonarrApi } from '../apis/sonarr/sonarr-api';
|
||||
import Button from '../components/Button.svelte';
|
||||
import { playerState } from '../components/VideoPlayer/VideoPlayer';
|
||||
import { modalStack } from '../components/Modal/modal.store';
|
||||
import ManageMediaModal from '../components/ManageMedia/ManageMediaModal.svelte';
|
||||
import { derived } from 'svelte/store';
|
||||
|
||||
export let id: string;
|
||||
|
||||
let heroIndex: number;
|
||||
const { promise: tmdbSeries, ...tmdbSeriesRest } = useRequest(tmdbApi.getTmdbSeries, Number(id));
|
||||
const { promise: sonarrItem, data: sonarrItemData } = useRequest(
|
||||
sonarrApi.getSeriesByTmdbId,
|
||||
Number(id)
|
||||
);
|
||||
const { promise: jellyfinItem, data: jellyfinItemData } = useRequest(
|
||||
(id: string) => jellyfinApi.getLibraryItemFromTmdbId(id),
|
||||
id
|
||||
);
|
||||
const { data: jellyfinSeriesItemsData } = useDependantRequest(
|
||||
jellyfinApi.getJellyfinEpisodes,
|
||||
jellyfinItemData,
|
||||
(data) => (data?.Id ? ([data.Id] as const) : undefined)
|
||||
);
|
||||
const nextJellyfinEpisode = derived(jellyfinSeriesItemsData, ($items) =>
|
||||
($items || []).find((i) => i.UserData?.Played === false)
|
||||
);
|
||||
|
||||
const { send: addSeriesToSonarr, isFetching: addSeriesToSonarrFetching } = useActionRequest(
|
||||
sonarrApi.addSeriesToSonarr
|
||||
);
|
||||
</script>
|
||||
|
||||
<SidebarMargin>
|
||||
<Container focusOnMount>
|
||||
<HeroCarousel bind:index={heroIndex} urls={Promise.resolve([])}>
|
||||
{id}
|
||||
<DetachedPage>
|
||||
<div class="min-h-screen flex flex-col py-12 px-20 relative">
|
||||
<HeroCarousel
|
||||
urls={$tmdbSeries.then(
|
||||
(series) =>
|
||||
series?.images.backdrops
|
||||
?.sort((a, b) => (b.vote_count || 0) - (a.vote_count || 0))
|
||||
?.map((i) => TMDB_IMAGES_ORIGINAL + i.file_path)
|
||||
.slice(0, 5) || []
|
||||
)}
|
||||
>
|
||||
<div class="h-full flex-1 flex flex-col justify-end">
|
||||
{#await $tmdbSeries then series}
|
||||
{#if series}
|
||||
<div
|
||||
class={classNames(
|
||||
'text-left font-medium tracking-wider text-stone-200 hover:text-amber-200 mt-2',
|
||||
{
|
||||
'text-4xl sm:text-5xl 2xl:text-6xl': series.name?.length || 0 < 15,
|
||||
'text-3xl sm:text-4xl 2xl:text-5xl': series?.name?.length || 0 >= 15
|
||||
}
|
||||
)}
|
||||
>
|
||||
{series?.name}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1 uppercase text-zinc-300 font-semibold tracking-wider mt-2 text-lg"
|
||||
>
|
||||
<p class="flex-shrink-0">
|
||||
{#if series.status !== 'Ended'}
|
||||
Since {new Date(series.first_air_date || Date.now())?.getFullYear()}
|
||||
{:else}
|
||||
Ended {new Date(series.last_air_date || Date.now())?.getFullYear()}
|
||||
{/if}
|
||||
</p>
|
||||
<!-- <DotFilled />
|
||||
<p class="flex-shrink-0">{movie.runtime}</p> -->
|
||||
<DotFilled />
|
||||
<p class="flex-shrink-0">
|
||||
<a href={'https://www.themoviedb.org/movie/' + series.id}
|
||||
>{series.vote_average} TMDB</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-stone-300 font-medium line-clamp-3 opacity-75 max-w-4xl mt-4">
|
||||
{series.overview}
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
{#await Promise.all([$jellyfinItem, $sonarrItem]) then [jellyfinItem, sonarrItem]}
|
||||
<Container direction="horizontal" class="flex mt-8" focusOnMount>
|
||||
{#if $nextJellyfinEpisode}
|
||||
<Button
|
||||
class="mr-2"
|
||||
on:click={() =>
|
||||
$nextJellyfinEpisode?.Id && playerState.streamJellyfinId($nextJellyfinEpisode.Id)}
|
||||
>
|
||||
Play Season {$nextJellyfinEpisode?.ParentIndexNumber} Episode
|
||||
{$nextJellyfinEpisode?.IndexNumber}
|
||||
<Play size={19} slot="icon" />
|
||||
</Button>
|
||||
{/if}
|
||||
{#if sonarrItem}
|
||||
<Button
|
||||
class="mr-2"
|
||||
on:click={() => modalStack.create(ManageMediaModal, { id: sonarrItem.id || -1 })}
|
||||
>
|
||||
{#if jellyfinItem}
|
||||
Manage Files
|
||||
{:else}
|
||||
Request
|
||||
{/if}
|
||||
<svelte:component this={jellyfinItem ? File : Download} size={19} slot="icon" />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
class="mr-2"
|
||||
on:click={() => addSeriesToSonarr(Number(id))}
|
||||
inactive={$addSeriesToSonarrFetching}
|
||||
>
|
||||
Add to Sonarr
|
||||
<Plus slot="icon" size={19} />
|
||||
</Button>
|
||||
{/if}
|
||||
{#if PLATFORM_WEB}
|
||||
<Button class="mr-2">
|
||||
Open In TMDB
|
||||
<ExternalLink size={19} slot="icon-after" />
|
||||
</Button>
|
||||
<Button class="mr-2">
|
||||
Open In Jellyfin
|
||||
<ExternalLink size={19} slot="icon-after" />
|
||||
</Button>
|
||||
{/if}
|
||||
</Container>
|
||||
{/await}
|
||||
</div>
|
||||
</HeroCarousel>
|
||||
<Container on:click={() => history.back()}>Go back</Container>
|
||||
</Container>
|
||||
</SidebarMargin>
|
||||
</div>
|
||||
</DetachedPage>
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { derived, writable } from 'svelte/store';
|
||||
import { derived, type Readable, writable } from 'svelte/store';
|
||||
import { settings } from './settings.store';
|
||||
import { jellyfinApi, type JellyfinItem } from '../apis/jellyfin/jellyfin-api';
|
||||
import {
|
||||
getSonarrDownloads,
|
||||
getSonarrSeries,
|
||||
type SonarrDownload,
|
||||
type SonarrSeries
|
||||
} from '../apis/sonarr/sonarrApi';
|
||||
import { type SeriesDownload, sonarrApi, type SonarrSeries } from '../apis/sonarr/sonarr-api';
|
||||
import { radarrApi, type MovieDownload } from '../apis/radarr/radarr-api';
|
||||
|
||||
async function waitForSettings() {
|
||||
@@ -88,7 +83,7 @@ export function createJellyfinItemStore(tmdbId: number | Promise<number>) {
|
||||
};
|
||||
}
|
||||
|
||||
export const sonarrSeriesStore = _createDataFetchStore(getSonarrSeries);
|
||||
export const sonarrSeriesStore = _createDataFetchStore(sonarrApi.getSonarrDownloads);
|
||||
export const radarrMoviesStore = _createDataFetchStore(radarrApi.getRadarrMovies);
|
||||
|
||||
export function createRadarrMovieStore(tmdbId: number) {
|
||||
@@ -136,7 +131,7 @@ export function createSonarrSeriesStore(name: Promise<string> | string) {
|
||||
};
|
||||
}
|
||||
|
||||
export const sonarrDownloadsStore = _createDataFetchStore(getSonarrDownloads);
|
||||
export const sonarrDownloadsStore = _createDataFetchStore(sonarrApi.getSonarrDownloads);
|
||||
export const radarrDownloadsStore = _createDataFetchStore(radarrApi.getRadarrDownloads);
|
||||
export const servarrDownloadsStore = (() => {
|
||||
const store = derived([sonarrDownloadsStore, radarrDownloadsStore], ([sonarr, radarr]) => {
|
||||
@@ -186,7 +181,7 @@ export function createRadarrDownloadStore(
|
||||
export function createSonarrDownloadStore(
|
||||
sonarrItemStore: ReturnType<typeof createSonarrSeriesStore>
|
||||
) {
|
||||
const store = writable<{ loading: boolean; downloads?: SonarrDownload[] }>({
|
||||
const store = writable<{ loading: boolean; downloads?: SeriesDownload[] }>({
|
||||
loading: true,
|
||||
downloads: undefined
|
||||
});
|
||||
@@ -255,6 +250,29 @@ export const useActionRequests = <P extends Record<string, (...args: any[]) => P
|
||||
};
|
||||
};
|
||||
|
||||
export const useDependantRequest = <P extends (...args: A) => Promise<any>, A extends any[], S>(
|
||||
fn: P,
|
||||
store: Readable<S>,
|
||||
subscribeFn: (store: S) => Parameters<P> | undefined
|
||||
) => {
|
||||
const isLoading = writable(true);
|
||||
const r = useActionRequest<P, A>(fn);
|
||||
|
||||
store.subscribe(($data) => {
|
||||
const args = subscribeFn($data);
|
||||
if (!args) return;
|
||||
r.send(...args).finally(() => isLoading.set(false));
|
||||
});
|
||||
|
||||
return {
|
||||
...r,
|
||||
refresh: r.send,
|
||||
isLoading: {
|
||||
subscribe: isLoading.subscribe
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useRequest = <P extends (...args: A) => Promise<any>, A extends any[]>(
|
||||
fn: P,
|
||||
...args: Parameters<P>
|
||||
|
||||
Reference in New Issue
Block a user