First working requests !!

pull/3800/merge^2
Anatole Sot 3 months ago
parent 8b95d3a300
commit 8cc3f8cfc4

@ -731,6 +731,36 @@ components:
items:
type: string
ReleaseGroupResult:
type: object
properties:
id:
type: string
mediaType:
type: string
enum: ['release-group']
type:
type: string
enum: ['Album', 'Single', 'EP', 'Broadcast', 'Other']
posterPath:
type: string
nullable: true
title:
type: string
releases:
type: array
items:
$ref: '#/components/schemas/ReleaseResult'
artist:
type: array
items:
$ref: '#/components/schemas/ArtistResult'
tags:
type: array
items:
type: string
mediaInfo:
$ref: '#/components/schemas/MediaInfo'
ReleaseResult:
type: object
properties:
@ -761,8 +791,8 @@ components:
type: string
mediaInfo:
$ref: '#/components/schemas/MediaInfo'
releaseGroupType:
type: string
releaseGroup:
$ref: '#/components/schemas/ReleaseGroupResult'
Genre:
type: object
properties:
@ -5085,7 +5115,7 @@ paths:
results:
type: array
items:
$ref: '#/components/schemas/MusicResult'
$ref: '#/components/schemas/ReleaseResult'
'500':
description: An error occured while getting musics
content:
@ -5367,10 +5397,12 @@ paths:
properties:
mediaType:
type: string
enum: [movie, tv]
enum: [movie, tv, music]
example: movie
mediaId:
type: number
oneOf:
- type: number
- type: string
example: 123
tvdbId:
type: number
@ -5481,7 +5513,7 @@ paths:
properties:
mediaType:
type: string
enum: [movie, tv]
enum: [movie, tv, music]
seasons:
type: array
items:
@ -6149,7 +6181,7 @@ paths:
name: type
schema:
type: string
enum: [all,movie,tv,music]
enum: [all,movie,tv,music,artist,release]
default: all
responses:
'200':

