Add music/book request flow support

- Add permission checks for music/book in MediaRequest.request()
- Add quota checks for music/book types
- Add externalServiceId and externalServiceTitle columns to Media entity
- Add foreignId/foreignTitle to MediaRequestBody interface
- Add requestMusicOrBook() method for simplified music/book requests
- Make TMDB lookup conditional (skip for music/book)
- Update request route filtering for music/book types
- Handle duplicate detection for foreign ID based media
This commit is contained in:
root
2026-04-03 21:11:34 -05:00
parent 1cf0d541d6
commit 466db07e37
4 changed files with 147 additions and 14 deletions

View File

@@ -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;

View File

@@ -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<MediaRequest> {
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;