feat: Series page & derived requests

This commit is contained in:
Aleksi Lassila
2024-04-03 19:02:16 +03:00
parent 27fa492d8d
commit d1eb3a4cfe
12 changed files with 899 additions and 364 deletions

View File

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

View File

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

View 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
// }
// });
// }

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@
}}
focusOnMount
trapFocus
class="fixed inset-0 z-20"
class="fixed inset-0 z-20 bg-stone-950"
>
<slot />
</Container>

View File

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

View File

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

View File

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

View File

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