feat: added support for Music Quotas and Music Media Requests

pull/3800/merge^2
Anatole Sot 4 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 SonarrAPI from '@server/api/servarr/sonarr';
import { MediaStatus, MediaType } from '@server/constants/media';
@ -20,7 +21,6 @@ import {
import Issue from './Issue';
import { MediaRequest } from './MediaRequest';
import Season from './Season';
import LidarrAPI from '@server/api/servarr/lidarr';
@Entity()
class Media {
@ -73,13 +73,13 @@ class Media {
@Column({ type: 'varchar' })
public mediaType: MediaType;
@Column({ nullable: true })
@Column()
@Index()
public tmdbId: number;
public tmdbId?: number;
@Column({ nullable: true })
@Column()
@Index()
public mbId: number;
public mbId?: string;
@Column({ nullable: true })
@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 RadarrAPI from '@server/api/servarr/radarr';
import type {
@ -5,18 +8,20 @@ import type {
SonarrSeries,
} 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 MusicBrainz from '@server/api/musicbrainz';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import {
MediaRequestStatus,
MediaStatus,
MediaType,
} from '@server/constants/media';
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 { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
@ -51,7 +56,7 @@ type MediaRequestOptions = {
@Entity()
export class MediaRequest {
public static async request(
requestBody: MediaRequestBody,
requestBody: VideoRequestBody | TvRequestBody | MusicRequestBody,
user: User,
options: MediaRequestOptions = {}
): Promise<MediaRequest> {
@ -115,6 +120,18 @@ export class MediaRequest {
requestBody.is4k ? '4K ' : ''
}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();
@ -123,49 +140,113 @@ export class MediaRequest {
throw new QuotaRestrictedError('Movie Quota exceeded.');
} else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) {
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
? await tmdb.getMovie({ movieId: requestBody.mediaId })
: requestBody.mediaType === MediaType.MUSIC
? await musicbrainz.getReleaseGroup(requestBody.mediaId)
: await tmdb.getTvShow({ tvId: requestBody.mediaId });
let media = await mediaRepository.findOne({
where: {
tmdbId: requestBody.mediaId,
mediaType: requestBody.mediaType,
},
relations: ['requests'],
});
let media =
requestBody.mediaType === MediaType.MUSIC
? await mediaRepository.findOne({
where: {
mbId: requestBody.mediaId,
mediaType: requestBody.mediaType,
},
relations: ['requests'],
})
: await mediaRepository.findOne({
where: {
tmdbId: Number(metaMedia.id), // Convert tmdbId to number
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 (requestBody.mediaType === MediaType.MUSIC) {
media = new Media({
mbId: requestBody.mediaId,
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,
status4k: requestBody.is4k
? MediaStatus.PENDING
: MediaStatus.UNKNOWN,
mediaType: requestBody.mediaType,
});
} else {
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;
}
if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) {
media.status4k = MediaStatus.PENDING;
if (
media.status4k === MediaStatus.UNKNOWN &&
(requestBody as VideoRequestBody | TvRequestBody).is4k
) {
media.status4k = MediaStatus.PENDING;
}
} else {
if (media.status === MediaStatus.UNKNOWN) {
media.status = 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();
const existing =
requestBody.mediaType !== MediaType.MUSIC
? await requestRepository
.createQueryBuilder('request')
.leftJoin('request.media', 'media')
.leftJoinAndSelect('request.requestedBy', 'user')
.where('request.is4k = :is4k', { is4k: requestBody.is4k })
.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', {
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.
@ -174,7 +255,7 @@ export class MediaRequest {
existing[0].status !== MediaRequestStatus.DECLINED
) {
logger.warn('Duplicate request for media blocked', {
tmdbId: tmdbMedia.id,
tmdbId: metaMedia.id,
mediaType: requestBody.mediaType,
is4k: requestBody.is4k,
label: 'Media Request',
@ -244,16 +325,17 @@ export class MediaRequest {
await requestRepository.save(request);
return request;
} else {
const tmdbMediaShow = tmdbMedia as Awaited<
} else if (requestBody.mediaType === MediaType.TV) {
const metaMediaShow = metaMedia as Awaited<
ReturnType<typeof tmdb.getTvShow>
>;
const requestedSeasons =
requestBody.seasons === 'all'
? tmdbMediaShow.seasons
(requestBody as TvRequestBody).seasons === 'all'
? metaMediaShow.seasons
.map((season) => season.season_number)
.filter((sn) => sn > 0)
: (requestBody.seasons as number[]);
: ((requestBody as TvRequestBody).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
@ -366,6 +448,43 @@ export class MediaRequest {
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);
return request;
}
@ -753,7 +872,9 @@ export class MediaRequest {
apiKey: radarrSettings.apiKey,
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({
where: { id: this.media.id },
@ -970,7 +1091,7 @@ export class MediaRequest {
apiKey: sonarrSettings.apiKey,
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;
if (!tvdbId) {
@ -1310,9 +1431,7 @@ export class MediaRequest {
}
}
if (
media['status'] === MediaStatus.AVAILABLE
) {
if (media['status'] === MediaStatus.AVAILABLE) {
logger.warn('Media already exists, marking request as APPROVED', {
label: 'Media Request',
requestId: this.id,
@ -1350,10 +1469,8 @@ export class MediaRequest {
throw new Error('Media data not found');
}
media['externalServiceId'] =
lidarrMusic.id;
media['externalServiceSlug'] =
lidarrMusic.titleSlug;
media['externalServiceId'] = lidarrMusic.id;
media['externalServiceSlug'] = lidarrMusic.titleSlug;
media['serviceId'] = lidarrSettings?.id;
await mediaRepository.save(media);
})
@ -1394,7 +1511,7 @@ export class MediaRequest {
private async sendNotification(media: Media, type: Notification) {
const tmdb = new TheMovieDb();
const musicbrainz = new MusicBrainz();
try {
const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series';
let event: string | undefined;
@ -1431,7 +1548,7 @@ export class MediaRequest {
}
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, {
media,
request: this,
@ -1450,7 +1567,7 @@ export class MediaRequest {
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
});
} 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, {
media,
request: this,
@ -1477,7 +1594,7 @@ export class MediaRequest {
],
});
} 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, {
media,
request: this,
@ -1485,15 +1602,13 @@ export class MediaRequest {
notifySystem,
notifyUser: notifyAdmin ? undefined : this.requestedBy,
event,
subject: `${music.name}${
music.first_realease_date ? ` (${music.first_realease_date.slice(0, 4)})` : ''
subject: `${music.title}${
music.firstReleased
? '(' + music.firstReleased.toLocaleDateString() + ')'
: ''
}`,
message: truncate(music.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
image: `http://coverartarchive.org/${music.type}/${music.mbid}/front-250`, //TODO: Add coverartarchive
message: music.artist.map((artist) => artist.name).join(', '),
image: `http://coverartarchive.org/release-group/${music.id}/front-250`,
});
}
} catch (e) {

@ -103,6 +103,12 @@ export class User {
@Column({ nullable: true })
public tvQuotaDays?: number;
@Column({ nullable: true })
public musicQuotaLimit?: number;
@Column({ nullable: true })
public musicQuotaDays?: number;
@OneToOne(() => UserSettings, (settings) => settings.user, {
cascade: true,
eager: true,
@ -306,6 +312,27 @@ export class User {
).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 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 {
movie: {
days: movieQuotaDays,
@ -329,6 +356,18 @@ export class User {
restricted:
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[];
}
export type MediaRequestBody = {
interface MediaRequestBody {
mediaType: MediaType;
mediaId: number;
tvdbId?: number;
seasons?: number[] | 'all';
is4k?: boolean;
mediaId: number | string;
serverId?: number;
profileId?: number;
rootFolder?: string;
languageProfileId?: number;
userId?: 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 {
movie: QuotaStatus;
tv: QuotaStatus;
music: QuotaStatus;
}
export interface UserWatchDataResponse {

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

Loading…
Cancel
Save