feat: plex watchlist sync integration (#2885)

pull/2957/head
Ryan Cohen 2 years ago committed by GitHub
parent 7943e0c339
commit 301f2bf7ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -171,4 +171,41 @@ describe('Discover', () => {
.find('[data-testid=request-card-title]')
.contains('Movie Not Found');
});
it('loads plex watchlist', () => {
cy.intercept('/api/v1/discover/watchlist', { fixture: 'watchlist' }).as(
'getWatchlist'
);
// Wait for one of the watchlist movies to resolve
cy.intercept('/api/v1/movie/361743').as('getTmdbMovie');
cy.visit('/');
cy.wait('@getWatchlist');
const sliderHeader = cy.contains('.slider-header', 'Plex Watchlist');
sliderHeader.scrollIntoView();
cy.wait('@getTmdbMovie');
// Wait a little longer to make sure the movie component reloaded
cy.wait(500);
sliderHeader
.next('[data-testid=media-slider]')
.find('[data-testid=title-card]')
.first()
.trigger('mouseover')
.find('[data-testid=title-card-title]')
.invoke('text')
.then((text) => {
cy.contains('.slider-header', 'Plex Watchlist')
.next('[data-testid=media-slider]')
.find('[data-testid=title-card]')
.first()
.click()
.click();
cy.get('[data-testid=media-title]').should('contain', text);
});
});
});

@ -0,0 +1,74 @@
const visitUserEditPage = (email: string): void => {
cy.visit('/users');
cy.contains('[data-testid=user-list-row]', email).contains('Edit').click();
};
describe('Auto Request Settings', () => {
beforeEach(() => {
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
});
it('should not see watchlist sync settings on an account without permissions', () => {
visitUserEditPage(Cypress.env('USER_EMAIL'));
cy.contains('Auto-Request Movies').should('not.exist');
cy.contains('Auto-Request Series').should('not.exist');
});
it('should see watchlist sync settings on an admin account', () => {
visitUserEditPage(Cypress.env('ADMIN_EMAIL'));
cy.contains('Auto-Request Movies').should('exist');
cy.contains('Auto-Request Series').should('exist');
});
it('should see auto-request settings after being given permission', () => {
visitUserEditPage(Cypress.env('USER_EMAIL'));
cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click();
cy.get('#autorequest').should('not.be.checked').click();
cy.intercept('/api/v1/user/*/settings/permissions').as('userPermissions');
cy.contains('Save Changes').click();
cy.wait('@userPermissions');
cy.reload();
cy.get('#autorequest').should('be.checked');
cy.get('#autorequestmovies').should('be.checked');
cy.get('#autorequesttv').should('be.checked');
cy.get('[data-testid=settings-nav-desktop').contains('General').click();
cy.contains('Auto-Request Movies').should('exist');
cy.contains('Auto-Request Series').should('exist');
cy.get('#watchlistSyncMovies').should('not.be.checked').click();
cy.get('#watchlistSyncTv').should('not.be.checked').click();
cy.intercept('/api/v1/user/*/settings/main').as('userMain');
cy.contains('Save Changes').click();
cy.wait('@userMain');
cy.reload();
cy.get('#watchlistSyncMovies').should('be.checked').click();
cy.get('#watchlistSyncTv').should('be.checked').click();
cy.contains('Save Changes').click();
cy.wait('@userMain');
cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click();
cy.get('#autorequest').should('be.checked').click();
cy.contains('Save Changes').click();
});
});

@ -0,0 +1,25 @@
{
"page": 1,
"totalPages": 1,
"totalResults": 20,
"results": [
{
"ratingKey": "5d776be17a53e9001e732ab9",
"title": "Top Gun: Maverick",
"mediaType": "movie",
"tmdbId": 361743
},
{
"ratingKey": "5e16338fbc1372003ea68ab3",
"title": "Nope",
"mediaType": "movie",
"tmdbId": 762504
},
{
"ratingKey": "5f409b8452f200004161e126",
"title": "Hocus Pocus 2",
"mediaType": "movie",
"tmdbId": 642885
}
]
}

@ -4403,6 +4403,46 @@ paths:
name:
type: string
example: Genre Name
/discover/watchlist:
get:
summary: Get the Plex watchlist.
tags:
- search
parameters:
- in: query
name: page
schema:
type: number
example: 1
default: 1
responses:
'200':
description: Watchlist data returned
content:
application/json:
schema:
type: object
properties:
page:
type: number
totalPages:
type: number
totalResults:
type: number
results:
type: array
items:
type: object
properties:
tmdbId:
type: number
example: 1
ratingKey:
type: string
type:
type: string
title:
type: string
/request:
get:
summary: Get all requests

