Broke the working adding feature but added multiple new features

pull/3800/merge^2
Anatole Sot 4 months ago
parent 8cc3f8cfc4
commit 8507c09e0f

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

@ -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<LidarrArtist> => {
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<LidarrArtist>('/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;

@ -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)', {

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

@ -116,7 +116,7 @@ export interface ReleaseResult {
title: string;
artist: ArtistResult[];
posterPath?: string;
date?: Date;
date?: Date | string;
tracks?: RecordingResult[];
tags: string[];
mediaInfo?: Media;

@ -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,
});
}
}
});

@ -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<MovieDetails | TvDetails>(
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 && (
<div className="absolute inset-0 z-0">
<CachedImage
alt=""
@ -343,20 +371,28 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
data-testid="request-card-title"
>
<div className="hidden text-xs font-medium text-white sm:flex">
{(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)}
</div>
<Link
href={
request.type === 'movie'
? `/movie/${requestData.media.tmdbId}`
: `/tv/${requestData.media.tmdbId}`
: request.type === 'tv'
? `/tv/${requestData.media.tmdbId}`
: `/music/${requestData.media.secondaryType}/${requestData.media.mbId}`
}
>
<a className="overflow-hidden overflow-ellipsis whitespace-nowrap text-base font-bold text-white hover:underline sm:text-lg">
{isMovie(title) ? title.title : title.name}
{isMovie(title) || isRelease(title) ? title.title : title.name}
</a>
</Link>
{hasPermission(
@ -378,7 +414,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
</Link>
</div>
)}
{!isMovie(title) && request.seasons.length > 0 && (
{isTv(title) && request.seasons.length > 0 && (
<div className="my-0.5 hidden items-center text-sm sm:my-1 sm:flex">
<span className="mr-2 font-bold ">
{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[

@ -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 = ({
<>
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.tmdbid)}
{requestData?.type === 'movie' || requestData?.type === 'tv'
? intl.formatMessage(messages.tmdbid)
: intl.formatMessage(messages.mbId)}
</span>
<span className="flex truncate text-sm text-gray-300">
{requestData.media.tmdbId}
{requestData?.type === 'movie' || requestData?.type === 'tv'
? requestData?.media.tmdbId
: requestData?.media.mbId}
</span>
</div>
{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<MovieDetails | TvDetails>(
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<MediaRequest>(
`/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}
/>
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
{title.backdropPath && (
{(isMovie(title) || isTv(title)) && title.backdropPath && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
@ -399,15 +427,18 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
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}`
}
>
<a className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105">
<CachedImage
src={
title.posterPath
title.posterPath && (isMovie(title) || isTv(title))
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/overseerr_poster_not_found.png'
: title.posterPath ??
'/images/overseerr_poster_not_found.png'
}
alt=""
layout="responsive"
@ -421,21 +452,29 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
<div className="pt-0.5 text-xs font-medium text-white sm:pt-1">
{(isMovie(title)
? title.releaseDate
: title.firstAirDate
: isTv(title)
? title.firstAirDate
: isRelease(title)
? new Date(title.date as string).toDateString()
: title.beginDate
)?.slice(0, 4)}
</div>
<Link
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}`
}
>
<a className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl">
{isMovie(title) ? title.title : title.name}
{isMovie(title) || isRelease(title)
? title.title
: title.name}
</a>
</Link>
{!isMovie(title) && request.seasons.length > 0 && (
{isTv(title) && request.seasons.length > 0 && (
<div className="card-field">
<span className="card-field-name">
{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[

@ -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: '<strong>{title}</strong> requested successfully!',
requestCancel: 'Request for <strong>{title}</strong> 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 <strong>{title}</strong> edited successfully!',
requestApproved: 'Request for <strong>{title}</strong> approved!',
requesterror: 'Something went wrong while submitting the request.',
pendingapproval: 'Your request is pending approval.',
});
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
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<RequestOverrides | null>(null);
const { addToast } = useToasts();
const { data, error } = useSWR<ArtistResult>(`/api/v1/music/artist/${mbId}`, {
revalidateOnMount: true,
});
const intl = useIntl();
const { user, hasPermission } = useUser();
const { data: quota } = useSWR<QuotaResponse>(
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<MediaRequest>('/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(
<span>
{intl.formatMessage(messages.requestSuccess, {
title: data?.name,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ 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<MediaRequest>(
`/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(
<span>
{intl.formatMessage(messages.requestCancel, {
title: data?.name,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ 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(
<span>
{intl.formatMessage(
alsoApproveRequest
? messages.requestApproved
: messages.requestedited,
{
title: data?.name,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
}
)}
</span>,
{
appearance: 'success',
autoDismiss: true,
}
);
if (onComplete) {
onComplete(MediaStatus.PENDING);
}
} catch (e) {
addToast(<span>{intl.formatMessage(messages.errorediting)}</span>, {
appearance: 'error',
autoDismiss: true,
});
} finally {
setIsUpdating(false);
}
};
if (editRequest) {
const isOwner = editRequest.requestedBy.id === user?.id;
return (
<Modal
loading={!data && !error}
backgroundClickable
onCancel={onCancel}
title={intl.formatMessage(messages.pendingrequest)}
subTitle={data?.name}
onOk={() =>
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)) && (
<AdvancedRequester
type="music"
secondaryType={SecondaryType.ARTIST}
requestUser={editRequest.requestedBy}
defaultOverrides={{
folder: editRequest.rootFolder,
profile: editRequest.profileId,
server: editRequest.serverId,
tags: editRequest.tags,
}}
onChange={(overrides) => {
setRequestOverrides(overrides);
}}
/>
)}
</Modal>
);
}
const hasAutoApprove = hasPermission(
[
Permission.MANAGE_REQUESTS,
Permission.AUTO_APPROVE,
Permission.AUTO_APPROVE_MUSIC,
],
{ type: 'or' }
);
return (
<Modal
loading={(!data && !error) || !quota}
backgroundClickable
onCancel={onCancel}
onOk={sendRequest}
okDisabled={isUpdating || quota?.music.restricted}
title={intl.formatMessage(messages.requestartisttitle)}
subTitle={data?.name}
okText={
isUpdating
? intl.formatMessage(globalMessages.requesting)
: intl.formatMessage(globalMessages.request)
}
okButtonType={'primary'}
backdrop={data?.posterPath}
>
{hasAutoApprove && !quota?.music.restricted && (
<div className="mt-6">
<Alert
title={intl.formatMessage(messages.requestadmin)}
type="info"
/>
</div>
)}
{(quota?.music.limit ?? 0) > 0 && (
<QuotaDisplay
mediaType="music"
secondaryType={SecondaryType.ARTIST}
quota={quota?.music}
userOverride={
requestOverrides?.user && requestOverrides.user.id !== user?.id
? requestOverrides?.user?.id
: undefined
}
/>
)}
{(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (
<AdvancedRequester
type="music"
secondaryType={SecondaryType.ARTIST}
onChange={(overrides) => {
setRequestOverrides(overrides);
}}
/>
)}
</Modal>
);
};
export default ArtistRequestModal;

@ -90,6 +90,7 @@ const ReleaseRequestModal = ({
const response = await axios.post<MediaRequest>('/api/v1/request', {
mediaId: data?.id,
mediaType: 'music',
secondaryType: 'release',
...overrideParams,
});
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');

@ -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' ? (
<ArtistRequestModal
onComplete={onComplete}
onCancel={onCancel}
mbId={mbId as string}
onUpdating={onUpdating}
editRequest={editRequest}
/>
) : null}
</Transition>
);

Loading…
Cancel
Save