Merge branch 'develop' of https://github.com/sct/overseerr into jellyfin-support
This commit is contained in:
@@ -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}`
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
27
server/entity/UserPushSubscription.ts
Normal file
27
server/entity/UserPushSubscription.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user