@ -194,7 +194,7 @@ function convertRelease(release: Release): mbRelease {
: undefined,
tracks: (release.media ?? []).flatMap(convertMedium),
tags: (release.tags ?? []).map(convertTag),
releaseGroupType: release['release-group']?.['primary-type'] || 'Other',
releaseGroup: convertReleaseGroup(release['release-group'] ?? {}),
};
}
@ -830,7 +830,7 @@ class MusicBrainz extends BaseNodeBrainz {
this.release(
releaseId,
{
inc: 'tags+artists+recordings',
inc: 'tags+artists+recordings+release-groups',
},
(error, data) => {
if (error) {

@ -44,7 +44,7 @@ export interface mbRelease extends mbDefaultType {
artist: mbArtist[];
date?: Date;
tracks?: mbRecording[];
releaseGroupType?: string;
releaseGroup?: mbReleaseGroup;
}
export enum mbReleaseGroupType {

@ -54,6 +54,7 @@ export interface LidarrAlbum {
}
export interface LidarrArtist {
addOptions?: { monitor: string; searchForMissingAlbums: boolean };
artistMetadataId: number;
status: string;
ended: boolean;
@ -193,7 +194,6 @@ class LidarrAPI extends ServarrBase<{ musicId: number }> {
public async getAlbum({
artistId,
foreignAlbumId,
albumId,
}: {
artistId?: number;
@ -204,7 +204,6 @@ class LidarrAPI extends ServarrBase<{ musicId: number }> {
const response = await this.axios.get<LidarrAlbum[]>('/album', {
params: {
artistId,
foreignAlbumId,
albumId,
},
});
@ -214,23 +213,117 @@ class LidarrAPI extends ServarrBase<{ musicId: number }> {
}
}
public async getAlbumByMusicBrainzId(mbId: string): Promise<LidarrAlbum> {
try {
const response = await this.axios.get<LidarrAlbum[]>('/album/lookup', {
params: {
term: `mbid:` + mbId,
},
});
if (!response.data[0]) {
throw new Error('Album not found');
}
return response.data[0];
} catch (e) {
logger.error('Error retrieving album by MusicBrainz ID', {
label: 'Midarr API',
errorMessage: e.message,
mbId: mbId,
});
throw new Error('Album not found');
}
}
public addAlbum = async (
options: LidarrAlbumOptions
): Promise<LidarrAlbum> => {
try {
const albums = await this.getAlbum({
foreignAlbumId: options.mbId.toString(),
});
if (albums.length > 0) {
const album = await this.getAlbumByMusicBrainzId(options.mbId);
// album exists in Lidarr but is neither downloaded nor monitored
if (album.id && !album.monitored) {
const response = await this.axios.post<LidarrAlbum>(`/album`, {
...album,
title: options.title,
qualityProfileId: options.qualityProfileId,
profileId: options.profileId,
foreignAlbumId: options.mbId.toString(),
tags: options.tags,
rootFolderPath: options.rootFolderPath,
monitored: options.monitored,
addOptions: {
searchForNewAlbum: options.searchNow,
},
});
if (response.data.monitored) {
logger.info(
'Found existing title in Lidarr and set it to monitored.',
{
label: 'Lidarr',
albumId: response.data.id,
albumTitle: response.data.title,
}
);
logger.debug('Lidarr update details', {
label: 'Lidarr',
album: response.data,
});
return response.data;
} else {
logger.error('Failed to update existing album in Lidarr.', {
label: 'Lidarr',
options,
});
throw new Error('Failed to update existing album in Lidarr');
}
}
if (album.id) {
logger.info(
'Album is already monitored in Lidarr. Skipping add and returning success',
{ label: 'Lidarr' }
);
return albums[0];
return album;
}
const response = await this.axios.put<LidarrAlbum>('/album', {
params: { id: options.mbId },
const artist = album.artist;
artist.monitored = true;
artist.monitorNewItems = 'all';
artist.qualityProfileId = options.qualityProfileId;
artist.rootFolderPath = options.rootFolderPath;
artist.addOptions = {
monitor: 'none',
searchForMissingAlbums: false,
};
album.anyReleaseOk = true;
const response = await this.axios.post<LidarrAlbum>(`/album/`, {
...album,
title: options.title,
qualityProfileId: options.qualityProfileId,
profileId: options.profileId,
foreignAlbumId: options.mbId.toString(),
tags: options.tags,
monitored: options.monitored,
artist: artist,
addOptions: {
searchForNewAlbum: options.searchNow,
},
});
if (response.data.id) {
logger.info('Lidarr accepted request', { label: 'Lidarr' });
} else {
logger.error('Failed to add album to Lidarr', {
label: 'Lidarr',
options,
});
throw new Error('Failed to add album to Lidarr');
}
return response.data;
} catch (e) {
logger.error('Error adding album by MUSICBRAINZ ID', {

@ -1,4 +1,5 @@
import MusicBrainz from '@server/api/musicbrainz';
import type { mbRelease } from '@server/api/musicbrainz/interfaces';
import type { LidarrAlbumOptions } from '@server/api/servarr/lidarr';
import LidarrAPI from '@server/api/servarr/lidarr';
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
@ -15,6 +16,7 @@ import {
MediaRequestStatus,
MediaStatus,
MediaType,
SecondaryType,
} from '@server/constants/media';
import { getRepository } from '@server/datasource';
import type {
@ -151,7 +153,7 @@ export class MediaRequest {
requestBody.mediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: requestBody.mediaId })
: requestBody.mediaType === MediaType.MUSIC
? await musicbrainz.getReleaseGroup(requestBody.mediaId)
? await musicbrainz.getRelease(requestBody.mediaId)
: await tmdb.getTvShow({ tvId: requestBody.mediaId });
let media =
@ -159,7 +161,8 @@ export class MediaRequest {
? await mediaRepository.findOne({
where: {
mbId: requestBody.mediaId,
mediaType: requestBody.mediaType,
mediaType: MediaType.MUSIC,
secondaryType: SecondaryType.RELEASE,
},
relations: ['requests'],
})
@ -176,7 +179,9 @@ export class MediaRequest {
media = new Media({
mbId: requestBody.mediaId,
status: MediaStatus.PENDING,
mediaType: requestBody.mediaType,
mediaType: MediaType.MUSIC,
secondaryType: SecondaryType.RELEASE,
title: (metaMedia as mbRelease).title,
});
} else if (requestBody.mediaType === MediaType.MOVIE) {
media = new Media({
@ -525,6 +530,9 @@ export class MediaRequest {
@Column({ type: 'varchar' })
public type: MediaType;
@Column({ type: 'varchar', nullable: true })
public secondaryType?: SecondaryType;
@RelationCount((request: MediaRequest) => request.seasons)
public seasonCount: number;
@ -590,7 +598,11 @@ export class MediaRequest {
@AfterUpdate()
@AfterInsert()
public async sendMedia(): Promise<void> {
await Promise.all([this.sendToRadarr(), this.sendToSonarr()]);
await Promise.all([
this.sendToRadarr(),
this.sendToSonarr(),
this.sendToLidarr(),
]);
}
@AfterInsert()
@ -1449,7 +1461,7 @@ export class MediaRequest {
qualityProfileId: qualityProfile,
rootFolderPath: rootFolder,
title: release.title,
mbId: release.id,
mbId: release.releaseGroup?.id ?? release.id,
monitored: true,
tags: tags.map((tag) => String(tag)),
searchNow: !lidarrSettings.preventSearch,
@ -1545,7 +1557,6 @@ export class MediaRequest {
event = `${this.is4k ? '4K ' : ''}${mediaType} Request Failed`;
break;
}
if (this.type === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: media.tmdbId as number });
notificationManager.sendNotification(type, {
@ -1593,7 +1604,7 @@ export class MediaRequest {
],
});
} else if (this.type === MediaType.MUSIC) {
const music = await musicbrainz.getReleaseGroup(media.mbId as string);
const music = await musicbrainz.getRelease(media.mbId as string);
notificationManager.sendNotification(type, {
media,
request: this,
@ -1602,12 +1613,10 @@ export class MediaRequest {
notifyUser: notifyAdmin ? undefined : this.requestedBy,
event,
subject: `${music.title}${
music.firstReleased
? '(' + music.firstReleased.toLocaleDateString() + ')'
: ''
music.date ? ` (${music.date.toLocaleDateString()})` : ''
}`,
message: music.artist.map((artist) => artist.name).join(', '),
image: `http://coverartarchive.org/release-group/${music.id}/front-250`,
image: `http://coverartarchive.org/release/${music.id}/front-250`,
});
}
} catch (e) {

@ -32,6 +32,4 @@ export interface TvRequestBody extends VideoRequestBody {
export interface MusicRequestBody extends MediaRequestBody {
mediaType: MediaType.MUSIC;
mediaId: string;
albumId?: number;
artistId?: number;
}

@ -0,0 +1,108 @@
import type { LidarrAlbum } from '@server/api/servarr/lidarr';
import LidarrAPI from '@server/api/servarr/lidarr';
import type {
RunnableScanner,
StatusBase,
} from '@server/lib/scanners/baseScanner';
import BaseScanner from '@server/lib/scanners/baseScanner';
import type { LidarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import { uniqWith } from 'lodash';
type SyncStatus = StatusBase & {
currentServer: LidarrSettings;
servers: LidarrSettings[];
};
class LidarrScanner
extends BaseScanner<LidarrAlbum>
implements RunnableScanner<SyncStatus>
{
private servers: LidarrSettings[];
private currentServer: LidarrSettings;
private lidarrApi: LidarrAPI;
constructor() {
super('Lidarr Scan', { bundleSize: 50 });
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentServer: this.currentServer,
servers: this.servers,
};
}
public async run(): Promise<void> {
const settings = getSettings();
const sessionId = this.startRun();
try {
this.servers = uniqWith(settings.lidarr, (lidarrA, lidarrB) => {
return (
lidarrA.hostname === lidarrB.hostname &&
lidarrA.port === lidarrB.port &&
lidarrA.baseUrl === lidarrB.baseUrl
);
});
for (const server of this.servers) {
this.currentServer = server;
if (server.syncEnabled) {
this.log(
`Beginning to process Lidarr server: ${server.name}`,
'info'
);
this.lidarrApi = new LidarrAPI({
apiKey: server.apiKey,
url: LidarrAPI.buildUrl(server, '/api/v1'),
});
this.items = await this.lidarrApi.getAlbums();
await this.loop(this.processLidarrAlbum.bind(this), { sessionId });
} else {
this.log(`Sync not enabled. Skipping Lidarr server: ${server.name}`);
}
}
this.log('Lidarr scan complete', 'info');
} catch (e) {
this.log('Scan interrupted', 'error', { errorMessage: e.message });
} finally {
this.endRun(sessionId);
}
}
private async processLidarrAlbum(lidarrAlbum: LidarrAlbum): Promise<void> {
if (!lidarrAlbum.monitored && !lidarrAlbum.anyReleaseOk) {
this.log(
'Title is unmonitored and has not been downloaded. Skipping item.',
'debug',
{
title: lidarrAlbum.title,
}
);
return;
}
try {
await this.processAlbum(lidarrAlbum.foreignAlbumId, {
serviceId: this.currentServer.id,
externalServiceId: lidarrAlbum.id,
title: lidarrAlbum.title,
processing: !lidarrAlbum.anyReleaseOk,
});
} catch (e) {
this.log('Failed to process Lidarr media', 'error', {
errorMessage: e.message,
title: lidarrAlbum.title,
});
}
}
}
export const lidarrScanner = new LidarrScanner();

@ -0,0 +1,75 @@
import type Media from '@server/entity/Media';
import type {
Cast,
Crew,
ExternalIds,
Genre,
Keyword,
ProductionCompany,
WatchProviders,
} from './common';
export interface Video {
url?: string;
site: 'YouTube';
key: string;
name: string;
size: number;
type:
| 'Clip'
| 'Teaser'
| 'Trailer'
| 'Featurette'
| 'Opening Credits'
| 'Behind the Scenes'
| 'Bloopers';
}
export interface ReleaseDetails {
id: number;
imdbId?: string;
adult: boolean;
backdropPath?: string;
budget: number;
genres: Genre[];
homepage?: string;
originalLanguage: string;
originalTitle: string;
overview?: string;
popularity: number;
relatedVideos?: Video[];
posterPath?: string;
productionCompanies: ProductionCompany[];
productionCountries: {
iso_3166_1: string;
name: string;
}[];
releaseDate: string;
revenue: number;
runtime?: number;
spokenLanguages: {
iso_639_1: string;
name: string;
}[];
status: string;
tagline?: string;
title: string;
video: boolean;
voteAverage: number;
voteCount: number;
credits: {
cast: Cast[];
crew: Crew[];
};
collection?: {
id: number;
name: string;
posterPath?: string;
backdropPath?: string;
};
mediaInfo?: Media;
externalIds: ExternalIds;
plexUrl?: string;
watchProviders?: WatchProviders[];
keywords: Keyword[];
}

@ -120,7 +120,7 @@ export interface ReleaseResult {
tracks?: RecordingResult[];
tags: string[];
mediaInfo?: Media;
releaseGroupType?: string;
releaseGroup?: ReleaseGroupResult;
}
export interface RecordingResult {
@ -307,7 +307,9 @@ export const mapReleaseResult = async (
: []
),
tags: release.tags,
releaseGroupType: release.releaseGroupType,
releaseGroup: release.releaseGroup
? await mapReleaseGroupResult(release.releaseGroup)
: undefined,
mediaInfo: media,
});

@ -18,7 +18,7 @@ import {
mapCollectionResult,
mapMovieResult,
mapPersonResult,
mapReleaseGroupResult,
mapReleaseResult,
mapTvResult,
} from '@server/models/Search';
import { mapNetwork } from '@server/models/Tv';
@ -858,7 +858,7 @@ discoverRoutes.get('/musics', async (req, res, next) => {
try {
const query = QueryFilterOptions.parse(req.query);
const keywords = query.keywords;
const results = await mb.searchReleaseGroups({
const results = await mb.searchReleases({
query: keywords ?? '',
limit: 20,
offset: (Number(query.page) - 1) * 20,
@ -869,7 +869,7 @@ discoverRoutes.get('/musics', async (req, res, next) => {
page: query.page,
results: await Promise.all(
results.map((result) => {
return mapReleaseGroupResult(
return mapReleaseResult(
result,
media.find(
(med) =>

@ -1,5 +1,5 @@
import TautulliAPI from '@server/api/tautulli';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaStatus, MediaType, SecondaryType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
@ -82,6 +82,18 @@ mediaRoutes.get('/', async (req, res, next) => {
mediaType: MediaType.MUSIC,
};
break;
case 'artist':
typeFilter = {
mediaType: MediaType.MUSIC,
secondaryType: SecondaryType.ARTIST,
};
break;
case 'release':
typeFilter = {
mediaType: MediaType.MUSIC,
secondaryType: SecondaryType.RELEASE,
};
break;
}
try {

@ -1,6 +1,6 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import LidarrAPI from '@server/api/servarr/lidarr';
import TheMovieDb from '@server/api/themoviedb';
import type {
ServiceCommonServer,
@ -212,7 +212,6 @@ serviceRoutes.get<{ tmdbId: string }>(
serviceRoutes.get('/lidarr', async (req, res) => {
const settings = getSettings();
const filteredLidarrServers: ServiceCommonServer[] = settings.lidarr.map(
(lidarr) => ({
id: lidarr.id,

@ -12,7 +12,7 @@ import RecentlyAddedSlider from '@app/components/Discover/RecentlyAddedSlider';
import useDiscover from '@app/hooks/useDiscover';
import Error from '@app/pages/_error';
import { FunnelIcon } from '@heroicons/react/24/solid';
import type { MusicResult } from '@server/models/Search';
import type { ReleaseResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@ -38,7 +38,7 @@ const DiscoverMusics = () => {
titles,
fetchMore,
error,
} = useDiscover<MusicResult, unknown, FilterOptions>(
} = useDiscover<ReleaseResult, unknown, FilterOptions>(
'/api/v1/discover/musics',
preparedFilters
);
@ -74,7 +74,7 @@ const DiscoverMusics = () => {
</div>
</div>
</div>
<RecentlyAddedSlider type="music" />
<RecentlyAddedSlider type="artist" />
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.discovermoremusics)}</span>

@ -15,7 +15,7 @@ const messages = defineMessages({
const RecentlyAddedSlider = ({
type = 'all',
}: {
type?: 'all' | 'movie' | 'tv' | 'music';
type?: 'all' | 'movie' | 'tv' | 'music' | 'artist' | 'release';
}) => {
const intl = useIntl();
const { hasPermission } = useUser();

@ -9,11 +9,8 @@ import {
InformationCircleIcon,
XMarkIcon,
} from '@heroicons/react/24/solid';
import {
MediaRequestStatus,
MediaStatus,
SecondaryType,
} from '@server/constants/media';
import type { SecondaryType } from '@server/constants/media';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import type Media from '@server/entity/Media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import axios from 'axios';
@ -278,6 +275,8 @@ const RequestButton = ({
Permission.REQUEST,
mediaType === 'movie'
? Permission.REQUEST_MOVIE
: mediaType === 'music'
? Permission.REQUEST_MUSIC
: Permission.REQUEST_TV,
],
{ type: 'or' }
@ -369,6 +368,8 @@ const RequestButton = ({
<>
<RequestModal
tmdbId={tmdbId}
mbId={mbId}
secondaryType={secondaryType}
show={showRequestModal}
type={mediaType}
editRequest={editRequest ? activeRequest : undefined}
@ -378,18 +379,20 @@ const RequestButton = ({
}}
onCancel={() => setShowRequestModal(false)}
/>
<RequestModal
tmdbId={tmdbId}
show={showRequest4kModal}
type={mediaType}
editRequest={editRequest ? active4kRequest : undefined}
is4k
onComplete={() => {
onUpdate();
setShowRequest4kModal(false);
}}
onCancel={() => setShowRequest4kModal(false)}
/>
{mediaType !== 'music' && (
<RequestModal
tmdbId={tmdbId}
show={showRequest4kModal}
type={mediaType}
editRequest={editRequest ? active4kRequest : undefined}
is4k
onComplete={() => {
onUpdate();
setShowRequest4kModal(false);
}}
onCancel={() => setShowRequest4kModal(false)}
/>
)}
<ButtonWithDropdown
text={
<>

@ -220,7 +220,9 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
const url =
request.type === 'movie'
? `/api/v1/movie/${request.media.tmdbId}`
: `/api/v1/tv/${request.media.tmdbId}`;
: request.type === 'tv'
? `/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

@ -6,6 +6,7 @@ import globalMessages from '@app/i18n/globalMessages';
import { formatBytes } from '@app/utils/numberHelpers';
import { Listbox, Transition } from '@headlessui/react';
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/solid';
import type { SecondaryType } from '@server/constants/media';
import type {
ServiceCommonServer,
ServiceCommonServerWithDetails,
@ -49,7 +50,8 @@ export type RequestOverrides = {
interface AdvancedRequesterProps {
type: 'movie' | 'tv' | 'music';
is4k: boolean;
secondaryType?: SecondaryType;
is4k?: boolean;
isAnime?: boolean;
defaultOverrides?: RequestOverrides;
requestUser?: User;
@ -67,14 +69,16 @@ const AdvancedRequester = ({
const intl = useIntl();
const { user: currentUser, hasPermission: currentHasPermission } = useUser();
const { data, error } = useSWR<ServiceCommonServer[]>(
`/api/v1/service/${type === 'movie' ? 'radarr' : 'sonarr'}`,
{
refreshInterval: 0,
refreshWhenHidden: false,
revalidateOnFocus: false,
revalidateOnMount: true,
}
);
`/api/v1/service/${
type === 'movie' ? 'radarr' : type === 'music' ? 'lidarr' : 'sonarr'
}`,
{
refreshInterval: 0,
refreshWhenHidden: false,
revalidateOnFocus: false,
revalidateOnMount: true,
}
);
const [selectedServer, setSelectedServer] = useState<number | null>(
defaultOverrides?.server !== undefined && defaultOverrides?.server >= 0
? defaultOverrides?.server
@ -99,7 +103,7 @@ const AdvancedRequester = ({
useSWR<ServiceCommonServerWithDetails>(
selectedServer !== null
? `/api/v1/service/${
type === 'movie' ? 'radarr' : (type === 'music' ? 'lidarr' : 'sonarr')
type === 'movie' ? 'radarr' : type === 'music' ? 'lidarr' : 'sonarr'
}/${selectedServer}`
: null,
{
@ -133,7 +137,9 @@ const AdvancedRequester = ({
Permission.REQUEST,
type === 'movie'
? Permission.REQUEST_MOVIE
: Permission.REQUEST_TV,
: type === 'tv'
? Permission.REQUEST_TV
: Permission.REQUEST_MUSIC,
],
user.permissions,
{ type: 'or' }

@ -1,5 +1,6 @@
import ProgressCircle from '@app/components/Common/ProgressCircle';
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/solid';
import type { SecondaryType } from '@server/constants/media';
import type { QuotaStatus } from '@server/interfaces/api/userInterfaces';
import Link from 'next/link';
import { useState } from 'react';
@ -29,7 +30,8 @@ const messages = defineMessages({
interface QuotaDisplayProps {
quota?: QuotaStatus;
mediaType: 'movie' | 'tv';
mediaType: 'movie' | 'tv' | 'music';
secondaryType?: SecondaryType;
userOverride?: number | null;
remaining?: number;
overLimit?: number;

@ -0,0 +1,345 @@
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 { ReleaseResult } 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.',
requestreleasetitle: 'Request Release',
edit: 'Edit Request',
approve: 'Approve Request',
cancel: 'Cancel Request',
pendingrequest: 'Pending Release 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 ReleaseRequestModal = ({
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<ReleaseResult>(
`/api/v1/music/release/${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',
...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?.title,
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?.title,
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: 'release',
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?.title,
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?.title}
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.RELEASE}
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.requestreleasetitle)}
subTitle={data?.title}
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.RELEASE}
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.RELEASE}
onChange={(overrides) => {
setRequestOverrides(overrides);
}}
/>
)}
</Modal>
);
};
export default ReleaseRequestModal;

@ -1,15 +1,17 @@
import CollectionRequestModal from '@app/components/RequestModal/CollectionRequestModal';
import MovieRequestModal from '@app/components/RequestModal/MovieRequestModal';
import ReleaseRequestModal from '@app/components/RequestModal/ReleaseRequestModal';
import TvRequestModal from '@app/components/RequestModal/TvRequestModal';
import { Transition } from '@headlessui/react';
import type { MediaStatus } from '@server/constants/media';
import type { MediaStatus, SecondaryType } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
interface RequestModalProps {
show: boolean;
type: 'movie' | 'tv' | 'collection' | 'music';
tmdbId: number;
mbId: string;
secondaryType?: SecondaryType;
tmdbId?: number;
mbId?: string;
is4k?: boolean;
editRequest?: MediaRequest;
onComplete?: (newStatus: MediaStatus) => void;
@ -27,6 +29,7 @@ const RequestModal = ({
onComplete,
onUpdating,
onCancel,
secondaryType,
}: RequestModalProps) => {
return (
<Transition
@ -43,7 +46,7 @@ const RequestModal = ({
<MovieRequestModal
onComplete={onComplete}
onCancel={onCancel}
tmdbId={tmdbId}
tmdbId={tmdbId as number}
onUpdating={onUpdating}
is4k={is4k}
editRequest={editRequest}
@ -52,7 +55,7 @@ const RequestModal = ({
<TvRequestModal
onComplete={onComplete}
onCancel={onCancel}
tmdbId={tmdbId}
tmdbId={tmdbId as number}
onUpdating={onUpdating}
is4k={is4k}
editRequest={editRequest}
@ -61,12 +64,18 @@ const RequestModal = ({
<CollectionRequestModal
onComplete={onComplete}
onCancel={onCancel}
tmdbId={tmdbId}
tmdbId={tmdbId as number}
onUpdating={onUpdating}
is4k={is4k}
/>
) : type === 'music' ? (
<div>Music:{mbId}</div>
) : type === 'music' && secondaryType === 'release' ? (
<ReleaseRequestModal
onComplete={onComplete}
onCancel={onCancel}
mbId={mbId as string}
onUpdating={onUpdating}
editRequest={editRequest}
/>
) : null}
</Transition>
);

@ -55,7 +55,7 @@ const FetchedDataTitleCard = ({
title={data.title}
mediaType={data.mediaType}
canExpand={canExpand}
type={data.releaseGroupType ?? MediaType.MUSIC}
type={data.releaseGroup?.type ?? MediaType.MUSIC}
/>
);
}

Loading…
Cancel
Save