From 8507c09e0f9ebe5cd924638a0e7a630ab8fac238 Mon Sep 17 00:00:00 2001 From: Anatole Sot <47571181+ano0002@users.noreply.github.com> Date: Thu, 22 Feb 2024 20:55:09 +0100 Subject: [PATCH] Broke the working adding feature but added multiple new features --- overseerr-api.yml | 5 + server/api/servarr/lidarr.ts | 60 +++ server/entity/MediaRequest.ts | 201 ++++++---- server/interfaces/api/requestInterfaces.ts | 3 +- server/models/Search.ts | 2 +- server/routes/request.ts | 6 +- src/components/RequestCard/index.tsx | 68 +++- .../RequestList/RequestItem/index.tsx | 81 ++++- .../RequestModal/ArtistRequestModal.tsx | 343 ++++++++++++++++++ .../RequestModal/ReleaseRequestModal.tsx | 1 + src/components/RequestModal/index.tsx | 9 + 11 files changed, 678 insertions(+), 101 deletions(-) create mode 100644 src/components/RequestModal/ArtistRequestModal.tsx diff --git a/overseerr-api.yml b/overseerr-api.yml index 8a1c96a17..f204c0928 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1267,6 +1267,8 @@ components: type: string example: '2020-09-12T10:00:27.000Z' readOnly: true + secondaryType: + type: string Cast: type: object properties: @@ -5429,6 +5431,9 @@ paths: userId: type: number nullable: true + secondaryType: + type: string + enum: [release,artist] required: - mediaType - mediaId diff --git a/server/api/servarr/lidarr.ts b/server/api/servarr/lidarr.ts index 0af34c191..9a4527fc1 100644 --- a/server/api/servarr/lidarr.ts +++ b/server/api/servarr/lidarr.ts @@ -12,6 +12,19 @@ export interface LidarrAlbumOptions { searchNow: boolean; } +export interface LidarrArtistOptions { + profileId: number; + qualityProfileId: number; + rootFolderPath: string; + mbId: string; + monitored: boolean; + tags: string[]; + searchNow: boolean; + monitorNewItems: string; + monitor: string; + searchForMissingAlbums: boolean; +} + export interface LidarrMusic { id: number; title: string; @@ -310,6 +323,7 @@ class LidarrAPI extends ServarrBase<{ musicId: number }> { tags: options.tags, monitored: options.monitored, artist: artist, + rootFolderPath: options.rootFolderPath, addOptions: { searchForNewAlbum: options.searchNow, }, @@ -334,6 +348,52 @@ class LidarrAPI extends ServarrBase<{ musicId: number }> { throw new Error(`[Lidarr] Failed to add album: ${options.mbId}`); } }; + + public addArtist = async ( + options: LidarrArtistOptions + ): Promise => { + try { + const artist = await this.getArtist(options.mbId); + if (artist.id) { + logger.info('Artist is already monitored in Lidarr. Skipping add.', { + label: 'Lidarr', + artistId: artist.id, + artistName: artist.artistName, + }); + return artist; + } + + const response = await this.axios.post('/artist', { + ...artist, + qualityProfileId: options.qualityProfileId, + monitored: true, + monitorNewItems: options.monitorNewItems, + rootFolderPath: options.rootFolderPath, + addOptions: { + monitor: options.monitor, + searchForMissingAlbums: options.searchForMissingAlbums, + }, + }); + + if (response.data.id) { + logger.info('Lidarr accepted request', { label: 'Lidarr' }); + } else { + logger.error('Failed to add artist to Lidarr', { + label: 'Lidarr', + mbId: options.mbId, + }); + throw new Error('Failed to add artist to Lidarr'); + } + return response.data; + } catch (e) { + logger.error('Error adding artist by MUSICBRAINZ ID', { + label: 'Lidarr API', + errorMessage: e.message, + mbId: options.mbId, + }); + throw new Error(`[Lidarr] Failed to add artist: ${options.mbId}`); + } + }; } export default LidarrAPI; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index ba9f062c2..6dd0945fe 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -1,6 +1,9 @@ import MusicBrainz from '@server/api/musicbrainz'; import type { mbRelease } from '@server/api/musicbrainz/interfaces'; -import type { LidarrAlbumOptions } from '@server/api/servarr/lidarr'; +import type { + LidarrAlbumOptions, + LidarrArtistOptions, +} 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'; @@ -153,7 +156,9 @@ export class MediaRequest { requestBody.mediaType === MediaType.MOVIE ? await tmdb.getMovie({ movieId: requestBody.mediaId }) : requestBody.mediaType === MediaType.MUSIC - ? await musicbrainz.getRelease(requestBody.mediaId) + ? requestBody.secondaryType === SecondaryType.RELEASE + ? await musicbrainz.getRelease(requestBody.mediaId) + : await musicbrainz.getArtist(requestBody.mediaId) : await tmdb.getTvShow({ tvId: requestBody.mediaId }); let media = @@ -162,7 +167,7 @@ export class MediaRequest { where: { mbId: requestBody.mediaId, mediaType: MediaType.MUSIC, - secondaryType: SecondaryType.RELEASE, + secondaryType: requestBody.secondaryType, }, relations: ['requests'], }) @@ -180,7 +185,7 @@ export class MediaRequest { mbId: requestBody.mediaId, status: MediaStatus.PENDING, mediaType: MediaType.MUSIC, - secondaryType: SecondaryType.RELEASE, + secondaryType: requestBody.secondaryType, title: (metaMedia as mbRelease).title, }); } else if (requestBody.mediaType === MediaType.MOVIE) { @@ -460,6 +465,7 @@ export class MediaRequest { const request = new MediaRequest({ type: MediaType.MUSIC, + secondaryType: (requestBody as MusicRequestBody).secondaryType, media, requestedBy: requestUser, // If the user is an admin or has the "auto approve" permission, automatically approve the request @@ -1396,8 +1402,6 @@ export class MediaRequest { apiKey: lidarrSettings.apiKey, url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'), }); - const release = await musicbrainz.getRelease(String(this.media.mbId)); - const media = await mediaRepository.findOne({ where: { id: this.media.id }, }); @@ -1456,53 +1460,110 @@ export class MediaRequest { return; } - const lidarrAlbumOptions: LidarrAlbumOptions = { - profileId: qualityProfile, - qualityProfileId: qualityProfile, - rootFolderPath: rootFolder, - title: release.title, - mbId: release.releaseGroup?.id ?? release.id, - monitored: true, - tags: tags.map((tag) => String(tag)), - searchNow: !lidarrSettings.preventSearch, - }; + if (this.media.secondaryType === SecondaryType.RELEASE) { + const release = await musicbrainz.getRelease(String(this.media.mbId)); + + const lidarrAlbumOptions: LidarrAlbumOptions = { + profileId: qualityProfile, + qualityProfileId: qualityProfile, + rootFolderPath: rootFolder, + title: release.title, + mbId: release.releaseGroup?.id ?? release.id, + monitored: true, + tags: tags.map((tag) => String(tag)), + searchNow: !lidarrSettings.preventSearch, + }; + + // Run this asynchronously so we don't wait for it on the UI side + lidarr + .addAlbum(lidarrAlbumOptions) + .then(async (lidarrAlbum) => { + // We grab media again here to make sure we have the latest version of it + const media = await mediaRepository.findOne({ + where: { id: this.media.id }, + }); + + if (!media) { + throw new Error('Media data not found'); + } - // Run this asynchronously so we don't wait for it on the UI side - lidarr - .addAlbum(lidarrAlbumOptions) - .then(async (lidarrAlbum) => { - // We grab media again here to make sure we have the latest version of it - const media = await mediaRepository.findOne({ - where: { id: this.media.id }, + media['externalServiceId'] = lidarrAlbum.id; + media['externalServiceSlug'] = lidarrAlbum.disambiguation; + media['serviceId'] = lidarrSettings?.id; + await mediaRepository.save(media); + }) + .catch(async () => { + const requestRepository = getRepository(MediaRequest); + + this.status = MediaRequestStatus.FAILED; + requestRepository.save(this); + + logger.warn( + 'Something went wrong sending music request to Lidarr, marking status as FAILED', + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + lidarrAlbumOptions, + } + ); + + this.sendNotification(media, Notification.MEDIA_FAILED); }); - - if (!media) { - throw new Error('Media data not found'); - } - - media['externalServiceId'] = lidarrAlbum.id; - media['externalServiceSlug'] = lidarrAlbum.disambiguation; - media['serviceId'] = lidarrSettings?.id; - await mediaRepository.save(media); - }) - .catch(async () => { - const requestRepository = getRepository(MediaRequest); - - this.status = MediaRequestStatus.FAILED; - requestRepository.save(this); - - logger.warn( - 'Something went wrong sending music request to Lidarr, marking status as FAILED', - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - lidarrAlbumOptions, + } else if (this.media.secondaryType === SecondaryType.ARTIST) { + const artist = await musicbrainz.getArtist(String(this.media.mbId)); + + const lidarrArtistOptions: LidarrArtistOptions = { + profileId: qualityProfile, + qualityProfileId: qualityProfile, + rootFolderPath: rootFolder, + mbId: artist.id, + monitored: true, + tags: tags.map((tag) => String(tag)), + searchNow: !lidarrSettings.preventSearch, + monitorNewItems: 'all', + monitor: 'all', + searchForMissingAlbums: true, + }; + + // Run this asynchronously so we don't wait for it on the UI side + lidarr + .addArtist(lidarrArtistOptions) + .then(async (lidarrArtist) => { + // We grab media again here to make sure we have the latest version of it + const media = await mediaRepository.findOne({ + where: { id: this.media.id }, + }); + + if (!media) { + throw new Error('Media data not found'); } - ); - this.sendNotification(media, Notification.MEDIA_FAILED); - }); + media['externalServiceId'] = lidarrArtist.id; + media['externalServiceSlug'] = lidarrArtist.disambiguation; + media['serviceId'] = lidarrSettings?.id; + await mediaRepository.save(media); + }) + .catch(async () => { + const requestRepository = getRepository(MediaRequest); + + this.status = MediaRequestStatus.FAILED; + requestRepository.save(this); + + logger.warn( + 'Something went wrong sending music request to Lidarr, marking status as FAILED', + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + lidarrArtistOptions, + } + ); + + this.sendNotification(media, Notification.MEDIA_FAILED); + }); + } + logger.info('Sent request to Lidarr', { label: 'Media Request', requestId: this.id, @@ -1604,20 +1665,34 @@ export class MediaRequest { ], }); } else if (this.type === MediaType.MUSIC) { - const music = await musicbrainz.getRelease(media.mbId as string); - notificationManager.sendNotification(type, { - media, - request: this, - notifyAdmin, - notifySystem, - notifyUser: notifyAdmin ? undefined : this.requestedBy, - event, - subject: `${music.title}${ - music.date ? ` (${music.date.toLocaleDateString()})` : '' - }`, - message: music.artist.map((artist) => artist.name).join(', '), - image: `http://coverartarchive.org/release/${music.id}/front-250`, - }); + if (this.media.secondaryType === SecondaryType.RELEASE) { + const music = await musicbrainz.getRelease(media.mbId as string); + notificationManager.sendNotification(type, { + media, + request: this, + notifyAdmin, + notifySystem, + notifyUser: notifyAdmin ? undefined : this.requestedBy, + event, + subject: `${music.title}${ + music.date ? ` (${music.date.toLocaleDateString()})` : '' + }`, + message: music.artist.map((artist) => artist.name).join(', '), + image: `http://coverartarchive.org/release/${music.id}/front-250`, + }); + } else if (this.media.secondaryType === SecondaryType.ARTIST) { + const artist = await musicbrainz.getArtist(media.mbId as string); + notificationManager.sendNotification(type, { + media, + request: this, + notifyAdmin, + notifySystem, + notifyUser: notifyAdmin ? undefined : this.requestedBy, + event, + subject: artist.name, + image: `http://coverartarchive.org/artist/${artist.id}/front-250`, + }); + } } } catch (e) { logger.error('Something went wrong sending media notification(s)', { diff --git a/server/interfaces/api/requestInterfaces.ts b/server/interfaces/api/requestInterfaces.ts index 5d1184f11..e1f158c44 100644 --- a/server/interfaces/api/requestInterfaces.ts +++ b/server/interfaces/api/requestInterfaces.ts @@ -1,4 +1,4 @@ -import type { MediaType } from '@server/constants/media'; +import type { MediaType, SecondaryType } from '@server/constants/media'; import type { MediaRequest } from '@server/entity/MediaRequest'; import type { PaginatedResponse } from './common'; @@ -30,6 +30,7 @@ export interface TvRequestBody extends VideoRequestBody { } export interface MusicRequestBody extends MediaRequestBody { + secondaryType: SecondaryType; mediaType: MediaType.MUSIC; mediaId: string; } diff --git a/server/models/Search.ts b/server/models/Search.ts index a99f5b12f..996e4903c 100644 --- a/server/models/Search.ts +++ b/server/models/Search.ts @@ -116,7 +116,7 @@ export interface ReleaseResult { title: string; artist: ArtistResult[]; posterPath?: string; - date?: Date; + date?: Date | string; tracks?: RecordingResult[]; tags: string[]; mediaInfo?: Media; diff --git a/server/routes/request.ts b/server/routes/request.ts index 2b3efcdc1..58f27fa67 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -173,7 +173,6 @@ requestRoutes.post< }); } const request = await MediaRequest.request(req.body, req.user); - return res.status(201).json(request); } catch (error) { if (!(error instanceof Error)) { @@ -189,7 +188,10 @@ requestRoutes.post< case NoSeasonsAvailableError: return next({ status: 202, message: error.message }); default: - return next({ status: 500, message: error.message }); + return next({ + status: 500, + message: error.message, + }); } } }); diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index 0fa4a48e9..ced15e2b7 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -19,6 +19,7 @@ import { import { MediaRequestStatus } from '@server/constants/media'; import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MovieDetails } from '@server/models/Movie'; +import type { ArtistResult, ReleaseResult } from '@server/models/Search'; import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; import Link from 'next/link'; @@ -42,8 +43,32 @@ const messages = defineMessages({ unknowntitle: 'Unknown Title', }); -const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { - return (movie as MovieDetails).title !== undefined; +const isMovie = ( + movie: MovieDetails | TvDetails | ReleaseResult | ArtistResult +): movie is MovieDetails => { + // Check if the object doesn't have a mediaType property and does have a title property then it's a movie + return !('mediaType' in movie) && 'title' in movie; +}; + +const isTv = ( + tv: MovieDetails | TvDetails | ReleaseResult | ArtistResult +): tv is TvDetails => { + // Check if the object doesn't have a mediaType property and does have a name property then it's a tv show + return !('mediaType' in tv) && 'name' in tv; +}; + +const isRelease = ( + release: MovieDetails | TvDetails | ReleaseResult | ArtistResult +): release is ReleaseResult => { + // Check if the object has a mediaType property and does have a title property then it's a release + return 'mediaType' in release && 'title' in release; +}; + +const isArtist = ( + artist: MovieDetails | TvDetails | ReleaseResult | ArtistResult +): artist is ArtistResult => { + // Check if the object has a mediaType property and does have a name property then it's an artist + return 'mediaType' in artist && 'name' in artist; }; const RequestCardPlaceholder = () => { @@ -205,7 +230,10 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => { interface RequestCardProps { request: MediaRequest; - onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void; + onTitleData?: ( + requestId: number, + title: MovieDetails | TvDetails | ReleaseResult | ArtistResult + ) => void; } const RequestCard = ({ request, onTitleData }: RequestCardProps) => { @@ -224,9 +252,9 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { ? `/api/v1/tv/${request.media.tmdbId}` : `/api/v1/music/${request.media.secondaryType}/${request.media.mbId}`; - const { data: title, error } = useSWR( - inView ? `${url}` : null - ); + const { data: title, error } = useSWR< + MovieDetails | TvDetails | ReleaseResult | ArtistResult + >(inView ? `${url}` : null); const { data: requestData, error: requestError, @@ -321,7 +349,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { className="relative flex w-72 overflow-hidden rounded-xl bg-gray-800 bg-cover bg-center p-4 text-gray-400 shadow ring-1 ring-gray-700 sm:w-96" data-testid="request-card" > - {title.backdropPath && ( + {(isMovie(title) || isTv(title)) && title.backdropPath && (
{ data-testid="request-card-title" >
- {(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice( - 0, - 4 - )} + {(isMovie(title) + ? title.releaseDate + : isTv(title) + ? title.firstAirDate + : isRelease(title) + ? title.date?.toDateString() + : isArtist(title) + ? title.beginDate + : '' + )?.slice(0, 4)}
- {isMovie(title) ? title.title : title.name} + {isMovie(title) || isRelease(title) ? title.title : title.name} {hasPermission( @@ -378,7 +414,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
)} - {!isMovie(title) && request.seasons.length > 0 && ( + {isTv(title) && request.seasons.length > 0 && (
{intl.formatMessage(messages.seasons, { @@ -423,7 +459,9 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { requestData.is4k ? 'downloadStatus4k' : 'downloadStatus' ] } - title={isMovie(title) ? title.title : title.name} + title={ + isMovie(title) || isRelease(title) ? title.title : title.name + } inProgress={ ( requestData.media[ diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index a42483abe..caba283dd 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -15,9 +15,11 @@ import { TrashIcon, XMarkIcon, } from '@heroicons/react/24/solid'; +import type { SecondaryType } from '@server/constants/media'; import { MediaRequestStatus } from '@server/constants/media'; import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MovieDetails } from '@server/models/Movie'; +import type { ArtistResult, ReleaseResult } from '@server/models/Search'; import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; import Link from 'next/link'; @@ -40,11 +42,29 @@ const messages = defineMessages({ cancelRequest: 'Cancel Request', tmdbid: 'TMDB ID', tvdbid: 'TheTVDB ID', + mbId: 'MusicBrainz ID', unknowntitle: 'Unknown Title', }); -const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { - return (movie as MovieDetails).title !== undefined; +const isMovie = ( + movie: MovieDetails | TvDetails | ReleaseResult | ArtistResult +): movie is MovieDetails => { + // Check if the object doesn't have a mediaType property and does have a title property then it's a movie + return !('mediaType' in movie) && 'title' in movie; +}; + +const isTv = ( + tv: MovieDetails | TvDetails | ReleaseResult | ArtistResult +): tv is TvDetails => { + // Check if the object doesn't have a mediaType property and does have a name property then it's a tv show + return !('mediaType' in tv) && 'name' in tv; +}; + +const isRelease = ( + release: MovieDetails | TvDetails | ReleaseResult | ArtistResult +): release is ReleaseResult => { + // Check if the object has a mediaType property and does have a title property then it's a release + return 'mediaType' in release && 'title' in release; }; interface RequestItemErrorProps { @@ -81,7 +101,9 @@ const RequestItemError = ({ requestData?.type ? requestData?.type === 'movie' ? globalMessages.movie - : globalMessages.tvshow + : requestData?.type === 'tv' + ? globalMessages.tvshow + : globalMessages.music : globalMessages.request ), })} @@ -90,10 +112,14 @@ const RequestItemError = ({ <>
- {intl.formatMessage(messages.tmdbid)} + {requestData?.type === 'movie' || requestData?.type === 'tv' + ? intl.formatMessage(messages.tmdbid) + : intl.formatMessage(messages.mbId)} - {requestData.media.tmdbId} + {requestData?.type === 'movie' || requestData?.type === 'tv' + ? requestData?.media.tmdbId + : requestData?.media.mbId}
{requestData.media.tvdbId && ( @@ -286,10 +312,12 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { const url = request.type === 'movie' ? `/api/v1/movie/${request.media.tmdbId}` - : `/api/v1/tv/${request.media.tmdbId}`; - const { data: title, error } = useSWR( - inView ? url : null - ); + : request.type === 'tv' + ? `/api/v1/tv/${request.media.tmdbId}` + : `/api/v1/music/${request.secondaryType}/${request.media.mbId}`; + const { data: title, error } = useSWR< + MovieDetails | TvDetails | ReleaseResult | ArtistResult + >(inView ? url : null); const { data: requestData, mutate: revalidate } = useSWR( `/api/v1/request/${request.id}`, { @@ -303,7 +331,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { ), } ); - const [isRetrying, setRetrying] = useState(false); const modifyRequest = async (type: 'approve' | 'decline') => { @@ -374,9 +401,10 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { revalidateList(); setShowEditModal(false); }} + secondaryType={request.secondaryType as SecondaryType} />
- {title.backdropPath && ( + {(isMovie(title) || isTv(title)) && title.backdropPath && (
{ href={ requestData.type === 'movie' ? `/movie/${requestData.media.tmdbId}` - : `/tv/${requestData.media.tmdbId}` + : requestData.type === 'tv' + ? `/tv/${requestData.media.tmdbId}` + : `/music/${requestData.secondaryType}/${requestData.media.mbId}` } > {
{(isMovie(title) ? title.releaseDate - : title.firstAirDate + : isTv(title) + ? title.firstAirDate + : isRelease(title) + ? new Date(title.date as string).toDateString() + : title.beginDate )?.slice(0, 4)}
- {isMovie(title) ? title.title : title.name} + {isMovie(title) || isRelease(title) + ? title.title + : title.name} - {!isMovie(title) && request.seasons.length > 0 && ( + {isTv(title) && request.seasons.length > 0 && (
{intl.formatMessage(messages.seasons, { @@ -484,7 +523,11 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { requestData.is4k ? 'downloadStatus4k' : 'downloadStatus' ] } - title={isMovie(title) ? title.title : title.name} + title={ + isMovie(title) || isRelease(title) + ? title.title + : title.name + } inProgress={ ( requestData.media[ diff --git a/src/components/RequestModal/ArtistRequestModal.tsx b/src/components/RequestModal/ArtistRequestModal.tsx new file mode 100644 index 000000000..111a2c1ea --- /dev/null +++ b/src/components/RequestModal/ArtistRequestModal.tsx @@ -0,0 +1,343 @@ +import Alert from '@app/components/Common/Alert'; +import Modal from '@app/components/Common/Modal'; +import type { RequestOverrides } from '@app/components/RequestModal/AdvancedRequester'; +import AdvancedRequester from '@app/components/RequestModal/AdvancedRequester'; +import QuotaDisplay from '@app/components/RequestModal/QuotaDisplay'; +import { useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import { MediaStatus, SecondaryType } from '@server/constants/media'; +import type { MediaRequest } from '@server/entity/MediaRequest'; +import type { QuotaResponse } from '@server/interfaces/api/userInterfaces'; +import { Permission } from '@server/lib/permissions'; +import type { ArtistResult } from '@server/models/Search'; +import axios from 'axios'; +import { useCallback, useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR, { mutate } from 'swr'; + +const messages = defineMessages({ + requestadmin: 'This request will be approved automatically.', + requestSuccess: '{title} requested successfully!', + requestCancel: 'Request for {title} canceled.', + requestartisttitle: 'Request Artist', + edit: 'Edit Request', + approve: 'Approve Request', + cancel: 'Cancel Request', + pendingrequest: 'Pending Artist Request', + requestfrom: "{username}'s request is pending approval.", + errorediting: 'Something went wrong while editing the request.', + requestedited: 'Request for {title} edited successfully!', + requestApproved: 'Request for {title} approved!', + requesterror: 'Something went wrong while submitting the request.', + pendingapproval: 'Your request is pending approval.', +}); + +interface RequestModalProps extends React.HTMLAttributes { + mbId: string; + editRequest?: MediaRequest; + onCancel?: () => void; + onComplete?: (newStatus: MediaStatus) => void; + onUpdating?: (isUpdating: boolean) => void; +} + +const ArtistRequestModal = ({ + onCancel, + onComplete, + mbId, + onUpdating, + editRequest, +}: RequestModalProps) => { + const [isUpdating, setIsUpdating] = useState(false); + const [requestOverrides, setRequestOverrides] = + useState(null); + const { addToast } = useToasts(); + const { data, error } = useSWR(`/api/v1/music/artist/${mbId}`, { + revalidateOnMount: true, + }); + const intl = useIntl(); + const { user, hasPermission } = useUser(); + const { data: quota } = useSWR( + user && + (!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS)) + ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` + : null + ); + + useEffect(() => { + if (onUpdating) { + onUpdating(isUpdating); + } + }, [isUpdating, onUpdating]); + + const sendRequest = useCallback(async () => { + setIsUpdating(true); + + try { + let overrideParams = {}; + if (requestOverrides) { + overrideParams = { + serverId: requestOverrides.server, + profileId: requestOverrides.profile, + rootFolder: requestOverrides.folder, + userId: requestOverrides.user?.id, + tags: requestOverrides.tags, + }; + } + const response = await axios.post('/api/v1/request', { + mediaId: data?.id, + mediaType: 'music', + secondaryType: 'artist', + ...overrideParams, + }); + mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); + + if (response.data) { + if (onComplete) { + onComplete( + hasPermission(Permission.AUTO_APPROVE) || + hasPermission(Permission.AUTO_APPROVE_MUSIC) + ? MediaStatus.PROCESSING + : MediaStatus.PENDING + ); + } + addToast( + + {intl.formatMessage(messages.requestSuccess, { + title: data?.name, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + } + } catch (e) { + addToast(intl.formatMessage(messages.requesterror), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setIsUpdating(false); + } + }, [data, onComplete, addToast, requestOverrides, hasPermission, intl]); + + const cancelRequest = async () => { + setIsUpdating(true); + + try { + const response = await axios.delete( + `/api/v1/request/${editRequest?.id}` + ); + mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); + + if (response.status === 204) { + if (onComplete) { + onComplete(MediaStatus.UNKNOWN); + } + addToast( + + {intl.formatMessage(messages.requestCancel, { + title: data?.name, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + } + } catch (e) { + setIsUpdating(false); + } + }; + + const updateRequest = async (alsoApproveRequest = false) => { + setIsUpdating(true); + + try { + await axios.put(`/api/v1/request/${editRequest?.id}`, { + mediaType: 'music', + secondaryType: 'artist', + serverId: requestOverrides?.server, + profileId: requestOverrides?.profile, + rootFolder: requestOverrides?.folder, + userId: requestOverrides?.user?.id, + tags: requestOverrides?.tags, + }); + + if (alsoApproveRequest) { + await axios.post(`/api/v1/request/${editRequest?.id}/approve`); + } + mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); + + addToast( + + {intl.formatMessage( + alsoApproveRequest + ? messages.requestApproved + : messages.requestedited, + { + title: data?.name, + strong: (msg: React.ReactNode) => {msg}, + } + )} + , + { + appearance: 'success', + autoDismiss: true, + } + ); + + if (onComplete) { + onComplete(MediaStatus.PENDING); + } + } catch (e) { + addToast({intl.formatMessage(messages.errorediting)}, { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setIsUpdating(false); + } + }; + + if (editRequest) { + const isOwner = editRequest.requestedBy.id === user?.id; + + return ( + + hasPermission(Permission.MANAGE_REQUESTS) + ? updateRequest(true) + : hasPermission(Permission.REQUEST_ADVANCED) + ? updateRequest() + : cancelRequest() + } + okDisabled={isUpdating} + okText={ + hasPermission(Permission.MANAGE_REQUESTS) + ? intl.formatMessage(messages.approve) + : hasPermission(Permission.REQUEST_ADVANCED) + ? intl.formatMessage(messages.edit) + : intl.formatMessage(messages.cancel) + } + okButtonType={ + hasPermission(Permission.MANAGE_REQUESTS) + ? 'success' + : hasPermission(Permission.REQUEST_ADVANCED) + ? 'primary' + : 'danger' + } + onSecondary={ + isOwner && + hasPermission( + [Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS], + { type: 'or' } + ) + ? () => cancelRequest() + : undefined + } + secondaryDisabled={isUpdating} + secondaryText={ + isOwner && + hasPermission( + [Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS], + { type: 'or' } + ) + ? intl.formatMessage(messages.cancel) + : undefined + } + secondaryButtonType="danger" + cancelText={intl.formatMessage(globalMessages.close)} + backdrop={data?.posterPath} + > + {isOwner + ? intl.formatMessage(messages.pendingapproval) + : intl.formatMessage(messages.requestfrom, { + username: editRequest.requestedBy.displayName, + })} + {(hasPermission(Permission.REQUEST_ADVANCED) || + hasPermission(Permission.MANAGE_REQUESTS)) && ( + { + setRequestOverrides(overrides); + }} + /> + )} + + ); + } + + const hasAutoApprove = hasPermission( + [ + Permission.MANAGE_REQUESTS, + Permission.AUTO_APPROVE, + Permission.AUTO_APPROVE_MUSIC, + ], + { type: 'or' } + ); + + return ( + + {hasAutoApprove && !quota?.music.restricted && ( +
+ +
+ )} + {(quota?.music.limit ?? 0) > 0 && ( + + )} + {(hasPermission(Permission.REQUEST_ADVANCED) || + hasPermission(Permission.MANAGE_REQUESTS)) && ( + { + setRequestOverrides(overrides); + }} + /> + )} +
+ ); +}; + +export default ArtistRequestModal; diff --git a/src/components/RequestModal/ReleaseRequestModal.tsx b/src/components/RequestModal/ReleaseRequestModal.tsx index bdee6a637..36b4f8c47 100644 --- a/src/components/RequestModal/ReleaseRequestModal.tsx +++ b/src/components/RequestModal/ReleaseRequestModal.tsx @@ -90,6 +90,7 @@ const ReleaseRequestModal = ({ const response = await axios.post('/api/v1/request', { mediaId: data?.id, mediaType: 'music', + secondaryType: 'release', ...overrideParams, }); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); diff --git a/src/components/RequestModal/index.tsx b/src/components/RequestModal/index.tsx index dcdd5f44e..7874a2c3c 100644 --- a/src/components/RequestModal/index.tsx +++ b/src/components/RequestModal/index.tsx @@ -1,3 +1,4 @@ +import ArtistRequestModal from '@app/components/RequestModal/ArtistRequestModal'; import CollectionRequestModal from '@app/components/RequestModal/CollectionRequestModal'; import MovieRequestModal from '@app/components/RequestModal/MovieRequestModal'; import ReleaseRequestModal from '@app/components/RequestModal/ReleaseRequestModal'; @@ -76,6 +77,14 @@ const RequestModal = ({ onUpdating={onUpdating} editRequest={editRequest} /> + ) : type === 'music' && secondaryType === 'artist' ? ( + ) : null} );