mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2026-01-01 04:08:45 -05:00
Merge branch 'develop' into features/deleteMediaFile
This commit is contained in:
@@ -69,6 +69,30 @@ class ExternalAPI {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
protected async post<T>(
|
||||
endpoint: string,
|
||||
data: Record<string, unknown>,
|
||||
config?: AxiosRequestConfig,
|
||||
ttl?: number
|
||||
): Promise<T> {
|
||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||
config: config?.params,
|
||||
data,
|
||||
});
|
||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||
if (cachedItem) {
|
||||
return cachedItem;
|
||||
}
|
||||
|
||||
const response = await this.axios.post<T>(endpoint, data, config);
|
||||
|
||||
if (this.cache) {
|
||||
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
protected async getRolling<T>(
|
||||
endpoint: string,
|
||||
config?: AxiosRequestConfig,
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface JellyfinLibraryItem {
|
||||
SeasonId?: string;
|
||||
SeasonName?: string;
|
||||
IndexNumber?: number;
|
||||
IndexNumberEnd?: number;
|
||||
ParentIndexNumber?: number;
|
||||
MediaType: string;
|
||||
}
|
||||
@@ -178,8 +179,10 @@ class JellyfinAPI {
|
||||
(Item: any) => {
|
||||
return (
|
||||
Item.Type === 'CollectionFolder' &&
|
||||
(Item.CollectionType === 'tvshows' ||
|
||||
Item.CollectionType === 'movies')
|
||||
Item.CollectionType !== 'music' &&
|
||||
Item.CollectionType !== 'books' &&
|
||||
Item.CollectionType !== 'musicvideos' &&
|
||||
Item.CollectionType !== 'homevideos'
|
||||
);
|
||||
}
|
||||
).map((Item: any) => {
|
||||
@@ -204,7 +207,7 @@ class JellyfinAPI {
|
||||
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
||||
try {
|
||||
const contents = await this.axios.get<any>(
|
||||
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie&Recursive=true&StartIndex=0&ParentId=${id}`
|
||||
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}`
|
||||
);
|
||||
|
||||
return contents.data.Items.filter(
|
||||
|
||||
@@ -226,12 +226,17 @@ class PlexAPI {
|
||||
id: string,
|
||||
options: { addedAt: number } = {
|
||||
addedAt: Date.now() - 1000 * 60 * 60,
|
||||
}
|
||||
},
|
||||
mediaType: 'movie' | 'show'
|
||||
): Promise<PlexLibraryItem[]> {
|
||||
const response = await this.plexClient.query<PlexLibraryResponse>({
|
||||
uri: `/library/sections/${id}/all?sort=addedAt%3Adesc&addedAt>>=${Math.floor(
|
||||
options.addedAt / 1000
|
||||
)}`,
|
||||
uri: `/library/sections/${id}/all?type=${
|
||||
mediaType === 'show' ? '4' : '1'
|
||||
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
|
||||
extraHeaders: {
|
||||
'X-Plex-Container-Start': `0`,
|
||||
'X-Plex-Container-Size': `500`,
|
||||
},
|
||||
});
|
||||
|
||||
return response.MediaContainer.Metadata;
|
||||
|
||||
@@ -1,28 +1,40 @@
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import ExternalAPI from './externalapi';
|
||||
|
||||
interface RTSearchResult {
|
||||
meterClass: 'certified_fresh' | 'fresh' | 'rotten';
|
||||
meterScore: number;
|
||||
url: string;
|
||||
interface RTAlgoliaSearchResponse {
|
||||
results: {
|
||||
hits: RTAlgoliaHit[];
|
||||
index: 'content_rt' | 'people_rt';
|
||||
}[];
|
||||
}
|
||||
|
||||
interface RTTvSearchResult extends RTSearchResult {
|
||||
interface RTAlgoliaHit {
|
||||
emsId: string;
|
||||
emsVersionId: string;
|
||||
tmsId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
startYear: number;
|
||||
endYear: number;
|
||||
}
|
||||
interface RTMovieSearchResult extends RTSearchResult {
|
||||
name: string;
|
||||
url: string;
|
||||
year: number;
|
||||
}
|
||||
|
||||
interface RTMultiSearchResponse {
|
||||
tvCount: number;
|
||||
tvSeries: RTTvSearchResult[];
|
||||
movieCount: number;
|
||||
movies: RTMovieSearchResult[];
|
||||
titles: string[];
|
||||
description: string;
|
||||
releaseYear: string;
|
||||
rating: string;
|
||||
genres: string[];
|
||||
updateDate: string;
|
||||
isEmsSearchable: boolean;
|
||||
rtId: number;
|
||||
vanity: string;
|
||||
aka: string[];
|
||||
posterImageUrl: string;
|
||||
rottenTomatoes: {
|
||||
audienceScore: number;
|
||||
criticsIconUrl: string;
|
||||
wantToSeeCount: number;
|
||||
audienceIconUrl: string;
|
||||
scoreSentiment: string;
|
||||
certifiedFresh: boolean;
|
||||
criticsScore: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RTRating {
|
||||
@@ -47,13 +59,20 @@ export interface RTRating {
|
||||
*/
|
||||
class RottenTomatoes extends ExternalAPI {
|
||||
constructor() {
|
||||
const settings = getSettings();
|
||||
super(
|
||||
'https://www.rottentomatoes.com/api/private',
|
||||
{},
|
||||
'https://79frdp12pn-dsn.algolia.net/1/indexes/*',
|
||||
{
|
||||
'x-algolia-agent':
|
||||
'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)',
|
||||
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
|
||||
'x-algolia-application-id': '79FRDP12PN',
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'x-algolia-usertoken': settings.clientId,
|
||||
},
|
||||
nodeCache: cacheManager.getCache('rt').data,
|
||||
}
|
||||
@@ -61,14 +80,11 @@ class RottenTomatoes extends ExternalAPI {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the 1.0 api for the movie title
|
||||
* Search the RT algolia api for the movie title
|
||||
*
|
||||
* We compare the release date to make sure its the correct
|
||||
* match. But it's not guaranteed to have results.
|
||||
*
|
||||
* We use the 1.0 API here because the 2.0 search api does
|
||||
* not return audience ratings.
|
||||
*
|
||||
* @param name Movie name
|
||||
* @param year Release Year
|
||||
*/
|
||||
@@ -77,30 +93,45 @@ class RottenTomatoes extends ExternalAPI {
|
||||
year: number
|
||||
): Promise<RTRating | null> {
|
||||
try {
|
||||
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
|
||||
params: { q: name, limit: 10 },
|
||||
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
|
||||
requests: [
|
||||
{
|
||||
indexName: 'content_rt',
|
||||
query: name,
|
||||
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const contentResults = data.results.find((r) => r.index === 'content_rt');
|
||||
|
||||
if (!contentResults) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// First, attempt to match exact name and year
|
||||
let movie = data.movies.find(
|
||||
(movie) => movie.year === year && movie.name === name
|
||||
let movie = contentResults.hits.find(
|
||||
(movie) => movie.releaseYear === year.toString() && movie.title === name
|
||||
);
|
||||
|
||||
// If we don't find a movie, try to match partial name and year
|
||||
if (!movie) {
|
||||
movie = data.movies.find(
|
||||
(movie) => movie.year === year && movie.name.includes(name)
|
||||
movie = contentResults.hits.find(
|
||||
(movie) =>
|
||||
movie.releaseYear === year.toString() && movie.title.includes(name)
|
||||
);
|
||||
}
|
||||
|
||||
// If we still dont find a movie, try to match just on year
|
||||
if (!movie) {
|
||||
movie = data.movies.find((movie) => movie.year === year);
|
||||
movie = contentResults.hits.find(
|
||||
(movie) => movie.releaseYear === year.toString()
|
||||
);
|
||||
}
|
||||
|
||||
// One last try, try exact name match only
|
||||
if (!movie) {
|
||||
movie = data.movies.find((movie) => movie.name === name);
|
||||
movie = contentResults.hits.find((movie) => movie.title === name);
|
||||
}
|
||||
|
||||
if (!movie) {
|
||||
@@ -108,16 +139,15 @@ class RottenTomatoes extends ExternalAPI {
|
||||
}
|
||||
|
||||
return {
|
||||
title: movie.name,
|
||||
url: `https://www.rottentomatoes.com${movie.url}`,
|
||||
criticsRating:
|
||||
movie.meterClass === 'certified_fresh'
|
||||
? 'Certified Fresh'
|
||||
: movie.meterClass === 'fresh'
|
||||
? 'Fresh'
|
||||
: 'Rotten',
|
||||
criticsScore: movie.meterScore,
|
||||
year: movie.year,
|
||||
title: movie.title,
|
||||
url: `https://www.rottentomatoes.com/m/${movie.vanity}`,
|
||||
criticsRating: movie.rottenTomatoes.certifiedFresh
|
||||
? 'Certified Fresh'
|
||||
: movie.rottenTomatoes.criticsScore >= 60
|
||||
? 'Fresh'
|
||||
: 'Rotten',
|
||||
criticsScore: movie.rottenTomatoes.criticsScore,
|
||||
year: Number(movie.releaseYear),
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
@@ -131,14 +161,28 @@ class RottenTomatoes extends ExternalAPI {
|
||||
year?: number
|
||||
): Promise<RTRating | null> {
|
||||
try {
|
||||
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
|
||||
params: { q: name, limit: 10 },
|
||||
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
|
||||
requests: [
|
||||
{
|
||||
indexName: 'content_rt',
|
||||
query: name,
|
||||
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let tvshow: RTTvSearchResult | undefined = data.tvSeries[0];
|
||||
const contentResults = data.results.find((r) => r.index === 'content_rt');
|
||||
|
||||
if (!contentResults) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let tvshow: RTAlgoliaHit | undefined = contentResults.hits[0];
|
||||
|
||||
if (year) {
|
||||
tvshow = data.tvSeries.find((series) => series.startYear === year);
|
||||
tvshow = contentResults.hits.find(
|
||||
(series) => series.releaseYear === year.toString()
|
||||
);
|
||||
}
|
||||
|
||||
if (!tvshow) {
|
||||
@@ -147,10 +191,11 @@ class RottenTomatoes extends ExternalAPI {
|
||||
|
||||
return {
|
||||
title: tvshow.title,
|
||||
url: `https://www.rottentomatoes.com${tvshow.url}`,
|
||||
criticsRating: tvshow.meterClass === 'fresh' ? 'Fresh' : 'Rotten',
|
||||
criticsScore: tvshow.meterScore,
|
||||
year: tvshow.startYear,
|
||||
url: `https://www.rottentomatoes.com/tv/${tvshow.vanity}`,
|
||||
criticsRating:
|
||||
tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten',
|
||||
criticsScore: tvshow.rottenTomatoes.criticsScore,
|
||||
year: Number(tvshow.releaseYear),
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(`[RT API] Failed to retrieve tv ratings: ${e.message}`);
|
||||
|
||||
@@ -158,7 +158,12 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
||||
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
||||
try {
|
||||
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
|
||||
`/queue`
|
||||
`/queue`,
|
||||
{
|
||||
params: {
|
||||
includeEpisode: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.records;
|
||||
|
||||
@@ -13,6 +13,21 @@ interface SonarrSeason {
|
||||
percentOfEpisodes: number;
|
||||
};
|
||||
}
|
||||
interface EpisodeResult {
|
||||
seriesId: number;
|
||||
episodeFileId: number;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
title: string;
|
||||
airDate: string;
|
||||
airDateUtc: string;
|
||||
overview: string;
|
||||
hasFile: boolean;
|
||||
monitored: boolean;
|
||||
absoluteEpisodeNumber: number;
|
||||
unverifiedSceneNumbering: boolean;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface SonarrSeries {
|
||||
title: string;
|
||||
@@ -82,7 +97,11 @@ export interface LanguageProfile {
|
||||
name: string;
|
||||
}
|
||||
|
||||
class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
||||
class SonarrAPI extends ServarrBase<{
|
||||
seriesId: number;
|
||||
episodeId: number;
|
||||
episode: EpisodeResult;
|
||||
}> {
|
||||
constructor({ url, apiKey }: { url: string; apiKey: string }) {
|
||||
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ import cacheManager from '@server/lib/cache';
|
||||
import { sortBy } from 'lodash';
|
||||
import type {
|
||||
TmdbCollection,
|
||||
TmdbCompanySearchResponse,
|
||||
TmdbExternalIdResponse,
|
||||
TmdbGenre,
|
||||
TmdbGenresResult,
|
||||
TmdbKeyword,
|
||||
TmdbKeywordSearchResponse,
|
||||
TmdbLanguage,
|
||||
TmdbMovieDetails,
|
||||
TmdbNetwork,
|
||||
@@ -19,6 +22,8 @@ import type {
|
||||
TmdbSeasonWithEpisodes,
|
||||
TmdbTvDetails,
|
||||
TmdbUpcomingMoviesResponse,
|
||||
TmdbWatchProviderDetails,
|
||||
TmdbWatchProviderRegion,
|
||||
} from './interfaces';
|
||||
|
||||
interface SearchOptions {
|
||||
@@ -32,30 +37,41 @@ interface SingleSearchOptions extends SearchOptions {
|
||||
year?: number;
|
||||
}
|
||||
|
||||
export type SortOptions =
|
||||
| 'popularity.asc'
|
||||
| 'popularity.desc'
|
||||
| 'release_date.asc'
|
||||
| 'release_date.desc'
|
||||
| 'revenue.asc'
|
||||
| 'revenue.desc'
|
||||
| 'primary_release_date.asc'
|
||||
| 'primary_release_date.desc'
|
||||
| 'original_title.asc'
|
||||
| 'original_title.desc'
|
||||
| 'vote_average.asc'
|
||||
| 'vote_average.desc'
|
||||
| 'vote_count.asc'
|
||||
| 'vote_count.desc'
|
||||
| 'first_air_date.asc'
|
||||
| 'first_air_date.desc';
|
||||
|
||||
interface DiscoverMovieOptions {
|
||||
page?: number;
|
||||
includeAdult?: boolean;
|
||||
language?: string;
|
||||
primaryReleaseDateGte?: string;
|
||||
primaryReleaseDateLte?: string;
|
||||
withRuntimeGte?: string;
|
||||
withRuntimeLte?: string;
|
||||
voteAverageGte?: string;
|
||||
voteAverageLte?: string;
|
||||
originalLanguage?: string;
|
||||
genre?: number;
|
||||
studio?: number;
|
||||
sortBy?:
|
||||
| 'popularity.asc'
|
||||
| 'popularity.desc'
|
||||
| 'release_date.asc'
|
||||
| 'release_date.desc'
|
||||
| 'revenue.asc'
|
||||
| 'revenue.desc'
|
||||
| 'primary_release_date.asc'
|
||||
| 'primary_release_date.desc'
|
||||
| 'original_title.asc'
|
||||
| 'original_title.desc'
|
||||
| 'vote_average.asc'
|
||||
| 'vote_average.desc'
|
||||
| 'vote_count.asc'
|
||||
| 'vote_count.desc';
|
||||
genre?: string;
|
||||
studio?: string;
|
||||
keywords?: string;
|
||||
sortBy?: SortOptions;
|
||||
watchRegion?: string;
|
||||
watchProviders?: string;
|
||||
}
|
||||
|
||||
interface DiscoverTvOptions {
|
||||
@@ -63,19 +79,18 @@ interface DiscoverTvOptions {
|
||||
language?: string;
|
||||
firstAirDateGte?: string;
|
||||
firstAirDateLte?: string;
|
||||
withRuntimeGte?: string;
|
||||
withRuntimeLte?: string;
|
||||
voteAverageGte?: string;
|
||||
voteAverageLte?: string;
|
||||
includeEmptyReleaseDate?: boolean;
|
||||
originalLanguage?: string;
|
||||
genre?: number;
|
||||
genre?: string;
|
||||
network?: number;
|
||||
sortBy?:
|
||||
| 'popularity.asc'
|
||||
| 'popularity.desc'
|
||||
| 'vote_average.asc'
|
||||
| 'vote_average.desc'
|
||||
| 'vote_count.asc'
|
||||
| 'vote_count.desc'
|
||||
| 'first_air_date.asc'
|
||||
| 'first_air_date.desc';
|
||||
keywords?: string;
|
||||
sortBy?: SortOptions;
|
||||
watchRegion?: string;
|
||||
watchProviders?: string;
|
||||
}
|
||||
|
||||
class TheMovieDb extends ExternalAPI {
|
||||
@@ -237,7 +252,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
params: {
|
||||
language,
|
||||
append_to_response:
|
||||
'credits,external_ids,videos,release_dates,watch/providers',
|
||||
'credits,external_ids,videos,keywords,release_dates,watch/providers',
|
||||
},
|
||||
},
|
||||
43200
|
||||
@@ -440,8 +455,25 @@ class TheMovieDb extends ExternalAPI {
|
||||
originalLanguage,
|
||||
genre,
|
||||
studio,
|
||||
keywords,
|
||||
withRuntimeGte,
|
||||
withRuntimeLte,
|
||||
voteAverageGte,
|
||||
voteAverageLte,
|
||||
watchProviders,
|
||||
watchRegion,
|
||||
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
||||
try {
|
||||
const defaultFutureDate = new Date(
|
||||
Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5)
|
||||
)
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
|
||||
const defaultPastDate = new Date('1900-01-01')
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
|
||||
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
|
||||
params: {
|
||||
sort_by: sortBy,
|
||||
@@ -449,11 +481,31 @@ class TheMovieDb extends ExternalAPI {
|
||||
include_adult: includeAdult,
|
||||
language,
|
||||
region: this.region,
|
||||
with_original_language: originalLanguage ?? this.originalLanguage,
|
||||
'primary_release_date.gte': primaryReleaseDateGte,
|
||||
'primary_release_date.lte': primaryReleaseDateLte,
|
||||
with_original_language:
|
||||
originalLanguage && originalLanguage !== 'all'
|
||||
? originalLanguage
|
||||
: originalLanguage === 'all'
|
||||
? undefined
|
||||
: this.originalLanguage,
|
||||
// Set our release date values, but check if one is set and not the other,
|
||||
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||
'primary_release_date.gte':
|
||||
!primaryReleaseDateGte && primaryReleaseDateLte
|
||||
? defaultPastDate
|
||||
: primaryReleaseDateGte,
|
||||
'primary_release_date.lte':
|
||||
!primaryReleaseDateLte && primaryReleaseDateGte
|
||||
? defaultFutureDate
|
||||
: primaryReleaseDateLte,
|
||||
with_genres: genre,
|
||||
with_companies: studio,
|
||||
with_keywords: keywords,
|
||||
'with_runtime.gte': withRuntimeGte,
|
||||
'with_runtime.lte': withRuntimeLte,
|
||||
'vote_average.gte': voteAverageGte,
|
||||
'vote_average.lte': voteAverageLte,
|
||||
watch_region: watchRegion,
|
||||
with_watch_providers: watchProviders,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -473,20 +525,57 @@ class TheMovieDb extends ExternalAPI {
|
||||
originalLanguage,
|
||||
genre,
|
||||
network,
|
||||
keywords,
|
||||
withRuntimeGte,
|
||||
withRuntimeLte,
|
||||
voteAverageGte,
|
||||
voteAverageLte,
|
||||
watchProviders,
|
||||
watchRegion,
|
||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||
try {
|
||||
const defaultFutureDate = new Date(
|
||||
Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5)
|
||||
)
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
|
||||
const defaultPastDate = new Date('1900-01-01')
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
|
||||
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
|
||||
params: {
|
||||
sort_by: sortBy,
|
||||
page,
|
||||
language,
|
||||
region: this.region,
|
||||
'first_air_date.gte': firstAirDateGte,
|
||||
'first_air_date.lte': firstAirDateLte,
|
||||
with_original_language: originalLanguage ?? this.originalLanguage,
|
||||
// Set our release date values, but check if one is set and not the other,
|
||||
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||
'first_air_date.gte':
|
||||
!firstAirDateGte && firstAirDateLte
|
||||
? defaultPastDate
|
||||
: firstAirDateGte,
|
||||
'first_air_date.lte':
|
||||
!firstAirDateLte && firstAirDateGte
|
||||
? defaultFutureDate
|
||||
: firstAirDateLte,
|
||||
with_original_language:
|
||||
originalLanguage && originalLanguage !== 'all'
|
||||
? originalLanguage
|
||||
: originalLanguage === 'all'
|
||||
? undefined
|
||||
: this.originalLanguage,
|
||||
include_null_first_air_dates: includeEmptyReleaseDate,
|
||||
with_genres: genre,
|
||||
with_networks: network,
|
||||
with_keywords: keywords,
|
||||
'with_runtime.gte': withRuntimeGte,
|
||||
'with_runtime.lte': withRuntimeLte,
|
||||
'vote_average.gte': voteAverageGte,
|
||||
'vote_average.lte': voteAverageLte,
|
||||
with_watch_providers: watchProviders,
|
||||
watch_region: watchRegion,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -874,6 +963,152 @@ class TheMovieDb extends ExternalAPI {
|
||||
throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getKeywordDetails({
|
||||
keywordId,
|
||||
}: {
|
||||
keywordId: number;
|
||||
}): Promise<TmdbKeyword> {
|
||||
try {
|
||||
const data = await this.get<TmdbKeyword>(
|
||||
`/keyword/${keywordId}`,
|
||||
undefined,
|
||||
604800 // 7 days
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async searchKeyword({
|
||||
query,
|
||||
page = 1,
|
||||
}: {
|
||||
query: string;
|
||||
page?: number;
|
||||
}): Promise<TmdbKeywordSearchResponse> {
|
||||
try {
|
||||
const data = await this.get<TmdbKeywordSearchResponse>(
|
||||
'/search/keyword',
|
||||
{
|
||||
params: {
|
||||
query,
|
||||
page,
|
||||
},
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to search keyword: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async searchCompany({
|
||||
query,
|
||||
page = 1,
|
||||
}: {
|
||||
query: string;
|
||||
page?: number;
|
||||
}): Promise<TmdbCompanySearchResponse> {
|
||||
try {
|
||||
const data = await this.get<TmdbCompanySearchResponse>(
|
||||
'/search/company',
|
||||
{
|
||||
params: {
|
||||
query,
|
||||
page,
|
||||
},
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to search companies: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getAvailableWatchProviderRegions({
|
||||
language,
|
||||
}: {
|
||||
language?: string;
|
||||
}) {
|
||||
try {
|
||||
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
|
||||
'/watch/providers/regions',
|
||||
{
|
||||
params: {
|
||||
language: language ?? this.originalLanguage,
|
||||
},
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
|
||||
return data.results;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDB] Failed to fetch available watch regions: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getMovieWatchProviders({
|
||||
language,
|
||||
watchRegion,
|
||||
}: {
|
||||
language?: string;
|
||||
watchRegion: string;
|
||||
}) {
|
||||
try {
|
||||
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
||||
'/watch/providers/movie',
|
||||
{
|
||||
params: {
|
||||
language: language ?? this.originalLanguage,
|
||||
watch_region: watchRegion,
|
||||
},
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
|
||||
return data.results;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDB] Failed to fetch movie watch providers: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getTvWatchProviders({
|
||||
language,
|
||||
watchRegion,
|
||||
}: {
|
||||
language?: string;
|
||||
watchRegion: string;
|
||||
}) {
|
||||
try {
|
||||
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
||||
'/watch/providers/tv',
|
||||
{
|
||||
params: {
|
||||
language: language ?? this.originalLanguage,
|
||||
watch_region: watchRegion,
|
||||
},
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
|
||||
return data.results;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDB] Failed to fetch TV watch providers: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TheMovieDb;
|
||||
|
||||
@@ -171,6 +171,9 @@ export interface TmdbMovieDetails {
|
||||
id: number;
|
||||
results?: { [iso_3166_1: string]: TmdbWatchProviders };
|
||||
};
|
||||
keywords: {
|
||||
keywords: TmdbKeyword[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface TmdbVideo {
|
||||
@@ -191,7 +194,7 @@ export interface TmdbVideo {
|
||||
|
||||
export interface TmdbTvEpisodeResult {
|
||||
id: number;
|
||||
air_date: string;
|
||||
air_date: string | null;
|
||||
episode_number: number;
|
||||
name: string;
|
||||
overview: string;
|
||||
@@ -372,7 +375,8 @@ export interface TmdbPersonCombinedCredits {
|
||||
crew: TmdbPersonCreditCrew[];
|
||||
}
|
||||
|
||||
export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult {
|
||||
export interface TmdbSeasonWithEpisodes
|
||||
extends Omit<TmdbTvSeasonResult, 'episode_count'> {
|
||||
episodes: TmdbTvEpisodeResult[];
|
||||
external_ids: TmdbExternalIds;
|
||||
}
|
||||
@@ -427,3 +431,24 @@ export interface TmdbWatchProviderDetails {
|
||||
provider_id: number;
|
||||
provider_name: string;
|
||||
}
|
||||
|
||||
export interface TmdbKeywordSearchResponse extends TmdbPaginatedResponse {
|
||||
results: TmdbKeyword[];
|
||||
}
|
||||
|
||||
// We have production companies, but the company search results return less data
|
||||
export interface TmdbCompany {
|
||||
id: number;
|
||||
logo_path?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TmdbCompanySearchResponse extends TmdbPaginatedResponse {
|
||||
results: TmdbCompany[];
|
||||
}
|
||||
|
||||
export interface TmdbWatchProviderRegion {
|
||||
iso_3166_1: string;
|
||||
english_name: string;
|
||||
native_name: string;
|
||||
}
|
||||
|
||||
98
server/constants/discover.ts
Normal file
98
server/constants/discover.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||
|
||||
export enum DiscoverSliderType {
|
||||
RECENTLY_ADDED = 1,
|
||||
RECENT_REQUESTS,
|
||||
PLEX_WATCHLIST,
|
||||
TRENDING,
|
||||
POPULAR_MOVIES,
|
||||
MOVIE_GENRES,
|
||||
UPCOMING_MOVIES,
|
||||
STUDIOS,
|
||||
POPULAR_TV,
|
||||
TV_GENRES,
|
||||
UPCOMING_TV,
|
||||
NETWORKS,
|
||||
TMDB_MOVIE_KEYWORD,
|
||||
TMDB_MOVIE_GENRE,
|
||||
TMDB_TV_KEYWORD,
|
||||
TMDB_TV_GENRE,
|
||||
TMDB_SEARCH,
|
||||
TMDB_STUDIO,
|
||||
TMDB_NETWORK,
|
||||
}
|
||||
|
||||
export const defaultSliders: Partial<DiscoverSlider>[] = [
|
||||
{
|
||||
type: DiscoverSliderType.RECENTLY_ADDED,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.RECENT_REQUESTS,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.PLEX_WATCHLIST,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.TRENDING,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.POPULAR_MOVIES,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 4,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.MOVIE_GENRES,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 5,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.UPCOMING_MOVIES,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 6,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.STUDIOS,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 7,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.POPULAR_TV,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 8,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.TV_GENRES,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 9,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.UPCOMING_TV,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.NETWORKS,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 11,
|
||||
},
|
||||
];
|
||||
@@ -34,7 +34,7 @@ const dataSource = new DataSource(
|
||||
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
|
||||
);
|
||||
|
||||
export const getRepository = <Entity>(
|
||||
export const getRepository = <Entity extends object>(
|
||||
target: EntityTarget<Entity>
|
||||
): Repository<Entity> => {
|
||||
return dataSource.getRepository(target);
|
||||
|
||||
69
server/entity/DiscoverSlider.ts
Normal file
69
server/entity/DiscoverSlider.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { DiscoverSliderType } from '@server/constants/discover';
|
||||
import { defaultSliders } from '@server/constants/discover';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import logger from '@server/logger';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
class DiscoverSlider {
|
||||
public static async bootstrapSliders(): Promise<void> {
|
||||
const sliderRepository = getRepository(DiscoverSlider);
|
||||
|
||||
for (const slider of defaultSliders) {
|
||||
const existingSlider = await sliderRepository.findOne({
|
||||
where: {
|
||||
type: slider.type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSlider) {
|
||||
logger.info('Creating built-in discovery slider', {
|
||||
label: 'Discover Slider',
|
||||
slider,
|
||||
});
|
||||
await sliderRepository.save(new DiscoverSlider(slider));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
public type: DiscoverSliderType;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
public order: number;
|
||||
|
||||
@Column({ default: false })
|
||||
public isBuiltIn: boolean;
|
||||
|
||||
@Column({ default: true })
|
||||
public enabled: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
// Title is not required for built in sliders because we will
|
||||
// use translations for them.
|
||||
public title?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public data?: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
public updatedAt: Date;
|
||||
|
||||
constructor(init?: Partial<DiscoverSlider>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
}
|
||||
|
||||
export default DiscoverSlider;
|
||||
@@ -200,15 +200,20 @@ class Media {
|
||||
const pageName =
|
||||
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
|
||||
const { serverId, hostname, externalHostname } = getSettings().jellyfin;
|
||||
const jellyfinHost =
|
||||
let jellyfinHost =
|
||||
externalHostname && externalHostname.length > 0
|
||||
? externalHostname
|
||||
: hostname;
|
||||
|
||||
jellyfinHost = jellyfinHost.endsWith('/')
|
||||
? jellyfinHost.slice(0, -1)
|
||||
: jellyfinHost;
|
||||
|
||||
if (this.jellyfinMediaId) {
|
||||
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
||||
}
|
||||
if (this.jellyfinMediaId4k) {
|
||||
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${serverId}`;
|
||||
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,7 +767,16 @@ export class MediaRequest {
|
||||
if (
|
||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
) {
|
||||
throw new Error('Media already available');
|
||||
logger.warn('Media already exists, marking request as APPROVED', {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
});
|
||||
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
this.status = MediaRequestStatus.APPROVED;
|
||||
await requestRepository.save(this);
|
||||
return;
|
||||
}
|
||||
|
||||
const radarrMovieOptions: RadarrMovieOptions = {
|
||||
@@ -908,7 +917,16 @@ export class MediaRequest {
|
||||
if (
|
||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
) {
|
||||
throw new Error('Media already available');
|
||||
logger.warn('Media already exists, marking request as APPROVED', {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
});
|
||||
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
this.status = MediaRequestStatus.APPROVED;
|
||||
await requestRepository.save(this);
|
||||
return;
|
||||
}
|
||||
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
@@ -39,7 +39,7 @@ export class User {
|
||||
return users.map((u) => u.filter(showFiltered));
|
||||
}
|
||||
|
||||
static readonly filteredFields: string[] = ['email'];
|
||||
static readonly filteredFields: string[] = ['email', 'plexId'];
|
||||
|
||||
public displayName: string;
|
||||
|
||||
@@ -76,7 +76,7 @@ export class User {
|
||||
@Column({ type: 'integer', default: UserType.PLEX })
|
||||
public userType: UserType;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Column({ nullable: true, select: true })
|
||||
public plexId?: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import PlexAPI from '@server/api/plexapi';
|
||||
import dataSource, { getRepository } from '@server/datasource';
|
||||
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||
import { Session } from '@server/entity/Session';
|
||||
import { User } from '@server/entity/User';
|
||||
import { startJobs } from '@server/job/schedule';
|
||||
@@ -17,6 +18,7 @@ import WebPushAgent from '@server/lib/notifications/agents/webpush';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import routes from '@server/routes';
|
||||
import imageproxy from '@server/routes/imageproxy';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import restartFlag from '@server/utils/restartFlag';
|
||||
import { getClientIp } from '@supercharge/request-ip';
|
||||
@@ -104,6 +106,9 @@ app
|
||||
);
|
||||
}
|
||||
|
||||
// Bootstrap Discovery Sliders
|
||||
await DiscoverSlider.bootstrapSliders();
|
||||
|
||||
const server = express();
|
||||
if (settings.main.trustProxy) {
|
||||
server.enable('trust proxy');
|
||||
@@ -186,6 +191,9 @@ app
|
||||
next();
|
||||
});
|
||||
server.use('/api/v1', routes);
|
||||
|
||||
server.use('/imageproxy', imageproxy);
|
||||
|
||||
server.get('*', (req, res) => handle(req, res));
|
||||
server.use(
|
||||
(
|
||||
|
||||
@@ -54,6 +54,11 @@ export interface CacheItem {
|
||||
};
|
||||
}
|
||||
|
||||
export interface CacheResponse {
|
||||
apiCaches: CacheItem[];
|
||||
imageCache: Record<'tmdb', { size: number; imageCount: number }>;
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
version: string;
|
||||
commitTag: string;
|
||||
|
||||
@@ -257,8 +257,19 @@ class JobJellyfinSync {
|
||||
//use for loop to make sure this loop _completes_ in full
|
||||
//before the next section
|
||||
for (const episode of episodes) {
|
||||
let episodeCount = 1;
|
||||
|
||||
// count number of combined episodes
|
||||
if (
|
||||
episode.IndexNumber !== undefined &&
|
||||
episode.IndexNumberEnd !== undefined
|
||||
) {
|
||||
episodeCount =
|
||||
episode.IndexNumberEnd - episode.IndexNumber + 1;
|
||||
}
|
||||
|
||||
if (!this.enable4kShow) {
|
||||
totalStandard++;
|
||||
totalStandard += episodeCount;
|
||||
} else {
|
||||
const ExtendedEpisodeData = await this.jfClient.getItemData(
|
||||
episode.Id
|
||||
@@ -268,10 +279,10 @@ class JobJellyfinSync {
|
||||
return MediaSource.MediaStreams.some((MediaStream) => {
|
||||
if (MediaStream.Type === 'Video') {
|
||||
if (MediaStream.Width ?? 0 < 2000) {
|
||||
totalStandard++;
|
||||
totalStandard += episodeCount;
|
||||
}
|
||||
} else {
|
||||
total4k++;
|
||||
total4k += episodeCount;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import downloadTracker from '@server/lib/downloadtracker';
|
||||
import ImageProxy from '@server/lib/imageproxy';
|
||||
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
|
||||
import { radarrScanner } from '@server/lib/scanners/radarr';
|
||||
import { sonarrScanner } from '@server/lib/scanners/sonarr';
|
||||
@@ -111,7 +112,7 @@ export const startJobs = (): void => {
|
||||
id: 'plex-watchlist-sync',
|
||||
name: 'Plex Watchlist Sync',
|
||||
type: 'process',
|
||||
interval: 'long',
|
||||
interval: 'short',
|
||||
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
||||
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
||||
@@ -181,5 +182,21 @@ export const startJobs = (): void => {
|
||||
}),
|
||||
});
|
||||
|
||||
// Run image cache cleanup every 5 minutes
|
||||
scheduledJobs.push({
|
||||
id: 'image-cache-cleanup',
|
||||
name: 'Image Cache Cleanup',
|
||||
type: 'process',
|
||||
interval: 'long',
|
||||
cronSchedule: jobs['image-cache-cleanup'].schedule,
|
||||
job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Image Cache Cleanup', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
// Clean TMDB image cache
|
||||
ImageProxy.clearCache('tmdb');
|
||||
}),
|
||||
});
|
||||
|
||||
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
|
||||
};
|
||||
|
||||
@@ -5,6 +5,12 @@ import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { uniqWith } from 'lodash';
|
||||
|
||||
interface EpisodeNumberResult {
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
absoluteEpisodeNumber: number;
|
||||
id: number;
|
||||
}
|
||||
export interface DownloadingItem {
|
||||
mediaType: MediaType;
|
||||
externalId: number;
|
||||
@@ -14,6 +20,7 @@ export interface DownloadingItem {
|
||||
timeLeft: string;
|
||||
estimatedCompletionTime: Date;
|
||||
title: string;
|
||||
episode?: EpisodeNumberResult;
|
||||
}
|
||||
|
||||
class DownloadTracker {
|
||||
@@ -164,6 +171,7 @@ class DownloadTracker {
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
episode: item.episode,
|
||||
}));
|
||||
|
||||
if (queueItems.length > 0) {
|
||||
|
||||
270
server/lib/imageproxy.ts
Normal file
270
server/lib/imageproxy.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import rateLimit, { type rateLimitOptions } from 'axios-rate-limit';
|
||||
import { createHash } from 'crypto';
|
||||
import { promises } from 'fs';
|
||||
import path, { join } from 'path';
|
||||
|
||||
type ImageResponse = {
|
||||
meta: {
|
||||
revalidateAfter: number;
|
||||
curRevalidate: number;
|
||||
isStale: boolean;
|
||||
etag: string;
|
||||
extension: string;
|
||||
cacheKey: string;
|
||||
cacheMiss: boolean;
|
||||
};
|
||||
imageBuffer: Buffer;
|
||||
};
|
||||
|
||||
class ImageProxy {
|
||||
public static async clearCache(key: string) {
|
||||
let deletedImages = 0;
|
||||
const cacheDirectory = path.join(
|
||||
__dirname,
|
||||
'../../config/cache/images/',
|
||||
key
|
||||
);
|
||||
|
||||
const files = await promises.readdir(cacheDirectory);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(cacheDirectory, file);
|
||||
const stat = await promises.lstat(filePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const imageFiles = await promises.readdir(filePath);
|
||||
|
||||
for (const imageFile of imageFiles) {
|
||||
const [, expireAtSt] = imageFile.split('.');
|
||||
const expireAt = Number(expireAtSt);
|
||||
const now = Date.now();
|
||||
|
||||
if (now > expireAt) {
|
||||
await promises.rm(path.join(filePath, imageFile));
|
||||
deletedImages += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Cleared ${deletedImages} stale image(s) from cache`, {
|
||||
label: 'Image Cache',
|
||||
});
|
||||
}
|
||||
|
||||
public static async getImageStats(
|
||||
key: string
|
||||
): Promise<{ size: number; imageCount: number }> {
|
||||
const cacheDirectory = path.join(
|
||||
__dirname,
|
||||
'../../config/cache/images/',
|
||||
key
|
||||
);
|
||||
|
||||
const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory);
|
||||
const imageCount = await ImageProxy.getImageCount(cacheDirectory);
|
||||
|
||||
return {
|
||||
size: imageTotalSize,
|
||||
imageCount,
|
||||
};
|
||||
}
|
||||
|
||||
private static async getDirectorySize(dir: string): Promise<number> {
|
||||
const files = await promises.readdir(dir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
const paths = files.map(async (file) => {
|
||||
const path = join(dir, file.name);
|
||||
|
||||
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
|
||||
|
||||
if (file.isFile()) {
|
||||
const { size } = await promises.stat(path);
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (await Promise.all(paths))
|
||||
.flat(Infinity)
|
||||
.reduce((i, size) => i + size, 0);
|
||||
}
|
||||
|
||||
private static async getImageCount(dir: string) {
|
||||
const files = await promises.readdir(dir);
|
||||
|
||||
return files.length;
|
||||
}
|
||||
|
||||
private axios;
|
||||
private cacheVersion;
|
||||
private key;
|
||||
|
||||
constructor(
|
||||
key: string,
|
||||
baseUrl: string,
|
||||
options: {
|
||||
cacheVersion?: number;
|
||||
rateLimitOptions?: rateLimitOptions;
|
||||
} = {}
|
||||
) {
|
||||
this.cacheVersion = options.cacheVersion ?? 1;
|
||||
this.key = key;
|
||||
this.axios = axios.create({
|
||||
baseURL: baseUrl,
|
||||
});
|
||||
|
||||
if (options.rateLimitOptions) {
|
||||
this.axios = rateLimit(this.axios, options.rateLimitOptions);
|
||||
}
|
||||
}
|
||||
|
||||
public async getImage(path: string): Promise<ImageResponse> {
|
||||
const cacheKey = this.getCacheKey(path);
|
||||
|
||||
const imageResponse = await this.get(cacheKey);
|
||||
|
||||
if (!imageResponse) {
|
||||
const newImage = await this.set(path, cacheKey);
|
||||
|
||||
if (!newImage) {
|
||||
throw new Error('Failed to load image');
|
||||
}
|
||||
|
||||
return newImage;
|
||||
}
|
||||
|
||||
// If the image is stale, we will revalidate it in the background.
|
||||
if (imageResponse.meta.isStale) {
|
||||
this.set(path, cacheKey);
|
||||
}
|
||||
|
||||
return imageResponse;
|
||||
}
|
||||
|
||||
private async get(cacheKey: string): Promise<ImageResponse | null> {
|
||||
try {
|
||||
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||
const files = await promises.readdir(directory);
|
||||
const now = Date.now();
|
||||
|
||||
for (const file of files) {
|
||||
const [maxAgeSt, expireAtSt, etag, extension] = file.split('.');
|
||||
const buffer = await promises.readFile(join(directory, file));
|
||||
const expireAt = Number(expireAtSt);
|
||||
const maxAge = Number(maxAgeSt);
|
||||
|
||||
return {
|
||||
meta: {
|
||||
curRevalidate: maxAge,
|
||||
revalidateAfter: maxAge * 1000 + now,
|
||||
isStale: now > expireAt,
|
||||
etag,
|
||||
extension,
|
||||
cacheKey,
|
||||
cacheMiss: false,
|
||||
},
|
||||
imageBuffer: buffer,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// No files. Treat as empty cache.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async set(
|
||||
path: string,
|
||||
cacheKey: string
|
||||
): Promise<ImageResponse | null> {
|
||||
try {
|
||||
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||
const response = await this.axios.get(path, {
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
|
||||
const buffer = Buffer.from(response.data, 'binary');
|
||||
const extension = path.split('.').pop() ?? '';
|
||||
const maxAge = Number(
|
||||
(response.headers['cache-control'] ?? '0').split('=')[1]
|
||||
);
|
||||
const expireAt = Date.now() + maxAge * 1000;
|
||||
const etag = (response.headers.etag ?? '').replace(/"/g, '');
|
||||
|
||||
await this.writeToCacheDir(
|
||||
directory,
|
||||
extension,
|
||||
maxAge,
|
||||
expireAt,
|
||||
buffer,
|
||||
etag
|
||||
);
|
||||
|
||||
return {
|
||||
meta: {
|
||||
curRevalidate: maxAge,
|
||||
revalidateAfter: expireAt,
|
||||
isStale: false,
|
||||
etag,
|
||||
extension,
|
||||
cacheKey,
|
||||
cacheMiss: true,
|
||||
},
|
||||
imageBuffer: buffer,
|
||||
};
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong caching image.', {
|
||||
label: 'Image Cache',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeToCacheDir(
|
||||
dir: string,
|
||||
extension: string,
|
||||
maxAge: number,
|
||||
expireAt: number,
|
||||
buffer: Buffer,
|
||||
etag: string
|
||||
) {
|
||||
const filename = join(dir, `${maxAge}.${expireAt}.${etag}.${extension}`);
|
||||
|
||||
await promises.rm(dir, { force: true, recursive: true }).catch(() => {
|
||||
// do nothing
|
||||
});
|
||||
|
||||
await promises.mkdir(dir, { recursive: true });
|
||||
await promises.writeFile(filename, buffer);
|
||||
}
|
||||
|
||||
private getCacheKey(path: string) {
|
||||
return this.getHash([this.key, this.cacheVersion, path]);
|
||||
}
|
||||
|
||||
private getHash(items: (string | number | Buffer)[]) {
|
||||
const hash = createHash('sha256');
|
||||
for (const item of items) {
|
||||
if (typeof item === 'number') hash.update(String(item));
|
||||
else {
|
||||
hash.update(item);
|
||||
}
|
||||
}
|
||||
// See https://en.wikipedia.org/wiki/Base64#Filenames
|
||||
return hash.digest('base64').replace(/\//g, '-');
|
||||
}
|
||||
|
||||
private getCacheDirectory() {
|
||||
return path.join(__dirname, '../../config/cache/images/', this.key);
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageProxy;
|
||||
@@ -96,7 +96,8 @@ class PlexScanner
|
||||
// We remove 10 minutes from the last scan as a buffer
|
||||
addedAt: library.lastScan - 1000 * 60 * 10,
|
||||
}
|
||||
: undefined
|
||||
: undefined,
|
||||
library.type
|
||||
);
|
||||
|
||||
// Bundle items up by rating keys
|
||||
|
||||
@@ -38,7 +38,7 @@ export interface PlexSettings {
|
||||
|
||||
export interface JellyfinSettings {
|
||||
name: string;
|
||||
hostname?: string;
|
||||
hostname: string;
|
||||
externalHostname?: string;
|
||||
libraries: Library[];
|
||||
serverId: string;
|
||||
@@ -263,7 +263,8 @@ export type JobId =
|
||||
| 'download-sync'
|
||||
| 'download-sync-reset'
|
||||
| 'jellyfin-recently-added-sync'
|
||||
| 'jellyfin-full-sync';
|
||||
| 'jellyfin-full-sync'
|
||||
| 'image-cache-cleanup';
|
||||
|
||||
interface AllSettings {
|
||||
clientId: string;
|
||||
@@ -446,6 +447,9 @@ class Settings {
|
||||
'jellyfin-full-sync': {
|
||||
schedule: '0 0 3 * * *',
|
||||
},
|
||||
'image-cache-cleanup': {
|
||||
schedule: '0 0 5 * * *',
|
||||
},
|
||||
},
|
||||
};
|
||||
if (initialSettings) {
|
||||
|
||||
15
server/migration/1672041273674-AddDiscoverSlider.ts
Normal file
15
server/migration/1672041273674-AddDiscoverSlider.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddDiscoverSlider1672041273674 implements MigrationInterface {
|
||||
name = 'AddDiscoverSlider1672041273674';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "discover_slider" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "type" integer NOT NULL, "order" integer NOT NULL, "isBuiltIn" boolean NOT NULL DEFAULT (0), "enabled" boolean NOT NULL DEFAULT (1), "title" varchar, "data" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "discover_slider"`);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
Crew,
|
||||
ExternalIds,
|
||||
Genre,
|
||||
Keyword,
|
||||
ProductionCompany,
|
||||
WatchProviders,
|
||||
} from './common';
|
||||
@@ -83,6 +84,7 @@ export interface MovieDetails {
|
||||
externalIds: ExternalIds;
|
||||
mediaUrl?: string;
|
||||
watchProviders?: WatchProviders[];
|
||||
keywords: Keyword[];
|
||||
}
|
||||
|
||||
export const mapProductionCompany = (
|
||||
@@ -142,4 +144,8 @@ export const mapMovieDetails = (
|
||||
externalIds: mapExternalIds(movie.external_ids),
|
||||
mediaInfo: media,
|
||||
watchProviders: mapWatchProviders(movie['watch/providers']?.results ?? {}),
|
||||
keywords: movie.keywords.keywords.map((keyword) => ({
|
||||
id: keyword.id,
|
||||
name: keyword.name,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ import type { Video } from './Movie';
|
||||
interface Episode {
|
||||
id: number;
|
||||
name: string;
|
||||
airDate: string;
|
||||
airDate: string | null;
|
||||
episodeNumber: number;
|
||||
overview: string;
|
||||
productionCode: string;
|
||||
@@ -50,7 +50,7 @@ interface Season {
|
||||
seasonNumber: number;
|
||||
}
|
||||
|
||||
export interface SeasonWithEpisodes extends Season {
|
||||
export interface SeasonWithEpisodes extends Omit<Season, 'episodeCount'> {
|
||||
episodes: Episode[];
|
||||
externalIds: ExternalIds;
|
||||
}
|
||||
@@ -141,7 +141,6 @@ export const mapSeasonWithEpisodes = (
|
||||
season: TmdbSeasonWithEpisodes
|
||||
): SeasonWithEpisodes => ({
|
||||
airDate: season.air_date,
|
||||
episodeCount: season.episode_count,
|
||||
episodes: season.episodes.map(mapEpisodeResult),
|
||||
externalIds: mapExternalIds(season.external_ids),
|
||||
id: season.id,
|
||||
|
||||
@@ -89,13 +89,28 @@ authRoutes.post('/plex', async (req, res, next) => {
|
||||
await userRepository.save(user);
|
||||
} else {
|
||||
const mainUser = await userRepository.findOneOrFail({
|
||||
select: { id: true, plexToken: true, plexId: true },
|
||||
select: { id: true, plexToken: true, plexId: true, email: true },
|
||||
where: { id: 1 },
|
||||
});
|
||||
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
|
||||
|
||||
if (!account.id) {
|
||||
logger.error('Plex ID was missing from Plex.tv response', {
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
email: account.email,
|
||||
plexUsername: account.username,
|
||||
});
|
||||
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Something went wrong. Try again.',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
account.id === mainUser.plexId ||
|
||||
(account.email === mainUser.email && !mainUser.plexId) ||
|
||||
(await mainPlexTv.checkUserAccess(account.id))
|
||||
) {
|
||||
if (user) {
|
||||
@@ -226,7 +241,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
const hostname =
|
||||
settings.jellyfin.hostname !== ''
|
||||
? settings.jellyfin.hostname
|
||||
: body.hostname;
|
||||
: body.hostname ?? '';
|
||||
const { externalHostname } = getSettings().jellyfin;
|
||||
|
||||
// Try to find deviceId that corresponds to jellyfin user, else generate a new one
|
||||
@@ -244,11 +259,15 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
}
|
||||
// First we need to attempt to log the user in to jellyfin
|
||||
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId);
|
||||
const jellyfinHost =
|
||||
let jellyfinHost =
|
||||
externalHostname && externalHostname.length > 0
|
||||
? externalHostname
|
||||
: hostname;
|
||||
|
||||
jellyfinHost = jellyfinHost.endsWith('/')
|
||||
? jellyfinHost.slice(0, -1)
|
||||
: jellyfinHost;
|
||||
|
||||
const account = await jellyfinserver.login(body.username, body.password);
|
||||
// Next let's see if the user already exists
|
||||
user = await userRepository.findOne({
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import PlexTvAPI from '@server/api/plextv';
|
||||
import type { SortOptions } from '@server/api/themoviedb';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
@@ -20,6 +22,7 @@ import { mapNetwork } from '@server/models/Tv';
|
||||
import { isMovie, isPerson } from '@server/utils/typeHelpers';
|
||||
import { Router } from 'express';
|
||||
import { sortBy } from 'lodash';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
|
||||
const settings = getSettings();
|
||||
@@ -46,25 +49,76 @@ export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
|
||||
|
||||
const discoverRoutes = Router();
|
||||
|
||||
const QueryFilterOptions = z.object({
|
||||
page: z.coerce.string().optional(),
|
||||
sortBy: z.coerce.string().optional(),
|
||||
primaryReleaseDateGte: z.coerce.string().optional(),
|
||||
primaryReleaseDateLte: z.coerce.string().optional(),
|
||||
firstAirDateGte: z.coerce.string().optional(),
|
||||
firstAirDateLte: z.coerce.string().optional(),
|
||||
studio: z.coerce.string().optional(),
|
||||
genre: z.coerce.string().optional(),
|
||||
keywords: z.coerce.string().optional(),
|
||||
language: z.coerce.string().optional(),
|
||||
withRuntimeGte: z.coerce.string().optional(),
|
||||
withRuntimeLte: z.coerce.string().optional(),
|
||||
voteAverageGte: z.coerce.string().optional(),
|
||||
voteAverageLte: z.coerce.string().optional(),
|
||||
network: z.coerce.string().optional(),
|
||||
watchProviders: z.coerce.string().optional(),
|
||||
watchRegion: z.coerce.string().optional(),
|
||||
});
|
||||
|
||||
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||
|
||||
discoverRoutes.get('/movies', async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||
|
||||
try {
|
||||
const query = QueryFilterOptions.parse(req.query);
|
||||
const keywords = query.keywords;
|
||||
const data = await tmdb.getDiscoverMovies({
|
||||
page: Number(req.query.page),
|
||||
language: req.locale ?? (req.query.language as string),
|
||||
genre: req.query.genre ? Number(req.query.genre) : undefined,
|
||||
studio: req.query.studio ? Number(req.query.studio) : undefined,
|
||||
page: Number(query.page),
|
||||
sortBy: query.sortBy as SortOptions,
|
||||
language: req.locale ?? query.language,
|
||||
originalLanguage: query.language,
|
||||
genre: query.genre,
|
||||
studio: query.studio,
|
||||
primaryReleaseDateLte: query.primaryReleaseDateLte
|
||||
? new Date(query.primaryReleaseDateLte).toISOString().split('T')[0]
|
||||
: undefined,
|
||||
primaryReleaseDateGte: query.primaryReleaseDateGte
|
||||
? new Date(query.primaryReleaseDateGte).toISOString().split('T')[0]
|
||||
: undefined,
|
||||
keywords,
|
||||
withRuntimeGte: query.withRuntimeGte,
|
||||
withRuntimeLte: query.withRuntimeLte,
|
||||
voteAverageGte: query.voteAverageGte,
|
||||
voteAverageLte: query.voteAverageLte,
|
||||
watchProviders: query.watchProviders,
|
||||
watchRegion: query.watchRegion,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
let keywordData: TmdbKeyword[] = [];
|
||||
if (keywords) {
|
||||
const splitKeywords = keywords.split(',');
|
||||
|
||||
keywordData = await Promise.all(
|
||||
splitKeywords.map(async (keywordId) => {
|
||||
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
page: data.page,
|
||||
totalPages: data.total_pages,
|
||||
totalResults: data.total_results,
|
||||
keywords: keywordData,
|
||||
results: data.results.map((result) =>
|
||||
mapMovieResult(
|
||||
result,
|
||||
@@ -163,7 +217,7 @@ discoverRoutes.get<{ genreId: string }>(
|
||||
const data = await tmdb.getDiscoverMovies({
|
||||
page: Number(req.query.page),
|
||||
language: req.locale ?? (req.query.language as string),
|
||||
genre: Number(req.params.genreId),
|
||||
genre: req.params.genreId as string,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
@@ -210,7 +264,7 @@ discoverRoutes.get<{ studioId: string }>(
|
||||
const data = await tmdb.getDiscoverMovies({
|
||||
page: Number(req.query.page),
|
||||
language: req.locale ?? (req.query.language as string),
|
||||
studio: Number(req.params.studioId),
|
||||
studio: req.params.studioId as string,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
@@ -296,21 +350,50 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||
|
||||
try {
|
||||
const query = QueryFilterOptions.parse(req.query);
|
||||
const keywords = query.keywords;
|
||||
const data = await tmdb.getDiscoverTv({
|
||||
page: Number(req.query.page),
|
||||
language: req.locale ?? (req.query.language as string),
|
||||
genre: req.query.genre ? Number(req.query.genre) : undefined,
|
||||
network: req.query.network ? Number(req.query.network) : undefined,
|
||||
page: Number(query.page),
|
||||
sortBy: query.sortBy as SortOptions,
|
||||
language: req.locale ?? query.language,
|
||||
genre: query.genre,
|
||||
network: query.network ? Number(query.network) : undefined,
|
||||
firstAirDateLte: query.firstAirDateLte
|
||||
? new Date(query.firstAirDateLte).toISOString().split('T')[0]
|
||||
: undefined,
|
||||
firstAirDateGte: query.firstAirDateGte
|
||||
? new Date(query.firstAirDateGte).toISOString().split('T')[0]
|
||||
: undefined,
|
||||
originalLanguage: query.language,
|
||||
keywords,
|
||||
withRuntimeGte: query.withRuntimeGte,
|
||||
withRuntimeLte: query.withRuntimeLte,
|
||||
voteAverageGte: query.voteAverageGte,
|
||||
voteAverageLte: query.voteAverageLte,
|
||||
watchProviders: query.watchProviders,
|
||||
watchRegion: query.watchRegion,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
let keywordData: TmdbKeyword[] = [];
|
||||
if (keywords) {
|
||||
const splitKeywords = keywords.split(',');
|
||||
|
||||
keywordData = await Promise.all(
|
||||
splitKeywords.map(async (keywordId) => {
|
||||
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
page: data.page,
|
||||
totalPages: data.total_pages,
|
||||
totalResults: data.total_results,
|
||||
keywords: keywordData,
|
||||
results: data.results.map((result) =>
|
||||
mapTvResult(
|
||||
result,
|
||||
@@ -408,7 +491,7 @@ discoverRoutes.get<{ genreId: string }>(
|
||||
const data = await tmdb.getDiscoverTv({
|
||||
page: Number(req.query.page),
|
||||
language: req.locale ?? (req.query.language as string),
|
||||
genre: Number(req.params.genreId),
|
||||
genre: req.params.genreId,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
@@ -643,7 +726,9 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
||||
|
||||
await Promise.all(
|
||||
genres.map(async (genre) => {
|
||||
const genreData = await tmdb.getDiscoverMovies({ genre: genre.id });
|
||||
const genreData = await tmdb.getDiscoverMovies({
|
||||
genre: genre.id.toString(),
|
||||
});
|
||||
|
||||
mappedGenres.push({
|
||||
id: genre.id,
|
||||
@@ -685,7 +770,9 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
||||
|
||||
await Promise.all(
|
||||
genres.map(async (genre) => {
|
||||
const genreData = await tmdb.getDiscoverTv({ genre: genre.id });
|
||||
const genreData = await tmdb.getDiscoverTv({
|
||||
genre: genre.id.toString(),
|
||||
});
|
||||
|
||||
mappedGenres.push({
|
||||
id: genre.id,
|
||||
|
||||
39
server/routes/imageproxy.ts
Normal file
39
server/routes/imageproxy.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import ImageProxy from '@server/lib/imageproxy';
|
||||
import logger from '@server/logger';
|
||||
import { Router } from 'express';
|
||||
|
||||
const router = Router();
|
||||
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
|
||||
rateLimitOptions: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 50,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Image Proxy
|
||||
*/
|
||||
router.get('/*', async (req, res) => {
|
||||
const imagePath = req.path.replace('/image', '');
|
||||
try {
|
||||
const imageData = await tmdbImageProxy.getImage(imagePath);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': `image/${imageData.meta.extension}`,
|
||||
'Content-Length': imageData.imageBuffer.length,
|
||||
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
|
||||
'OS-Cache-Key': imageData.meta.cacheKey,
|
||||
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
|
||||
});
|
||||
|
||||
res.end(imageData.imageBuffer);
|
||||
} catch (e) {
|
||||
logger.error('Failed to proxy image', {
|
||||
imagePath,
|
||||
errorMessage: e.message,
|
||||
});
|
||||
res.status(500).send();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -4,11 +4,14 @@ import type {
|
||||
TmdbMovieResult,
|
||||
TmdbTvResult,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { checkUser, isAuthenticated } from '@server/middleware/auth';
|
||||
import { mapWatchProviderDetails } from '@server/models/common';
|
||||
import { mapProductionCompany } from '@server/models/Movie';
|
||||
import { mapNetwork } from '@server/models/Tv';
|
||||
import settingsRoutes from '@server/routes/settings';
|
||||
@@ -102,6 +105,13 @@ router.get('/settings/public', async (req, res) => {
|
||||
return res.status(200).json(settings.fullPublicSettings);
|
||||
}
|
||||
});
|
||||
router.get('/settings/discover', isAuthenticated(), async (_req, res) => {
|
||||
const sliderRepository = getRepository(DiscoverSlider);
|
||||
|
||||
const sliders = await sliderRepository.find({ order: { order: 'ASC' } });
|
||||
|
||||
return res.json(sliders);
|
||||
});
|
||||
router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes);
|
||||
router.use('/search', isAuthenticated(), searchRoutes);
|
||||
router.use('/discover', isAuthenticated(), discoverRoutes);
|
||||
@@ -269,6 +279,87 @@ router.get('/backdrops', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/keyword/:keywordId', async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanguage();
|
||||
|
||||
try {
|
||||
const result = await tmdb.getKeywordDetails({
|
||||
keywordId: Number(req.params.keywordId),
|
||||
});
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving keyword data', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve keyword data.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/watchproviders/regions', async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanguage();
|
||||
|
||||
try {
|
||||
const result = await tmdb.getAvailableWatchProviderRegions({});
|
||||
return res.status(200).json(result);
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving watch provider regions', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve watch provider regions.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/watchproviders/movies', async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanguage();
|
||||
|
||||
try {
|
||||
const result = await tmdb.getMovieWatchProviders({
|
||||
watchRegion: req.query.watchRegion as string,
|
||||
});
|
||||
|
||||
return res.status(200).json(mapWatchProviderDetails(result));
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving movie watch providers', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve movie watch providers.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/watchproviders/tv', async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanguage();
|
||||
|
||||
try {
|
||||
const result = await tmdb.getTvWatchProviders({
|
||||
watchRegion: req.query.watchRegion as string,
|
||||
});
|
||||
|
||||
return res.status(200).json(mapWatchProviderDetails(result));
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving tv watch providers', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve tv watch providers.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
return res.status(200).json({
|
||||
api: 'Overseerr API',
|
||||
|
||||
@@ -308,7 +308,9 @@ issueRoutes.post<{ issueId: string }, Issue, { message: string }>(
|
||||
|
||||
issueRoutes.post<{ issueId: string; status: string }, Issue>(
|
||||
'/:issueId/:status',
|
||||
isAuthenticated(Permission.MANAGE_ISSUES),
|
||||
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
|
||||
type: 'or',
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
const issueRepository = getRepository(Issue);
|
||||
// Satisfy typescript here. User is set, we assure you!
|
||||
@@ -321,6 +323,16 @@ issueRoutes.post<{ issueId: string; status: string }, Issue>(
|
||||
where: { id: Number(req.params.issueId) },
|
||||
});
|
||||
|
||||
if (
|
||||
!req.user?.hasPermission(Permission.MANAGE_ISSUES) &&
|
||||
issue.createdBy.id !== req.user?.id
|
||||
) {
|
||||
return next({
|
||||
status: 401,
|
||||
message: 'You do not have permission to modify this issue.',
|
||||
});
|
||||
}
|
||||
|
||||
let newStatus: IssueStatus | undefined;
|
||||
|
||||
switch (req.params.status) {
|
||||
|
||||
@@ -492,8 +492,10 @@ requestRoutes.post<{
|
||||
relations: { requestedBy: true, modifiedBy: true },
|
||||
});
|
||||
|
||||
await request.updateParentStatus();
|
||||
await request.sendMedia();
|
||||
// this also triggers updating the parent media's status & sending to *arr
|
||||
request.status = MediaRequestStatus.APPROVED;
|
||||
await requestRepository.save(request);
|
||||
|
||||
return res.status(200).json(request);
|
||||
} catch (e) {
|
||||
logger.error('Error processing request retry', {
|
||||
|
||||
@@ -56,4 +56,50 @@ searchRoutes.get('/', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
searchRoutes.get('/keyword', async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const results = await tmdb.searchKeyword({
|
||||
query: req.query.query as string,
|
||||
page: Number(req.query.page),
|
||||
});
|
||||
|
||||
return res.status(200).json(results);
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving keyword search results', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
query: req.query.query,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve keyword search results.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
searchRoutes.get('/company', async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const results = await tmdb.searchCompany({
|
||||
query: req.query.query as string,
|
||||
page: Number(req.query.page),
|
||||
});
|
||||
|
||||
return res.status(200).json(results);
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving company search results', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
query: req.query.query,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve company search results.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default searchRoutes;
|
||||
|
||||
131
server/routes/settings/discover.ts
Normal file
131
server/routes/settings/discover.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { getRepository } from '@server/datasource';
|
||||
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||
import logger from '@server/logger';
|
||||
import { Router } from 'express';
|
||||
|
||||
const discoverSettingRoutes = Router();
|
||||
|
||||
discoverSettingRoutes.post('/', async (req, res) => {
|
||||
const sliderRepository = getRepository(DiscoverSlider);
|
||||
|
||||
const sliders = req.body as DiscoverSlider[];
|
||||
|
||||
if (!Array.isArray(sliders)) {
|
||||
return res.status(400).json({ message: 'Invalid request body.' });
|
||||
}
|
||||
|
||||
for (let x = 0; x < sliders.length; x++) {
|
||||
const slider = sliders[x];
|
||||
const existingSlider = await sliderRepository.findOne({
|
||||
where: {
|
||||
id: slider.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingSlider && slider.id) {
|
||||
existingSlider.enabled = slider.enabled;
|
||||
existingSlider.order = x;
|
||||
|
||||
// Only allow changes to the following when the slider is not built in
|
||||
if (!existingSlider.isBuiltIn) {
|
||||
existingSlider.title = slider.title;
|
||||
existingSlider.data = slider.data;
|
||||
existingSlider.type = slider.type;
|
||||
}
|
||||
|
||||
await sliderRepository.save(existingSlider);
|
||||
} else {
|
||||
const newSlider = new DiscoverSlider({
|
||||
isBuiltIn: false,
|
||||
data: slider.data,
|
||||
title: slider.title,
|
||||
enabled: slider.enabled,
|
||||
order: x,
|
||||
type: slider.type,
|
||||
});
|
||||
await sliderRepository.save(newSlider);
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(sliders);
|
||||
});
|
||||
|
||||
discoverSettingRoutes.post('/add', async (req, res) => {
|
||||
const sliderRepository = getRepository(DiscoverSlider);
|
||||
|
||||
const slider = req.body as DiscoverSlider;
|
||||
|
||||
const newSlider = new DiscoverSlider({
|
||||
isBuiltIn: false,
|
||||
data: slider.data,
|
||||
title: slider.title,
|
||||
enabled: false,
|
||||
order: -1,
|
||||
type: slider.type,
|
||||
});
|
||||
await sliderRepository.save(newSlider);
|
||||
|
||||
return res.json(newSlider);
|
||||
});
|
||||
|
||||
discoverSettingRoutes.get('/reset', async (_req, res) => {
|
||||
const sliderRepository = getRepository(DiscoverSlider);
|
||||
|
||||
await sliderRepository.clear();
|
||||
await DiscoverSlider.bootstrapSliders();
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
discoverSettingRoutes.put('/:sliderId', async (req, res, next) => {
|
||||
const sliderRepository = getRepository(DiscoverSlider);
|
||||
|
||||
const slider = req.body as DiscoverSlider;
|
||||
|
||||
try {
|
||||
const existingSlider = await sliderRepository.findOneOrFail({
|
||||
where: {
|
||||
id: Number(req.params.sliderId),
|
||||
},
|
||||
});
|
||||
|
||||
// Only allow changes to the following when the slider is not built in
|
||||
if (!existingSlider.isBuiltIn) {
|
||||
existingSlider.title = slider.title;
|
||||
existingSlider.data = slider.data;
|
||||
existingSlider.type = slider.type;
|
||||
}
|
||||
|
||||
await sliderRepository.save(existingSlider);
|
||||
|
||||
return res.status(200).json(existingSlider);
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong updating a slider.', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Slider not found or cannot be updated.' });
|
||||
}
|
||||
});
|
||||
|
||||
discoverSettingRoutes.delete('/:sliderId', async (req, res, next) => {
|
||||
const sliderRepository = getRepository(DiscoverSlider);
|
||||
|
||||
try {
|
||||
const slider = await sliderRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.sliderId), isBuiltIn: false },
|
||||
});
|
||||
|
||||
await sliderRepository.remove(slider);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong deleting a slider.', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Slider not found or cannot be deleted.' });
|
||||
}
|
||||
});
|
||||
|
||||
export default discoverSettingRoutes;
|
||||
@@ -16,12 +16,14 @@ import { jobJellyfinFullSync } from '@server/job/jellyfinsync';
|
||||
import { scheduledJobs } from '@server/job/schedule';
|
||||
import type { AvailableCacheIds } from '@server/lib/cache';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import ImageProxy from '@server/lib/imageproxy';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { plexFullScanner } from '@server/lib/scanners/plex';
|
||||
import type { Library, MainSettings } from '@server/lib/settings';
|
||||
import type { JobId, Library, MainSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import discoverSettingRoutes from '@server/routes/settings/discover';
|
||||
import { appDataPath } from '@server/utils/appDataVolume';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import { Router } from 'express';
|
||||
@@ -41,6 +43,7 @@ const settingsRoutes = Router();
|
||||
settingsRoutes.use('/notifications', notificationRoutes);
|
||||
settingsRoutes.use('/radarr', radarrRoutes);
|
||||
settingsRoutes.use('/sonarr', sonarrRoutes);
|
||||
settingsRoutes.use('/discover', discoverSettingRoutes);
|
||||
|
||||
const filteredMainSettings = (
|
||||
user: User,
|
||||
@@ -307,11 +310,14 @@ settingsRoutes.get('/jellyfin/library', async (req, res) => {
|
||||
settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const { hostname, externalHostname } = getSettings().jellyfin;
|
||||
const jellyfinHost =
|
||||
let jellyfinHost =
|
||||
externalHostname && externalHostname.length > 0
|
||||
? externalHostname
|
||||
: hostname;
|
||||
|
||||
jellyfinHost = jellyfinHost.endsWith('/')
|
||||
? jellyfinHost.slice(0, -1)
|
||||
: jellyfinHost;
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOneOrFail({
|
||||
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
|
||||
@@ -601,7 +607,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
|
||||
});
|
||||
});
|
||||
|
||||
settingsRoutes.post<{ jobId: string }>(
|
||||
settingsRoutes.post<{ jobId: JobId }>(
|
||||
'/jobs/:jobId/cancel',
|
||||
(req, res, next) => {
|
||||
const scheduledJob = scheduledJobs.find(
|
||||
@@ -628,7 +634,7 @@ settingsRoutes.post<{ jobId: string }>(
|
||||
}
|
||||
);
|
||||
|
||||
settingsRoutes.post<{ jobId: string }>(
|
||||
settingsRoutes.post<{ jobId: JobId }>(
|
||||
'/jobs/:jobId/schedule',
|
||||
(req, res, next) => {
|
||||
const scheduledJob = scheduledJobs.find(
|
||||
@@ -663,16 +669,23 @@ settingsRoutes.post<{ jobId: string }>(
|
||||
}
|
||||
);
|
||||
|
||||
settingsRoutes.get('/cache', (req, res) => {
|
||||
const caches = cacheManager.getAllCaches();
|
||||
settingsRoutes.get('/cache', async (_req, res) => {
|
||||
const cacheManagerCaches = cacheManager.getAllCaches();
|
||||
|
||||
return res.status(200).json(
|
||||
Object.values(caches).map((cache) => ({
|
||||
id: cache.id,
|
||||
name: cache.name,
|
||||
stats: cache.getStats(),
|
||||
}))
|
||||
);
|
||||
const apiCaches = Object.values(cacheManagerCaches).map((cache) => ({
|
||||
id: cache.id,
|
||||
name: cache.name,
|
||||
stats: cache.getStats(),
|
||||
}));
|
||||
|
||||
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
|
||||
|
||||
return res.status(200).json({
|
||||
apiCaches,
|
||||
imageCache: {
|
||||
tmdb: tmdbImageCache,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
settingsRoutes.post<{ cacheId: AvailableCacheIds }>(
|
||||
|
||||
@@ -497,11 +497,14 @@ router.post(
|
||||
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
||||
const createdUsers: User[] = [];
|
||||
const { hostname, externalHostname } = getSettings().jellyfin;
|
||||
const jellyfinHost =
|
||||
let jellyfinHost =
|
||||
externalHostname && externalHostname.length > 0
|
||||
? externalHostname
|
||||
: hostname;
|
||||
|
||||
jellyfinHost = jellyfinHost.endsWith('/')
|
||||
? jellyfinHost.slice(0, -1)
|
||||
: jellyfinHost;
|
||||
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
||||
const jellyfinUsers = await jellyfinClient.getUsers();
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import IssueComment from '@server/entity/IssueComment';
|
||||
import Media from '@server/entity/Media';
|
||||
import { User } from '@server/entity/User';
|
||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import logger from '@server/logger';
|
||||
@@ -32,6 +33,10 @@ export class IssueCommentSubscriber
|
||||
})
|
||||
).issue;
|
||||
|
||||
const createdBy = await getRepository(User).findOneOrFail({
|
||||
where: { id: issue.createdBy.id },
|
||||
});
|
||||
|
||||
const media = await getRepository(Media).findOneOrFail({
|
||||
where: { id: issue.media.id },
|
||||
});
|
||||
@@ -71,9 +76,9 @@ export class IssueCommentSubscriber
|
||||
notifyAdmin: true,
|
||||
notifySystem: true,
|
||||
notifyUser:
|
||||
!issue.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
|
||||
issue.createdBy.id !== entity.user.id
|
||||
? issue.createdBy
|
||||
!createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
|
||||
createdBy.id !== entity.user.id
|
||||
? createdBy
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
|
||||
notifySystem: true,
|
||||
notifyUser:
|
||||
!entity.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
|
||||
entity.modifiedBy?.id !== entity.createdBy.id &&
|
||||
(type === Notification.ISSUE_RESOLVED ||
|
||||
type === Notification.ISSUE_REOPENED)
|
||||
? entity.createdBy
|
||||
|
||||
9
server/types/express-session.d.ts
vendored
Normal file
9
server/types/express-session.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import 'express-session';
|
||||
|
||||
// Declaration merging to apply our own types to SessionData
|
||||
// See: (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express-session/index.d.ts#L23)
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
userId: number;
|
||||
}
|
||||
}
|
||||
9
server/types/express.d.ts
vendored
9
server/types/express.d.ts
vendored
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import type { User } from '@server/entity/User';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import 'express-session';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
@@ -16,11 +17,3 @@ declare global {
|
||||
next: NextFunction
|
||||
) => Promise<void | NextFunction> | void | NextFunction;
|
||||
}
|
||||
|
||||
// Declaration merging to apply our own types to SessionData
|
||||
// See: (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express-session/index.d.ts#L23)
|
||||
declare module 'express-session' {
|
||||
export interface SessionData {
|
||||
userId: number;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user