@ -1,9 +1,9 @@
import type { AxiosInstance } from 'axios';
import axios from 'axios';
import xml2js from 'xml2js';
import type { PlexDevice } from '../interfaces/api/plexInterfaces';
import cacheManager from '../lib/cache';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import ExternalAPI from './externalapi';
interface PlexAccountResponse {
user: PlexUser;
@ -112,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[]> {
@ -253,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;

@ -94,7 +94,7 @@ class TheMovieDb extends ExternalAPI {
nodeCache: cacheManager.getCache('tmdb').data,
rateLimit: {
maxRequests: 20,
maxRPS: 1,
maxRPS: 50,
},
}
);

@ -20,15 +20,346 @@ import TheMovieDb from '../api/themoviedb';
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
import { getRepository } from '../datasource';
import type { MediaRequestBody } from '../interfaces/api/requestInterfaces';
import notificationManager, { Notification } from '../lib/notifications';
import { Permission } from '../lib/permissions';
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;
@ -119,6 +450,9 @@ export class MediaRequest {
})
public tags?: number[];
@Column({ default: false })
public isAutoRequest: boolean;
constructor(init?: Partial<MediaRequest>) {
Object.assign(this, init);
}

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

@ -3,3 +3,10 @@ export interface GenreSliderItem {
name: string;
backdrops: string[];
}
export interface WatchlistItem {
ratingKey: string;
tmdbId: number;
mediaType: 'movie' | 'tv';
title: string;
}

@ -1,6 +1,21 @@
import type { PaginatedResponse } from './common';
import type { MediaRequest } from '../../entity/MediaRequest';
import type { MediaType } from '../../constants/media';
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[];
};

@ -14,6 +14,8 @@ export interface UserSettingsGeneralResponse {
globalMovieQuotaLimit?: number;
globalTvQuotaLimit?: number;
globalTvQuotaDays?: number;
watchlistSyncMovies?: boolean;
watchlistSyncTv?: boolean;
}
export type NotificationAgentTypes = Record<NotificationAgentKey, number>;

@ -5,6 +5,7 @@ import { radarrScanner } from '../lib/scanners/radarr';
import { sonarrScanner } from '../lib/scanners/sonarr';
import type { JobId } from '../lib/settings';
import { getSettings } from '../lib/settings';
import watchlistSync from '../lib/watchlistsync';
import logger from '../logger';
interface ScheduledJob {
@ -54,6 +55,20 @@ export const startJobs = (): void => {
cancelFn: () => plexFullScanner.cancel(),
});
// 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',

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

@ -21,6 +21,9 @@ 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,
}

@ -243,6 +243,7 @@ interface JobSettings {
export type JobId =
| 'plex-recently-added-scan'
| 'plex-full-scan'
| 'plex-watchlist-sync'
| 'radarr-scan'
| 'sonarr-scan'
| 'download-sync'
@ -398,6 +399,9 @@ class Settings {
'plex-full-scan': {
schedule: '0 0 3 * * *',
},
'plex-watchlist-sync': {
schedule: '0 */10 * * * *',
},
'radarr-scan': {
schedule: '0 0 4 * * *',
},

@ -0,0 +1,165 @@
import { Not } from 'typeorm';
import PlexTvAPI from '../api/plextv';
import { User } from '../entity/User';
import Media from '../entity/Media';
import logger from '../logger';
import { MediaType } from '../constants/media';
import { MediaStatus } from '../constants/media';
import {
DuplicateMediaRequestError,
MediaRequest,
NoSeasonsAvailableError,
QuotaRestrictedError,
RequestPermissionError,
} from '../entity/MediaRequest';
import { Permission } from './permissions';
import { getRepository } from '../datasource';
class WatchlistSync {
public async syncWatchlist() {
const userRepository = getRepository(User);
// Get users who actually have plex tokens
const users = await userRepository.find({
select: { id: true, plexToken: true, permissions: true },
where: {
plexToken: Not(''),
},
});
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',
userId: user.id,
});
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;

@ -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"`);
}
}

@ -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"`);
}
}

