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