feat: added support for Music Quotas and Music Media Requests

Anatole Sot 8 months ago
parent d2d203afbd
commit 3b3cd27950

@ -1,3 +1,4 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import RadarrAPI from '@server/api/servarr/radarr'; import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaStatus, MediaType } from '@server/constants/media'; import { MediaStatus, MediaType } from '@server/constants/media';
@ -20,7 +21,6 @@ import {
import Issue from './Issue'; import Issue from './Issue';
import { MediaRequest } from './MediaRequest'; import { MediaRequest } from './MediaRequest';
import Season from './Season'; import Season from './Season';
import LidarrAPI from '@server/api/servarr/lidarr';
@Entity() @Entity()
class Media { class Media {
@ -73,13 +73,13 @@ class Media {
@Column({ type: 'varchar' }) @Column({ type: 'varchar' })
public mediaType: MediaType; public mediaType: MediaType;
@Column({ nullable: true }) @Column()
@Index() @Index()
public tmdbId: number; public tmdbId?: number;
@Column({ nullable: true }) @Column()
@Index() @Index()
public mbId: number; public mbId?: string;
@Column({ nullable: true }) @Column({ nullable: true })
@Index() @Index()

@ -1,3 +1,6 @@
import MusicBrainz from '@server/api/musicbrainz';
import type { LidarrMusicOptions } from '@server/api/servarr/lidarr';
import LidarrAPI from '@server/api/servarr/lidarr';
import type { RadarrMovieOptions } from '@server/api/servarr/radarr'; import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
import RadarrAPI from '@server/api/servarr/radarr'; import RadarrAPI from '@server/api/servarr/radarr';
import type { import type {
@ -5,18 +8,20 @@ import type {
SonarrSeries, SonarrSeries,
} from '@server/api/servarr/sonarr'; } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr';
import type { LidarrMusicOptions } from '@server/api/servarr/lidarr';
import LidarrAPI from '@server/api/servarr/lidarr';
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb';
import MusicBrainz from '@server/api/musicbrainz';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import { import {
MediaRequestStatus, MediaRequestStatus,
MediaStatus, MediaStatus,
MediaType, MediaType,
} from '@server/constants/media'; } from '@server/constants/media';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces'; import type {
MusicRequestBody,
TvRequestBody,
VideoRequestBody,
} from '@server/interfaces/api/requestInterfaces';
import notificationManager, { Notification } from '@server/lib/notifications'; import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
@ -51,7 +56,7 @@ type MediaRequestOptions = {
@Entity() @Entity()
export class MediaRequest { export class MediaRequest {
public static async request( public static async request(
requestBody: MediaRequestBody, requestBody: VideoRequestBody | TvRequestBody | MusicRequestBody,
user: User, user: User,
options: MediaRequestOptions = {} options: MediaRequestOptions = {}
): Promise<MediaRequest> { ): Promise<MediaRequest> {
@ -115,6 +120,18 @@ export class MediaRequest {
requestBody.is4k ? '4K ' : '' requestBody.is4k ? '4K ' : ''
}series requests.` }series requests.`
); );
} else if (
requestBody.mediaType === MediaType.MUSIC &&
!requestUser.hasPermission(
[Permission.REQUEST, Permission.REQUEST_MUSIC],
{
type: 'or',
}
)
) {
throw new RequestPermissionError(
`You do not have permission to make music requests.`
);
} }
const quotas = await requestUser.getQuota(); const quotas = await requestUser.getQuota();
@ -123,45 +140,109 @@ export class MediaRequest {
throw new QuotaRestrictedError('Movie Quota exceeded.'); throw new QuotaRestrictedError('Movie Quota exceeded.');
} else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) { } else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) {
throw new QuotaRestrictedError('Series Quota exceeded.'); throw new QuotaRestrictedError('Series Quota exceeded.');
} else if (
requestBody.mediaType === MediaType.MUSIC &&
quotas.music.restricted
) {
throw new QuotaRestrictedError('Music Quota exceeded.');
} }
const tmdbMedia = const metaMedia =
requestBody.mediaType === MediaType.MOVIE requestBody.mediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: requestBody.mediaId }) ? await tmdb.getMovie({ movieId: requestBody.mediaId })
: requestBody.mediaType === MediaType.MUSIC
? await musicbrainz.getReleaseGroup(requestBody.mediaId)
: await tmdb.getTvShow({ tvId: requestBody.mediaId }); : await tmdb.getTvShow({ tvId: requestBody.mediaId });
let media = await mediaRepository.findOne({ let media =
requestBody.mediaType === MediaType.MUSIC
? await mediaRepository.findOne({
where: { where: {
tmdbId: requestBody.mediaId, mbId: requestBody.mediaId,
mediaType: requestBody.mediaType,
},
relations: ['requests'],
})
: await mediaRepository.findOne({
where: {
tmdbId: Number(metaMedia.id), // Convert tmdbId to number
mediaType: requestBody.mediaType, mediaType: requestBody.mediaType,
}, },
relations: ['requests'], relations: ['requests'],
}); });
if (!media) { if (!media) {
if (requestBody.mediaType === MediaType.MUSIC) {
media = new Media({ media = new Media({
tmdbId: tmdbMedia.id, mbId: requestBody.mediaId,
tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id, status: MediaStatus.PENDING,
mediaType: requestBody.mediaType,
});
} else if (requestBody.mediaType === MediaType.MOVIE) {
media = new Media({
tmdbId: requestBody.mediaId,
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, status4k: requestBody.is4k
? MediaStatus.PENDING
: MediaStatus.UNKNOWN,
mediaType: requestBody.mediaType, mediaType: requestBody.mediaType,
}); });
} else { } else {
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) { let tvdbId: number | undefined;
if (requestBody.mediaType === MediaType.TV) {
const tvMedia = metaMedia as TmdbTvDetails;
tvdbId = tvMedia.external_ids?.tvdb_id;
}
media = new Media({
tmdbId: requestBody.mediaId,
tvdbId: (requestBody as TvRequestBody).tvdbId ?? tvdbId,
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
status4k: requestBody.is4k
? MediaStatus.PENDING
: MediaStatus.UNKNOWN,
mediaType: requestBody.mediaType,
});
}
} else {
if (media.mediaType !== MediaType.MUSIC) {
if (
media.status === MediaStatus.UNKNOWN &&
!(requestBody as VideoRequestBody | TvRequestBody).is4k
) {
media.status = MediaStatus.PENDING; media.status = MediaStatus.PENDING;
} }
if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) { if (
media.status4k === MediaStatus.UNKNOWN &&
(requestBody as VideoRequestBody | TvRequestBody).is4k
) {
media.status4k = MediaStatus.PENDING; media.status4k = MediaStatus.PENDING;
} }
} else {
if (media.status === MediaStatus.UNKNOWN) {
media.status = MediaStatus.PENDING;
}
}
} }
const existing = await requestRepository const existing =
requestBody.mediaType !== MediaType.MUSIC
? await requestRepository
.createQueryBuilder('request') .createQueryBuilder('request')
.leftJoin('request.media', 'media') .leftJoin('request.media', 'media')
.leftJoinAndSelect('request.requestedBy', 'user') .leftJoinAndSelect('request.requestedBy', 'user')
.where('request.is4k = :is4k', { is4k: requestBody.is4k }) .where('request.is4k = :is4k', { is4k: requestBody.is4k })
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id }) .andWhere('media.tmdbId = :tmdbId', { tmdbId: metaMedia.id })
.andWhere('media.mediaType = :mediaType', {
mediaType: requestBody.mediaType,
})
.getMany()
: await requestRepository
.createQueryBuilder('request')
.leftJoin('request.media', 'media')
.leftJoinAndSelect('request.requestedBy', 'user')
.where('media.mbId = :mbId', { mbId: requestBody.mediaId })
.andWhere('media.mediaType = :mediaType', { .andWhere('media.mediaType = :mediaType', {
mediaType: requestBody.mediaType, mediaType: requestBody.mediaType,
}) })
@ -174,7 +255,7 @@ export class MediaRequest {
existing[0].status !== MediaRequestStatus.DECLINED existing[0].status !== MediaRequestStatus.DECLINED
) { ) {
logger.warn('Duplicate request for media blocked', { logger.warn('Duplicate request for media blocked', {
tmdbId: tmdbMedia.id, tmdbId: metaMedia.id,
mediaType: requestBody.mediaType, mediaType: requestBody.mediaType,
is4k: requestBody.is4k, is4k: requestBody.is4k,
label: 'Media Request', label: 'Media Request',
@ -244,16 +325,17 @@ export class MediaRequest {
await requestRepository.save(request); await requestRepository.save(request);
return request; return request;
} else { } else if (requestBody.mediaType === MediaType.TV) {
const tmdbMediaShow = tmdbMedia as Awaited< const metaMediaShow = metaMedia as Awaited<
ReturnType<typeof tmdb.getTvShow> ReturnType<typeof tmdb.getTvShow>
>; >;
const requestedSeasons = const requestedSeasons =
requestBody.seasons === 'all' (requestBody as TvRequestBody).seasons === 'all'
? tmdbMediaShow.seasons ? metaMediaShow.seasons
.map((season) => season.season_number) .map((season) => season.season_number)
.filter((sn) => sn > 0) .filter((sn) => sn > 0)
: (requestBody.seasons as number[]); : ((requestBody as TvRequestBody).seasons as number[]);
let existingSeasons: 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 // We need to check existing requests on this title to make sure we don't double up on seasons that were
@ -366,6 +448,43 @@ export class MediaRequest {
isAutoRequest: options.isAutoRequest ?? false, isAutoRequest: options.isAutoRequest ?? false,
}); });
await requestRepository.save(request);
return request;
} else {
await mediaRepository.save(media);
const request = new MediaRequest({
type: MediaType.MUSIC,
media,
requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request
status: user.hasPermission(
[
Permission.AUTO_APPROVE,
Permission.AUTO_APPROVE_MUSIC,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy: user.hasPermission(
[
Permission.AUTO_APPROVE,
Permission.AUTO_APPROVE_MUSIC,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? user
: undefined,
serverId: requestBody.serverId,
profileId: requestBody.profileId,
rootFolder: requestBody.rootFolder,
tags: requestBody.tags,
isAutoRequest: options.isAutoRequest ?? false,
});
await requestRepository.save(request); await requestRepository.save(request);
return request; return request;
} }
@ -753,7 +872,9 @@ export class MediaRequest {
apiKey: radarrSettings.apiKey, apiKey: radarrSettings.apiKey,
url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'), url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
}); });
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); const movie = await tmdb.getMovie({
movieId: Number(this.media.tmdbId),
});
const media = await mediaRepository.findOne({ const media = await mediaRepository.findOne({
where: { id: this.media.id }, where: { id: this.media.id },
@ -970,7 +1091,7 @@ export class MediaRequest {
apiKey: sonarrSettings.apiKey, apiKey: sonarrSettings.apiKey,
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'), url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
}); });
const series = await tmdb.getTvShow({ tvId: media.tmdbId }); const series = await tmdb.getTvShow({ tvId: Number(media.tmdbId) });
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
if (!tvdbId) { if (!tvdbId) {
@ -1310,9 +1431,7 @@ export class MediaRequest {
} }
} }
if ( if (media['status'] === MediaStatus.AVAILABLE) {
media['status'] === MediaStatus.AVAILABLE
) {
logger.warn('Media already exists, marking request as APPROVED', { logger.warn('Media already exists, marking request as APPROVED', {
label: 'Media Request', label: 'Media Request',
requestId: this.id, requestId: this.id,
@ -1350,10 +1469,8 @@ export class MediaRequest {
throw new Error('Media data not found'); throw new Error('Media data not found');
} }
media['externalServiceId'] = media['externalServiceId'] = lidarrMusic.id;
lidarrMusic.id; media['externalServiceSlug'] = lidarrMusic.titleSlug;
media['externalServiceSlug'] =
lidarrMusic.titleSlug;
media['serviceId'] = lidarrSettings?.id; media['serviceId'] = lidarrSettings?.id;
await mediaRepository.save(media); await mediaRepository.save(media);
}) })
@ -1394,7 +1511,7 @@ export class MediaRequest {
private async sendNotification(media: Media, type: Notification) { private async sendNotification(media: Media, type: Notification) {
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();
const musicbrainz = new MusicBrainz();
try { try {
const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series'; const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series';
let event: string | undefined; let event: string | undefined;
@ -1431,7 +1548,7 @@ export class MediaRequest {
} }
if (this.type === MediaType.MOVIE) { if (this.type === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: media.tmdbId }); const movie = await tmdb.getMovie({ movieId: media.tmdbId as number });
notificationManager.sendNotification(type, { notificationManager.sendNotification(type, {
media, media,
request: this, request: this,
@ -1450,7 +1567,7 @@ export class MediaRequest {
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
}); });
} else if (this.type === MediaType.TV) { } else if (this.type === MediaType.TV) {
const tv = await tmdb.getTvShow({ tvId: media.tmdbId }); const tv = await tmdb.getTvShow({ tvId: media.tmdbId as number });
notificationManager.sendNotification(type, { notificationManager.sendNotification(type, {
media, media,
request: this, request: this,
@ -1477,7 +1594,7 @@ export class MediaRequest {
], ],
}); });
} else if (this.type === MediaType.MUSIC) { } else if (this.type === MediaType.MUSIC) {
const music = await musicbrainz.getMusic({ mbId: media.tmdbId }); const music = await musicbrainz.getReleaseGroup(media.mbId as string);
notificationManager.sendNotification(type, { notificationManager.sendNotification(type, {
media, media,
request: this, request: this,
@ -1485,15 +1602,13 @@ export class MediaRequest {
notifySystem, notifySystem,
notifyUser: notifyAdmin ? undefined : this.requestedBy, notifyUser: notifyAdmin ? undefined : this.requestedBy,
event, event,
subject: `${music.name}${ subject: `${music.title}${
music.first_realease_date ? ` (${music.first_realease_date.slice(0, 4)})` : '' music.firstReleased
? '(' + music.firstReleased.toLocaleDateString() + ')'
: ''
}`, }`,
message: truncate(music.overview, { message: music.artist.map((artist) => artist.name).join(', '),
length: 500, image: `http://coverartarchive.org/release-group/${music.id}/front-250`,
separator: /\s/,
omission: '…',
}),
image: `http://coverartarchive.org/${music.type}/${music.mbid}/front-250`, //TODO: Add coverartarchive
}); });
} }
} catch (e) { } catch (e) {

@ -103,6 +103,12 @@ export class User {
@Column({ nullable: true }) @Column({ nullable: true })
public tvQuotaDays?: number; public tvQuotaDays?: number;
@Column({ nullable: true })
public musicQuotaLimit?: number;
@Column({ nullable: true })
public musicQuotaDays?: number;
@OneToOne(() => UserSettings, (settings) => settings.user, { @OneToOne(() => UserSettings, (settings) => settings.user, {
cascade: true, cascade: true,
eager: true, eager: true,
@ -306,6 +312,27 @@ export class User {
).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0) ).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0)
: 0; : 0;
const musicQuotaLimit = !canBypass
? this.musicQuotaLimit ?? defaultQuotas.music.quotaLimit
: 0;
const musicQuotaDays = this.musicQuotaDays ?? defaultQuotas.music.quotaDays;
const musicDate = new Date();
if (musicQuotaDays) {
musicDate.setDate(musicDate.getDate() - musicQuotaDays);
}
const musicQuotaUsed = musicQuotaLimit
? await requestRepository.count({
where: {
requestedBy: {
id: this.id,
},
createdAt: AfterDate(musicDate),
type: MediaType.MUSIC,
status: Not(MediaRequestStatus.DECLINED),
},
})
: 0;
return { return {
movie: { movie: {
days: movieQuotaDays, days: movieQuotaDays,
@ -329,6 +356,18 @@ export class User {
restricted: restricted:
tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0 ? true : false, tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0 ? true : false,
}, },
music: {
days: musicQuotaDays,
limit: musicQuotaLimit,
used: musicQuotaUsed,
remaining: musicQuotaLimit
? Math.max(0, musicQuotaLimit - musicQuotaUsed)
: undefined,
restricted:
musicQuotaLimit && musicQuotaLimit - musicQuotaUsed <= 0
? true
: false,
},
}; };
} }
} }

@ -6,16 +6,32 @@ export interface RequestResultsResponse extends PaginatedResponse {
results: MediaRequest[]; results: MediaRequest[];
} }
export type MediaRequestBody = { interface MediaRequestBody {
mediaType: MediaType; mediaType: MediaType;
mediaId: number; mediaId: number | string;
tvdbId?: number;
seasons?: number[] | 'all';
is4k?: boolean;
serverId?: number; serverId?: number;
profileId?: number; profileId?: number;
rootFolder?: string; rootFolder?: string;
languageProfileId?: number; languageProfileId?: number;
userId?: number; userId?: number;
tags?: number[]; tags?: number[];
}; }
export interface VideoRequestBody extends MediaRequestBody {
mediaType: MediaType.MOVIE | MediaType.TV;
mediaId: number;
is4k?: boolean;
}
export interface TvRequestBody extends VideoRequestBody {
mediaType: MediaType.TV;
tvdbId?: number;
seasons?: number[] | 'all';
}
export interface MusicRequestBody extends MediaRequestBody {
mediaType: MediaType.MUSIC;
mediaId: string;
albumId?: number;
artistId?: number;
}

@ -22,6 +22,7 @@ export interface QuotaStatus {
export interface QuotaResponse { export interface QuotaResponse {
movie: QuotaStatus; movie: QuotaStatus;
tv: QuotaStatus; tv: QuotaStatus;
music: QuotaStatus;
} }
export interface UserWatchDataResponse { export interface UserWatchDataResponse {

@ -8,24 +8,27 @@ export enum Permission {
AUTO_APPROVE = 128, AUTO_APPROVE = 128,
AUTO_APPROVE_MOVIE = 256, AUTO_APPROVE_MOVIE = 256,
AUTO_APPROVE_TV = 512, AUTO_APPROVE_TV = 512,
REQUEST_4K = 1024, AUTO_APPROVE_MUSIC = 268_435_456,
REQUEST_4K_MOVIE = 2048, REQUEST_4K = 1_024,
REQUEST_4K_TV = 4096, REQUEST_4K_MOVIE = 2_048,
REQUEST_ADVANCED = 8192, REQUEST_4K_TV = 4_096,
REQUEST_VIEW = 16384, REQUEST_ADVANCED = 8_192,
AUTO_APPROVE_4K = 32768, REQUEST_VIEW = 16_384,
AUTO_APPROVE_4K_MOVIE = 65536, AUTO_APPROVE_4K = 32_768,
AUTO_APPROVE_4K_TV = 131072, AUTO_APPROVE_4K_MOVIE = 65_536,
REQUEST_MOVIE = 262144, AUTO_APPROVE_4K_TV = 131_072,
REQUEST_TV = 524288, REQUEST_MOVIE = 262_144,
MANAGE_ISSUES = 1048576, REQUEST_TV = 524_288,
VIEW_ISSUES = 2097152, REQUEST_MUSIC = 536_870_912,
CREATE_ISSUES = 4194304, MANAGE_ISSUES = 1_048_576,
AUTO_REQUEST = 8388608, VIEW_ISSUES = 2_097_152,
AUTO_REQUEST_MOVIE = 16777216, CREATE_ISSUES = 4_194_304,
AUTO_REQUEST_TV = 33554432, AUTO_REQUEST = 8_388_608,
RECENT_VIEW = 67108864, AUTO_REQUEST_MOVIE = 16_777_216,
WATCHLIST_VIEW = 134217728, AUTO_REQUEST_TV = 33_554_432,
AUTO_REQUEST_MUSIC = 1_073_741_824,
RECENT_VIEW = 67_108_864,
WATCHLIST_VIEW = 134_217_728,
} }
export interface PermissionCheckOptions { export interface PermissionCheckOptions {

Loading…
Cancel
Save