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

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