Merge branch 'develop' of https://github.com/sct/overseerr into jellyfin-support

This commit is contained in:
Juan D. Jara
2021-09-27 02:24:30 +02:00
411 changed files with 35232 additions and 20531 deletions

View File

@@ -1,24 +1,24 @@
import {
Entity,
PrimaryGeneratedColumn,
AfterLoad,
Column,
Index,
OneToMany,
CreateDateColumn,
UpdateDateColumn,
Entity,
getRepository,
In,
AfterLoad,
Index,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { MediaRequest } from './MediaRequest';
import RadarrAPI from '../api/servarr/radarr';
import SonarrAPI from '../api/servarr/sonarr';
import { MediaStatus, MediaType } from '../constants/media';
import logger from '../logger';
import Season from './Season';
import { getSettings } from '../lib/settings';
import RadarrAPI from '../api/radarr';
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
import SonarrAPI from '../api/sonarr';
import { MediaServerType } from '../constants/server';
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import { MediaRequest } from './MediaRequest';
import Season from './Season';
@Entity()
class Media {
@@ -164,10 +164,10 @@ class Media {
}
} else {
if (this.jellyfinMediaId) {
this.mediaUrl = `${settings.jellyfin.hostname}/web/#!/details?id=${this.jellyfinMediaId}&context=home&serverId=${settings.jellyfin.serverId}`;
this.mediaUrl = `${settings.jellyfin.hostname}/web/index.html#!/details?id=${this.jellyfinMediaId}&context=home&serverId=${settings.jellyfin.serverId}`;
}
if (this.jellyfinMediaId4k) {
this.mediaUrl4k = `${settings.jellyfin.hostname}/web/#!/details?id=${this.jellyfinMediaId4k}&context=home&serverId=${settings.jellyfin.serverId}`;
this.mediaUrl4k = `${settings.jellyfin.hostname}/web/index.html#!/details?id=${this.jellyfinMediaId4k}&context=home&serverId=${settings.jellyfin.serverId}`;
}
}
}
@@ -184,10 +184,7 @@ class Media {
if (server) {
this.serviceUrl = server.externalUrl
? `${server.externalUrl}/movie/${this.externalServiceSlug}`
: RadarrAPI.buildRadarrUrl(
server,
`/movie/${this.externalServiceSlug}`
);
: RadarrAPI.buildUrl(server, `/movie/${this.externalServiceSlug}`);
}
}
@@ -200,7 +197,7 @@ class Media {
if (server) {
this.serviceUrl4k = server.externalUrl
? `${server.externalUrl}/movie/${this.externalServiceSlug4k}`
: RadarrAPI.buildRadarrUrl(
: RadarrAPI.buildUrl(
server,
`/movie/${this.externalServiceSlug4k}`
);
@@ -218,10 +215,7 @@ class Media {
if (server) {
this.serviceUrl = server.externalUrl
? `${server.externalUrl}/series/${this.externalServiceSlug}`
: SonarrAPI.buildSonarrUrl(
server,
`/series/${this.externalServiceSlug}`
);
: SonarrAPI.buildUrl(server, `/series/${this.externalServiceSlug}`);
}
}
@@ -234,7 +228,7 @@ class Media {
if (server) {
this.serviceUrl4k = server.externalUrl
? `${server.externalUrl}/series/${this.externalServiceSlug4k}`
: SonarrAPI.buildSonarrUrl(
: SonarrAPI.buildUrl(
server,
`/series/${this.externalServiceSlug4k}`
);

View File

@@ -1,27 +1,29 @@
import { isEqual, truncate } from 'lodash';
import {
Entity,
PrimaryGeneratedColumn,
ManyToOne,
AfterInsert,
AfterRemove,
AfterUpdate,
Column,
CreateDateColumn,
UpdateDateColumn,
AfterUpdate,
AfterInsert,
Entity,
getRepository,
ManyToOne,
OneToMany,
AfterRemove,
PrimaryGeneratedColumn,
RelationCount,
UpdateDateColumn,
} from 'typeorm';
import { User } from './User';
import Media from './Media';
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
import { getSettings } from '../lib/settings';
import RadarrAPI from '../api/servarr/radarr';
import SonarrAPI, { SonarrSeries } from '../api/servarr/sonarr';
import TheMovieDb from '../api/themoviedb';
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
import RadarrAPI from '../api/radarr';
import logger from '../logger';
import SeasonRequest from './SeasonRequest';
import SonarrAPI, { SonarrSeries } from '../api/sonarr';
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';
@Entity()
export class MediaRequest {
@@ -60,6 +62,9 @@ export class MediaRequest {
@Column({ type: 'varchar' })
public type: MediaType;
@RelationCount((request: MediaRequest) => request.seasons)
public seasonCount: number;
@OneToMany(() => SeasonRequest, (season) => season.request, {
eager: true,
cascade: true,
@@ -81,6 +86,37 @@ export class MediaRequest {
@Column({ nullable: true })
public languageProfileId: number;
@Column({
type: 'text',
nullable: true,
transformer: {
from: (value: string | null): number[] | null => {
if (value) {
if (value === 'none') {
return [];
}
return value.split(',').map((v) => Number(v));
}
return null;
},
to: (value: number[] | null): string | null => {
if (value) {
const finalValue = value.join(',');
// We want to keep the actual state of an "empty array" so we use
// the keyword "none" to track this.
if (!finalValue) {
return 'none';
}
return finalValue;
}
return null;
},
},
})
public tags?: number[];
constructor(init?: Partial<MediaRequest>) {
Object.assign(this, init);
}
@@ -106,10 +142,15 @@ export class MediaRequest {
if (this.type === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_PENDING, {
subject: movie.title,
message: movie.overview,
subject: `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`,
message: truncate(movie.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
notifyUser: this.requestedBy,
media,
request: this,
});
@@ -118,10 +159,15 @@ export class MediaRequest {
if (this.type === MediaType.TV) {
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_PENDING, {
subject: tv.name,
message: tv.overview,
subject: `${tv.name}${
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
}`,
message: truncate(tv.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
notifyUser: this.requestedBy,
media,
extra: [
{
@@ -144,7 +190,7 @@ export class MediaRequest {
* auto approved content
*/
@AfterUpdate()
public async notifyApprovedOrDeclined(): Promise<void> {
public async notifyApprovedOrDeclined(autoApproved = false): Promise<void> {
if (
this.status === MediaRequestStatus.APPROVED ||
this.status === MediaRequestStatus.DECLINED
@@ -171,13 +217,21 @@ export class MediaRequest {
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
notificationManager.sendNotification(
this.status === MediaRequestStatus.APPROVED
? Notification.MEDIA_APPROVED
? autoApproved
? Notification.MEDIA_AUTO_APPROVED
: Notification.MEDIA_APPROVED
: Notification.MEDIA_DECLINED,
{
subject: movie.title,
message: movie.overview,
subject: `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`,
message: truncate(movie.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
notifyUser: this.requestedBy,
notifyUser: autoApproved ? undefined : this.requestedBy,
media,
request: this,
}
@@ -186,13 +240,21 @@ export class MediaRequest {
const tv = await tmdb.getTvShow({ tvId: this.media.tmdbId });
notificationManager.sendNotification(
this.status === MediaRequestStatus.APPROVED
? Notification.MEDIA_APPROVED
? autoApproved
? Notification.MEDIA_AUTO_APPROVED
: Notification.MEDIA_APPROVED
: Notification.MEDIA_DECLINED,
{
subject: tv.name,
message: tv.overview,
subject: `${tv.name}${
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
}`,
message: truncate(tv.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
notifyUser: this.requestedBy,
notifyUser: autoApproved ? undefined : this.requestedBy,
media,
extra: [
{
@@ -211,13 +273,8 @@ export class MediaRequest {
@AfterInsert()
public async autoapprovalNotification(): Promise<void> {
const settings = getSettings().notifications;
if (
settings.autoapprovalEnabled &&
this.status === MediaRequestStatus.APPROVED
) {
this.notifyApprovedOrDeclined();
if (this.status === MediaRequestStatus.APPROVED) {
this.notifyApprovedOrDeclined(true);
}
}
@@ -241,11 +298,7 @@ export class MediaRequest {
media[this.is4k ? 'status4k' : 'status'] !==
MediaStatus.PARTIALLY_AVAILABLE
) {
if (this.is4k) {
media.status4k = MediaStatus.PROCESSING;
} else {
media.status = MediaStatus.PROCESSING;
}
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING;
mediaRepository.save(media);
}
@@ -253,11 +306,7 @@ export class MediaRequest {
media.mediaType === MediaType.MOVIE &&
this.status === MediaRequestStatus.DECLINED
) {
if (this.is4k) {
media.status4k = MediaStatus.UNKNOWN;
} else {
media.status = MediaStatus.UNKNOWN;
}
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
mediaRepository.save(media);
}
@@ -273,9 +322,9 @@ export class MediaRequest {
media.requests.filter(
(request) => request.status === MediaRequestStatus.PENDING
).length === 0 &&
media.status === MediaStatus.PENDING
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING
) {
media.status = MediaStatus.UNKNOWN;
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
mediaRepository.save(media);
}
@@ -326,7 +375,7 @@ export class MediaRequest {
const settings = getSettings();
if (settings.radarr.length === 0 && !settings.radarr[0]) {
logger.info(
'Skipped radarr request as there is no radarr configured',
'Skipped Radarr request as there is no Radarr server configured',
{ label: 'Media Request' }
);
return;
@@ -354,7 +403,9 @@ export class MediaRequest {
logger.info(
`There is no default ${
this.is4k ? '4K ' : ''
}radarr configured. Did you set any of your Radarr servers as default?`,
}Radarr server configured. Did you set any of your ${
this.is4k ? '4K ' : ''
}Radarr servers as default?`,
{ label: 'Media Request' }
);
return;
@@ -362,6 +413,7 @@ export class MediaRequest {
let rootFolder = radarrSettings.activeDirectory;
let qualityProfile = radarrSettings.activeProfileId;
let tags = radarrSettings.tags;
if (
this.rootFolder &&
@@ -384,10 +436,18 @@ export class MediaRequest {
});
}
if (this.tags && !isEqual(this.tags, radarrSettings.tags)) {
tags = this.tags;
logger.info(`Request has override tags`, {
label: 'Media Request',
tagIds: tags,
});
}
const tmdb = new TheMovieDb();
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'),
url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
});
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
@@ -417,6 +477,7 @@ export class MediaRequest {
tmdbId: movie.id,
year: Number(movie.release_date.slice(0, 4)),
monitored: true,
tags,
searchNow: !radarrSettings.preventSearch,
})
.then(async (radarrMovie) => {
@@ -437,7 +498,7 @@ export class MediaRequest {
await mediaRepository.save(media);
})
.catch(async () => {
media.status = MediaStatus.UNKNOWN;
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
logger.warn(
'Newly added movie request failed to add to Radarr, marking as unknown',
@@ -445,15 +506,16 @@ export class MediaRequest {
label: 'Media Request',
}
);
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
subject: movie.title,
message: 'Movie failed to add to Radarr',
notifyUser: admin,
subject: `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`,
message: truncate(movie.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
media,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
request: this,
@@ -461,7 +523,7 @@ export class MediaRequest {
});
logger.info('Sent request to Radarr', { label: 'Media Request' });
} catch (e) {
const errorMessage = `Request failed to send to radarr: ${e.message}`;
const errorMessage = `Request failed to send to Radarr: ${e.message}`;
logger.error('Request failed to send to Radarr', {
label: 'Media Request',
errorMessage,
@@ -481,7 +543,7 @@ export class MediaRequest {
const settings = getSettings();
if (settings.sonarr.length === 0 && !settings.sonarr[0]) {
logger.info(
'Skipped sonarr request as there is no sonarr configured',
'Skipped Sonarr request as there is no Sonarr server configured',
{ label: 'Media Request' }
);
return;
@@ -509,7 +571,9 @@ export class MediaRequest {
logger.info(
`There is no default ${
this.is4k ? '4K ' : ''
}sonarr configured. Did you set any of your Sonarr servers as default?`,
}Sonarr server configured. Did you set any of your ${
this.is4k ? '4K ' : ''
}Sonarr servers as default?`,
{ label: 'Media Request' }
);
return;
@@ -533,7 +597,7 @@ export class MediaRequest {
const tmdb = new TheMovieDb();
const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey,
url: SonarrAPI.buildSonarrUrl(sonarrSettings, '/api/v3'),
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
});
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
@@ -570,6 +634,11 @@ export class MediaRequest {
? sonarrSettings.activeAnimeLanguageProfileId
: sonarrSettings.activeLanguageProfileId;
let tags =
seriesType === 'anime'
? sonarrSettings.animeTags
: sonarrSettings.tags;
if (
this.rootFolder &&
this.rootFolder !== '' &&
@@ -601,6 +670,14 @@ export class MediaRequest {
);
}
if (this.tags && !isEqual(this.tags, tags)) {
tags = this.tags;
logger.info(`Request has override tags`, {
label: 'Media Request',
tagIds: tags,
});
}
// Run this asynchronously so we don't wait for it on the UI side
sonarr
.addSeries({
@@ -612,6 +689,7 @@ export class MediaRequest {
seasons: this.seasons.map((season) => season.seasonNumber),
seasonFolder: sonarrSettings.enableSeasonFolders,
seriesType,
tags,
monitored: true,
searchNow: !sonarrSettings.preventSearch,
})
@@ -634,7 +712,7 @@ export class MediaRequest {
await mediaRepository.save(media);
})
.catch(async () => {
media.status = MediaStatus.UNKNOWN;
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
logger.warn(
'Newly added series request failed to add to Sonarr, marking as unknown',
@@ -642,14 +720,18 @@ export class MediaRequest {
label: 'Media Request',
}
);
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
order: { id: 'ASC' },
});
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
subject: series.name,
message: 'Series failed to add to Sonarr',
notifyUser: admin,
subject: `${series.name}${
series.first_air_date
? ` (${series.first_air_date.slice(0, 4)})`
: ''
}`,
message: truncate(series.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${series.poster_path}`,
media,
extra: [
@@ -665,7 +747,7 @@ export class MediaRequest {
});
logger.info('Sent request to Sonarr', { label: 'Media Request' });
} catch (e) {
const errorMessage = `Request failed to send to sonarr: ${e.message}`;
const errorMessage = `Request failed to send to Sonarr: ${e.message}`;
logger.error('Request failed to send to Sonarr', {
label: 'Media Request',
errorMessage,

View File

@@ -1,28 +1,35 @@
import bcrypt from 'bcrypt';
import { randomUUID } from 'crypto';
import path from 'path';
import { default as generatePassword } from 'secure-random-password';
import {
Entity,
PrimaryGeneratedColumn,
AfterLoad,
Column,
CreateDateColumn,
UpdateDateColumn,
Entity,
getRepository,
MoreThan,
Not,
OneToMany,
RelationCount,
AfterLoad,
OneToOne,
PrimaryGeneratedColumn,
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 {
Permission,
hasPermission,
Permission,
PermissionCheckOptions,
} from '../lib/permissions';
import { MediaRequest } from './MediaRequest';
import bcrypt from 'bcrypt';
import path from 'path';
import PreparedEmail from '../lib/email';
import logger from '../logger';
import { getSettings } from '../lib/settings';
import { default as generatePassword } from 'secure-random-password';
import { UserType } from '../constants/user';
import { v4 as uuid } from 'uuid';
import logger from '../logger';
import { MediaRequest } from './MediaRequest';
import SeasonRequest from './SeasonRequest';
import { UserPushSubscription } from './UserPushSubscription';
import { UserSettings } from './UserSettings';
@Entity()
@@ -41,11 +48,17 @@ export class User {
@PrimaryGeneratedColumn()
public id: number;
@Column({ unique: true })
@Column({
unique: true,
transformer: {
from: (value: string): string => (value ?? '').toLowerCase(),
to: (value: string): string => (value ?? '').toLowerCase(),
},
})
public email: string;
@Column({ nullable: true })
public plexUsername: string;
public plexUsername?: string;
@Column({ nullable: true })
public jellyfinUsername: string;
@@ -92,6 +105,18 @@ export class User {
@OneToMany(() => MediaRequest, (request) => request.requestedBy)
public requests: MediaRequest[];
@Column({ nullable: true })
public movieQuotaLimit?: number;
@Column({ nullable: true })
public movieQuotaDays?: number;
@Column({ nullable: true })
public tvQuotaLimit?: number;
@Column({ nullable: true })
public tvQuotaDays?: number;
@OneToOne(() => UserSettings, (settings) => settings.user, {
cascade: true,
eager: true,
@@ -99,6 +124,9 @@ export class User {
})
public settings?: UserSettings;
@OneToMany(() => UserPushSubscription, (pushSub) => pushSub.user)
public pushSubscriptions: UserPushSubscription[];
@CreateDateColumn()
public createdAt: Date;
@@ -151,7 +179,8 @@ export class User {
logger.info(`Sending generated password email for ${this.email}`, {
label: 'User Management',
});
const email = new PreparedEmail();
const email = new PreparedEmail(getSettings().notifications.agents.email);
await email.send({
template: path.join(__dirname, '../templates/email/generatedpassword'),
message: {
@@ -172,7 +201,7 @@ export class User {
}
public async resetPassword(): Promise<void> {
const guid = uuid();
const guid = randomUUID();
this.resetPasswordGuid = guid;
// 24 hours into the future
@@ -187,7 +216,7 @@ export class User {
logger.info(`Sending reset password email for ${this.email}`, {
label: 'User Management',
});
const email = new PreparedEmail();
const email = new PreparedEmail(getSettings().notifications.agents.email);
await email.send({
template: path.join(__dirname, '../templates/email/resetpassword'),
message: {
@@ -195,7 +224,7 @@ export class User {
},
locals: {
resetPasswordLink,
applicationUrl: resetPasswordLink,
applicationUrl,
applicationTitle,
},
});
@@ -211,5 +240,104 @@ export class User {
public setDisplayName(): void {
this.displayName =
this.username || this.plexUsername || this.jellyfinUsername;
this.displayName = this.username || this.plexUsername || this.email;
}
public async getQuota(): Promise<QuotaResponse> {
const {
main: { defaultQuotas },
} = getSettings();
const requestRepository = getRepository(MediaRequest);
const canBypass = this.hasPermission([Permission.MANAGE_USERS], {
type: 'or',
});
const movieQuotaLimit = !canBypass
? this.movieQuotaLimit ?? defaultQuotas.movie.quotaLimit
: 0;
const movieQuotaDays = this.movieQuotaDays ?? defaultQuotas.movie.quotaDays;
// Count movie requests made during quota period
const movieDate = new Date();
if (movieQuotaDays) {
movieDate.setDate(movieDate.getDate() - movieQuotaDays);
}
const movieQuotaStartDate = movieDate.toJSON();
const movieQuotaUsed = movieQuotaLimit
? await requestRepository.count({
where: {
requestedBy: this,
createdAt: MoreThan(movieQuotaStartDate),
type: MediaType.MOVIE,
status: Not(MediaRequestStatus.DECLINED),
},
})
: 0;
const tvQuotaLimit = !canBypass
? this.tvQuotaLimit ?? defaultQuotas.tv.quotaLimit
: 0;
const tvQuotaDays = this.tvQuotaDays ?? defaultQuotas.tv.quotaDays;
// Count tv season requests made during quota period
const tvDate = new Date();
if (tvQuotaDays) {
tvDate.setDate(tvDate.getDate() - tvQuotaDays);
}
const tvQuotaStartDate = tvDate.toJSON();
const tvQuotaUsed = tvQuotaLimit
? (
await requestRepository
.createQueryBuilder('request')
.leftJoin('request.seasons', 'seasons')
.leftJoin('request.requestedBy', 'requestedBy')
.where('request.type = :requestType', {
requestType: MediaType.TV,
})
.andWhere('requestedBy.id = :userId', {
userId: this.id,
})
.andWhere('request.createdAt > :date', {
date: tvQuotaStartDate,
})
.andWhere('request.status != :declinedStatus', {
declinedStatus: MediaRequestStatus.DECLINED,
})
.addSelect((subQuery) => {
return subQuery
.select('COUNT(season.id)', 'seasonCount')
.from(SeasonRequest, 'season')
.leftJoin('season.request', 'parentRequest')
.where('parentRequest.id = request.id');
}, 'seasonCount')
.getMany()
).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0)
: 0;
return {
movie: {
days: movieQuotaDays,
limit: movieQuotaLimit,
used: movieQuotaUsed,
remaining: movieQuotaLimit
? Math.max(0, movieQuotaLimit - movieQuotaUsed)
: undefined,
restricted:
movieQuotaLimit && movieQuotaLimit - movieQuotaUsed <= 0
? true
: false,
},
tv: {
days: tvQuotaDays,
limit: tvQuotaLimit,
used: tvQuotaUsed,
remaining: tvQuotaLimit
? Math.max(0, tvQuotaLimit - tvQuotaUsed)
: undefined,
restricted:
tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0 ? true : false,
},
};
}
}

View File

@@ -0,0 +1,27 @@
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { User } from './User';
@Entity()
export class UserPushSubscription {
@PrimaryGeneratedColumn()
public id: number;
@ManyToOne(() => User, (user) => user.pushSubscriptions, {
eager: true,
onDelete: 'CASCADE',
})
public user: User;
@Column()
public endpoint: string;
@Column()
public p256dh: string;
@Column({ unique: true })
public auth: string;
constructor(init?: Partial<UserPushSubscription>) {
Object.assign(this, init);
}
}

View File

@@ -5,8 +5,15 @@ 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)
.filter((v) => !isNaN(Number(v)))
.reduce((a, v) => a + Number(v), 0);
@Entity()
export class UserSettings {
constructor(init?: Partial<UserSettings>) {
@@ -20,15 +27,91 @@ export class UserSettings {
@JoinColumn()
public user: User;
@Column({ default: true })
public enableNotifications: boolean;
@Column({ nullable: true })
public discordId?: string;
@Column({ default: '' })
public locale?: string;
@Column({ nullable: true })
public region?: string;
@Column({ nullable: true })
public originalLanguage?: string;
@Column({ nullable: true })
public pgpKey?: string;
@Column({ nullable: true })
public discordId?: string;
@Column({ nullable: true })
public telegramChatId?: string;
@Column({ nullable: true })
public telegramSendSilently?: boolean;
@Column({
type: 'text',
nullable: true,
transformer: {
from: (value: string | null): Partial<NotificationAgentTypes> => {
const defaultTypes = {
email: ALL_NOTIFICATIONS,
discord: 0,
pushbullet: 0,
pushover: 0,
slack: 0,
telegram: 0,
webhook: 0,
webpush: ALL_NOTIFICATIONS,
};
if (!value) {
return defaultTypes;
}
const values = JSON.parse(value) as Partial<NotificationAgentTypes>;
// Something with the migration to this field has caused some issue where
// the value pre-populates with just a raw "2"? Here we check if that's the case
// and return the default notification types if so
if (typeof values !== 'object') {
return defaultTypes;
}
if (values.email == null) {
values.email = ALL_NOTIFICATIONS;
}
if (values.webpush == null) {
values.webpush = ALL_NOTIFICATIONS;
}
return values;
},
to: (value: Partial<NotificationAgentTypes>): string | null => {
if (!value || typeof value !== 'object') {
return null;
}
const allowedKeys = Object.values(NotificationAgentKey);
// Remove any unknown notification agent keys before saving to db
(Object.keys(value) as (keyof NotificationAgentTypes)[]).forEach(
(key) => {
if (!allowedKeys.includes(key)) {
delete value[key];
}
}
);
return JSON.stringify(value);
},
},
})
public notificationTypes: Partial<NotificationAgentTypes>;
public hasNotificationType(
key: NotificationAgentKey,
type: Notification
): boolean {
return hasNotificationType(type, this.notificationTypes[key] ?? 0);
}
}