diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 3746a6c0..99801fc5 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -98,6 +98,13 @@ class Media { @Index() public imdbId?: string; + @Column({ nullable: true }) + @Index() + public externalServiceId?: string; + + @Column({ nullable: true }) + public externalServiceTitle?: string; + @Column({ type: 'int', default: MediaStatus.UNKNOWN }) @Index() public status: MediaStatus; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 445b9e6a..1172d118 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -108,6 +108,26 @@ 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.' + ); + } else if ( + requestBody.mediaType === MediaType.BOOK && + !requestUser.hasPermission( + [Permission.REQUEST, Permission.REQUEST_BOOK], + { type: 'or' } + ) + ) { + throw new RequestPermissionError( + 'You do not have permission to make book requests.' + ); } const quotas = await requestUser.getQuota(); @@ -116,25 +136,42 @@ 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.'); + } else if (requestBody.mediaType === MediaType.BOOK && quotas.book?.restricted) { + throw new QuotaRestrictedError('Book Quota exceeded.'); } - const tmdbMedia = - requestBody.mediaType === MediaType.MOVIE - ? await tmdb.getMovie({ movieId: requestBody.mediaId }) - : await tmdb.getTvShow({ tvId: requestBody.mediaId }); + // Music and Book requests don't use TMDB - they use foreign IDs from Lidarr/Readarr + const isMusicOrBook = requestBody.mediaType === MediaType.MUSIC || requestBody.mediaType === MediaType.BOOK; + + let tmdbMedia: any = null; + if (!isMusicOrBook) { + tmdbMedia = + requestBody.mediaType === MediaType.MOVIE + ? await tmdb.getMovie({ movieId: requestBody.mediaId }) + : await tmdb.getTvShow({ tvId: requestBody.mediaId }); + } let media = await mediaRepository.findOne({ - where: { - tmdbId: requestBody.mediaId, - mediaType: requestBody.mediaType, - }, + where: isMusicOrBook + ? { + externalServiceId: requestBody.foreignId, + mediaType: requestBody.mediaType, + } + : { + tmdbId: requestBody.mediaId, + mediaType: requestBody.mediaType, + }, relations: ['requests'], }); if (!media) { media = new Media({ - tmdbId: tmdbMedia.id, - tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id, + tmdbId: isMusicOrBook ? 0 : tmdbMedia.id, + tvdbId: isMusicOrBook ? 0 : (requestBody.tvdbId ?? tmdbMedia.external_ids?.tvdb_id), + externalServiceId: isMusicOrBook ? requestBody.foreignId : undefined, + externalServiceTitle: isMusicOrBook ? requestBody.foreignTitle : undefined, status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, mediaType: requestBody.mediaType, @@ -159,16 +196,26 @@ export class MediaRequest { } } - const existing = await requestRepository + const existingQuery = requestRepository .createQueryBuilder('request') .leftJoin('request.media', 'media') .leftJoinAndSelect('request.requestedBy', 'user') .where('request.is4k = :is4k', { is4k: requestBody.is4k }) - .andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id }) .andWhere('media.mediaType = :mediaType', { mediaType: requestBody.mediaType, - }) - .getMany(); + }); + + if (isMusicOrBook) { + existingQuery.andWhere('media.externalServiceId = :foreignId', { + foreignId: requestBody.foreignId, + }); + } else { + existingQuery.andWhere('media.tmdbId = :tmdbId', { + tmdbId: tmdbMedia.id, + }); + } + + const existing = await existingQuery.getMany(); if (existing && existing.length > 0) { // If there is an existing movie request that isn't declined, don't allow a new one. @@ -510,6 +557,72 @@ export class MediaRequest { } } + /** + * Handle music/book request creation (simpler flow - no TMDB, no seasons, no 4K) + */ + public static async requestMusicOrBook( + requestBody: MediaRequestBody, + user: User, + options: MediaRequestOptions = {} + ): Promise { + const mediaRepository = getRepository(Media); + const requestRepository = getRepository(MediaRequest); + + let media = await mediaRepository.findOne({ + where: { + externalServiceId: requestBody.foreignId, + mediaType: requestBody.mediaType, + }, + relations: ['requests'], + }); + + if (!media) { + media = new Media({ + tmdbId: 0, + tvdbId: 0, + externalServiceId: requestBody.foreignId, + externalServiceTitle: requestBody.foreignTitle, + status: MediaStatus.PENDING, + status4k: MediaStatus.UNKNOWN, + mediaType: requestBody.mediaType, + }); + } + + await mediaRepository.save(media); + + const autoApprovePermission = + requestBody.mediaType === MediaType.MUSIC + ? Permission.AUTO_APPROVE_MUSIC + : Permission.AUTO_APPROVE_BOOK; + + const request = new MediaRequest({ + type: requestBody.mediaType, + media, + requestedBy: user, + status: user.hasPermission( + [Permission.AUTO_APPROVE, autoApprovePermission, Permission.MANAGE_REQUESTS], + { type: 'or' } + ) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + modifiedBy: user.hasPermission( + [Permission.AUTO_APPROVE, autoApprovePermission, Permission.MANAGE_REQUESTS], + { type: 'or' } + ) + ? user + : undefined, + is4k: false, + serverId: requestBody.serverId, + profileId: requestBody.profileId, + rootFolder: requestBody.rootFolder, + tags: requestBody.tags, + isAutoRequest: options.isAutoRequest ?? false, + }); + + await requestRepository.save(request); + return request; + } + @PrimaryGeneratedColumn() public id: number; diff --git a/server/interfaces/api/requestInterfaces.ts b/server/interfaces/api/requestInterfaces.ts index 9b9c6299..91e8d03e 100644 --- a/server/interfaces/api/requestInterfaces.ts +++ b/server/interfaces/api/requestInterfaces.ts @@ -26,4 +26,7 @@ export type MediaRequestBody = { languageProfileId?: number; userId?: number; tags?: number[]; + // Music/Book specific fields + foreignId?: string; + foreignTitle?: string; }; diff --git a/server/routes/request.ts b/server/routes/request.ts index ba2cffac..ce89ed7d 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -173,6 +173,16 @@ requestRoutes.get, RequestResultsResponse>( type: MediaType.TV, }); break; + case 'music': + query = query.andWhere('request.type = :type', { + type: MediaType.MUSIC, + }); + break; + case 'book': + query = query.andWhere('request.type = :type', { + type: MediaType.BOOK, + }); + break; } const [requests, requestCount] = await query