Merge remote-tracking branch 'overseerr/develop' into develop

This commit is contained in:
notfakie
2022-09-01 18:11:15 +12:00
473 changed files with 15548 additions and 8433 deletions

View File

@@ -1,8 +1,8 @@
import logger from '@server/logger';
import axios from 'axios';
import xml2js from 'xml2js';
import fs, { promises as fsp } from 'fs';
import path from 'path';
import logger from '../logger';
import xml2js from 'xml2js';
const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds
// originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml
@@ -14,7 +14,7 @@ const LOCAL_PATH = process.env.CONFIG_DIRECTORY
const mappingRegexp = new RegExp(/;[0-9]+-([0-9]+)/g);
// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to TVDB/TMDb IDs
// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to TVDB/TMDB IDs
// https://github.com/Anime-Lists/anime-lists/
interface AnimeMapping {

View File

@@ -1,5 +1,7 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import NodeCache from 'node-cache';
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import axios from 'axios';
import rateLimit from 'axios-rate-limit';
import type NodeCache from 'node-cache';
// 5 minute default TTL (in seconds)
const DEFAULT_TTL = 300;
@@ -10,6 +12,10 @@ const DEFAULT_ROLLING_BUFFER = 10000;
interface ExternalAPIOptions {
nodeCache?: NodeCache;
headers?: Record<string, unknown>;
rateLimit?: {
maxRPS: number;
maxRequests: number;
};
}
class ExternalAPI {
@@ -31,6 +37,14 @@ class ExternalAPI {
...options.headers,
},
});
if (options.rateLimit) {
this.axios = rateLimit(this.axios, {
maxRequests: options.rateLimit.maxRequests,
maxRPS: options.rateLimit.maxRPS,
});
}
this.baseUrl = baseUrl;
this.cache = options.nodeCache;
}

View File

@@ -1,5 +1,5 @@
import cacheManager from '../lib/cache';
import logger from '../logger';
import cacheManager from '@server/lib/cache';
import logger from '@server/logger';
import ExternalAPI from './externalapi';
interface GitHubRelease {

View File

@@ -1,6 +1,7 @@
import type { Library, PlexSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import NodePlexAPI from 'plex-api';
import { getSettings, Library, PlexSettings } from '../lib/settings';
import logger from '../logger';
export interface PlexLibraryItem {
ratingKey: string;
@@ -130,7 +131,6 @@ class PlexAPI {
});
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
public async getStatus() {
return await this.plexClient.query('/');
}

View File

@@ -1,8 +1,9 @@
import axios, { AxiosInstance } from 'axios';
import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import xml2js from 'xml2js';
import { PlexDevice } from '../interfaces/api/plexInterfaces';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import ExternalAPI from './externalapi';
interface PlexAccountResponse {
user: PlexUser;
@@ -111,20 +112,54 @@ interface UsersResponse {
};
}
class PlexTvAPI {
interface WatchlistResponse {
MediaContainer: {
totalSize: number;
Metadata?: {
ratingKey: string;
}[];
};
}
interface MetadataResponse {
MediaContainer: {
Metadata: {
ratingKey: string;
type: 'movie' | 'show';
title: string;
Guid: {
id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`;
}[];
}[];
};
}
export interface PlexWatchlistItem {
ratingKey: string;
tmdbId: number;
tvdbId?: number;
type: 'movie' | 'show';
title: string;
}
class PlexTvAPI extends ExternalAPI {
private authToken: string;
private axios: AxiosInstance;
constructor(authToken: string) {
super(
'https://plex.tv',
{},
{
headers: {
'X-Plex-Token': authToken,
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('plextv').data,
}
);
this.authToken = authToken;
this.axios = axios.create({
baseURL: 'https://plex.tv',
headers: {
'X-Plex-Token': this.authToken,
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
}
public async getDevices(): Promise<PlexDevice[]> {
@@ -252,6 +287,83 @@ class PlexTvAPI {
)) as UsersResponse;
return parsedXml;
}
public async getWatchlist({
offset = 0,
size = 20,
}: { offset?: number; size?: number } = {}): Promise<{
offset: number;
size: number;
totalSize: number;
items: PlexWatchlistItem[];
}> {
try {
const response = await this.axios.get<WatchlistResponse>(
'/library/sections/watchlist/all',
{
params: {
'X-Plex-Container-Start': offset,
'X-Plex-Container-Size': size,
},
baseURL: 'https://metadata.provider.plex.tv',
}
);
const watchlistDetails = await Promise.all(
(response.data.MediaContainer.Metadata ?? []).map(
async (watchlistItem) => {
const detailedResponse = await this.getRolling<MetadataResponse>(
`/library/metadata/${watchlistItem.ratingKey}`,
{
baseURL: 'https://metadata.provider.plex.tv',
}
);
const metadata = detailedResponse.MediaContainer.Metadata[0];
const tmdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tmdb')
);
const tvdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tvdb')
);
return {
ratingKey: metadata.ratingKey,
// This should always be set? But I guess it also cannot be?
// We will filter out the 0's afterwards
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
tvdbId: tvdbString
? Number(tvdbString.id.split('//')[1])
: undefined,
title: metadata.title,
type: metadata.type,
};
}
)
);
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
return {
offset,
size,
totalSize: response.data.MediaContainer.totalSize,
items: filteredList,
};
} catch (e) {
logger.error('Failed to retrieve watchlist items', {
label: 'Plex.TV Metadata API',
errorMessage: e.message,
});
return {
offset,
size,
totalSize: 0,
items: [],
};
}
}
}
export default PlexTvAPI;

View File

@@ -1,4 +1,4 @@
import cacheManager from '../lib/cache';
import cacheManager from '@server/lib/cache';
import ExternalAPI from './externalapi';
interface RTSearchResult {

View File

@@ -1,6 +1,7 @@
import cacheManager, { AvailableCacheIds } from '../../lib/cache';
import { DVRSettings } from '../../lib/settings';
import ExternalAPI from '../externalapi';
import ExternalAPI from '@server/api/externalapi';
import type { AvailableCacheIds } from '@server/lib/cache';
import cacheManager from '@server/lib/cache';
import type { DVRSettings } from '@server/lib/settings';
export interface SystemStatus {
version: string;

View File

@@ -1,4 +1,4 @@
import logger from '../../logger';
import logger from '@server/logger';
import ServarrBase from './base';
export interface RadarrMovieOptions {
@@ -69,7 +69,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
return response.data[0];
} catch (e) {
logger.error('Error retrieving movie by TMDb ID', {
logger.error('Error retrieving movie by TMDB ID', {
label: 'Radarr API',
errorMessage: e.message,
tmdbId: id,

View File

@@ -1,4 +1,4 @@
import logger from '../../logger';
import logger from '@server/logger';
import ServarrBase from './base';
interface SonarrSeason {

View File

@@ -1,8 +1,9 @@
import axios, { AxiosInstance } from 'axios';
import type { User } from '@server/entity/User';
import type { TautulliSettings } from '@server/lib/settings';
import logger from '@server/logger';
import type { AxiosInstance } from 'axios';
import axios from 'axios';
import { uniqWith } from 'lodash';
import { User } from '../entity/User';
import { TautulliSettings } from '../lib/settings';
import logger from '../logger';
export interface TautulliHistoryRecord {
date: number;

View File

@@ -1,7 +1,7 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache';
import { sortBy } from 'lodash';
import cacheManager from '../../lib/cache';
import ExternalAPI from '../externalapi';
import {
import type {
TmdbCollection,
TmdbExternalIdResponse,
TmdbGenre,
@@ -92,6 +92,10 @@ class TheMovieDb extends ExternalAPI {
},
{
nodeCache: cacheManager.getCache('tmdb').data,
rateLimit: {
maxRequests: 20,
maxRPS: 50,
},
}
);
this.region = region;
@@ -192,7 +196,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch person details: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`);
}
};
@@ -214,7 +218,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(
`[TMDb] Failed to fetch person combined credits: ${e.message}`
`[TMDB] Failed to fetch person combined credits: ${e.message}`
);
}
};
@@ -241,7 +245,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch movie details: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`);
}
};
@@ -267,7 +271,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);
}
};
@@ -293,7 +297,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);
}
};
@@ -319,7 +323,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
}
@@ -345,7 +349,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
}
@@ -371,7 +375,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch movies by keyword: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`);
}
}
@@ -398,7 +402,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(
`[TMDb] Failed to fetch TV recommendations: ${e.message}`
`[TMDB] Failed to fetch TV recommendations: ${e.message}`
);
}
}
@@ -422,7 +426,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch TV similar: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch TV similar: ${e.message}`);
}
}
@@ -455,7 +459,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
};
@@ -488,7 +492,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch discover TV: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch discover TV: ${e.message}`);
}
};
@@ -514,7 +518,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch upcoming movies: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`);
}
};
@@ -541,7 +545,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
}
};
@@ -564,7 +568,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
}
};
@@ -587,7 +591,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
}
};
@@ -619,7 +623,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to find by external ID: ${e.message}`);
throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`);
}
}
@@ -657,7 +661,7 @@ class TheMovieDb extends ExternalAPI {
throw new Error(`No movie or show returned from API for ID ${imdbId}`);
} catch (e) {
throw new Error(
`[TMDb] Failed to find media using external IMDb ID: ${e.message}`
`[TMDB] Failed to find media using external IMDb ID: ${e.message}`
);
}
}
@@ -687,7 +691,7 @@ class TheMovieDb extends ExternalAPI {
throw new Error(`No show returned from API for ID ${tvdbId}`);
} catch (e) {
throw new Error(
`[TMDb] Failed to get TV show using the external TVDB ID: ${e.message}`
`[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}`
);
}
}
@@ -711,7 +715,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch collection: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`);
}
}
@@ -727,7 +731,7 @@ class TheMovieDb extends ExternalAPI {
return regions;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch countries: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch countries: ${e.message}`);
}
}
@@ -743,7 +747,7 @@ class TheMovieDb extends ExternalAPI {
return languages;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch langauges: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch langauges: ${e.message}`);
}
}
@@ -755,7 +759,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch movie studio: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch movie studio: ${e.message}`);
}
}
@@ -765,7 +769,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch TV network: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch TV network: ${e.message}`);
}
}
@@ -816,7 +820,7 @@ class TheMovieDb extends ExternalAPI {
return movieGenres;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch movie genres: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch movie genres: ${e.message}`);
}
}
@@ -867,7 +871,7 @@ class TheMovieDb extends ExternalAPI {
return tvGenres;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch TV genres: ${e.message}`);
throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`);
}
}
}

View File

@@ -2,6 +2,7 @@ export enum MediaRequestStatus {
PENDING = 1,
APPROVED,
DECLINED,
FAILED,
}
export enum MediaType {

43
server/datasource.ts Normal file
View File

@@ -0,0 +1,43 @@
import 'reflect-metadata';
import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm';
import { DataSource } from 'typeorm';
const devConfig: DataSourceOptions = {
type: 'sqlite',
database: process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3`
: 'config/db/db.sqlite3',
synchronize: true,
migrationsRun: false,
logging: false,
enableWAL: true,
entities: ['server/entity/**/*.ts'],
migrations: ['server/migration/**/*.ts'],
subscribers: ['server/subscriber/**/*.ts'],
};
const prodConfig: DataSourceOptions = {
type: 'sqlite',
database: process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3`
: 'config/db/db.sqlite3',
synchronize: false,
migrationsRun: false,
logging: false,
enableWAL: true,
entities: ['dist/entity/**/*.js'],
migrations: ['dist/migration/**/*.js'],
subscribers: ['dist/subscriber/**/*.js'],
};
const dataSource = new DataSource(
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
);
export const getRepository = <Entity>(
target: EntityTarget<Entity>
): Repository<Entity> => {
return dataSource.getRepository(target);
};
export default dataSource;

View File

@@ -1,3 +1,5 @@
import type { IssueType } from '@server/constants/issue';
import { IssueStatus } from '@server/constants/issue';
import {
Column,
CreateDateColumn,
@@ -7,7 +9,6 @@ import {
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { IssueStatus, IssueType } from '../constants/issue';
import IssueComment from './IssueComment';
import Media from './Media';
import { User } from './User';

View File

@@ -1,22 +1,23 @@
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
import type { DownloadingItem } from '@server/lib/downloadtracker';
import downloadTracker from '@server/lib/downloadtracker';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import {
AfterLoad,
Column,
CreateDateColumn,
Entity,
getRepository,
In,
Index,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import RadarrAPI from '../api/servarr/radarr';
import SonarrAPI from '../api/servarr/sonarr';
import { MediaStatus, MediaType } from '../constants/media';
import { MediaServerType } from '../constants/server';
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import Issue from './Issue';
import { MediaRequest } from './MediaRequest';
import Season from './Season';
@@ -37,7 +38,7 @@ class Media {
}
const media = await mediaRepository.find({
tmdbId: In(finalIds),
where: { tmdbId: In(finalIds) },
});
return media;
@@ -56,10 +57,10 @@ class Media {
try {
const media = await mediaRepository.findOne({
where: { tmdbId: id, mediaType },
relations: ['requests', 'issues'],
relations: { requests: true, issues: true },
});
return media;
return media ?? undefined;
} catch (e) {
logger.error(e.message);
return undefined;
@@ -152,6 +153,9 @@ class Media {
public mediaUrl?: string;
public mediaUrl4k?: string;
public iOSPlexUrl?: string;
public iOSPlexUrl4k?: string;
public tautulliUrl?: string;
public tautulliUrl4k?: string;
@@ -172,36 +176,41 @@ class Media {
this.ratingKey
}`;
this.iOSPlexUrl = `plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F${this.ratingKey}&server=${machineId}`;
if (tautulliUrl) {
this.tautulliUrl = `${tautulliUrl}/info?rating_key=${this.ratingKey}`;
}
}
if (this.ratingKey4k) {
this.mediaUrl4k = `${
webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop'
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
this.ratingKey4k
}`;
if (this.ratingKey4k) {
this.mediaUrl4k = `${
webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop'
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
this.ratingKey4k
}`;
if (tautulliUrl) {
this.tautulliUrl4k = `${tautulliUrl}/info?rating_key=${this.ratingKey4k}`;
this.iOSPlexUrl4k = `plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F${this.ratingKey4k}&server=${machineId}`;
if (tautulliUrl) {
this.tautulliUrl4k = `${tautulliUrl}/info?rating_key=${this.ratingKey4k}`;
}
} else {
const pageName =
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
const { serverId, hostname, externalHostname } =
getSettings().jellyfin;
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
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}`;
}
}
}
} else {
const pageName =
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
const { serverId, hostname, externalHostname } = getSettings().jellyfin;
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
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}`;
}
}
}