@ -1,10 +1,15 @@
import { Router } from 'express';
import { sortBy } from 'lodash';
import PlexTvAPI from '../api/plextv';
import TheMovieDb from '../api/themoviedb';
import { MediaType } from '../constants/media';
import { getRepository } from '../datasource';
import Media from '../entity/Media';
import type { User } from '../entity/User';
import type { GenreSliderItem } from '../interfaces/api/discoverInterfaces';
import { User } from '../entity/User';
import type {
GenreSliderItem,
WatchlistItem,
} from '../interfaces/api/discoverInterfaces';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import { mapProductionCompany } from '../models/Movie';
@ -704,4 +709,50 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
}
);
discoverRoutes.get<
{ page?: number },
{
page: number;
totalPages: number;
totalResults: number;
results: WatchlistItem[];
}
>('/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;

@ -1,12 +1,20 @@
import { Router } from 'express';
import TheMovieDb from '../api/themoviedb';
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
import { getRepository } from '../datasource';
import Media from '../entity/Media';
import { MediaRequest } from '../entity/MediaRequest';
import {
DuplicateMediaRequestError,
MediaRequest,
NoSeasonsAvailableError,
QuotaRestrictedError,
RequestPermissionError,
} from '../entity/MediaRequest';
import SeasonRequest from '../entity/SeasonRequest';
import { User } from '../entity/User';
import type { RequestResultsResponse } from '../interfaces/api/requestInterfaces';
import type {
MediaRequestBody,
RequestResultsResponse,
} from '../interfaces/api/requestInterfaces';
import { Permission } from '../lib/permissions';
import logger from '../logger';
import { isAuthenticated } from '../middleware/auth';
@ -146,302 +154,38 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
}
);
requestRoutes.post('/', async (req, res, next) => {
const tmdb = new TheMovieDb();
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User);
try {
let requestUser = req.user;
if (
req.body.userId &&
!req.user?.hasPermission([
Permission.MANAGE_USERS,
Permission.MANAGE_REQUESTS,
])
) {
return next({
status: 403,
message: 'You do not have permission to modify the request user.',
});
} else if (req.body.userId) {
requestUser = await userRepository.findOneOrFail({
where: { id: req.body.userId },
});
}
if (!requestUser) {
return next({
status: 500,
message: 'User missing from request context.',
});
}
if (
req.body.mediaType === MediaType.MOVIE &&
!req.user?.hasPermission(
req.body.is4k
? [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE]
: [Permission.REQUEST, Permission.REQUEST_MOVIE],
{
type: 'or',
}
)
) {
return next({
status: 403,
message: `You do not have permission to make ${
req.body.is4k ? '4K ' : ''
}movie requests.`,
});
} else if (
req.body.mediaType === MediaType.TV &&
!req.user?.hasPermission(
req.body.is4k
? [Permission.REQUEST_4K, Permission.REQUEST_4K_TV]
: [Permission.REQUEST, Permission.REQUEST_TV],
{
type: 'or',
}
)
) {
return next({
status: 403,
message: `You do not have permission to make ${
req.body.is4k ? '4K ' : ''
}series requests.`,
});
}
const quotas = await requestUser.getQuota();
if (req.body.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
return next({
status: 403,
message: 'Movie Quota Exceeded',
});
} else if (req.body.mediaType === MediaType.TV && quotas.tv.restricted) {
return next({
status: 403,
message: 'Series Quota Exceeded',
});
}
const tmdbMedia =
req.body.mediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: req.body.mediaId })
: await tmdb.getTvShow({ tvId: req.body.mediaId });
let media = await mediaRepository.findOne({
where: { tmdbId: req.body.mediaId, mediaType: req.body.mediaType },
relations: { requests: true },
});
if (!media) {
media = new Media({
tmdbId: tmdbMedia.id,
tvdbId: req.body.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
mediaType: req.body.mediaType,
});
} else {
if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) {
media.status = MediaStatus.PENDING;
}
if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) {
media.status4k = MediaStatus.PENDING;
}
}
if (req.body.mediaType === MediaType.MOVIE) {
const existing = await requestRepository
.createQueryBuilder('request')
.leftJoin('request.media', 'media')
.where('request.is4k = :is4k', { is4k: req.body.is4k })
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
.andWhere('media.mediaType = :mediaType', {
mediaType: MediaType.MOVIE,
})
.andWhere('request.status != :requestStatus', {
requestStatus: MediaRequestStatus.DECLINED,
})
.getOne();
if (existing) {
logger.warn('Duplicate request for media blocked', {
tmdbId: tmdbMedia.id,
mediaType: req.body.mediaType,
is4k: req.body.is4k,
label: 'Media Request',
});
requestRoutes.post<never, MediaRequest, MediaRequestBody>(
'/',
async (req, res, next) => {
try {
if (!req.user) {
return next({
status: 409,
message: 'Request for this media already exists.',
status: 401,
message: 'You must be logged in to request media.',
});
}
const request = await MediaRequest.request(req.body, req.user);
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: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_MOVIE
: Permission.AUTO_APPROVE_MOVIE,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_MOVIE
: Permission.AUTO_APPROVE_MOVIE,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? req.user
: undefined,
is4k: req.body.is4k,
serverId: req.body.serverId,
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
tags: req.body.tags,
});
await requestRepository.save(request);
return res.status(201).json(request);
} else if (req.body.mediaType === MediaType.TV) {
const requestedSeasons = req.body.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 === req.body.is4k &&
request.status !== MediaRequestStatus.DECLINED
)
.reduce((seasons, request) => {
const combinedSeasons = request.seasons.map(
(season) => season.seasonNumber
);
return [...seasons, ...combinedSeasons];
}, [] as number[]);
} catch (error) {
if (!(error instanceof Error)) {
return;
}
const finalSeasons = requestedSeasons.filter(
(rs) => !existingSeasons.includes(rs)
);
if (finalSeasons.length === 0) {
return next({
status: 202,
message: 'No seasons available to request',
});
} else if (
quotas.tv.limit &&
finalSeasons.length > (quotas.tv.remaining ?? 0)
) {
return next({
status: 403,
message: 'Series Quota Exceeded',
});
switch (error.constructor) {
case RequestPermissionError:
case QuotaRestrictedError:
return next({ status: 403, message: error.message });
case DuplicateMediaRequestError:
return next({ status: 409, message: error.message });
case NoSeasonsAvailableError:
return next({ status: 202, message: error.message });
default:
return next({ status: 500, message: error.message });
}
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: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? req.user
: undefined,
is4k: req.body.is4k,
serverId: req.body.serverId,
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
languageProfileId: req.body.languageProfileId,
tags: req.body.tags,
seasons: finalSeasons.map(
(sn) =>
new SeasonRequest({
seasonNumber: sn,
status: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
})
),
});
await requestRepository.save(request);
return res.status(201).json(request);
}
next({ status: 500, message: 'Invalid media type' });
} catch (e) {
next({ status: 500, message: e.message });
}
});
);
requestRoutes.get('/count', async (_req, res, next) => {
const requestRepository = getRepository(MediaRequest);

@ -63,6 +63,8 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>(
globalMovieQuotaLimit: defaultQuotas.movie.quotaLimit,
globalTvQuotaDays: defaultQuotas.tv.quotaDays,
globalTvQuotaLimit: defaultQuotas.tv.quotaLimit,
watchlistSyncMovies: user.settings?.watchlistSyncMovies,
watchlistSyncTv: user.settings?.watchlistSyncTv,
});
} catch (e) {
next({ status: 500, message: e.message });
@ -114,12 +116,16 @@ userSettingsRoutes.post<
locale: req.body.locale,
region: req.body.region,
originalLanguage: req.body.originalLanguage,
watchlistSyncMovies: req.body.watchlistSyncMovies,
watchlistSyncTv: req.body.watchlistSyncTv,
});
} else {
user.settings.discordId = req.body.discordId;
user.settings.locale = req.body.locale;
user.settings.region = req.body.region;
user.settings.originalLanguage = req.body.originalLanguage;
user.settings.watchlistSyncMovies = req.body.watchlistSyncMovies;
user.settings.watchlistSyncTv = req.body.watchlistSyncTv;
}
await userRepository.save(user);
@ -130,6 +136,8 @@ userSettingsRoutes.post<
locale: user.settings.locale,
region: user.settings.region,
originalLanguage: user.settings.originalLanguage,
watchlistSyncMovies: user.settings.watchlistSyncMovies,
watchlistSyncTv: user.settings.watchlistSyncTv,
});
} catch (e) {
next({ status: 500, message: e.message });

@ -14,7 +14,9 @@ const prepareDb = async () => {
// Connect to DB and seed test data
const dbConnection = await dataSource.initialize();
await dbConnection.dropDatabase();
if (process.env.PRESERVE_DB !== 'true') {
await dbConnection.dropDatabase();
}
// Run migrations in production
if (process.env.WITH_MIGRATIONS === 'true') {
@ -41,9 +43,11 @@ const prepareDb = async () => {
// Create the other user
const otherUser = new User();
otherUser.plexId = 1;
otherUser.plexToken = '1234';
otherUser.plexUsername = 'friend';
otherUser.username = 'friend';
otherUser.email = 'friend@seerr.dev';
otherUser.userType = UserType.LOCAL;
otherUser.userType = UserType.PLEX;
await otherUser.setPassword('test1234');
otherUser.permissions = 32;
otherUser.avatar = 'https://plex.tv/assets/images/avatar/default.png';

@ -1,4 +1,5 @@
import { useIntl } from 'react-intl';
import type { WatchlistItem } from '../../../../server/interfaces/api/discoverInterfaces';
import type {
MovieResult,
PersonResult,
@ -8,14 +9,16 @@ import useVerticalScroll from '../../../hooks/useVerticalScroll';
import globalMessages from '../../../i18n/globalMessages';
import PersonCard from '../../PersonCard';
import TitleCard from '../../TitleCard';
import TmdbTitleCard from '../../TitleCard/TmdbTitleCard';
interface ListViewProps {
type ListViewProps = {
items?: (TvResult | MovieResult | PersonResult)[];
plexItems?: WatchlistItem[];
isEmpty?: boolean;
isLoading?: boolean;
isReachingEnd?: boolean;
onScrollBottom: () => void;
}
};
const ListView = ({
items,
@ -23,6 +26,7 @@ const ListView = ({
isLoading,
onScrollBottom,
isReachingEnd,
plexItems,
}: ListViewProps) => {
const intl = useIntl();
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
@ -34,6 +38,18 @@ const ListView = ({
</div>
)}
<ul className="cards-vertical">
{plexItems?.map((title, index) => {
return (
<li key={`${title.ratingKey}-${index}`}>
<TmdbTitleCard
id={title.tmdbId}
tmdbId={title.tmdbId}
type={title.mediaType}
canExpand
/>
</li>
);
})}
{items?.map((title, index) => {
let titleCard: React.ReactNode;

@ -143,7 +143,7 @@ const SettingsTabs = ({
</div>
) : (
<div className="hide-scrollbar hidden overflow-x-scroll border-b border-gray-600 sm:block">
<nav className="flex">
<nav className="flex" data-testid="settings-nav-desktop">
{settingsRoutes
.filter(
(route) =>

@ -0,0 +1,51 @@
import ListView from '../../Common/ListView';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../../Common/Header';
import PageTitle from '../../Common/PageTitle';
import useDiscover from '../../../hooks/useDiscover';
import Error from '../../../pages/_error';
import type { WatchlistItem } from '../../../../server/interfaces/api/discoverInterfaces';
const messages = defineMessages({
discoverwatchlist: 'Your Plex Watchlist',
});
const DiscoverWatchlist = () => {
const intl = useIntl();
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<WatchlistItem>('/api/v1/discover/watchlist');
if (error) {
return <Error statusCode={500} />;
}
const title = intl.formatMessage(messages.discoverwatchlist);
return (
<>
<PageTitle title={title} />
<div className="mt-1 mb-5">
<Header>{title}</Header>
</div>
<ListView
plexItems={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverWatchlist;

@ -2,9 +2,10 @@ import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import Link from 'next/link';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { WatchlistItem } from '../../../server/interfaces/api/discoverInterfaces';
import type { MediaResultsResponse } from '../../../server/interfaces/api/mediaInterfaces';
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
import { Permission, useUser } from '../../hooks/useUser';
import { Permission, UserType, useUser } from '../../hooks/useUser';
import PageTitle from '../Common/PageTitle';
import MediaSlider from '../MediaSlider';
import RequestCard from '../RequestCard';
@ -25,11 +26,12 @@ const messages = defineMessages({
noRequests: 'No requests.',
upcoming: 'Upcoming Movies',
trending: 'Trending',
plexwatchlist: 'Your Plex Watchlist',
});
const Discover = () => {
const intl = useIntl();
const { hasPermission } = useUser();
const { user, hasPermission } = useUser();
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
@ -44,6 +46,15 @@ const Discover = () => {
}
);
const { data: watchlistItems, error: watchlistError } = useSWR<{
page: number;
totalPages: number;
totalResults: number;
results: WatchlistItem[];
}>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, {
revalidateOnMount: true,
});
return (
<>
<PageTitle title={intl.formatMessage(messages.discover)} />
@ -93,6 +104,30 @@ const Discover = () => {
placeholder={<RequestCard.Placeholder />}
emptyMessage={intl.formatMessage(messages.noRequests)}
/>
{(!watchlistItems || !!watchlistItems.results.length) && !watchlistError && (
<>
<div className="slider-header">
<Link href="/discover/watchlist">
<a className="slider-title">
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
<ArrowCircleRightIcon />
</a>
</Link>
</div>
<Slider
sliderKey="watchlist"
isLoading={!watchlistItems && !watchlistError}
items={watchlistItems?.results.map((item) => (
<TmdbTitleCard
id={item.tmdbId}
key={`watchlist-slider-item-${item.ratingKey}`}
tmdbId={item.tmdbId}
type={item.mediaType}
/>
))}
/>
</>
)}
<MediaSlider
sliderKey="trending"
title={intl.formatMessage(messages.trending)}

@ -71,9 +71,13 @@ interface ManageSlideOverTvProps extends ManageSlideOverProps {
data: TvDetails;
}
const ManageSlideOver: React.FC<
ManageSlideOverMovieProps | ManageSlideOverTvProps
> = ({ show, mediaType, onClose, data, revalidate }) => {
const ManageSlideOver = ({
show,
mediaType,
onClose,
data,
revalidate,
}: ManageSlideOverMovieProps | ManageSlideOverTvProps) => {
const { user: currentUser, hasPermission } = useUser();
const intl = useIntl();
const settings = useSettings();

@ -50,6 +50,15 @@ export const messages = defineMessages({
advancedrequest: 'Advanced Requests',
advancedrequestDescription:
'Grant permission to modify advanced media request options.',
autorequest: 'Auto-Request',
autorequestDescription:
'Grant permission to automatically submit requests for non-4K media via Plex Watchlist.',
autorequestMovies: 'Auto-Request Movies',
autorequestMoviesDescription:
'Grant permission to automatically submit requests for non-4K movies via Plex Watchlist.',
autorequestSeries: 'Auto-Request Series',
autorequestSeriesDescription:
'Grant permission to automatically submit requests for non-4K series via Plex Watchlist.',
viewrequests: 'View Requests',
viewrequestsDescription:
'Grant permission to view media requests submitted by other users.',
@ -176,6 +185,43 @@ export const PermissionEdit = ({
},
],
},
{
id: 'autorequest',
name: intl.formatMessage(messages.autorequest),
description: intl.formatMessage(messages.autorequestDescription),
permission: Permission.AUTO_REQUEST,
requires: [{ permissions: [Permission.REQUEST] }],
children: [
{
id: 'autorequestmovies',
name: intl.formatMessage(messages.autorequestMovies),
description: intl.formatMessage(
messages.autorequestMoviesDescription
),
permission: Permission.AUTO_REQUEST_MOVIE,
requires: [
{
permissions: [Permission.REQUEST, Permission.REQUEST_MOVIE],
type: 'or',
},
],
},
{
id: 'autorequesttv',
name: intl.formatMessage(messages.autorequestSeries),
description: intl.formatMessage(
messages.autorequestSeriesDescription
),
permission: Permission.AUTO_REQUEST_TV,
requires: [
{
permissions: [Permission.REQUEST, Permission.REQUEST_TV],
type: 'or',
},
],
},
],
},
{
id: 'request4k',
name: intl.formatMessage(messages.request4k),

@ -47,6 +47,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
unknownJob: 'Unknown Job',
'plex-recently-added-scan': 'Plex Recently Added Scan',
'plex-full-scan': 'Plex Full Library Scan',
'plex-watchlist-sync': 'Plex Watchlist Sync',
'radarr-scan': 'Radarr Scan',
'sonarr-scan': 'Sonarr Scan',
'download-sync': 'Download Sync',

@ -10,7 +10,7 @@ interface SliderProps {
sliderKey: string;
items?: JSX.Element[];
isLoading: boolean;
isEmpty: boolean;
isEmpty?: boolean;
emptyMessage?: string;
placeholder?: React.ReactNode;
}
@ -24,7 +24,7 @@ const Slider = ({
sliderKey,
items,
isLoading,
isEmpty,
isEmpty = false,
emptyMessage,
placeholder = <TitleCard.Placeholder />,
}: SliderProps) => {

@ -10,13 +10,20 @@ export interface TmdbTitleCardProps {
tmdbId: number;
tvdbId?: number;
type: 'movie' | 'tv';
canExpand?: boolean;
}
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
};
const TmdbTitleCard = ({ id, tmdbId, tvdbId, type }: TmdbTitleCardProps) => {
const TmdbTitleCard = ({
id,
tmdbId,
tvdbId,
type,
canExpand,
}: TmdbTitleCardProps) => {
const { hasPermission } = useUser();
const { ref, inView } = useInView({
@ -31,7 +38,7 @@ const TmdbTitleCard = ({ id, tmdbId, tvdbId, type }: TmdbTitleCardProps) => {
if (!title && !error) {
return (
<div ref={ref}>
<TitleCard.Placeholder />
<TitleCard.Placeholder canExpand={canExpand} />
</div>
);
}
@ -57,6 +64,7 @@ const TmdbTitleCard = ({ id, tmdbId, tvdbId, type }: TmdbTitleCardProps) => {
userScore={title.voteAverage}
year={title.releaseDate}
mediaType={'movie'}
canExpand={canExpand}
/>
) : (
<TitleCard
@ -68,6 +76,7 @@ const TmdbTitleCard = ({ id, tmdbId, tvdbId, type }: TmdbTitleCardProps) => {
userScore={title.voteAverage}
year={title.firstAirDate}
mediaType={'tv'}
canExpand={canExpand}
/>
);
};

@ -588,7 +588,10 @@ const UserList = () => {
</Link>
<div className="ml-4">
<Link href={`/users/${user.id}`}>
<a className="text-base font-bold leading-5 transition duration-300 hover:underline">
<a
className="text-base font-bold leading-5 transition duration-300 hover:underline"
data-testid="user-list-username-link"
>
{user.displayName}
</a>
</Link>

@ -49,6 +49,12 @@ const messages = defineMessages({
discordIdTip:
'The <FindDiscordIdLink>multi-digit ID number</FindDiscordIdLink> associated with your Discord user account',
validationDiscordId: 'You must provide a valid Discord user ID',
plexwatchlistsyncmovies: 'Auto-Request Movies',
plexwatchlistsyncmoviestip:
'Automatically request movies on your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink>',
plexwatchlistsyncseries: 'Auto-Request Series',
plexwatchlistsyncseriestip:
'Automatically request series on your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink>',
});
const UserGeneralSettings = () => {
@ -122,6 +128,8 @@ const UserGeneralSettings = () => {
movieQuotaDays: data?.movieQuotaDays,
tvQuotaLimit: data?.tvQuotaLimit,
tvQuotaDays: data?.tvQuotaDays,
watchlistSyncMovies: data?.watchlistSyncMovies,
watchlistSyncTv: data?.watchlistSyncTv,
}}
validationSchema={UserGeneralSettingsSchema}
enableReinitialize
@ -139,6 +147,8 @@ const UserGeneralSettings = () => {
movieQuotaDays: movieQuotaEnabled ? values.movieQuotaDays : null,
tvQuotaLimit: tvQuotaEnabled ? values.tvQuotaLimit : null,
tvQuotaDays: tvQuotaEnabled ? values.tvQuotaDays : null,
watchlistSyncMovies: values.watchlistSyncMovies,
watchlistSyncTv: values.watchlistSyncTv,
});
if (currentUser?.id === user?.id && setLocale) {
@ -409,6 +419,99 @@ const UserGeneralSettings = () => {
</div>
</>
)}
{hasPermission(
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_MOVIE],
{ type: 'or' }
) &&
user?.userType === UserType.PLEX && (
<div className="form-row">
<label
htmlFor="watchlistSyncMovies"
className="checkbox-label"
>
<span>
{intl.formatMessage(messages.plexwatchlistsyncmovies)}
</span>
<span className="label-tip">
{intl.formatMessage(
messages.plexwatchlistsyncmoviestip,
{
PlexWatchlistSupportLink: (
msg: React.ReactNode
) => (
<a
href="https://support.plex.tv/articles/universal-watchlist/"
className="text-white transition duration-300 hover:underline"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
),
}
)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="watchlistSyncMovies"
name="watchlistSyncMovies"
onChange={() => {
setFieldValue(
'watchlistSyncMovies',
!values.watchlistSyncMovies
);
}}
/>
</div>
</div>
)}
{hasPermission(
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_TV],
{ type: 'or' }
) &&
user?.userType === UserType.PLEX && (
<div className="form-row">
<label htmlFor="watchlistSyncTv" className="checkbox-label">
<span>
{intl.formatMessage(messages.plexwatchlistsyncseries)}
</span>
<span className="label-tip">
{intl.formatMessage(
messages.plexwatchlistsyncseriestip,
{
PlexWatchlistSupportLink: (
msg: React.ReactNode
) => (
<a
href="https://support.plex.tv/articles/universal-watchlist/"
className="text-white transition duration-300 hover:underline"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
),
}
)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="watchlistSyncTv"
name="watchlistSyncTv"
onChange={() => {
setFieldValue(
'watchlistSyncTv',
!values.watchlistSyncTv
);
}}
/>
</div>
</div>
)}
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">

@ -10,6 +10,7 @@
"components.Discover.DiscoverStudio.studioMovies": "{studio} Movies",
"components.Discover.DiscoverTvGenre.genreSeries": "{genre} Series",
"components.Discover.DiscoverTvLanguage.languageSeries": "{language} Series",
"components.Discover.DiscoverWatchlist.discoverwatchlist": "Your Plex Watchlist",
"components.Discover.MovieGenreList.moviegenres": "Movie Genres",
"components.Discover.MovieGenreSlider.moviegenres": "Movie Genres",
"components.Discover.NetworkSlider.networks": "Networks",
@ -20,6 +21,7 @@
"components.Discover.discovermovies": "Popular Movies",
"components.Discover.discovertv": "Popular Series",
"components.Discover.noRequests": "No requests.",
"components.Discover.plexwatchlist": "Your Plex Watchlist",
"components.Discover.popularmovies": "Popular Movies",
"components.Discover.populartv": "Popular Series",
"components.Discover.recentlyAdded": "Recently Added",
@ -230,6 +232,12 @@
"components.PermissionEdit.autoapproveMoviesDescription": "Grant automatic approval for non-4K movie requests.",
"components.PermissionEdit.autoapproveSeries": "Auto-Approve Series",
"components.PermissionEdit.autoapproveSeriesDescription": "Grant automatic approval for non-4K series requests.",
"components.PermissionEdit.autorequest": "Auto-Request",
"components.PermissionEdit.autorequestDescription": "Grant permission to automatically submit requests for non-4K media via Plex Watchlist.",
"components.PermissionEdit.autorequestMovies": "Auto-Request Movies",
"components.PermissionEdit.autorequestMoviesDescription": "Grant permission to automatically submit requests for non-4K movies via Plex Watchlist.",
"components.PermissionEdit.autorequestSeries": "Auto-Request Series",
"components.PermissionEdit.autorequestSeriesDescription": "Grant permission to automatically submit requests for non-4K series via Plex Watchlist.",
"components.PermissionEdit.createissues": "Report Issues",
"components.PermissionEdit.createissuesDescription": "Grant permission to report media issues.",
"components.PermissionEdit.manageissues": "Manage Issues",
@ -618,6 +626,7 @@
"components.Settings.SettingsJobsCache.nextexecution": "Next Execution",
"components.Settings.SettingsJobsCache.plex-full-scan": "Plex Full Library Scan",
"components.Settings.SettingsJobsCache.plex-recently-added-scan": "Plex Recently Added Scan",
"components.Settings.SettingsJobsCache.plex-watchlist-sync": "Plex Watchlist Sync",
"components.Settings.SettingsJobsCache.process": "Process",
"components.Settings.SettingsJobsCache.radarr-scan": "Radarr Scan",
"components.Settings.SettingsJobsCache.runnow": "Run Now",
@ -920,6 +929,10 @@
"components.UserProfile.UserSettings.UserGeneralSettings.originallanguageTip": "Filter content by original language",
"components.UserProfile.UserSettings.UserGeneralSettings.owner": "Owner",
"components.UserProfile.UserSettings.UserGeneralSettings.plexuser": "Plex User",
"components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncmovies": "Auto-Request Movies",
"components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncmoviestip": "Automatically request movies on your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink>",
"components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncseries": "Auto-Request Series",
"components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncseriestip": "Automatically request series on your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink>",
"components.UserProfile.UserSettings.UserGeneralSettings.region": "Discover Region",
"components.UserProfile.UserSettings.UserGeneralSettings.regionTip": "Filter content by regional availability",
"components.UserProfile.UserSettings.UserGeneralSettings.role": "Role",

@ -0,0 +1,8 @@
import type { NextPage } from 'next';
import DiscoverWatchlist from '../../components/Discover/DiscoverWatchlist';
const WatchlistPage: NextPage = () => {
return <DiscoverWatchlist />;
};
export default WatchlistPage;
Loading…
Cancel
Save