Broke the working adding feature but added multiple new features

Anatole Sot 9 months ago
parent 8cc3f8cfc4
commit 8507c09e0f

@ -1267,6 +1267,8 @@ components:
type: string type: string
example: '2020-09-12T10:00:27.000Z' example: '2020-09-12T10:00:27.000Z'
readOnly: true readOnly: true
secondaryType:
type: string
Cast: Cast:
type: object type: object
properties: properties:
@ -5429,6 +5431,9 @@ paths:
userId: userId:
type: number type: number
nullable: true nullable: true
secondaryType:
type: string
enum: [release,artist]
required: required:
- mediaType - mediaType
- mediaId - mediaId

@ -12,6 +12,19 @@ export interface LidarrAlbumOptions {
searchNow: boolean; 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 { export interface LidarrMusic {
id: number; id: number;
title: string; title: string;
@ -310,6 +323,7 @@ class LidarrAPI extends ServarrBase<{ musicId: number }> {
tags: options.tags, tags: options.tags,
monitored: options.monitored, monitored: options.monitored,
artist: artist, artist: artist,
rootFolderPath: options.rootFolderPath,
addOptions: { addOptions: {
searchForNewAlbum: options.searchNow, searchForNewAlbum: options.searchNow,
}, },
@ -334,6 +348,52 @@ class LidarrAPI extends ServarrBase<{ musicId: number }> {
throw new Error(`[Lidarr] Failed to add album: ${options.mbId}`); 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; export default LidarrAPI;

@ -1,6 +1,9 @@
import MusicBrainz from '@server/api/musicbrainz'; import MusicBrainz from '@server/api/musicbrainz';
import type { mbRelease } from '@server/api/musicbrainz/interfaces'; 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 LidarrAPI from '@server/api/servarr/lidarr';
import type { RadarrMovieOptions } from '@server/api/servarr/radarr'; import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
import RadarrAPI from '@server/api/servarr/radarr'; import RadarrAPI from '@server/api/servarr/radarr';
@ -153,7 +156,9 @@ export class MediaRequest {
requestBody.mediaType === MediaType.MOVIE requestBody.mediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: requestBody.mediaId }) ? await tmdb.getMovie({ movieId: requestBody.mediaId })
: requestBody.mediaType === MediaType.MUSIC : requestBody.mediaType === MediaType.MUSIC
? requestBody.secondaryType === SecondaryType.RELEASE
? await musicbrainz.getRelease(requestBody.mediaId) ? await musicbrainz.getRelease(requestBody.mediaId)
: await musicbrainz.getArtist(requestBody.mediaId)
: await tmdb.getTvShow({ tvId: requestBody.mediaId }); : await tmdb.getTvShow({ tvId: requestBody.mediaId });
let media = let media =
@ -162,7 +167,7 @@ export class MediaRequest {
where: { where: {
mbId: requestBody.mediaId, mbId: requestBody.mediaId,
mediaType: MediaType.MUSIC, mediaType: MediaType.MUSIC,
secondaryType: SecondaryType.RELEASE, secondaryType: requestBody.secondaryType,
}, },
relations: ['requests'], relations: ['requests'],
}) })
@ -180,7 +185,7 @@ export class MediaRequest {
mbId: requestBody.mediaId, mbId: requestBody.mediaId,
status: MediaStatus.PENDING, status: MediaStatus.PENDING,
mediaType: MediaType.MUSIC, mediaType: MediaType.MUSIC,
secondaryType: SecondaryType.RELEASE, secondaryType: requestBody.secondaryType,
title: (metaMedia as mbRelease).title, title: (metaMedia as mbRelease).title,
}); });
} else if (requestBody.mediaType === MediaType.MOVIE) { } else if (requestBody.mediaType === MediaType.MOVIE) {
@ -460,6 +465,7 @@ export class MediaRequest {
const request = new MediaRequest({ const request = new MediaRequest({
type: MediaType.MUSIC, type: MediaType.MUSIC,
secondaryType: (requestBody as MusicRequestBody).secondaryType,
media, media,
requestedBy: requestUser, requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request // 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, apiKey: lidarrSettings.apiKey,
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'), url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'),
}); });
const release = await musicbrainz.getRelease(String(this.media.mbId));
const media = await mediaRepository.findOne({ const media = await mediaRepository.findOne({
where: { id: this.media.id }, where: { id: this.media.id },
}); });
@ -1456,6 +1460,9 @@ export class MediaRequest {
return; return;
} }
if (this.media.secondaryType === SecondaryType.RELEASE) {
const release = await musicbrainz.getRelease(String(this.media.mbId));
const lidarrAlbumOptions: LidarrAlbumOptions = { const lidarrAlbumOptions: LidarrAlbumOptions = {
profileId: qualityProfile, profileId: qualityProfile,
qualityProfileId: qualityProfile, qualityProfileId: qualityProfile,
@ -1503,6 +1510,60 @@ export class MediaRequest {
this.sendNotification(media, Notification.MEDIA_FAILED); this.sendNotification(media, Notification.MEDIA_FAILED);
}); });
} 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');
}
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', { logger.info('Sent request to Lidarr', {
label: 'Media Request', label: 'Media Request',
requestId: this.id, requestId: this.id,
@ -1604,6 +1665,7 @@ export class MediaRequest {
], ],
}); });
} else if (this.type === MediaType.MUSIC) { } else if (this.type === MediaType.MUSIC) {
if (this.media.secondaryType === SecondaryType.RELEASE) {
const music = await musicbrainz.getRelease(media.mbId as string); const music = await musicbrainz.getRelease(media.mbId as string);
notificationManager.sendNotification(type, { notificationManager.sendNotification(type, {
media, media,
@ -1618,6 +1680,19 @@ export class MediaRequest {
message: music.artist.map((artist) => artist.name).join(', '), message: music.artist.map((artist) => artist.name).join(', '),
image: `http://coverartarchive.org/release/${music.id}/front-250`, 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) { } catch (e) {
logger.error('Something went wrong sending media notification(s)', { 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 { MediaRequest } from '@server/entity/MediaRequest';
import type { PaginatedResponse } from './common'; import type { PaginatedResponse } from './common';
@ -30,6 +30,7 @@ export interface TvRequestBody extends VideoRequestBody {
} }
export interface MusicRequestBody extends MediaRequestBody { export interface MusicRequestBody extends MediaRequestBody {
secondaryType: SecondaryType;
mediaType: MediaType.MUSIC; mediaType: MediaType.MUSIC;
mediaId: string; mediaId: string;
} }

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

@ -173,7 +173,6 @@ requestRoutes.post<
}); });
} }
const request = await MediaRequest.request(req.body, req.user); const request = await MediaRequest.request(req.body, req.user);
return res.status(201).json(request); return res.status(201).json(request);
} catch (error) { } catch (error) {
if (!(error instanceof Error)) { if (!(error instanceof Error)) {
@ -189,7 +188,10 @@ requestRoutes.post<
case NoSeasonsAvailableError: case NoSeasonsAvailableError:
return next({ status: 202, message: error.message }); return next({ status: 202, message: error.message });
default: 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 { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { ArtistResult, ReleaseResult } from '@server/models/Search';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios'; import axios from 'axios';
import Link from 'next/link'; import Link from 'next/link';
@ -42,8 +43,32 @@ const messages = defineMessages({
unknowntitle: 'Unknown Title', unknowntitle: 'Unknown Title',
}); });
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { const isMovie = (
return (movie as MovieDetails).title !== undefined; 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 = () => { const RequestCardPlaceholder = () => {
@ -205,7 +230,10 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
interface RequestCardProps { interface RequestCardProps {
request: MediaRequest; request: MediaRequest;
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void; onTitleData?: (
requestId: number,
title: MovieDetails | TvDetails | ReleaseResult | ArtistResult
) => void;
} }
const RequestCard = ({ request, onTitleData }: RequestCardProps) => { const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
@ -224,9 +252,9 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
? `/api/v1/tv/${request.media.tmdbId}` ? `/api/v1/tv/${request.media.tmdbId}`
: `/api/v1/music/${request.media.secondaryType}/${request.media.mbId}`; : `/api/v1/music/${request.media.secondaryType}/${request.media.mbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>( const { data: title, error } = useSWR<
inView ? `${url}` : null MovieDetails | TvDetails | ReleaseResult | ArtistResult
); >(inView ? `${url}` : null);
const { const {
data: requestData, data: requestData,
error: requestError, 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" 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" data-testid="request-card"
> >
{title.backdropPath && ( {(isMovie(title) || isTv(title)) && title.backdropPath && (
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
<CachedImage <CachedImage
alt="" alt=""
@ -343,20 +371,28 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
data-testid="request-card-title" data-testid="request-card-title"
> >
<div className="hidden text-xs font-medium text-white sm:flex"> <div className="hidden text-xs font-medium text-white sm:flex">
{(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice( {(isMovie(title)
0, ? title.releaseDate
4 : isTv(title)
)} ? title.firstAirDate
: isRelease(title)
? title.date?.toDateString()
: isArtist(title)
? title.beginDate
: ''
)?.slice(0, 4)}
</div> </div>
<Link <Link
href={ href={
request.type === 'movie' request.type === 'movie'
? `/movie/${requestData.media.tmdbId}` ? `/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"> <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> </a>
</Link> </Link>
{hasPermission( {hasPermission(
@ -378,7 +414,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
</Link> </Link>
</div> </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"> <div className="my-0.5 hidden items-center text-sm sm:my-1 sm:flex">
<span className="mr-2 font-bold "> <span className="mr-2 font-bold ">
{intl.formatMessage(messages.seasons, { {intl.formatMessage(messages.seasons, {
@ -423,7 +459,9 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus' requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
] ]
} }
title={isMovie(title) ? title.title : title.name} title={
isMovie(title) || isRelease(title) ? title.title : title.name
}
inProgress={ inProgress={
( (
requestData.media[ requestData.media[

@ -15,9 +15,11 @@ import {
TrashIcon, TrashIcon,
XMarkIcon, XMarkIcon,
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import type { SecondaryType } from '@server/constants/media';
import { MediaRequestStatus } from '@server/constants/media'; import { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { ArtistResult, ReleaseResult } from '@server/models/Search';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios'; import axios from 'axios';
import Link from 'next/link'; import Link from 'next/link';
@ -40,11 +42,29 @@ const messages = defineMessages({
cancelRequest: 'Cancel Request', cancelRequest: 'Cancel Request',
tmdbid: 'TMDB ID', tmdbid: 'TMDB ID',
tvdbid: 'TheTVDB ID', tvdbid: 'TheTVDB ID',
mbId: 'MusicBrainz ID',
unknowntitle: 'Unknown Title', unknowntitle: 'Unknown Title',
}); });
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { const isMovie = (
return (movie as MovieDetails).title !== undefined; 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 { interface RequestItemErrorProps {
@ -81,7 +101,9 @@ const RequestItemError = ({
requestData?.type requestData?.type
? requestData?.type === 'movie' ? requestData?.type === 'movie'
? globalMessages.movie ? globalMessages.movie
: globalMessages.tvshow : requestData?.type === 'tv'
? globalMessages.tvshow
: globalMessages.music
: globalMessages.request : globalMessages.request
), ),
})} })}
@ -90,10 +112,14 @@ const RequestItemError = ({
<> <>
<div className="card-field"> <div className="card-field">
<span className="card-field-name"> <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>
<span className="flex truncate text-sm text-gray-300"> <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> </span>
</div> </div>
{requestData.media.tvdbId && ( {requestData.media.tvdbId && (
@ -286,10 +312,12 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
const url = const url =
request.type === 'movie' request.type === 'movie'
? `/api/v1/movie/${request.media.tmdbId}` ? `/api/v1/movie/${request.media.tmdbId}`
: `/api/v1/tv/${request.media.tmdbId}`; : request.type === 'tv'
const { data: title, error } = useSWR<MovieDetails | TvDetails>( ? `/api/v1/tv/${request.media.tmdbId}`
inView ? url : null : `/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>( const { data: requestData, mutate: revalidate } = useSWR<MediaRequest>(
`/api/v1/request/${request.id}`, `/api/v1/request/${request.id}`,
{ {
@ -303,7 +331,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
), ),
} }
); );
const [isRetrying, setRetrying] = useState(false); const [isRetrying, setRetrying] = useState(false);
const modifyRequest = async (type: 'approve' | 'decline') => { const modifyRequest = async (type: 'approve' | 'decline') => {
@ -374,9 +401,10 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
revalidateList(); revalidateList();
setShowEditModal(false); 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"> <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"> <div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage <CachedImage
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`} src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
@ -399,15 +427,18 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
href={ href={
requestData.type === 'movie' requestData.type === 'movie'
? `/movie/${requestData.media.tmdbId}` ? `/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"> <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 <CachedImage
src={ src={
title.posterPath title.posterPath && (isMovie(title) || isTv(title))
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` ? `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="" alt=""
layout="responsive" 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"> <div className="pt-0.5 text-xs font-medium text-white sm:pt-1">
{(isMovie(title) {(isMovie(title)
? title.releaseDate ? title.releaseDate
: title.firstAirDate : isTv(title)
? title.firstAirDate
: isRelease(title)
? new Date(title.date as string).toDateString()
: title.beginDate
)?.slice(0, 4)} )?.slice(0, 4)}
</div> </div>
<Link <Link
href={ href={
requestData.type === 'movie' requestData.type === 'movie'
? `/movie/${requestData.media.tmdbId}` ? `/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"> <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> </a>
</Link> </Link>
{!isMovie(title) && request.seasons.length > 0 && ( {isTv(title) && request.seasons.length > 0 && (
<div className="card-field"> <div className="card-field">
<span className="card-field-name"> <span className="card-field-name">
{intl.formatMessage(messages.seasons, { {intl.formatMessage(messages.seasons, {
@ -484,7 +523,11 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus' requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
] ]
} }
title={isMovie(title) ? title.title : title.name} title={
isMovie(title) || isRelease(title)
? title.title
: title.name
}
inProgress={ inProgress={
( (
requestData.media[ 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', { const response = await axios.post<MediaRequest>('/api/v1/request', {
mediaId: data?.id, mediaId: data?.id,
mediaType: 'music', mediaType: 'music',
secondaryType: 'release',
...overrideParams, ...overrideParams,
}); });
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); 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 CollectionRequestModal from '@app/components/RequestModal/CollectionRequestModal';
import MovieRequestModal from '@app/components/RequestModal/MovieRequestModal'; import MovieRequestModal from '@app/components/RequestModal/MovieRequestModal';
import ReleaseRequestModal from '@app/components/RequestModal/ReleaseRequestModal'; import ReleaseRequestModal from '@app/components/RequestModal/ReleaseRequestModal';
@ -76,6 +77,14 @@ const RequestModal = ({
onUpdating={onUpdating} onUpdating={onUpdating}
editRequest={editRequest} editRequest={editRequest}
/> />
) : type === 'music' && secondaryType === 'artist' ? (
<ArtistRequestModal
onComplete={onComplete}
onCancel={onCancel}
mbId={mbId as string}
onUpdating={onUpdating}
editRequest={editRequest}
/>
) : null} ) : null}
</Transition> </Transition>
); );

Loading…
Cancel
Save