View File

@@ -1,3 +1,23 @@
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
import RadarrAPI from '@server/api/servarr/radarr';
import type {
AddSeriesOptions,
SonarrSeries,
} from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import {
MediaRequestStatus,
MediaStatus,
MediaType,
} from '@server/constants/media';
import { getRepository } from '@server/datasource';
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isEqual, truncate } from 'lodash';
import {
AfterInsert,
@@ -6,30 +26,347 @@ import {
Column,
CreateDateColumn,
Entity,
getRepository,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
RelationCount,
UpdateDateColumn,
} from 'typeorm';
import RadarrAPI, { RadarrMovieOptions } from '../api/servarr/radarr';
import SonarrAPI, {
AddSeriesOptions,
SonarrSeries,
} from '../api/servarr/sonarr';
import TheMovieDb from '../api/themoviedb';
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
import notificationManager, { Notification } from '../lib/notifications';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import Media from './Media';
import SeasonRequest from './SeasonRequest';
import { User } from './User';
export class RequestPermissionError extends Error {}
export class QuotaRestrictedError extends Error {}
export class DuplicateMediaRequestError extends Error {}
export class NoSeasonsAvailableError extends Error {}
type MediaRequestOptions = {
isAutoRequest?: boolean;
};
@Entity()
export class MediaRequest {
public static async request(
requestBody: MediaRequestBody,
user: User,
options: MediaRequestOptions = {}
): Promise<MediaRequest> {
const tmdb = new TheMovieDb();
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User);
let requestUser = user;
if (
requestBody.userId &&
!requestUser.hasPermission([
Permission.MANAGE_USERS,
Permission.MANAGE_REQUESTS,
])
) {
throw new RequestPermissionError(
'You do not have permission to modify the request user.'
);
} else if (requestBody.userId) {
requestUser = await userRepository.findOneOrFail({
where: { id: requestBody.userId },
});
}
if (!requestUser) {
throw new Error('User missing from request context.');
}
if (
requestBody.mediaType === MediaType.MOVIE &&
!requestUser.hasPermission(
requestBody.is4k
? [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE]
: [Permission.REQUEST, Permission.REQUEST_MOVIE],
{
type: 'or',
}
)
) {
throw new RequestPermissionError(
`You do not have permission to make ${
requestBody.is4k ? '4K ' : ''
}movie requests.`
);
} else if (
requestBody.mediaType === MediaType.TV &&
!requestUser.hasPermission(
requestBody.is4k
? [Permission.REQUEST_4K, Permission.REQUEST_4K_TV]
: [Permission.REQUEST, Permission.REQUEST_TV],
{
type: 'or',
}
)
) {
throw new RequestPermissionError(
`You do not have permission to make ${
requestBody.is4k ? '4K ' : ''
}series requests.`
);
}
const quotas = await requestUser.getQuota();
if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
throw new QuotaRestrictedError('Movie Quota exceeded.');
} else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) {
throw new QuotaRestrictedError('Series Quota exceeded.');
}
const tmdbMedia =
requestBody.mediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: requestBody.mediaId })
: await tmdb.getTvShow({ tvId: requestBody.mediaId });
let media = await mediaRepository.findOne({
where: {
tmdbId: requestBody.mediaId,
mediaType: requestBody.mediaType,
},
relations: ['requests'],
});
if (!media) {
media = new Media({
tmdbId: tmdbMedia.id,
tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
mediaType: requestBody.mediaType,
});
} else {
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
media.status = MediaStatus.PENDING;
}
if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) {
media.status4k = MediaStatus.PENDING;
}
}
const existing = await requestRepository
.createQueryBuilder('request')
.leftJoin('request.media', 'media')
.leftJoinAndSelect('request.requestedBy', 'user')
.where('request.is4k = :is4k', { is4k: requestBody.is4k })
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
.andWhere('media.mediaType = :mediaType', {
mediaType: requestBody.mediaType,
})
.getMany();
if (existing && existing.length > 0) {
// If there is an existing movie request that isn't declined, don't allow a new one.
if (
requestBody.mediaType === MediaType.MOVIE &&
existing[0].status !== MediaRequestStatus.DECLINED
) {
logger.warn('Duplicate request for media blocked', {
tmdbId: tmdbMedia.id,
mediaType: requestBody.mediaType,
is4k: requestBody.is4k,
label: 'Media Request',
});
throw new DuplicateMediaRequestError(
'Request for this media already exists.'
);
}
// If an existing auto-request for this media exists from the same user,
// don't allow a new one.
if (
existing.find(
(r) => r.requestedBy.id === requestUser.id && r.isAutoRequest
)
) {
throw new DuplicateMediaRequestError(
'Auto-request for this media and user already exists.'
);
}
}
if (requestBody.mediaType === MediaType.MOVIE) {
await mediaRepository.save(media);
const request = new MediaRequest({
type: MediaType.MOVIE,
media,
requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request
status: user.hasPermission(
[
requestBody.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
requestBody.is4k
? Permission.AUTO_APPROVE_4K_MOVIE
: Permission.AUTO_APPROVE_MOVIE,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy: user.hasPermission(
[
requestBody.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
requestBody.is4k
? Permission.AUTO_APPROVE_4K_MOVIE
: Permission.AUTO_APPROVE_MOVIE,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? user
: undefined,
is4k: requestBody.is4k,
serverId: requestBody.serverId,
profileId: requestBody.profileId,
rootFolder: requestBody.rootFolder,
tags: requestBody.tags,
isAutoRequest: options.isAutoRequest ?? false,
});
await requestRepository.save(request);
return request;
} else {
const tmdbMediaShow = tmdbMedia as Awaited<
ReturnType<typeof tmdb.getTvShow>
>;
const requestedSeasons =
requestBody.seasons === 'all'
? tmdbMediaShow.seasons
.map((season) => season.season_number)
.filter((sn) => sn > 0)
: (requestBody.seasons as number[]);
let existingSeasons: number[] = [];
// We need to check existing requests on this title to make sure we don't double up on seasons that were
// already requested. In the case they were, we just throw out any duplicates but still approve the request.
// (Unless there are no seasons, in which case we abort)
if (media.requests) {
existingSeasons = media.requests
.filter(
(request) =>
request.is4k === requestBody.is4k &&
request.status !== MediaRequestStatus.DECLINED
)
.reduce((seasons, request) => {
const combinedSeasons = request.seasons.map(
(season) => season.seasonNumber
);
return [...seasons, ...combinedSeasons];
}, [] as number[]);
}
// We should also check seasons that are available/partially available but don't have existing requests
if (media.seasons) {
existingSeasons = [
...existingSeasons,
...media.seasons
.filter(
(season) =>
season[requestBody.is4k ? 'status4k' : 'status'] !==
MediaStatus.UNKNOWN
)
.map((season) => season.seasonNumber),
];
}
const finalSeasons = requestedSeasons.filter(
(rs) => !existingSeasons.includes(rs)
);
if (finalSeasons.length === 0) {
throw new NoSeasonsAvailableError('No seasons available to request');
} else if (
quotas.tv.limit &&
finalSeasons.length > (quotas.tv.remaining ?? 0)
) {
throw new QuotaRestrictedError('Series Quota exceeded.');
}
await mediaRepository.save(media);
const request = new MediaRequest({
type: MediaType.TV,
media,
requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request
status: user.hasPermission(
[
requestBody.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
requestBody.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy: user.hasPermission(
[
requestBody.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
requestBody.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? user
: undefined,
is4k: requestBody.is4k,
serverId: requestBody.serverId,
profileId: requestBody.profileId,
rootFolder: requestBody.rootFolder,
languageProfileId: requestBody.languageProfileId,
tags: requestBody.tags,
seasons: finalSeasons.map(
(sn) =>
new SeasonRequest({
seasonNumber: sn,
status: user.hasPermission(
[
requestBody.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
requestBody.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
})
),
isAutoRequest: options.isAutoRequest ?? false,
});
await requestRepository.save(request);
return request;
}
}
@PrimaryGeneratedColumn()
public id: number;
@@ -120,6 +457,9 @@ export class MediaRequest {
})
public tags?: number[];
@Column({ default: false })
public isAutoRequest: boolean;
constructor(init?: Partial<MediaRequest>) {
Object.assign(this, init);
}
@@ -147,6 +487,10 @@ export class MediaRequest {
}
this.sendNotification(media, Notification.MEDIA_PENDING);
if (this.isAutoRequest) {
this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED);
}
}
}
@@ -191,6 +535,14 @@ export class MediaRequest {
: Notification.MEDIA_APPROVED
: Notification.MEDIA_DECLINED
);
if (
this.status === MediaRequestStatus.APPROVED &&
autoApproved &&
this.isAutoRequest
) {
this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED);
}
}
}
@@ -207,7 +559,7 @@ export class MediaRequest {
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({
where: { id: this.media.id },
relations: ['requests'],
relations: { requests: true },
});
if (!media) {
logger.error('Media data not found', {
@@ -272,7 +624,7 @@ export class MediaRequest {
const mediaRepository = getRepository(Media);
const fullMedia = await mediaRepository.findOneOrFail({
where: { id: this.media.id },
relations: ['requests'],
relations: { requests: true },
});
if (
@@ -452,10 +804,13 @@ export class MediaRequest {
await mediaRepository.save(media);
})
.catch(async () => {
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.FAILED;
requestRepository.save(this);
logger.warn(
'Something went wrong sending movie request to Radarr, marking status as UNKNOWN',
'Something went wrong sending movie request to Radarr, marking status as FAILED',
{
label: 'Media Request',
requestId: this.id,
@@ -543,7 +898,7 @@ export class MediaRequest {
const media = await mediaRepository.findOne({
where: { id: this.media.id },
relations: ['requests'],
relations: { requests: true },
});
if (!media) {
@@ -670,7 +1025,7 @@ export class MediaRequest {
// We grab media again here to make sure we have the latest version of it
const media = await mediaRepository.findOne({
where: { id: this.media.id },
relations: ['requests'],
relations: { requests: true },
});
if (!media) {
@@ -685,10 +1040,13 @@ export class MediaRequest {
await mediaRepository.save(media);
})
.catch(async () => {
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.FAILED;
requestRepository.save(this);
logger.warn(
'Something went wrong sending series request to Sonarr, marking status as UNKNOWN',
'Something went wrong sending series request to Sonarr, marking status as FAILED',
{
label: 'Media Request',
requestId: this.id,
@@ -723,6 +1081,7 @@ export class MediaRequest {
const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series';
let event: string | undefined;
let notifyAdmin = true;
let notifySystem = true;
switch (type) {
case Notification.MEDIA_APPROVED:
@@ -736,6 +1095,13 @@ export class MediaRequest {
case Notification.MEDIA_PENDING:
event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`;
break;
case Notification.MEDIA_AUTO_REQUESTED:
event = `${
this.is4k ? '4K ' : ''
}${mediaType} Request Automatically Submitted`;
notifyAdmin = false;
notifySystem = false;
break;
case Notification.MEDIA_AUTO_APPROVED:
event = `${
this.is4k ? '4K ' : ''
@@ -752,6 +1118,7 @@ export class MediaRequest {
media,
request: this,
notifyAdmin,
notifySystem,
notifyUser: notifyAdmin ? undefined : this.requestedBy,
event,
subject: `${movie.title}${
@@ -770,6 +1137,7 @@ export class MediaRequest {
media,
request: this,
notifyAdmin,
notifySystem,
notifyUser: notifyAdmin ? undefined : this.requestedBy,
event,
subject: `${tv.name}${

View File

@@ -1,12 +1,12 @@
import { MediaStatus } from '@server/constants/media';
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { MediaStatus } from '../constants/media';
import Media from './Media';
@Entity()

View File

@@ -1,12 +1,12 @@
import { MediaRequestStatus } from '@server/constants/media';
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { MediaRequestStatus } from '../constants/media';
import { MediaRequest } from './MediaRequest';
@Entity()

View File

@@ -1,5 +1,5 @@
import { ISession } from 'connect-typeorm';
import { Index, Column, PrimaryColumn, Entity } from 'typeorm';
import type { ISession } from 'connect-typeorm';
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity()
export class Session implements ISession {

View File

@@ -1,3 +1,13 @@
import { MediaRequestStatus, MediaType } from '@server/constants/media';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import PreparedEmail from '@server/lib/email';
import type { PermissionCheckOptions } from '@server/lib/permissions';
import { hasPermission, Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { AfterDate } from '@server/utils/dateHelpers';
import bcrypt from 'bcrypt';
import { randomUUID } from 'crypto';
import path from 'path';
@@ -7,8 +17,6 @@ import {
Column,
CreateDateColumn,
Entity,
getRepository,
MoreThan,
Not,
OneToMany,
OneToOne,
@@ -16,17 +24,6 @@ import {
RelationCount,
UpdateDateColumn,
} from 'typeorm';
import { MediaRequestStatus, MediaType } from '../constants/media';
import { UserType } from '../constants/user';
import { QuotaResponse } from '../interfaces/api/userInterfaces';
import PreparedEmail from '../lib/email';
import {
hasPermission,
Permission,
PermissionCheckOptions,
} from '../lib/permissions';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import Issue from './Issue';
import { MediaRequest } from './MediaRequest';
import SeasonRequest from './SeasonRequest';
@@ -270,13 +267,14 @@ export class User {
if (movieQuotaDays) {
movieDate.setDate(movieDate.getDate() - movieQuotaDays);
}
const movieQuotaStartDate = movieDate.toJSON();
const movieQuotaUsed = movieQuotaLimit
? await requestRepository.count({
where: {
requestedBy: this,
createdAt: MoreThan(movieQuotaStartDate),
requestedBy: {
id: this.id,
},
createdAt: AfterDate(movieDate),
type: MediaType.MOVIE,
status: Not(MediaRequestStatus.DECLINED),
},

View File

@@ -1,3 +1,6 @@
import type { NotificationAgentTypes } from '@server/interfaces/api/userSettingsInterfaces';
import { hasNotificationType, Notification } from '@server/lib/notifications';
import { NotificationAgentKey } from '@server/lib/settings';
import {
Column,
Entity,
@@ -5,9 +8,6 @@ import {
OneToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { NotificationAgentTypes } from '../interfaces/api/userSettingsInterfaces';
import { hasNotificationType, Notification } from '../lib/notifications';
import { NotificationAgentKey } from '../lib/settings';
import { User } from './User';
export const ALL_NOTIFICATIONS = Object.values(Notification)
@@ -57,6 +57,12 @@ export class UserSettings {
@Column({ nullable: true })
public telegramSendSilently?: boolean;
@Column({ nullable: true })
public watchlistSyncMovies?: boolean;
@Column({ nullable: true })
public watchlistSyncTv?: boolean;
@Column({
type: 'text',
nullable: true,

View File

@@ -1,34 +1,37 @@
import PlexAPI from '@server/api/plexapi';
import dataSource, { getRepository } from '@server/datasource';
import { Session } from '@server/entity/Session';
import { User } from '@server/entity/User';
import { startJobs } from '@server/job/schedule';
import notificationManager from '@server/lib/notifications';
import DiscordAgent from '@server/lib/notifications/agents/discord';
import EmailAgent from '@server/lib/notifications/agents/email';
import GotifyAgent from '@server/lib/notifications/agents/gotify';
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
import PushoverAgent from '@server/lib/notifications/agents/pushover';
import SlackAgent from '@server/lib/notifications/agents/slack';
import TelegramAgent from '@server/lib/notifications/agents/telegram';
import WebhookAgent from '@server/lib/notifications/agents/webhook';
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 { getAppVersion } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag';
import { getClientIp } from '@supercharge/request-ip';
import { TypeormStore } from 'connect-typeorm/out';
import cookieParser from 'cookie-parser';
import csurf from 'csurf';
import express, { NextFunction, Request, Response } from 'express';
import type { NextFunction, Request, Response } from 'express';
import express from 'express';
import * as OpenApiValidator from 'express-openapi-validator';
import session, { Store } from 'express-session';
import type { Store } from 'express-session';
import session from 'express-session';
import next from 'next';
import path from 'path';
import swaggerUi from 'swagger-ui-express';
import { createConnection, getRepository } from 'typeorm';
import YAML from 'yamljs';
import PlexAPI from './api/plexapi';
import { Session } from './entity/Session';
import { User } from './entity/User';
import { startJobs } from './job/schedule';
import notificationManager from './lib/notifications';
import DiscordAgent from './lib/notifications/agents/discord';
import EmailAgent from './lib/notifications/agents/email';
import GotifyAgent from './lib/notifications/agents/gotify';
import LunaSeaAgent from './lib/notifications/agents/lunasea';
import PushbulletAgent from './lib/notifications/agents/pushbullet';
import PushoverAgent from './lib/notifications/agents/pushover';
import SlackAgent from './lib/notifications/agents/slack';
import TelegramAgent from './lib/notifications/agents/telegram';
import WebhookAgent from './lib/notifications/agents/webhook';
import WebPushAgent from './lib/notifications/agents/webpush';
import { getSettings } from './lib/settings';
import logger from './logger';
import routes from './routes';
import { getAppVersion } from './utils/appVersion';
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
@@ -40,7 +43,7 @@ const handle = app.getRequestHandler();
app
.prepare()
.then(async () => {
const dbConnection = await createConnection();
const dbConnection = await dataSource.initialize();
// Run migrations in production
if (process.env.NODE_ENV === 'production') {
@@ -51,6 +54,7 @@ app
// Load Settings
const settings = getSettings().load();
restartFlag.initializeSettings(settings.main);
// Migrate library types
if (
@@ -59,8 +63,8 @@ app
) {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
select: { id: true, plexToken: true },
where: { id: 1 },
});
if (admin) {

View File

@@ -3,3 +3,17 @@ export interface GenreSliderItem {
name: string;
backdrops: string[];
}
export interface WatchlistItem {
ratingKey: string;
tmdbId: number;
mediaType: 'movie' | 'tv';
title: string;
}
export interface WatchlistResponse {
page: number;
totalPages: number;
totalResults: number;
results: WatchlistItem[];
}

View File

@@ -1,5 +1,5 @@
import Issue from '../../entity/Issue';
import { PaginatedResponse } from './common';
import type Issue from '@server/entity/Issue';
import type { PaginatedResponse } from './common';
export interface IssueResultsResponse extends PaginatedResponse {
results: Issue[];

View File

@@ -1,6 +1,6 @@
import type Media from '../../entity/Media';
import { User } from '../../entity/User';
import { PaginatedResponse } from './common';
import type Media from '@server/entity/Media';
import type { User } from '@server/entity/User';
import type { PaginatedResponse } from './common';
export interface MediaResultsResponse extends PaginatedResponse {
results: Media[];

View File

@@ -1,4 +1,4 @@
import { PersonCreditCast, PersonCreditCrew } from '../../models/Person';
import type { PersonCreditCast, PersonCreditCrew } from '@server/models/Person';
export interface PersonCombinedCreditsResponse {
id: number;

View File

@@ -1,4 +1,4 @@
import { PlexSettings } from '../../lib/settings';
import type { PlexSettings } from '@server/lib/settings';
export interface PlexStatus {
settings: PlexSettings;

View File

@@ -1,6 +1,21 @@
import type { MediaType } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import type { PaginatedResponse } from './common';
import type { MediaRequest } from '../../entity/MediaRequest';
export interface RequestResultsResponse extends PaginatedResponse {
results: MediaRequest[];
}
export type MediaRequestBody = {
mediaType: MediaType;
mediaId: number;
tvdbId?: number;
seasons?: number[] | 'all';
is4k?: boolean;
serverId?: number;
profileId?: number;
rootFolder?: string;
languageProfileId?: number;
userId?: number;
tags?: number[];
};

View File

@@ -1,5 +1,5 @@
import { QualityProfile, RootFolder, Tag } from '../../api/servarr/base';
import { LanguageProfile } from '../../api/servarr/sonarr';
import type { QualityProfile, RootFolder, Tag } from '@server/api/servarr/base';
import type { LanguageProfile } from '@server/api/servarr/sonarr';
export interface ServiceCommonServer {
id: number;

View File

@@ -59,4 +59,5 @@ export interface StatusResponse {
commitTag: string;
updateAvailable: boolean;
commitsBehind: number;
restartRequired: boolean;
}

View File

@@ -1,7 +1,7 @@
import Media from '../../entity/Media';
import { MediaRequest } from '../../entity/MediaRequest';
import type { User } from '../../entity/User';
import { PaginatedResponse } from './common';
import type Media from '@server/entity/Media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import type { User } from '@server/entity/User';
import type { PaginatedResponse } from './common';
export interface UserResultsResponse extends PaginatedResponse {
results: User[];
@@ -23,6 +23,7 @@ export interface QuotaResponse {
movie: QuotaStatus;
tv: QuotaStatus;
}
export interface UserWatchDataResponse {
recentlyWatched: Media[];
playCount: number;

View File

@@ -1,4 +1,4 @@
import { NotificationAgentKey } from '../../lib/settings';
import type { NotificationAgentKey } from '@server/lib/settings';
export interface UserSettingsGeneralResponse {
username?: string;
@@ -15,6 +15,8 @@ export interface UserSettingsGeneralResponse {
globalMovieQuotaLimit?: number;
globalTvQuotaLimit?: number;
globalTvQuotaDays?: number;
watchlistSyncMovies?: boolean;
watchlistSyncTv?: boolean;
}
export type NotificationAgentTypes = Record<NotificationAgentKey, number>;

View File

@@ -1,17 +1,19 @@
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
import JellyfinAPI from '@server/api/jellyfin';
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import Season from '@server/entity/Season';
import { User } from '@server/entity/User';
import type { Library } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import AsyncLock from '@server/utils/asyncLock';
import { randomUUID as uuid } from 'crypto';
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import JellyfinAPI, { JellyfinLibraryItem } from '../../api/jellyfin';
import TheMovieDb from '../../api/themoviedb';
import { TmdbTvDetails } from '../../api/themoviedb/interfaces';
import { MediaStatus, MediaType } from '../../constants/media';
import { MediaServerType } from '../../constants/server';
import Media from '../../entity/Media';
import Season from '../../entity/Season';
import { User } from '../../entity/User';
import { getSettings, Library } from '../../lib/settings';
import logger from '../../logger';
import AsyncLock from '../../utils/asyncLock';
const BUNDLE_SIZE = 20;
const UPDATE_RATE = 4 * 1000;
@@ -552,6 +554,7 @@ class JobJellyfinSync {
this.running = true;
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
where: { id: 1 },
select: [
'id',
'jellyfinAuthToken',

View File

@@ -1,11 +1,13 @@
import { MediaServerType } from '@server/constants/server';
import downloadTracker from '@server/lib/downloadtracker';
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
import { radarrScanner } from '@server/lib/scanners/radarr';
import { sonarrScanner } from '@server/lib/scanners/sonarr';
import type { JobId } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import watchlistSync from '@server/lib/watchlistsync';
import logger from '@server/logger';
import schedule from 'node-schedule';
import { MediaServerType } from '../constants/server';
import downloadTracker from '../lib/downloadtracker';
import { plexFullScanner, plexRecentScanner } from '../lib/scanners/plex';
import { radarrScanner } from '../lib/scanners/radarr';
import { sonarrScanner } from '../lib/scanners/sonarr';
import { getSettings, JobId } from '../lib/settings';
import logger from '../logger';
import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync';
interface ScheduledJob {
@@ -99,6 +101,20 @@ export const startJobs = (): void => {
});
}
// Run watchlist sync every 5 minutes
scheduledJobs.push({
id: 'plex-watchlist-sync',
name: 'Plex Watchlist Sync',
type: 'process',
interval: 'long',
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
logger.info('Starting scheduled job: Plex Watchlist Sync', {
label: 'Jobs',
});
watchlistSync.syncWatchlist();
}),
});
// Run full radarr scan every 24 hours
scheduledJobs.push({
id: 'radarr-scan',

View File

@@ -6,7 +6,8 @@ export type AvailableCacheIds =
| 'sonarr'
| 'rt'
| 'github'
| 'plexguid';
| 'plexguid'
| 'plextv';
const DEFAULT_TTL = 300;
const DEFAULT_CHECK_PERIOD = 120;
@@ -58,6 +59,10 @@ class CacheManager {
stdTtl: 86400 * 7, // 1 week cache
checkPeriod: 60 * 30,
}),
plextv: new Cache('plextv', 'Plex TV', {
stdTtl: 86400 * 7, // 1 week cache
checkPeriod: 60,
}),
};
public getCache(id: AvailableCacheIds): Cache {

View File

@@ -1,9 +1,9 @@
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaType } from '@server/constants/media';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { uniqWith } from 'lodash';
import RadarrAPI from '../api/servarr/radarr';
import SonarrAPI from '../api/servarr/sonarr';
import { MediaType } from '../constants/media';
import logger from '../logger';
import { getSettings } from './settings';
export interface DownloadingItem {
mediaType: MediaType;

View File

@@ -1,7 +1,8 @@
import type { NotificationAgentEmail } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import Email from 'email-templates';
import nodemailer from 'nodemailer';
import { URL } from 'url';
import { getSettings, NotificationAgentEmail } from '../settings';
import { openpgpEncrypt } from './openpgpEncrypt';
class PreparedEmail extends Email {

View File

@@ -1,7 +1,8 @@
import logger from '@server/logger';
import { randomBytes } from 'crypto';
import * as openpgp from 'openpgp';
import { Transform, TransformCallback } from 'stream';
import logger from '../../logger';
import type { TransformCallback } from 'stream';
import { Transform } from 'stream';
interface EncryptorOptions {
signingKey?: string;
@@ -26,7 +27,7 @@ class PGPEncryptor extends Transform {
// just save the whole message
_transform = (
chunk: any,
chunk: Uint8Array,
_encoding: BufferEncoding,
callback: TransformCallback
): void => {
@@ -184,6 +185,9 @@ class PGPEncryptor extends Transform {
}
export const openpgpEncrypt = (options: EncryptorOptions) => {
// Disabling this line because I don't want to fix it but I am tired
// of seeing the lint warning
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return function (mail: any, callback: () => unknown): void {
if (!options.encryptionKeys.length) {
setImmediate(callback);

View File

@@ -1,14 +1,15 @@
import { Notification } from '..';
import type Issue from '../../../entity/Issue';
import IssueComment from '../../../entity/IssueComment';
import Media from '../../../entity/Media';
import { MediaRequest } from '../../../entity/MediaRequest';
import { User } from '../../../entity/User';
import { NotificationAgentConfig } from '../../settings';
import type Issue from '@server/entity/Issue';
import type IssueComment from '@server/entity/IssueComment';
import type Media from '@server/entity/Media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import type { User } from '@server/entity/User';
import type { NotificationAgentConfig } from '@server/lib/settings';
import type { Notification } from '..';
export interface NotificationPayload {
event?: string;
subject: string;
notifySystem: boolean;
notifyAdmin: boolean;
notifyUser?: User;
media?: Media;

View File

@@ -1,19 +1,17 @@
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type { NotificationAgentDiscord } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { getRepository } from 'typeorm';
import {
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import {
getSettings,
NotificationAgentDiscord,
NotificationAgentKey,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
enum EmbedColors {
DEFAULT = 0,
@@ -245,7 +243,10 @@ class DiscordAgent
): Promise<boolean> {
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
if (
!payload.notifySystem ||
!hasNotificationType(type, settings.types ?? 0)
) {
return true;
}

View File

@@ -1,19 +1,17 @@
import { EmailOptions } from 'email-templates';
import path from 'path';
import { getRepository } from 'typeorm';
import { Notification, shouldSendAdminNotification } from '..';
import { IssueType, IssueTypeName } from '../../../constants/issue';
import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import PreparedEmail from '../../email';
import {
getSettings,
NotificationAgentEmail,
NotificationAgentKey,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import { IssueType, IssueTypeName } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import PreparedEmail from '@server/lib/email';
import type { NotificationAgentEmail } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import type { EmailOptions } from 'email-templates';
import * as EmailValidator from 'email-validator';
import path from 'path';
import { Notification, shouldSendAdminNotification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
class EmailAgent
extends BaseAgent<NotificationAgentEmail>
@@ -84,6 +82,11 @@ class EmailAgent
is4k ? 'in 4K ' : ''
}is pending approval:`;
break;
case Notification.MEDIA_AUTO_REQUESTED:
body = `A new request for the following ${mediaType} ${
is4k ? 'in 4K ' : ''
}was automatically submitted:`;
break;
case Notification.MEDIA_APPROVED:
body = `Your request for the following ${mediaType} ${
is4k ? 'in 4K ' : ''

View File

@@ -1,15 +1,17 @@
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import type { NotificationAgentGotify } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import logger from '../../../logger';
import { getSettings, NotificationAgentGotify } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
interface GotifyPayload {
title: string;
message: string;
priority: number;
extras: any;
extras: Record<string, unknown>;
}
class GotifyAgent
@@ -115,7 +117,10 @@ class GotifyAgent
): Promise<boolean> {
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
if (
!payload.notifySystem ||
!hasNotificationType(type, settings.types ?? 0)
) {
return true;
}

View File

@@ -1,10 +1,12 @@
import { IssueStatus, IssueType } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media';
import type { NotificationAgentLunaSea } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueType } from '../../../constants/issue';
import { MediaStatus } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentLunaSea } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
class LunaSeaAgent
extends BaseAgent<NotificationAgentLunaSea>
@@ -85,7 +87,10 @@ class LunaSeaAgent
): Promise<boolean> {
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
if (
!payload.notifySystem ||
!hasNotificationType(type, settings.types ?? 0)
) {
return true;
}

View File

@@ -1,19 +1,18 @@
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type { NotificationAgentPushbullet } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { getRepository } from 'typeorm';
import {
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import {
getSettings,
NotificationAgentKey,
NotificationAgentPushbullet,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
interface PushbulletPayload {
type: string;
@@ -54,6 +53,12 @@ class PushbulletAgent
let status = '';
switch (type) {
case Notification.MEDIA_AUTO_REQUESTED:
status =
payload.media?.status === MediaStatus.PENDING
? 'Pending Approval'
: 'Processing';
break;
case Notification.MEDIA_PENDING:
status = 'Pending Approval';
break;
@@ -106,6 +111,7 @@ class PushbulletAgent
// Send system notification
if (
payload.notifySystem &&
hasNotificationType(type, settings.types ?? 0) &&
settings.enabled &&
settings.options.accessToken

View File

@@ -1,19 +1,18 @@
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type { NotificationAgentPushover } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { getRepository } from 'typeorm';
import {
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import {
getSettings,
NotificationAgentKey,
NotificationAgentPushover,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
interface PushoverPayload {
token: string;
@@ -63,6 +62,12 @@ class PushoverAgent
let status = '';
switch (type) {
case Notification.MEDIA_AUTO_REQUESTED:
status =
payload.media?.status === MediaStatus.PENDING
? 'Pending Approval'
: 'Processing';
break;
case Notification.MEDIA_PENDING:
status = 'Pending Approval';
break;
@@ -137,6 +142,7 @@ class PushoverAgent
// Send system notification
if (
payload.notifySystem &&
hasNotificationType(type, settings.types ?? 0) &&
settings.enabled &&
settings.options.accessToken &&

View File

@@ -1,9 +1,11 @@
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import type { NotificationAgentSlack } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import logger from '../../../logger';
import { getSettings, NotificationAgentSlack } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
interface EmbedField {
type: 'plain_text' | 'mrkdwn';
@@ -223,7 +225,10 @@ class SlackAgent
): Promise<boolean> {
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
if (
!payload.notifySystem ||
!hasNotificationType(type, settings.types ?? 0)
) {
return true;
}

View File

@@ -1,19 +1,18 @@
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type { NotificationAgentTelegram } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { getRepository } from 'typeorm';
import {
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import {
getSettings,
NotificationAgentKey,
NotificationAgentTelegram,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
interface TelegramMessagePayload {
text: string;
@@ -81,6 +80,12 @@ class TelegramAgent
let status = '';
switch (type) {
case Notification.MEDIA_AUTO_REQUESTED:
status =
payload.media?.status === MediaStatus.PENDING
? 'Pending Approval'
: 'Processing';
break;
case Notification.MEDIA_PENDING:
status = 'Pending Approval';
break;
@@ -159,6 +164,7 @@ class TelegramAgent
// Send system notification
if (
payload.notifySystem &&
hasNotificationType(type, settings.types ?? 0) &&
settings.options.chatId
) {

View File

@@ -1,11 +1,13 @@
import { IssueStatus, IssueType } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media';
import type { NotificationAgentWebhook } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { get } from 'lodash';
import { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueType } from '../../../constants/issue';
import { MediaStatus } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentWebhook } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
type KeyMapFunction = (
payload: NotificationPayload,
@@ -162,7 +164,10 @@ class WebhookAgent
): Promise<boolean> {
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
if (
!payload.notifySystem ||
!hasNotificationType(type, settings.types ?? 0)
) {
return true;
}

View File

@@ -1,17 +1,15 @@
import { getRepository } from 'typeorm';
import { IssueType, IssueTypeName } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
import type { NotificationAgentConfig } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import webpush from 'web-push';
import { Notification, shouldSendAdminNotification } from '..';
import { IssueType, IssueTypeName } from '../../../constants/issue';
import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User';
import { UserPushSubscription } from '../../../entity/UserPushSubscription';
import logger from '../../../logger';
import {
getSettings,
NotificationAgentConfig,
NotificationAgentKey,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
interface PushNotificationPayload {
notificationType: string;
@@ -59,6 +57,11 @@ class WebPushAgent
case Notification.TEST_NOTIFICATION:
message = payload.message;
break;
case Notification.MEDIA_AUTO_REQUESTED:
message = `Automatically submitted a new ${
is4k ? '4K ' : ''
}${mediaType} request.`;
break;
case Notification.MEDIA_APPROVED:
message = `Your ${
is4k ? '4K ' : ''
@@ -160,7 +163,7 @@ class WebPushAgent
true)
) {
const notifySubs = await userPushSubRepository.find({
where: { user: payload.notifyUser.id },
where: { user: { id: payload.notifyUser.id } },
});
pushSubs.push(...notifySubs);

View File

@@ -1,6 +1,6 @@
import { User } from '../../entity/User';
import logger from '../../logger';
import { Permission } from '../permissions';
import type { User } from '@server/entity/User';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
import type { NotificationAgent, NotificationPayload } from './agents/agent';
export enum Notification {
@@ -16,6 +16,7 @@ export enum Notification {
ISSUE_COMMENT = 512,
ISSUE_RESOLVED = 1024,
ISSUE_REOPENED = 2048,
MEDIA_AUTO_REQUESTED = 4096,
}
export const hasNotificationType = (

View File

@@ -22,6 +22,11 @@ export enum Permission {
MANAGE_ISSUES = 1048576,
VIEW_ISSUES = 2097152,
CREATE_ISSUES = 4194304,
AUTO_REQUEST = 8388608,
AUTO_REQUEST_MOVIE = 16777216,
AUTO_REQUEST_TV = 33554432,
RECENT_VIEW = 67108864,
WATCHLIST_VIEW = 134217728,
}
export interface PermissionCheckOptions {

View File

@@ -1,12 +1,12 @@
import TheMovieDb from '@server/api/themoviedb';
import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import Season from '@server/entity/Season';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import AsyncLock from '@server/utils/asyncLock';
import { randomUUID } from 'crypto';
import { getRepository } from 'typeorm';
import TheMovieDb from '../../api/themoviedb';
import { MediaStatus, MediaType } from '../../constants/media';
import Media from '../../entity/Media';
import Season from '../../entity/Season';
import logger from '../../logger';
import AsyncLock from '../../utils/asyncLock';
import { getSettings } from '../settings';
// Default scan rates (can be overidden)
const BUNDLE_SIZE = 20;
@@ -210,7 +210,7 @@ class BaseScanner<T> {
}
/**
* processShow takes a TMDb ID and an array of ProcessableSeasons, which
* processShow takes a TMDB ID and an array of ProcessableSeasons, which
* should include the total episodes a sesaon has + the total available
* episodes that each season currently has. Unlike processMovie, this method
* does not take an `is4k` option. We handle both the 4k _and_ non 4k status

View File

@@ -1,17 +1,20 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import animeList from '../../../api/animelist';
import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../../api/plexapi';
import { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
import { User } from '../../../entity/User';
import cacheManager from '../../cache';
import { getSettings, Library } from '../../settings';
import BaseScanner, {
import animeList from '@server/api/animelist';
import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi';
import PlexAPI from '@server/api/plexapi';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import cacheManager from '@server/lib/cache';
import type {
MediaIds,
ProcessableSeason,
RunnableScanner,
StatusBase,
} from '../baseScanner';
} from '@server/lib/scanners/baseScanner';
import BaseScanner from '@server/lib/scanners/baseScanner';
import type { Library } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import { uniqWith } from 'lodash';
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
@@ -59,8 +62,8 @@ class PlexScanner
try {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
select: { id: true, plexToken: true },
where: { id: 1 },
});
if (!admin) {
@@ -141,7 +144,9 @@ class PlexScanner
'info'
);
} catch (e) {
this.log('Scan interrupted', 'error', { errorMessage: e.message });
this.log('Scan interrupted', 'error', {
errorMessage: e.message,
});
} finally {
this.endRun(sessionId);
}
@@ -369,7 +374,7 @@ class PlexScanner
}
});
// If we got an IMDb ID, but no TMDb ID, lookup the TMDb ID with the IMDb ID
// If we got an IMDb ID, but no TMDB ID, lookup the TMDB ID with the IMDb ID
if (mediaIds.imdbId && !mediaIds.tmdbId) {
const tmdbMedia = await this.tmdb.getMediaByImdbId({
imdbId: mediaIds.imdbId,
@@ -390,7 +395,7 @@ class PlexScanner
});
mediaIds.tmdbId = tmdbMedia.id;
}
// Check if the agent is TMDb
// Check if the agent is TMDB
} else if (plexitem.guid.match(tmdbRegex)) {
const tmdbMatch = plexitem.guid.match(tmdbRegex);
if (tmdbMatch) {
@@ -409,7 +414,7 @@ class PlexScanner
mediaIds.tvdbId = Number(matchedtvdb[1]);
mediaIds.tmdbId = show.id;
}
// Check if the agent (for shows) is TMDb
// Check if the agent (for shows) is TMDB
} else if (plexitem.guid.match(tmdbShowRegex)) {
const matchedtmdb = plexitem.guid.match(tmdbShowRegex);
if (matchedtmdb) {
@@ -484,10 +489,10 @@ class PlexScanner
}
if (!mediaIds.tmdbId) {
throw new Error('Unable to find TMDb ID');
throw new Error('Unable to find TMDB ID');
}
// We check above if we have the TMDb ID, so we can safely assert the type below
// We check above if we have the TMDB ID, so we can safely assert the type below
return mediaIds as MediaIds;
}

View File

@@ -1,7 +1,13 @@
import type { RadarrMovie } from '@server/api/servarr/radarr';
import RadarrAPI from '@server/api/servarr/radarr';
import type {
RunnableScanner,
StatusBase,
} from '@server/lib/scanners/baseScanner';
import BaseScanner from '@server/lib/scanners/baseScanner';
import type { RadarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import { uniqWith } from 'lodash';
import RadarrAPI, { RadarrMovie } from '../../../api/servarr/radarr';
import { getSettings, RadarrSettings } from '../../settings';
import BaseScanner, { RunnableScanner, StatusBase } from '../baseScanner';
type SyncStatus = StatusBase & {
currentServer: RadarrSettings;

View File

@@ -1,14 +1,17 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import SonarrAPI, { SonarrSeries } from '../../../api/servarr/sonarr';
import { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
import Media from '../../../entity/Media';
import { getSettings, SonarrSettings } from '../../settings';
import BaseScanner, {
import type { SonarrSeries } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import type {
ProcessableSeason,
RunnableScanner,
StatusBase,
} from '../baseScanner';
} from '@server/lib/scanners/baseScanner';
import BaseScanner from '@server/lib/scanners/baseScanner';
import type { SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import { uniqWith } from 'lodash';
type SyncStatus = StatusBase & {
currentServer: SonarrSettings;

View File

@@ -1,5 +1,5 @@
import TheMovieDb from '../api/themoviedb';
import {
import TheMovieDb from '@server/api/themoviedb';
import type {
TmdbMovieDetails,
TmdbMovieResult,
TmdbPersonDetails,
@@ -9,13 +9,17 @@ import {
TmdbSearchTvResponse,
TmdbTvDetails,
TmdbTvResult,
} from '../api/themoviedb/interfaces';
} from '@server/api/themoviedb/interfaces';
import {
mapMovieDetailsToResult,
mapPersonDetailsToResult,
mapTvDetailsToResult,
} from '../models/Search';
import { isMovie, isMovieDetails, isTvDetails } from '../utils/typeHelpers';
} from '@server/models/Search';
import {
isMovie,
isMovieDetails,
isTvDetails,
} from '@server/utils/typeHelpers';
interface SearchProvider {
pattern: RegExp;

View File

@@ -1,9 +1,9 @@
import { MediaServerType } from '@server/constants/server';
import { randomUUID } from 'crypto';
import fs from 'fs';
import { merge } from 'lodash';
import path from 'path';
import webpush from 'web-push';
import { MediaServerType } from '../constants/server';
import { Permission } from './permissions';
export interface Library {
@@ -257,6 +257,7 @@ interface JobSettings {
export type JobId =
| 'plex-recently-added-scan'
| 'plex-full-scan'
| 'plex-watchlist-sync'
| 'radarr-scan'
| 'sonarr-scan'
| 'download-sync'
@@ -424,6 +425,9 @@ class Settings {
'plex-full-scan': {
schedule: '0 0 3 * * *',
},
'plex-watchlist-sync': {
schedule: '0 */10 * * * *',
},
'radarr-scan': {
schedule: '0 0 4 * * *',
},

163
server/lib/watchlistsync.ts Normal file
View File

@@ -0,0 +1,163 @@
import PlexTvAPI from '@server/api/plextv';
import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import {
DuplicateMediaRequestError,
MediaRequest,
NoSeasonsAvailableError,
QuotaRestrictedError,
RequestPermissionError,
} from '@server/entity/MediaRequest';
import { User } from '@server/entity/User';
import logger from '@server/logger';
import { Permission } from './permissions';
class WatchlistSync {
public async syncWatchlist() {
const userRepository = getRepository(User);
// Get users who actually have plex tokens
const users = await userRepository
.createQueryBuilder('user')
.addSelect('user.plexToken')
.leftJoinAndSelect('user.settings', 'settings')
.where("user.plexToken != ''")
.getMany();
for (const user of users) {
await this.syncUserWatchlist(user);
}
}
private async syncUserWatchlist(user: User) {
if (!user.plexToken) {
logger.warn('Skipping user watchlist sync for user without plex token', {
label: 'Plex Watchlist Sync',
user: user.displayName,
});
return;
}
if (
!user.hasPermission(
[
Permission.AUTO_REQUEST,
Permission.AUTO_REQUEST_MOVIE,
Permission.AUTO_APPROVE_TV,
],
{ type: 'or' }
)
) {
return;
}
if (
!user.settings?.watchlistSyncMovies &&
!user.settings?.watchlistSyncTv
) {
// Skip sync if user settings have it disabled
return;
}
const plexTvApi = new PlexTvAPI(user.plexToken);
const response = await plexTvApi.getWatchlist({ size: 200 });
const mediaItems = await Media.getRelatedMedia(
response.items.map((i) => i.tmdbId)
);
const unavailableItems = response.items.filter(
// If we can find watchlist items in our database that are also available, we should exclude them
(i) =>
!mediaItems.find(
(m) =>
m.tmdbId === i.tmdbId &&
((m.status !== MediaStatus.UNKNOWN && m.mediaType === 'movie') ||
(m.mediaType === 'tv' && m.status === MediaStatus.AVAILABLE))
)
);
await Promise.all(
unavailableItems.map(async (mediaItem) => {
try {
logger.info("Creating media request from user's Plex Watchlist", {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
});
if (mediaItem.type === 'show' && !mediaItem.tvdbId) {
throw new Error('Missing TVDB ID from Plex Metadata');
}
// Check if they have auto-request permissons and watchlist sync
// enabled for the media type
if (
((!user.hasPermission(
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_MOVIE],
{ type: 'or' }
) ||
!user.settings?.watchlistSyncMovies) &&
mediaItem.type === 'movie') ||
((!user.hasPermission(
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_TV],
{ type: 'or' }
) ||
!user.settings?.watchlistSyncTv) &&
mediaItem.type === 'show')
) {
return;
}
await MediaRequest.request(
{
mediaId: mediaItem.tmdbId,
mediaType:
mediaItem.type === 'show' ? MediaType.TV : MediaType.MOVIE,
seasons: mediaItem.type === 'show' ? 'all' : undefined,
tvdbId: mediaItem.tvdbId,
is4k: false,
},
user,
{ isAutoRequest: true }
);
} catch (e) {
if (!(e instanceof Error)) {
return;
}
switch (e.constructor) {
// During watchlist sync, these errors aren't necessarily
// a problem with Overseerr. Since we are auto syncing these constantly, it's
// possible they are unexpectedly at their quota limit, for example. So we'll
// instead log these as debug messages.
case RequestPermissionError:
case DuplicateMediaRequestError:
case QuotaRestrictedError:
case NoSeasonsAvailableError:
logger.debug('Failed to create media request from watchlist', {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
errorMessage: e.message,
});
break;
default:
logger.error('Failed to create media request from watchlist', {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
errorMessage: e.message,
});
}
}
})
);
}
}
const watchlistSync = new WatchlistSync();
export default watchlistSync;

View File

@@ -26,7 +26,7 @@ const hformat = winston.format.printf(
);
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'debug',
level: process.env.LOG_LEVEL?.toLowerCase() || 'debug',
format: winston.format.combine(
winston.format.splat(),
winston.format.timestamp(),

View File

@@ -1,11 +1,14 @@
import { getRepository } from 'typeorm';
import { User } from '../entity/User';
import { Permission, PermissionCheckOptions } from '../lib/permissions';
import { getSettings } from '../lib/settings';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type {
Permission,
PermissionCheckOptions,
} from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
export const checkUser: Middleware = async (req, _res, next) => {
const settings = getSettings();
let user: User | undefined;
let user: User | undefined | null;
if (req.header('X-API-Key') === settings.main.apiKey) {
const userRepository = getRepository(User);

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class InitialMigration1603944374840 implements MigrationInterface {
name = 'InitialMigration1603944374840';

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class SeasonStatus1605085519544 implements MigrationInterface {
name = 'SeasonStatus1605085519544';

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class CascadeMigration1606730060700 implements MigrationInterface {
name = 'CascadeMigration1606730060700';

View File

@@ -1,4 +1,5 @@
import { MigrationInterface, QueryRunner, TableUnique } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
import { TableUnique } from 'typeorm';
export class DropImdbIdConstraint1607928251245 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserRequestDeleteCascades1608219049304
implements MigrationInterface

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLastSeasonChangeMedia1608477467935
implements MigrationInterface

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class ForceDropImdbUniqueConstraint1608477467935
implements MigrationInterface

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveTmdbIdUniqueConstraint1609236552057
implements MigrationInterface

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class LocalUsers1610070934506 implements MigrationInterface {
name = 'LocalUsers1610070934506';

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class Add4kStatusFields1610370640747 implements MigrationInterface {
name = 'Add4kStatusFields1610370640747';

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMediaAddedFieldToMedia1610522845513
implements MigrationInterface

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDisplayNameToUser1611508672722 implements MigrationInterface {
name = 'AddDisplayNameToUser1611508672722';

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class SonarrRadarrSyncServiceFields1611757511674
implements MigrationInterface

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRatingKeysToMedia1611801511397 implements MigrationInterface {
name = 'AddRatingKeysToMedia1611801511397';

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddResetPasswordGuidAndExpiryDate1612482778137
implements MigrationInterface

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLanguageProfileId1612571545781 implements MigrationInterface {
name = 'AddLanguageProfileId1612571545781';

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateUserSettings1613615266968 implements MigrationInterface {
name = 'CreateUserSettings1613615266968';

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateUserSettingsRegions1613955393450
implements MigrationInterface

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTelegramSettingsToUserSettings1614334195680
implements MigrationInterface

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPGPToUserSettings1615333940450 implements MigrationInterface {
name = 'AddPGPToUserSettings1615333940450';

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserQuotaFields1616576677254 implements MigrationInterface {
name = 'AddUserQuotaFields1616576677254';

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateTagsFieldonMediaRequest1617624225464
implements MigrationInterface

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserSettingsNotificationAgentsField1617730837489
implements MigrationInterface

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateUserPushSubscriptions1618912653565
implements MigrationInterface

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserSettingsLocale1619239659754 implements MigrationInterface {
name = 'AddUserSettingsLocale1619239659754';

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserSettingsNotificationTypes1619339817343
implements MigrationInterface

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddIssues1634904083966 implements MigrationInterface {
name = 'AddIssues1634904083966';

View File

@@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPushbulletPushoverUserSettings1635079863457
implements MigrationInterface

View File

@@ -0,0 +1,33 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddWatchlistSyncUserSetting1660632269368
implements MigrationInterface
{
name = 'AddWatchlistSyncUserSetting1660632269368';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey" FROM "user_settings"`
);
await queryRunner.query(`DROP TABLE "user_settings"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
);
await queryRunner.query(
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey" FROM "temporary_user_settings"`
);
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
}
}

View File

@@ -0,0 +1,33 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMediaRequestIsAutoRequestedField1660714479373
implements MigrationInterface
{
name = 'AddMediaRequestIsAutoRequestedField1660714479373';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags" FROM "media_request"`
);
await queryRunner.query(`DROP TABLE "media_request"`);
await queryRunner.query(
`ALTER TABLE "temporary_media_request" RENAME TO "media_request"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "media_request" RENAME TO "temporary_media_request"`
);
await queryRunner.query(
`CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags" FROM "temporary_media_request"`
);
await queryRunner.query(`DROP TABLE "temporary_media_request"`);
}
}

View File

@@ -1,8 +1,9 @@
import type { TmdbCollection } from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import type Media from '@server/entity/Media';
import { sortBy } from 'lodash';
import type { TmdbCollection } from '../api/themoviedb/interfaces';
import { MediaType } from '../constants/media';
import Media from '../entity/Media';
import { mapMovieResult, MovieResult } from './Search';
import type { MovieResult } from './Search';
import { mapMovieResult } from './Search';
export interface Collection {
id: number;

View File

@@ -2,20 +2,22 @@ import type {
TmdbMovieDetails,
TmdbMovieReleaseResult,
TmdbProductionCompany,
} from '../api/themoviedb/interfaces';
import Media from '../entity/Media';
import {
} from '@server/api/themoviedb/interfaces';
import type Media from '@server/entity/Media';
import type {
Cast,
Crew,
ExternalIds,
Genre,
ProductionCompany,
WatchProviders,
} from './common';
import {
mapCast,
mapCrew,
mapExternalIds,
mapVideos,
mapWatchProviders,
ProductionCompany,
WatchProviders,
} from './common';
export interface Video {

View File

@@ -2,8 +2,8 @@ import type {
TmdbPersonCreditCast,
TmdbPersonCreditCrew,
TmdbPersonDetails,
} from '../api/themoviedb/interfaces';
import Media from '../entity/Media';
} from '@server/api/themoviedb/interfaces';
import type Media from '@server/entity/Media';
export interface PersonDetails {
id: number;

View File

@@ -5,9 +5,9 @@ import type {
TmdbPersonResult,
TmdbTvDetails,
TmdbTvResult,
} from '../api/themoviedb/interfaces';
import { MediaType as MainMediaType } from '../constants/media';
import Media from '../entity/Media';
} from '@server/api/themoviedb/interfaces';
import { MediaType as MainMediaType } from '@server/constants/media';
import type Media from '@server/entity/Media';
export type MediaType = 'tv' | 'movie' | 'person';

View File

@@ -5,24 +5,26 @@ import type {
TmdbTvEpisodeResult,
TmdbTvRatingResult,
TmdbTvSeasonResult,
} from '../api/themoviedb/interfaces';
import type Media from '../entity/Media';
import {
} from '@server/api/themoviedb/interfaces';
import type Media from '@server/entity/Media';
import type {
Cast,
Crew,
ExternalIds,
Genre,
Keyword,
ProductionCompany,
TvNetwork,
WatchProviders,
} from './common';
import {
mapAggregateCast,
mapCrew,
mapExternalIds,
mapVideos,
mapWatchProviders,
ProductionCompany,
TvNetwork,
WatchProviders,
} from './common';
import { Video } from './Movie';
import type { Video } from './Movie';
interface Episode {
id: number;

View File

@@ -7,8 +7,8 @@ import type {
TmdbVideoResult,
TmdbWatchProviderDetails,
TmdbWatchProviders,
} from '../api/themoviedb/interfaces';
import { Video } from '../models/Movie';
} from '@server/api/themoviedb/interfaces';
import type { Video } from '@server/models/Movie';
export interface ProductionCompany {
id: number;

View File

@@ -1,16 +1,16 @@
import { Router } from 'express';
import { getRepository } from 'typeorm';
import JellyfinAPI from '../api/jellyfin';
import PlexTvAPI from '../api/plextv';
import { MediaServerType } from '../constants/server';
import { UserType } from '../constants/user';
import { User } from '../entity/User';
import { startJobs } from '../job/schedule';
import { Permission } from '../lib/permissions';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import { isAuthenticated } from '../middleware/auth';
import JellyfinAPI from '@server/api/jellyfin';
import PlexTvAPI from '@server/api/plextv';
import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { startJobs } from '@server/job/schedule';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import * as EmailValidator from 'email-validator';
import { Router } from 'express';
const authRoutes = Router();
@@ -89,8 +89,8 @@ authRoutes.post('/plex', async (req, res, next) => {
await userRepository.save(user);
} else {
const mainUser = await userRepository.findOneOrFail({
select: ['id', 'plexToken', 'plexId'],
order: { id: 'ASC' },
select: { id: true, plexToken: true, plexId: true },
where: { id: 1 },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
@@ -424,8 +424,8 @@ authRoutes.post('/local', async (req, res, next) => {
}
const mainUser = await userRepository.findOneOrFail({
select: ['id', 'plexToken', 'plexId'],
order: { id: 'ASC' },
select: { id: true, plexToken: true, plexId: true },
where: { id: 1 },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');

View File

@@ -1,8 +1,8 @@
import TheMovieDb from '@server/api/themoviedb';
import Media from '@server/entity/Media';
import logger from '@server/logger';
import { mapCollection } from '@server/models/Collection';
import { Router } from 'express';
import TheMovieDb from '../api/themoviedb';
import Media from '../entity/Media';
import logger from '../logger';
import { mapCollection } from '../models/Collection';
const collectionRoutes = Router();

View File

@@ -1,16 +1,25 @@
import PlexTvAPI from '@server/api/plextv';
import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
import type {
GenreSliderItem,
WatchlistResponse,
} from '@server/interfaces/api/discoverInterfaces';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { mapProductionCompany } from '@server/models/Movie';
import {
mapMovieResult,
mapPersonResult,
mapTvResult,
} from '@server/models/Search';
import { mapNetwork } from '@server/models/Tv';
import { isMovie, isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express';
import { sortBy } from 'lodash';
import TheMovieDb from '../api/themoviedb';
import { MediaType } from '../constants/media';
import Media from '../entity/Media';
import { User } from '../entity/User';
import { GenreSliderItem } from '../interfaces/api/discoverInterfaces';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import { mapProductionCompany } from '../models/Movie';
import { mapMovieResult, mapPersonResult, mapTvResult } from '../models/Search';
import { mapNetwork } from '../models/Tv';
import { isMovie, isPerson } from '../utils/typeHelpers';
export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
const settings = getSettings();
@@ -704,4 +713,45 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
}
);
discoverRoutes.get<{ page?: number }, WatchlistResponse>(
'/watchlist',
async (req, res) => {
const userRepository = getRepository(User);
const itemsPerPage = 20;
const page = req.params.page ?? 1;
const offset = (page - 1) * itemsPerPage;
const activeUser = await userRepository.findOne({
where: { id: req.user?.id },
select: ['id', 'plexToken'],
});
if (!activeUser?.plexToken) {
// We will just return an empty array if the user has no Plex token
return res.json({
page: 1,
totalPages: 1,
totalResults: 0,
results: [],
});
}
const plexTV = new PlexTvAPI(activeUser.plexToken);
const watchlist = await plexTV.getWatchlist({ offset });
return res.json({
page,
totalPages: Math.ceil(watchlist.size / itemsPerPage),
totalResults: watchlist.size,
results: watchlist.items.map((item) => ({
ratingKey: item.ratingKey,
title: item.title,
mediaType: item.type === 'show' ? 'tv' : 'movie',
tmdbId: item.tmdbId,
})),
});
}
);
export default discoverRoutes;

View File

@@ -1,17 +1,22 @@
import GithubAPI from '@server/api/github';
import TheMovieDb from '@server/api/themoviedb';
import type {
TmdbMovieResult,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
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 { mapProductionCompany } from '@server/models/Movie';
import { mapNetwork } from '@server/models/Tv';
import settingsRoutes from '@server/routes/settings';
import { appDataPath, appDataStatus } from '@server/utils/appDataVolume';
import { getAppVersion, getCommitTag } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag';
import { isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express';
import GithubAPI from '../api/github';
import TheMovieDb from '../api/themoviedb';
import { TmdbMovieResult, TmdbTvResult } from '../api/themoviedb/interfaces';
import { StatusResponse } from '../interfaces/api/settingsInterfaces';
import { Permission } from '../lib/permissions';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import { checkUser, isAuthenticated } from '../middleware/auth';
import { mapProductionCompany } from '../models/Movie';
import { mapNetwork } from '../models/Tv';
import { appDataPath, appDataStatus } from '../utils/appDataVolume';
import { getAppVersion, getCommitTag } from '../utils/appVersion';
import { isPerson } from '../utils/typeHelpers';
import authRoutes from './auth';
import collectionRoutes from './collection';
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
@@ -23,7 +28,6 @@ import personRoutes from './person';
import requestRoutes from './request';
import searchRoutes from './search';
import serviceRoutes from './service';
import settingsRoutes from './settings';
import tvRoutes from './tv';
import user from './user';
@@ -75,6 +79,7 @@ router.get<unknown, StatusResponse>('/status', async (req, res) => {
commitTag: getCommitTag(),
updateAvailable,
commitsBehind,
restartRequired: restartFlag.isSet(),
});
});
@@ -97,11 +102,7 @@ router.get('/settings/public', async (req, res) => {
return res.status(200).json(settings.fullPublicSettings);
}
});
router.use(
'/settings',
isAuthenticated(Permission.MANAGE_SETTINGS),
settingsRoutes
);
router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes);
router.use('/search', isAuthenticated(), searchRoutes);
router.use('/discover', isAuthenticated(), discoverRoutes);
router.use('/request', isAuthenticated(), requestRoutes);

Some files were not shown because too many files have changed in this diff Show More