Merge 2e7f050189
into 36283f214a
commit
be4d75bc4b
Binary file not shown.
@ -0,0 +1,944 @@
|
|||||||
|
import logger from '@server/logger';
|
||||||
|
import type {
|
||||||
|
ArtistSearchResponse,
|
||||||
|
luceneSearchOptions,
|
||||||
|
RecordingSearchResponse,
|
||||||
|
ReleaseGroupSearchResponse,
|
||||||
|
ReleaseSearchResponse,
|
||||||
|
TagSearchResponse,
|
||||||
|
WorkSearchResponse,
|
||||||
|
} from 'nodebrainz';
|
||||||
|
import BaseNodeBrainz from 'nodebrainz';
|
||||||
|
import type {
|
||||||
|
Artist,
|
||||||
|
ArtistCredit,
|
||||||
|
Group,
|
||||||
|
mbArtist,
|
||||||
|
mbRecording,
|
||||||
|
mbRelease,
|
||||||
|
mbReleaseGroup,
|
||||||
|
mbWork,
|
||||||
|
Medium,
|
||||||
|
Recording,
|
||||||
|
Relation,
|
||||||
|
Release,
|
||||||
|
SearchOptions,
|
||||||
|
Tag,
|
||||||
|
Track,
|
||||||
|
Work,
|
||||||
|
} from './interfaces';
|
||||||
|
import { mbArtistType, mbReleaseGroupType, mbWorkType } from './interfaces';
|
||||||
|
|
||||||
|
interface ArtistSearchOptions {
|
||||||
|
query: string;
|
||||||
|
tags?: string[]; // (part of) a tag attached to the artist
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecordingSearchOptions {
|
||||||
|
query: string;
|
||||||
|
tags?: string[]; // (part of) a tag attached to the recording
|
||||||
|
artistname?: string; // (part of) the name of any of the recording artists
|
||||||
|
release?: string; // the name of a release that the recording appears on
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReleaseSearchOptions {
|
||||||
|
query: string;
|
||||||
|
artistname?: string; // (part of) the name of any of the release artists
|
||||||
|
tags?: string[]; // (part of) a tag attached to the release
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReleaseGroupSearchOptions {
|
||||||
|
query: string;
|
||||||
|
artistname?: string; // (part of) the name of any of the release group artists
|
||||||
|
tags?: string[]; // (part of) a tag attached to the release group
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkSearchOptions {
|
||||||
|
query: string;
|
||||||
|
artist?: string; // (part of) the name of an artist related to the work (e.g. a composer or lyricist)
|
||||||
|
tags?: string[]; // (part of) a tag attached to the work
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchOptionstoArtistSearchOptions(
|
||||||
|
options: SearchOptions
|
||||||
|
): ArtistSearchOptions {
|
||||||
|
const data: ArtistSearchOptions = {
|
||||||
|
query: options.query,
|
||||||
|
};
|
||||||
|
if (options.tags) {
|
||||||
|
data.tags = options.tags;
|
||||||
|
}
|
||||||
|
if (options.limit) {
|
||||||
|
data.limit = options.limit;
|
||||||
|
} else {
|
||||||
|
data.limit = 25;
|
||||||
|
}
|
||||||
|
if (options.page) {
|
||||||
|
data.offset = (options.page - 1) * data.limit;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchOptionstoReleaseSearchOptions(
|
||||||
|
options: SearchOptions
|
||||||
|
): ReleaseSearchOptions {
|
||||||
|
const data: ReleaseSearchOptions = {
|
||||||
|
query: options.query,
|
||||||
|
};
|
||||||
|
if (options.artistname) {
|
||||||
|
data.artistname = options.artistname;
|
||||||
|
}
|
||||||
|
if (options.tags) {
|
||||||
|
data.tags = options.tags;
|
||||||
|
}
|
||||||
|
if (options.limit) {
|
||||||
|
data.limit = options.limit;
|
||||||
|
} else {
|
||||||
|
data.limit = 25;
|
||||||
|
}
|
||||||
|
if (options.page) {
|
||||||
|
data.offset = (options.page - 1) * data.limit;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertRelease(release: Release): mbRelease {
|
||||||
|
return {
|
||||||
|
media_type: 'release',
|
||||||
|
id: release.id,
|
||||||
|
title: release.title,
|
||||||
|
artist: (release['artist-credit'] ?? []).map(convertArtistCredit),
|
||||||
|
date:
|
||||||
|
release['release-events'] && release['release-events'].length > 0
|
||||||
|
? new Date(String(release['release-events'][0].date))
|
||||||
|
: undefined,
|
||||||
|
tracks: (release.media ?? []).flatMap(convertMedium),
|
||||||
|
tags: (release.tags ?? []).map(convertTag),
|
||||||
|
releaseGroup: convertReleaseGroup(release['release-group'] ?? {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertReleaseGroup(releaseGroup: Group): mbReleaseGroup {
|
||||||
|
return {
|
||||||
|
media_type: 'release-group',
|
||||||
|
id: releaseGroup.id,
|
||||||
|
title: releaseGroup.title,
|
||||||
|
artist: (releaseGroup['artist-credit'] ?? []).map(convertArtistCredit),
|
||||||
|
releases: (releaseGroup.releases ?? []).map(convertRelease),
|
||||||
|
type:
|
||||||
|
(releaseGroup['primary-type'] as mbReleaseGroupType) ||
|
||||||
|
mbReleaseGroupType.OTHER,
|
||||||
|
firstReleased: new Date(releaseGroup['first-release-date']),
|
||||||
|
tags: (releaseGroup.tags ?? []).map((tag: Tag) => tag.name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertRecording(recording: Recording): mbRecording {
|
||||||
|
return {
|
||||||
|
media_type: 'recording',
|
||||||
|
id: recording.id,
|
||||||
|
title: recording.title,
|
||||||
|
artist: (recording['artist-credit'] ?? []).map(convertArtistCredit),
|
||||||
|
length: recording.length,
|
||||||
|
firstReleased: new Date(recording['first-release-date']),
|
||||||
|
tags: (recording.tags ?? []).map(convertTag),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertArtist(artist: Artist): mbArtist {
|
||||||
|
return {
|
||||||
|
media_type: 'artist',
|
||||||
|
id: artist.id,
|
||||||
|
name: artist.name,
|
||||||
|
sortName: artist['sort-name'],
|
||||||
|
type: (artist.type as mbArtistType) || mbArtistType.OTHER,
|
||||||
|
recordings: (artist.recordings ?? []).map(convertRecording),
|
||||||
|
releases: (artist.releases ?? []).map(convertRelease),
|
||||||
|
releaseGroups: (artist['release-groups'] ?? []).map(convertReleaseGroup),
|
||||||
|
works: (artist.works ?? []).map(convertWork),
|
||||||
|
tags: (artist.tags ?? []).map(convertTag),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertWork(work: Work): mbWork {
|
||||||
|
return {
|
||||||
|
media_type: 'work',
|
||||||
|
id: work.id,
|
||||||
|
title: work.title,
|
||||||
|
type: (work.type as mbWorkType) || mbWorkType.OTHER,
|
||||||
|
artist: (work.relations ?? []).map(convertRelation),
|
||||||
|
tags: (work.tags ?? []).map(convertTag),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertArtistCredit(artistCredit: ArtistCredit): mbArtist {
|
||||||
|
return {
|
||||||
|
media_type: 'artist',
|
||||||
|
id: artistCredit.artist.id,
|
||||||
|
name: artistCredit.artist.name,
|
||||||
|
sortName: artistCredit.artist['sort-name'],
|
||||||
|
type: (artistCredit.artist.type as mbArtistType) || mbArtistType.OTHER,
|
||||||
|
tags: (artistCredit.artist.tags ?? []).map(convertTag),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertRelation(relation: Relation): mbArtist {
|
||||||
|
return {
|
||||||
|
media_type: 'artist',
|
||||||
|
id: relation.artist.id,
|
||||||
|
name: relation.artist.name,
|
||||||
|
sortName: relation.artist['sort-name'],
|
||||||
|
type: (relation.artist.type as mbArtistType) || mbArtistType.OTHER,
|
||||||
|
tags: (relation.artist.tags ?? []).map(convertTag),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertTag(tag: Tag): string {
|
||||||
|
return tag.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertMedium(medium: Medium): mbRecording[] {
|
||||||
|
return (medium.tracks ?? []).map(convertTrack);
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertTrack(track: Track): mbRecording {
|
||||||
|
return {
|
||||||
|
media_type: 'recording',
|
||||||
|
id: track.id,
|
||||||
|
title: track.title,
|
||||||
|
artist: (track.recording['artist-credit'] ?? []).map(convertArtistCredit),
|
||||||
|
length: track.recording.length,
|
||||||
|
tags: (track.recording.tags ?? []).map(convertTag),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function processReleaseSearchParams(
|
||||||
|
search: ReleaseSearchOptions
|
||||||
|
): luceneSearchOptions {
|
||||||
|
const processedSearchParams: luceneSearchOptions = {
|
||||||
|
query: search.query,
|
||||||
|
limit: search.limit,
|
||||||
|
offset: search.offset,
|
||||||
|
};
|
||||||
|
if (search.artistname) {
|
||||||
|
processedSearchParams.query += ` AND artist:"${search.artistname}"`;
|
||||||
|
}
|
||||||
|
if (search.tags) {
|
||||||
|
processedSearchParams.query += ` AND tag:"${search.tags.join(
|
||||||
|
'" AND tag:"'
|
||||||
|
)}"`;
|
||||||
|
}
|
||||||
|
return processedSearchParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processArtistSearchParams(
|
||||||
|
search: ArtistSearchOptions
|
||||||
|
): luceneSearchOptions {
|
||||||
|
const processedSearchParams: luceneSearchOptions = {
|
||||||
|
query: search.query,
|
||||||
|
limit: search.limit,
|
||||||
|
offset: search.offset,
|
||||||
|
};
|
||||||
|
if (search.tags) {
|
||||||
|
processedSearchParams.query += ` AND tag:"${search.tags.join(
|
||||||
|
'" AND tag:"'
|
||||||
|
)}"`;
|
||||||
|
}
|
||||||
|
return processedSearchParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MusicBrainz extends BaseNodeBrainz {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
userAgent:
|
||||||
|
'Overseer-with-lidar-support/0.1 ( https://github.com/ano0002/overseerr )',
|
||||||
|
retryOn: true,
|
||||||
|
retryDelay: 3000,
|
||||||
|
retryCount: 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public searchMulti = async (search: SearchOptions) => {
|
||||||
|
try {
|
||||||
|
const artistSearch = searchOptionstoArtistSearchOptions(search);
|
||||||
|
const releaseSearch = searchOptionstoReleaseSearchOptions(search);
|
||||||
|
const artistResults = await this.searchArtists(artistSearch);
|
||||||
|
const releaseResults = await this.searchReleases(releaseSearch);
|
||||||
|
|
||||||
|
const combinedResults = {
|
||||||
|
status: 'ok',
|
||||||
|
artistResults,
|
||||||
|
releaseResults,
|
||||||
|
};
|
||||||
|
|
||||||
|
return combinedResults;
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
artistResults: [],
|
||||||
|
releaseResults: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public searchArtists = async (
|
||||||
|
search: ArtistSearchOptions
|
||||||
|
): Promise<mbArtist[]> => {
|
||||||
|
try {
|
||||||
|
return await new Promise<mbArtist[]>((resolve, reject) => {
|
||||||
|
const processedSearch = processArtistSearchParams(search);
|
||||||
|
this.luceneSearch('artist', processedSearch, (error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
const rawResults = data as unknown as ArtistSearchResponse;
|
||||||
|
const results = rawResults.artists.map(convertArtist);
|
||||||
|
resolve(results);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to search for artists', {
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return new Promise<mbArtist[]>((resolve) => resolve([]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public searchRecordings = async (
|
||||||
|
search: RecordingSearchOptions
|
||||||
|
): Promise<mbRecording[]> => {
|
||||||
|
try {
|
||||||
|
return await new Promise<mbRecording[]>((resolve, reject) => {
|
||||||
|
this.search('recording', search, (error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
const rawResults = data as unknown as RecordingSearchResponse;
|
||||||
|
const results = rawResults.recordings.map(convertRecording);
|
||||||
|
resolve(results);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to search for recordings', {
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return new Promise<mbRecording[]>((resolve) => resolve([]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public searchReleaseGroups = (
|
||||||
|
search: ReleaseGroupSearchOptions
|
||||||
|
): Promise<mbReleaseGroup[]> => {
|
||||||
|
try {
|
||||||
|
return new Promise<mbReleaseGroup[]>((resolve, reject) => {
|
||||||
|
this.search('release-group', search, (error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
const rawResults = data as unknown as ReleaseGroupSearchResponse;
|
||||||
|
const results =
|
||||||
|
rawResults['release-groups'].map(convertReleaseGroup);
|
||||||
|
resolve(results);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to search for release groups', {
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return new Promise<mbReleaseGroup[]>((resolve) => resolve([]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public searchReleases = (
|
||||||
|
search: ReleaseSearchOptions
|
||||||
|
): Promise<mbRelease[]> => {
|
||||||
|
try {
|
||||||
|
const processedSearchParams = processReleaseSearchParams(search);
|
||||||
|
return new Promise<mbRelease[]>((resolve, reject) => {
|
||||||
|
this.luceneSearch('release', processedSearchParams, (error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
const rawResults = data as unknown as ReleaseSearchResponse;
|
||||||
|
const results = rawResults.releases.map(convertRelease);
|
||||||
|
resolve(results);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to search for releases', {
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return new Promise<mbRelease[]>((resolve) => resolve([]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public searchWorks = (search: WorkSearchOptions): Promise<mbWork[]> => {
|
||||||
|
try {
|
||||||
|
return new Promise<mbWork[]>((resolve, reject) => {
|
||||||
|
this.search('work', search, (error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
const rawResults = data as unknown as WorkSearchResponse;
|
||||||
|
const results = rawResults.works.map(convertWork);
|
||||||
|
resolve(results);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to search for works', {
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return new Promise<mbWork[]>((resolve) => resolve([]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public searchTags = (query: string): Promise<string[]> => {
|
||||||
|
try {
|
||||||
|
return new Promise<string[]>((resolve, reject) => {
|
||||||
|
this.search('tag', { tag: query }, (error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
const rawResults = data as TagSearchResponse;
|
||||||
|
const results = rawResults.tags.map((tag) => tag.name);
|
||||||
|
resolve(results);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to search for tags', {
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return new Promise<string[]>((resolve) => resolve([]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public getArtist = (artistId: string): Promise<mbArtist> => {
|
||||||
|
try {
|
||||||
|
return new Promise<mbArtist>((resolve, reject) => {
|
||||||
|
this.artist(
|
||||||
|
artistId,
|
||||||
|
{
|
||||||
|
inc: 'tags+recordings+releases+release-groups+works',
|
||||||
|
},
|
||||||
|
(error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
const results = convertArtist(data as Artist);
|
||||||
|
resolve(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to get artist', {
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return new Promise<mbArtist>((resolve) => resolve({} as mbArtist));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public getFullArtist = (
|
||||||
|
artistId: string,
|
||||||
|
maxElements = 25,
|
||||||
|
startOffset = 0
|
||||||
|
): Promise<mbArtist> => {
|
||||||
|
try {
|
||||||
|
return new Promise<mbArtist>((resolve, reject) => {
|
||||||
|
this.artist(
|
||||||
|
artistId,
|
||||||
|
{
|
||||||
|
inc: 'tags',
|
||||||
|
},
|
||||||
|
async (error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
const results = convertArtist(data as Artist);
|
||||||
|
results.releases = await this.getReleases(
|
||||||
|
artistId,
|
||||||
|
maxElements,
|
||||||
|
startOffset
|
||||||
|
);
|
||||||
|
resolve(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to get full artist', {
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return new Promise<mbArtist>((resolve) => resolve({} as mbArtist));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public getRecordings = (
|
||||||
|
artistId: string,
|
||||||
|
maxElements = 50,
|
||||||
|
startOffset = 0
|
||||||
|
): Promise<mbRecording[]> => {
|
||||||
|
try {
|
||||||
|
return new Promise<mbRecording[]>((resolve, reject) => {
|
||||||
|
this.browse(
|
||||||
|
'recording',
|
||||||
|
{ artist: artistId, offset: startOffset },
|
||||||
|
async (error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
data = data as {
|
||||||
|
'recording-count': number;
|
||||||
|
'recording-offset': number;
|
||||||
|
recordings: Recording[];
|
||||||
|
};
|
||||||
|
// Get the first 25 results
|
||||||
|
const total = data['recording-count'];
|
||||||
|
let results: mbRecording[] =
|
||||||
|
data.recordings.map(convertRecording);
|
||||||
|
|
||||||
|
// Slice the results into smaller chunks to avoid hitting the limit of 100
|
||||||
|
|
||||||
|
for (
|
||||||
|
let i = data.recordings.length + startOffset;
|
||||||
|
i < total && i < maxElements;
|
||||||
|
i += 100
|
||||||
|
) {
|
||||||
|
results = results.concat(
|
||||||
|
await new Promise<mbRecording[]>((resolve2, reject2) => {
|
||||||
|
this.browse(
|
||||||
|
'recording',
|
||||||
|
{
|
||||||
|
artist: artistId,
|
||||||
|
offset: i,
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
(error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject2(error);
|
||||||
|
} else {
|
||||||
|
const results = (
|
||||||
|
(
|
||||||
|
data as {
|
||||||
|
'recording-count': number;
|
||||||
|
'recording-offset': number;
|
||||||
|
recordings: Recording[];
|
||||||
|
}
|
||||||
|
).recordings ?? []
|
||||||
|
).map(convertRecording);
|
||||||
|
resolve2(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
results = results.reduce((arr: mbRecording[], item) => {
|
||||||
|
const exists = !!arr.find((x) => x.title === item.title);
|
||||||
|
if (!exists) {
|
||||||
|
arr.push(item);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, []);
|
||||||
|
resolve(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to get recordings by artist', {
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return new Promise<mbRecording[]>((resolve) => resolve([]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public getRecording = (recordingId: string): Promise<mbRecording> => {
|
||||||
|
try {
|
||||||
|
return new Promise<mbRecording>((resolve, reject) => {
|
||||||
|
this.recording(
|
||||||
|
recordingId,
|
||||||
|
{
|
||||||
|
inc: 'tags+artists+releases',
|
||||||
|
},
|
||||||
|
(error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
const results = convertRecording(data as Recording);
|
||||||
|
resolve(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to get recording', {
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return new Promise<mbRecording>((resolve) => resolve({} as mbRecording));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public getReleaseGroups = (
|
||||||
|
artistId: string,
|
||||||
|
maxElements = 50,
|
||||||
|
startOffset = 0
|
||||||
|
): Promise<mbReleaseGroup[]> => {
|
||||||
|
try {
|
||||||
|
return new Promise<mbReleaseGroup[]>((resolve, reject) => {
|
||||||
|
this.browse(
|
||||||
|
'release-group',
|
||||||
|
{ artist: artistId, offset: startOffset },
|
||||||
|
async (error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
data = data as {
|
||||||
|
'release-group-count': number;
|
||||||
|
'release-group-offset': number;
|
||||||
|
'release-groups': Group[];
|
||||||
|
};
|
||||||
|
// Get the first 25 results
|
||||||
|
const total = data['release-group-count'];
|
||||||
|
let results: mbReleaseGroup[] =
|
||||||
|
data['release-groups'].map(convertReleaseGroup);
|
||||||
|
|
||||||
|
// Slice the results into smaller chunks to avoid hitting the limit of 100
|
||||||
|
|
||||||
|
for (
|
||||||
|
let i = data['release-groups'].length + startOffset;
|
||||||
|
i < total && i < maxElements;
|
||||||
|
i += 100
|
||||||
|
) {
|
||||||
|
results = results.concat(
|
||||||
|
await new Promise<mbReleaseGroup[]>((resolve2, reject2) => {
|
||||||
|
this.browse(
|
||||||
|
'release-group',
|
||||||
|
{
|
||||||
|
artist: artistId,
|
||||||
|
offset: i,
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
(error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject2(error);
|
||||||
|
} else {
|
||||||
|
const results = (
|
||||||
|
(
|
||||||
|
data as {
|
||||||
|
'release-group-count': number;
|
||||||
|
'release-group-offset': number;
|
||||||
|
'release-groups': Group[];
|
||||||
|
}
|
||||||
|
)['release-groups'] ?? []
|
||||||
|
).map(convertReleaseGroup);
|
||||||
|
resolve2(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
results = results.reduce((arr: mbReleaseGroup[], item) => {
|
||||||
|
const exists = !!arr.find((x) => x.title === item.title);
|
||||||
|
if (!exists) {
|
||||||
|
arr.push(item);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, []);
|
||||||
|
resolve(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to get release-groups by artist', {
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return new Promise<mbReleaseGroup[]>((resolve) => resolve([]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public getReleaseGroup = (
|
||||||
|
releaseGroupId: string
|
||||||
|
): Promise<mbReleaseGroup> => {
|
||||||
|
try {
|
||||||
|
return new Promise<mbReleaseGroup>((resolve, reject) => {
|
||||||
|
this.releaseGroup(
|
||||||
|
releaseGroupId,
|
||||||
|
{
|
||||||
|
inc: 'tags+artists+releases',
|
||||||
|
},
|
||||||
|
(error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
const results = convertReleaseGroup(data as Group);
|
||||||
|
resolve(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to get release-group', {
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return new Promise<mbReleaseGroup>((resolve) =>
|
||||||
|
resolve({} as mbReleaseGroup)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public getReleases = (
|
||||||
|
artistId: string,
|
||||||
|
maxElements = 50,
|
||||||
|
startOffset = 0
|
||||||
|
): Promise<mbRelease[]> => {
|
||||||
|
try {
|
||||||
|
return new Promise<mbRelease[]>((resolve, reject) => {
|
||||||
|
this.browse(
|
||||||
|
'release',
|
||||||
|
{ artist: artistId, offset: startOffset, inc: 'tags+release-groups' },
|
||||||
|
async (error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
data = data as {
|
||||||
|
'release-count': number;
|
||||||
|
'release-offset': number;
|
||||||
|
releases: Release[];
|
||||||
|
};
|
||||||
|
// Get the first 25 results
|
||||||
|
const total = data['release-count'];
|
||||||
|
let results: mbRelease[] = data.releases.map(convertRelease);
|
||||||
|
|
||||||
|
// Slice the results into smaller chunks to avoid hitting the limit of 100
|
||||||
|
|
||||||
|
for (
|
||||||
|
let i = data.releases.length + startOffset;
|
||||||
|
i < total && i < maxElements;
|
||||||
|
i += 100
|
||||||
|
) {
|
||||||
|
results = results.concat(
|
||||||
|
await new Promise<mbRelease[]>((resolve2, reject2) => {
|
||||||
|
this.browse(
|
||||||
|
'release',
|
||||||
|
{
|
||||||
|
artist: artistId,
|
||||||
|
offset: i,
|
||||||
|
limit: 100,
|
||||||
|
inc: 'tags+release-groups',
|
||||||
|
},
|
||||||
|
(error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject2(error);
|
||||||
|
} else {
|
||||||
|
const results = (
|
||||||
|
(
|
||||||
|
data as {
|
||||||
|
'release-count': number;
|
||||||
|
'release-offset': number;
|
||||||
|
releases: Release[];
|
||||||
|
}
|
||||||
|
).releases ?? []
|
||||||
|
).map(convertRelease);
|
||||||
|
resolve2(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
results = results.reduce((arr: mbRelease[], item) => {
|
||||||
|
const exists = !!arr.find((x) => x.title === item.title);
|
||||||
|
if (!exists) {
|
||||||
|
arr.push(item);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, []);
|
||||||
|
resolve(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to get releases by artist', {
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return new Promise<mbRelease[]>((resolve) => resolve([]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public getRelease = (releaseId: string): Promise<mbRelease> => {
|
||||||
|
try {
|
||||||
|
return new Promise<mbRelease>((resolve, reject) => {
|
||||||
|
this.release(
|
||||||
|
releaseId,
|
||||||
|
{
|
||||||
|
inc: 'tags+artists+recordings+release-groups',
|
||||||
|
},
|
||||||
|
(error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
const results = convertRelease(data as Release);
|
||||||
|
resolve(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to get release', {
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return new Promise<mbRelease>((resolve) => resolve({} as mbRelease));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public getWorks = (
|
||||||
|
artistId: string,
|
||||||
|
maxElements = 50,
|
||||||
|
startOffset = 0
|
||||||
|
): Promise<mbWork[]> => {
|
||||||
|
try {
|
||||||
|
return new Promise<mbWork[]>((resolve, reject) => {
|
||||||
|
this.browse(
|
||||||
|
'work',
|
||||||
|
{ artist: artistId, offset: startOffset },
|
||||||
|
async (error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
data = data as {
|
||||||
|
'work-count': number;
|
||||||
|
'work-offset': number;
|
||||||
|
works: Work[];
|
||||||
|
};
|
||||||
|
// Get the first 25 results
|
||||||
|
const total = data['work-count'];
|
||||||
|
let results: mbWork[] = data.works.map(convertWork);
|
||||||
|
|
||||||
|
// Slice the results into smaller chunks to avoid hitting the limit of 100
|
||||||
|
|
||||||
|
for (
|
||||||
|
let i = data.works.length + startOffset;
|
||||||
|
i < total && i < maxElements;
|
||||||
|
i += 100
|
||||||
|
) {
|
||||||
|
results = results.concat(
|
||||||
|
await new Promise<mbWork[]>((resolve2, reject2) => {
|
||||||
|
this.browse(
|
||||||
|
'work',
|
||||||
|
{
|
||||||
|
artist: artistId,
|
||||||
|
offset: i,
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
(error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject2(error);
|
||||||
|
} else {
|
||||||
|
const results = (
|
||||||
|
(
|
||||||
|
data as {
|
||||||
|
'work-count': number;
|
||||||
|
'work-offset': number;
|
||||||
|
works: Work[];
|
||||||
|
}
|
||||||
|
).works ?? []
|
||||||
|
).map(convertWork);
|
||||||
|
resolve2(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
results = results.reduce((arr: mbWork[], item) => {
|
||||||
|
const exists = !!arr.find((x) => x.title === item.title);
|
||||||
|
if (!exists) {
|
||||||
|
arr.push(item);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, []);
|
||||||
|
resolve(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to get works by artist', {
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return new Promise<mbWork[]>((resolve) => resolve([]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public getWork = (workId: string): Promise<mbWork> => {
|
||||||
|
try {
|
||||||
|
return new Promise<mbWork>((resolve, reject) => {
|
||||||
|
this.work(
|
||||||
|
workId,
|
||||||
|
{
|
||||||
|
inc: 'tags+artist-rels',
|
||||||
|
},
|
||||||
|
(error, data) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
const results = convertWork(data as Work);
|
||||||
|
resolve(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to get work', {
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return new Promise<mbWork>((resolve) => resolve({} as mbWork));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MusicBrainz;
|
||||||
|
export type {
|
||||||
|
SearchOptions,
|
||||||
|
ArtistSearchOptions,
|
||||||
|
RecordingSearchOptions,
|
||||||
|
ReleaseSearchOptions,
|
||||||
|
ReleaseGroupSearchOptions,
|
||||||
|
WorkSearchOptions,
|
||||||
|
};
|
@ -0,0 +1,287 @@
|
|||||||
|
// Purpose: Interfaces for MusicBrainz data.
|
||||||
|
|
||||||
|
export interface mbDefaultType {
|
||||||
|
media_type: string;
|
||||||
|
id: string;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum mbArtistType {
|
||||||
|
PERSON = 'Person',
|
||||||
|
GROUP = 'Group',
|
||||||
|
ORCHESTRA = 'Orchestra',
|
||||||
|
CHOIR = 'Choir',
|
||||||
|
CHARACTER = 'Character',
|
||||||
|
OTHER = 'Other',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface mbArtist extends mbDefaultType {
|
||||||
|
media_type: 'artist';
|
||||||
|
name: string;
|
||||||
|
sortName: string;
|
||||||
|
type: mbArtistType;
|
||||||
|
recordings?: mbRecording[];
|
||||||
|
releases?: mbRelease[];
|
||||||
|
releaseGroups?: mbReleaseGroup[];
|
||||||
|
works?: mbWork[];
|
||||||
|
gender?: string;
|
||||||
|
area?: string;
|
||||||
|
beginDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface mbRecording extends mbDefaultType {
|
||||||
|
media_type: 'recording';
|
||||||
|
title: string;
|
||||||
|
artist: mbArtist[];
|
||||||
|
length: number;
|
||||||
|
firstReleased?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface mbRelease extends mbDefaultType {
|
||||||
|
media_type: 'release';
|
||||||
|
title: string;
|
||||||
|
artist: mbArtist[];
|
||||||
|
date?: Date;
|
||||||
|
tracks?: mbRecording[];
|
||||||
|
releaseGroup?: mbReleaseGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum mbReleaseGroupType {
|
||||||
|
ALBUM = 'Album',
|
||||||
|
SINGLE = 'Single',
|
||||||
|
EP = 'EP',
|
||||||
|
BROADCAST = 'Broadcast',
|
||||||
|
OTHER = 'Other',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface mbReleaseGroup extends mbDefaultType {
|
||||||
|
media_type: 'release-group';
|
||||||
|
title: string;
|
||||||
|
artist: mbArtist[];
|
||||||
|
type: mbReleaseGroupType;
|
||||||
|
firstReleased?: Date;
|
||||||
|
releases?: mbRelease[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum mbWorkType {
|
||||||
|
ARIA = 'Aria',
|
||||||
|
BALLET = 'Ballet',
|
||||||
|
CANTATA = 'Cantata',
|
||||||
|
CONCERTO = 'Concerto',
|
||||||
|
SONATA = 'Sonata',
|
||||||
|
SUITE = 'Suite',
|
||||||
|
MADRIGAL = 'Madrigal',
|
||||||
|
MASS = 'Mass',
|
||||||
|
MOTET = 'Motet',
|
||||||
|
OPERA = 'Opera',
|
||||||
|
ORATORIO = 'Oratorio',
|
||||||
|
OVERTURE = 'Overture',
|
||||||
|
PARTITA = 'Partita',
|
||||||
|
QUARTET = 'Quartet',
|
||||||
|
SONG_CYCLE = 'Song-cycle',
|
||||||
|
SYMPHONY = 'Symphony',
|
||||||
|
SONG = 'Song',
|
||||||
|
SYMPHONIC_POEM = 'Symphonic poem',
|
||||||
|
ZARZUELA = 'Zarzuela',
|
||||||
|
ETUDE = 'Étude',
|
||||||
|
POEM = 'Poem',
|
||||||
|
SOUNDTRACK = 'Soundtrack',
|
||||||
|
PROSE = 'Prose',
|
||||||
|
OPERETTA = 'Operetta',
|
||||||
|
AUDIO_DRAMA = 'Audio drama',
|
||||||
|
BEIJING_OPERA = 'Beijing opera',
|
||||||
|
PLAY = 'Play',
|
||||||
|
MUSICAL = 'Musical',
|
||||||
|
INCIDENTAL_MUSIC = 'Incidental music',
|
||||||
|
OTHER = 'Other',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface mbWork extends mbDefaultType {
|
||||||
|
media_type: 'work';
|
||||||
|
title: string;
|
||||||
|
type: mbWorkType;
|
||||||
|
artist: mbArtist[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Artist {
|
||||||
|
'end-area': Area;
|
||||||
|
tags: Tag[];
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
ipis: string[];
|
||||||
|
gender: string;
|
||||||
|
area: Area;
|
||||||
|
begin_area: Area;
|
||||||
|
id: string;
|
||||||
|
releases: Release[];
|
||||||
|
'type-id': string;
|
||||||
|
'begin-area': Area;
|
||||||
|
isnis: string[];
|
||||||
|
recordings: Recording[];
|
||||||
|
'sort-name': string;
|
||||||
|
'release-groups': Group[];
|
||||||
|
works: Work[];
|
||||||
|
type: string;
|
||||||
|
'gender-id': string;
|
||||||
|
disambiguation: string;
|
||||||
|
end_area: Area;
|
||||||
|
'life-span': LifeSpan;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
count: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Area {
|
||||||
|
type: string;
|
||||||
|
disambiguation: string;
|
||||||
|
'iso-3166-1-codes'?: string[];
|
||||||
|
'type-id': string;
|
||||||
|
id: string;
|
||||||
|
'sort-name': string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Release {
|
||||||
|
'packaging-id'?: string;
|
||||||
|
title: string;
|
||||||
|
'release-events'?: Event[];
|
||||||
|
tags: Tag[];
|
||||||
|
country?: string;
|
||||||
|
status: string;
|
||||||
|
'release-group': Group;
|
||||||
|
quality: string;
|
||||||
|
media: Medium[];
|
||||||
|
date?: string;
|
||||||
|
packaging?: string;
|
||||||
|
disambiguation: string;
|
||||||
|
barcode?: string;
|
||||||
|
'status-id': string;
|
||||||
|
'text-representation': TextRepresentation;
|
||||||
|
id: string;
|
||||||
|
'cover-art-archive': CoverArtArchive;
|
||||||
|
'artist-credit': ArtistCredit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoverArtArchive {
|
||||||
|
artwork: boolean;
|
||||||
|
back: boolean;
|
||||||
|
count: number;
|
||||||
|
darkened: boolean;
|
||||||
|
front: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtistCredit {
|
||||||
|
name: string;
|
||||||
|
joinphrase: string;
|
||||||
|
artist: Artist;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Event {
|
||||||
|
area?: Area;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Medium {
|
||||||
|
position: number;
|
||||||
|
'format-id': string;
|
||||||
|
format: string;
|
||||||
|
title: string;
|
||||||
|
'track-count': number;
|
||||||
|
'track-offset'?: number;
|
||||||
|
tracks?: Track[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Track {
|
||||||
|
title: string;
|
||||||
|
position: number;
|
||||||
|
number: string;
|
||||||
|
recording: Recording;
|
||||||
|
length: number;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextRepresentation {
|
||||||
|
language: string;
|
||||||
|
script: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recording {
|
||||||
|
title: string;
|
||||||
|
tags: Tag[];
|
||||||
|
disambiguation: string;
|
||||||
|
id: string;
|
||||||
|
releases: Release[];
|
||||||
|
'first-release-date': string;
|
||||||
|
length: number;
|
||||||
|
'artist-credit': ArtistCredit[];
|
||||||
|
video: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
id: string;
|
||||||
|
releases: Release[];
|
||||||
|
'first-release-date': string;
|
||||||
|
'primary-type': string;
|
||||||
|
tags: Tag[];
|
||||||
|
'secondary-types': string[];
|
||||||
|
disambiguation: string;
|
||||||
|
'secondary-type-ids': string[];
|
||||||
|
'primary-type-id': string;
|
||||||
|
title: string;
|
||||||
|
'artist-credit': ArtistCredit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Work {
|
||||||
|
attributes: Attribute[];
|
||||||
|
language: string;
|
||||||
|
type: string;
|
||||||
|
disambiguation: string;
|
||||||
|
id: string;
|
||||||
|
'type-id': string;
|
||||||
|
iswcs: string[];
|
||||||
|
title: string;
|
||||||
|
tags: Tag[];
|
||||||
|
languages: string[];
|
||||||
|
relations: Relation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Relation {
|
||||||
|
type: string;
|
||||||
|
attributes: Attribute[];
|
||||||
|
begin: string;
|
||||||
|
'target-credit': string;
|
||||||
|
end: string;
|
||||||
|
'type-id': string;
|
||||||
|
direction: string;
|
||||||
|
ended: boolean;
|
||||||
|
'target-type': string;
|
||||||
|
'source-credit': string;
|
||||||
|
artist: Artist;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Attribute {
|
||||||
|
'type-id': string;
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LifeSpan {
|
||||||
|
ended: boolean;
|
||||||
|
end: string;
|
||||||
|
begin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchOptions {
|
||||||
|
query: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
keywords?: string;
|
||||||
|
artistname?: string;
|
||||||
|
albumname?: string;
|
||||||
|
recordingname?: string;
|
||||||
|
tags?: string[];
|
||||||
|
tag?: string;
|
||||||
|
}
|
@ -0,0 +1,70 @@
|
|||||||
|
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import type { mbArtist, mbRelease, mbReleaseGroup } from './interfaces';
|
||||||
|
|
||||||
|
async function getPosterFromMB(
|
||||||
|
element: mbRelease | mbReleaseGroup | mbArtist
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (element.media_type === 'artist') {
|
||||||
|
const settings = getSettings();
|
||||||
|
const lidarrSettings = settings.lidarr.find((lidarr) => lidarr.isDefault);
|
||||||
|
if (!lidarrSettings) {
|
||||||
|
throw new Error('No default Lidarr instance found');
|
||||||
|
}
|
||||||
|
const lidarr: LidarrAPI = new LidarrAPI({
|
||||||
|
apiKey: lidarrSettings.apiKey,
|
||||||
|
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const artist = await (lidarr as LidarrAPI).getArtist(element.id);
|
||||||
|
if (artist.images.find((i) => i.coverType === 'poster')?.url) {
|
||||||
|
return LidarrAPI.buildUrl(
|
||||||
|
lidarrSettings,
|
||||||
|
artist.images.find((i) => i.coverType === 'poster')?.url
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `https://coverartarchive.org/${element.media_type}/${element.id}/front-250.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFanartFromMB(element: mbArtist): Promise<string | undefined> {
|
||||||
|
const settings = getSettings();
|
||||||
|
const lidarrSettings = settings.lidarr.find((lidarr) => lidarr.isDefault);
|
||||||
|
if (!lidarrSettings) {
|
||||||
|
throw new Error('No default Lidarr instance found');
|
||||||
|
}
|
||||||
|
const lidarr = new LidarrAPI({
|
||||||
|
apiKey: lidarrSettings.apiKey,
|
||||||
|
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const artist = await lidarr.getArtist(element.id);
|
||||||
|
return (
|
||||||
|
artist.images ?? [{ coverType: 'fanart', remoteUrl: undefined }]
|
||||||
|
).filter((i) => i.coverType === 'fanart')[0].remoteUrl;
|
||||||
|
} catch (e) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const memoize = <T = unknown>(fn: (...val: T[]) => unknown) => {
|
||||||
|
const cache = new Map();
|
||||||
|
const cached = function (this: unknown, val: T) {
|
||||||
|
return cache.has(val)
|
||||||
|
? cache.get(val)
|
||||||
|
: cache.set(val, fn.call(this, val) as ReturnType<typeof fn>) &&
|
||||||
|
cache.get(val);
|
||||||
|
};
|
||||||
|
cached.cache = cache;
|
||||||
|
return cached;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cachedFanartFromMB = memoize(getFanartFromMB);
|
||||||
|
|
||||||
|
export default memoize(getPosterFromMB);
|
||||||
|
export { cachedFanartFromMB };
|
@ -0,0 +1,390 @@
|
|||||||
|
import logger from '@server/logger';
|
||||||
|
import ServarrBase from './base';
|
||||||
|
|
||||||
|
export interface LidarrAlbumOptions {
|
||||||
|
profileId: number;
|
||||||
|
qualityProfileId: number;
|
||||||
|
rootFolderPath: string;
|
||||||
|
title: string;
|
||||||
|
mbId: string;
|
||||||
|
monitored: boolean;
|
||||||
|
tags: string[];
|
||||||
|
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;
|
||||||
|
isAvailable: boolean;
|
||||||
|
monitored: boolean;
|
||||||
|
mbId: number;
|
||||||
|
titleSlug: string;
|
||||||
|
folderName: string;
|
||||||
|
path: string;
|
||||||
|
profileId: number;
|
||||||
|
qualityProfileId: number;
|
||||||
|
added: string;
|
||||||
|
hasFile: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LidarrAlbum {
|
||||||
|
title: string;
|
||||||
|
disambiguation: string;
|
||||||
|
overview: string;
|
||||||
|
artistId: number;
|
||||||
|
foreignAlbumId: string;
|
||||||
|
monitored: boolean;
|
||||||
|
anyReleaseOk: boolean;
|
||||||
|
profileId: number;
|
||||||
|
duration: number;
|
||||||
|
albumType: string;
|
||||||
|
secondaryTypes: string[];
|
||||||
|
mediumCount: number;
|
||||||
|
ratings: Ratings;
|
||||||
|
releaseDate: string;
|
||||||
|
releases: LidarrRelease[];
|
||||||
|
genres: string[];
|
||||||
|
media: Medium[];
|
||||||
|
artist: LidarrArtist;
|
||||||
|
images: Image[];
|
||||||
|
links: Link[];
|
||||||
|
statistics: Statistics;
|
||||||
|
grabbed: boolean;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LidarrArtist {
|
||||||
|
addOptions?: { monitor: string; searchForMissingAlbums: boolean };
|
||||||
|
artistMetadataId: number;
|
||||||
|
status: string;
|
||||||
|
ended: boolean;
|
||||||
|
artistName: string;
|
||||||
|
foreignArtistId: string;
|
||||||
|
tadbId: number;
|
||||||
|
discogsId: number;
|
||||||
|
overview: string;
|
||||||
|
artistType: string;
|
||||||
|
disambiguation: string;
|
||||||
|
links: Link[];
|
||||||
|
images: Image[];
|
||||||
|
path: string;
|
||||||
|
qualityProfileId: number;
|
||||||
|
metadataProfileId: number;
|
||||||
|
monitored: boolean;
|
||||||
|
monitorNewItems: string;
|
||||||
|
rootFolderPath?: string;
|
||||||
|
genres: string[];
|
||||||
|
cleanName: string;
|
||||||
|
sortName: string;
|
||||||
|
tags: Tag[];
|
||||||
|
added: string;
|
||||||
|
ratings: Ratings;
|
||||||
|
statistics: Statistics;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LidarrRelease {
|
||||||
|
id: number;
|
||||||
|
albumId: number;
|
||||||
|
foreignReleaseId: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
duration: number;
|
||||||
|
trackCount: number;
|
||||||
|
media: Medium[];
|
||||||
|
mediumCount: number;
|
||||||
|
disambiguation: string;
|
||||||
|
country: string[];
|
||||||
|
label: string[];
|
||||||
|
format: string;
|
||||||
|
monitored: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Link {
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Ratings {
|
||||||
|
votes: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Statistics {
|
||||||
|
albumCount?: number;
|
||||||
|
trackFileCount: number;
|
||||||
|
trackCount: number;
|
||||||
|
totalTrackCount: number;
|
||||||
|
sizeOnDisk: number;
|
||||||
|
percentOfTracks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Image {
|
||||||
|
url: string;
|
||||||
|
coverType: string;
|
||||||
|
extension: string;
|
||||||
|
remoteUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Medium {
|
||||||
|
mediumNumber: number;
|
||||||
|
mediumName: string;
|
||||||
|
mediumFormat: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LidarrAPI extends ServarrBase<{ musicId: number }> {
|
||||||
|
static lastArtistsUpdate = 0;
|
||||||
|
static artists: LidarrArtist[] = [];
|
||||||
|
static delay = 1000 * 60;
|
||||||
|
constructor({ url, apiKey }: { url: string; apiKey: string }) {
|
||||||
|
super({ url, apiKey, cacheName: 'lidarr', apiName: 'Lidarr' });
|
||||||
|
if (LidarrAPI.lastArtistsUpdate < Date.now() - LidarrAPI.delay) {
|
||||||
|
this.getArtists();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getArtists = (): void => {
|
||||||
|
try {
|
||||||
|
LidarrAPI.lastArtistsUpdate = Date.now();
|
||||||
|
this.axios.get<LidarrArtist[]>('/artist').then((response) => {
|
||||||
|
LidarrAPI.artists = response.data;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[Lidarr] Failed to retrieve artists: ${e.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public getArtist = async (id: string | number): Promise<LidarrArtist> => {
|
||||||
|
try {
|
||||||
|
if (LidarrAPI.lastArtistsUpdate < Date.now() - LidarrAPI.delay) {
|
||||||
|
this.getArtists();
|
||||||
|
}
|
||||||
|
if (typeof id === 'number') {
|
||||||
|
const result = LidarrAPI.artists.find((artist) => artist.id === id);
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
throw new Error(`Artist not found (using Lidarr Id): ${id}`);
|
||||||
|
}
|
||||||
|
const result = LidarrAPI.artists.find(
|
||||||
|
(artist) => artist.foreignArtistId === id
|
||||||
|
);
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const artist = await this.getArtistByMusicBrainzId(id);
|
||||||
|
if (artist) {
|
||||||
|
return artist;
|
||||||
|
}
|
||||||
|
throw new Error(`Artist not found (using MusicBrainz Id): ${id}`);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[Lidarr] ${e.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public async getArtistByMusicBrainzId(mbId: string): Promise<LidarrArtist> {
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get<LidarrArtist[]>('/artist/lookup', {
|
||||||
|
params: {
|
||||||
|
term: `mbid:` + mbId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.data[0]) {
|
||||||
|
throw new Error('Artist not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data[0];
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Error retrieving artist by MusicBrainz ID', {
|
||||||
|
label: 'Midarr API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
mbId: mbId,
|
||||||
|
});
|
||||||
|
throw new Error('Artist not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAlbums = async (): Promise<LidarrAlbum[]> => {
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get<LidarrAlbum[]>('/album');
|
||||||
|
return response.data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[Lidarr] Failed to retrieve albums: ${e.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public async getAlbum({
|
||||||
|
artistId,
|
||||||
|
albumId,
|
||||||
|
}: {
|
||||||
|
artistId?: number;
|
||||||
|
foreignAlbumId?: string;
|
||||||
|
albumId?: number;
|
||||||
|
}): Promise<LidarrAlbum[]> {
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get<LidarrAlbum[]>('/album', {
|
||||||
|
params: {
|
||||||
|
artistId,
|
||||||
|
albumId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[Lidarr] Failed to retrieve album: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 album = await this.getAlbumByMusicBrainzId(options.mbId);
|
||||||
|
|
||||||
|
if (album.id) {
|
||||||
|
logger.info(
|
||||||
|
'Album is already monitored in Lidarr. Starting search for download.',
|
||||||
|
{ label: 'Lidarr' }
|
||||||
|
);
|
||||||
|
this.axios.post(`/command`, {
|
||||||
|
name: 'AlbumSearch',
|
||||||
|
albumIds: [album.id],
|
||||||
|
});
|
||||||
|
return album;
|
||||||
|
}
|
||||||
|
|
||||||
|
const artist = album.artist;
|
||||||
|
|
||||||
|
artist.monitored = true;
|
||||||
|
artist.monitorNewItems = 'all';
|
||||||
|
artist.qualityProfileId = options.qualityProfileId;
|
||||||
|
artist.rootFolderPath = options.rootFolderPath;
|
||||||
|
artist.addOptions = {
|
||||||
|
monitor: 'none',
|
||||||
|
searchForMissingAlbums: true,
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
rootFolderPath: options.rootFolderPath,
|
||||||
|
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', {
|
||||||
|
label: 'Lidarr API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
mbId: options.mbId,
|
||||||
|
});
|
||||||
|
throw new Error(`[Lidarr] Failed to add album: ${options.mbId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public addArtist = async (
|
||||||
|
options: LidarrArtistOptions
|
||||||
|
): Promise<LidarrArtist> => {
|
||||||
|
try {
|
||||||
|
const artist = await this.getArtistByMusicBrainzId(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,
|
||||||
|
metadataProfileId: options.profileId,
|
||||||
|
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;
|
@ -0,0 +1,109 @@
|
|||||||
|
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.processGroup(lidarrAlbum.foreignAlbumId, {
|
||||||
|
serviceId: this.currentServer.id,
|
||||||
|
externalServiceId: lidarrAlbum.id,
|
||||||
|
title: lidarrAlbum.title,
|
||||||
|
processing: !lidarrAlbum.anyReleaseOk,
|
||||||
|
releases: lidarrAlbum.releases,
|
||||||
|
});
|
||||||
|
} 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[];
|
||||||
|
}
|
@ -0,0 +1,113 @@
|
|||||||
|
import MusicBrainz from '@server/api/musicbrainz';
|
||||||
|
import { MediaType, SecondaryType } from '@server/constants/media';
|
||||||
|
import Media from '@server/entity/Media';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import type { ReleaseResult } from '@server/models/Search';
|
||||||
|
import { mapArtistResult, mapReleaseResult } from '@server/models/Search';
|
||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
const musicRoutes = Router();
|
||||||
|
|
||||||
|
musicRoutes.get('/artist/:id', async (req, res, next) => {
|
||||||
|
const mb = new MusicBrainz();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
|
||||||
|
const maxElements = req.query.maxElements
|
||||||
|
? parseInt(req.query.maxElements as string)
|
||||||
|
: 25;
|
||||||
|
const artist = req.query.full
|
||||||
|
? await mb.getFullArtist(req.params.id, maxElements, offset)
|
||||||
|
: await mb.getArtist(req.params.id);
|
||||||
|
|
||||||
|
const media = await Media.getMedia(artist.id, MediaType.MUSIC);
|
||||||
|
|
||||||
|
const results = await mapArtistResult(artist, media);
|
||||||
|
|
||||||
|
let existingReleaseMedia: Media[] = [];
|
||||||
|
if (media) {
|
||||||
|
existingReleaseMedia =
|
||||||
|
(await Media.getChildMedia(Number(media.ratingKey) ?? 0)) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let newReleases: ReleaseResult[] = await Promise.all(
|
||||||
|
existingReleaseMedia.map(async (releaseMedia) => {
|
||||||
|
return await mapReleaseResult(
|
||||||
|
{
|
||||||
|
id: <string>releaseMedia.mbId,
|
||||||
|
media_type: SecondaryType.RELEASE,
|
||||||
|
title: <string>releaseMedia.title,
|
||||||
|
artist: [],
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
releaseMedia
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
newReleases = newReleases.slice(offset, offset + maxElements);
|
||||||
|
|
||||||
|
for (const release of results.releases) {
|
||||||
|
if (newReleases.length >= maxElements) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (newReleases.find((r: ReleaseResult) => r.id === release.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (newReleases.find((r: ReleaseResult) => r.title === release.title)) {
|
||||||
|
if (
|
||||||
|
newReleases.find(
|
||||||
|
(r: ReleaseResult) => r.mediaInfo && !release.mediaInfo
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
newReleases.find(
|
||||||
|
(r: ReleaseResult) => !r.mediaInfo && !release.mediaInfo
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newReleases.push(release);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.releases = newReleases;
|
||||||
|
|
||||||
|
return res.status(200).json(results);
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Something went wrong retrieving artist', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
artistId: req.params.id,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Unable to retrieve artist.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
musicRoutes.get('/release/:id', async (req, res, next) => {
|
||||||
|
const mb = new MusicBrainz();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const release = await mb.getRelease(req.params.id);
|
||||||
|
|
||||||
|
const media = await Media.getMedia(release.id, MediaType.MUSIC);
|
||||||
|
|
||||||
|
return res.status(200).json(await mapReleaseResult(release, media));
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Something went wrong retrieving release', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
releaseId: req.params.id,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Unable to retrieve release.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default musicRoutes;
|
@ -0,0 +1,135 @@
|
|||||||
|
import LidarrAPI from '@server/api/servarr/lidarr';
|
||||||
|
import type { LidarrSettings } from '@server/lib/settings';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
const lidarrRoutes = Router();
|
||||||
|
|
||||||
|
lidarrRoutes.get('/', (_req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
res.status(200).json(settings.lidarr);
|
||||||
|
});
|
||||||
|
|
||||||
|
lidarrRoutes.post('/', (req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
const newLidarr = req.body as LidarrSettings;
|
||||||
|
const lastItem = settings.lidarr[settings.lidarr.length - 1];
|
||||||
|
newLidarr.id = lastItem ? lastItem.id + 1 : 0;
|
||||||
|
|
||||||
|
// If we are setting this as the default, clear any previous defaults for the same type first
|
||||||
|
settings.lidarr = [...settings.lidarr, newLidarr];
|
||||||
|
settings.save();
|
||||||
|
|
||||||
|
return res.status(201).json(newLidarr);
|
||||||
|
});
|
||||||
|
|
||||||
|
lidarrRoutes.post<
|
||||||
|
undefined,
|
||||||
|
Record<string, unknown>,
|
||||||
|
LidarrSettings & { tagLabel?: string }
|
||||||
|
>('/test', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const lidarr = new LidarrAPI({
|
||||||
|
apiKey: req.body.apiKey,
|
||||||
|
url: LidarrAPI.buildUrl(req.body, '/api/v1'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const urlBase = await lidarr
|
||||||
|
.getSystemStatus()
|
||||||
|
.then((value) => value.urlBase)
|
||||||
|
.catch(() => req.body.baseUrl);
|
||||||
|
const profiles = await lidarr.getProfiles();
|
||||||
|
const folders = await lidarr.getRootFolders();
|
||||||
|
const tags = await lidarr.getTags();
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
profiles,
|
||||||
|
rootFolders: folders.map((folder) => ({
|
||||||
|
id: folder.id,
|
||||||
|
path: folder.path,
|
||||||
|
})),
|
||||||
|
tags,
|
||||||
|
urlBase,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to test Lidarr', {
|
||||||
|
label: 'Lidarr',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
next({ status: 500, message: 'Failed to connect to Lidarr' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
lidarrRoutes.put<{ id: string }, LidarrSettings, LidarrSettings>(
|
||||||
|
'/:id',
|
||||||
|
(req, res, next) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
const lidarrIndex = settings.lidarr.findIndex(
|
||||||
|
(r) => r.id === Number(req.params.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lidarrIndex === -1) {
|
||||||
|
return next({ status: '404', message: 'Settings instance not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are setting this as the default, clear any previous defaults for the same type first
|
||||||
|
|
||||||
|
settings.lidarr[lidarrIndex] = {
|
||||||
|
...req.body,
|
||||||
|
id: Number(req.params.id),
|
||||||
|
} as LidarrSettings;
|
||||||
|
settings.save();
|
||||||
|
|
||||||
|
return res.status(200).json(settings.lidarr[lidarrIndex]);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
lidarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
const lidarrSettings = settings.lidarr.find(
|
||||||
|
(r) => r.id === Number(req.params.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!lidarrSettings) {
|
||||||
|
return next({ status: '404', message: 'Settings instance not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lidarr = new LidarrAPI({
|
||||||
|
apiKey: lidarrSettings.apiKey,
|
||||||
|
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const profiles = await lidarr.getProfiles();
|
||||||
|
|
||||||
|
return res.status(200).json(
|
||||||
|
profiles.map((profile) => ({
|
||||||
|
id: profile.id,
|
||||||
|
name: profile.name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
lidarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
const lidarrIndex = settings.lidarr.findIndex(
|
||||||
|
(r) => r.id === Number(req.params.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lidarrIndex === -1) {
|
||||||
|
return next({ status: '404', message: 'Settings instance not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = settings.lidarr.splice(lidarrIndex, 1);
|
||||||
|
settings.save();
|
||||||
|
|
||||||
|
return res.status(200).json(removed[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default lidarrRoutes;
|
@ -0,0 +1,150 @@
|
|||||||
|
declare module 'nodebrainz' {
|
||||||
|
import type {
|
||||||
|
Artist,
|
||||||
|
Group,
|
||||||
|
Recording,
|
||||||
|
Release,
|
||||||
|
SearchOptions,
|
||||||
|
Work,
|
||||||
|
} from '@server/api/musicbrainz/interfaces';
|
||||||
|
interface RawSearchResponse {
|
||||||
|
created: string;
|
||||||
|
count: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
export interface ArtistSearchResponse extends RawSearchResponse {
|
||||||
|
artists: Artist[];
|
||||||
|
}
|
||||||
|
export interface ReleaseSearchResponse extends RawSearchResponse {
|
||||||
|
releases: Release[];
|
||||||
|
}
|
||||||
|
export interface RecordingSearchResponse extends RawSearchResponse {
|
||||||
|
recordings: Recording[];
|
||||||
|
}
|
||||||
|
export interface ReleaseGroupSearchResponse extends RawSearchResponse {
|
||||||
|
'release-groups': Group[];
|
||||||
|
}
|
||||||
|
export interface WorkSearchResponse extends RawSearchResponse {
|
||||||
|
works: Work[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagSearchResponse extends RawSearchResponse {
|
||||||
|
tags: {
|
||||||
|
score: number;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowseRequestParams {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
artist?: string;
|
||||||
|
release?: string;
|
||||||
|
recording?: string;
|
||||||
|
'release-group'?: string;
|
||||||
|
work?: string;
|
||||||
|
// or anything else
|
||||||
|
[key: string]: string | number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface luceneSearchOptions {
|
||||||
|
query: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class BaseNodeBrainz {
|
||||||
|
constructor(options: {
|
||||||
|
userAgent: string;
|
||||||
|
retryOn: boolean;
|
||||||
|
retryDelay: number;
|
||||||
|
retryCount: number;
|
||||||
|
});
|
||||||
|
artist(
|
||||||
|
artistId: string,
|
||||||
|
{ inc }: { inc: string },
|
||||||
|
callback: (err: Error, data: Artist) => void
|
||||||
|
): Promise<Artist>;
|
||||||
|
recording(
|
||||||
|
recordingId: string,
|
||||||
|
{ inc }: { inc: string },
|
||||||
|
callback: (err: Error, data: Recording) => void
|
||||||
|
): Promise<Recording>;
|
||||||
|
release(
|
||||||
|
releaseId: string,
|
||||||
|
{ inc }: { inc: string },
|
||||||
|
callback: (err: Error, data: Release) => void
|
||||||
|
): Promise<Release>;
|
||||||
|
releaseGroup(
|
||||||
|
releaseGroupId: string,
|
||||||
|
{ inc }: { inc: string },
|
||||||
|
callback: (err: Error, data: Group) => void
|
||||||
|
): Promise<Group>;
|
||||||
|
work(
|
||||||
|
workId: string,
|
||||||
|
{ inc }: { inc: string },
|
||||||
|
callback: (err: Error, data: Work) => void
|
||||||
|
): Promise<Work>;
|
||||||
|
search(
|
||||||
|
type: string,
|
||||||
|
search: SearchOptions | { tag: string },
|
||||||
|
callback: (
|
||||||
|
err: Error,
|
||||||
|
data:
|
||||||
|
| ArtistSearchResponse
|
||||||
|
| ReleaseSearchResponse
|
||||||
|
| RecordingSearchResponse
|
||||||
|
| ReleaseGroupSearchResponse
|
||||||
|
| WorkSearchResponse
|
||||||
|
| TagSearchResponse
|
||||||
|
) => void
|
||||||
|
): Promise<Artist[] | Release[] | Recording[] | Group[] | Work[]>;
|
||||||
|
browse(
|
||||||
|
type: string,
|
||||||
|
data: BrowseRequestParams,
|
||||||
|
callback: (
|
||||||
|
err: Error,
|
||||||
|
data:
|
||||||
|
| {
|
||||||
|
'release-group-count': number;
|
||||||
|
'release-group-offset': number;
|
||||||
|
'release-groups': Group[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
'release-count': number;
|
||||||
|
'release-offset': number;
|
||||||
|
releases: Release[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
'recording-count': number;
|
||||||
|
'recording-offset': number;
|
||||||
|
recordings: Recording[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
'work-count': number;
|
||||||
|
'work-offset': number;
|
||||||
|
works: Work[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
'artist-count': number;
|
||||||
|
'artist-offset': number;
|
||||||
|
artists: Artist[];
|
||||||
|
}
|
||||||
|
) => void
|
||||||
|
): Promise<Artist[] | Release[] | Recording[] | Group[] | Work[]>;
|
||||||
|
luceneSearch(
|
||||||
|
type: string,
|
||||||
|
search: luceneSearchOptions,
|
||||||
|
callback: (
|
||||||
|
err: Error,
|
||||||
|
data:
|
||||||
|
| ArtistSearchResponse
|
||||||
|
| ReleaseSearchResponse
|
||||||
|
| RecordingSearchResponse
|
||||||
|
| ReleaseGroupSearchResponse
|
||||||
|
| WorkSearchResponse
|
||||||
|
| TagSearchResponse
|
||||||
|
) => void
|
||||||
|
): Promise<Artist[] | Release[] | Recording[] | Group[] | Work[]>;
|
||||||
|
}
|
||||||
|
}
|
After Width: | Height: | Size: 897 B |
@ -0,0 +1,98 @@
|
|||||||
|
import Button from '@app/components/Common/Button';
|
||||||
|
import Header from '@app/components/Common/Header';
|
||||||
|
import ListView from '@app/components/Common/ListView';
|
||||||
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
|
import type { FilterOptions } from '@app/components/Discover/constants';
|
||||||
|
import {
|
||||||
|
countActiveFilters,
|
||||||
|
prepareFilterValues,
|
||||||
|
} from '@app/components/Discover/constants';
|
||||||
|
import FilterSlideover from '@app/components/Discover/FilterSlideover';
|
||||||
|
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 { ArtistResult, ReleaseResult } from '@server/models/Search';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
discovermusics: 'Musics',
|
||||||
|
activefilters:
|
||||||
|
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
||||||
|
discovermoremusics: 'Discover More Musics',
|
||||||
|
});
|
||||||
|
|
||||||
|
const DiscoverMusics = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const preparedFilters = prepareFilterValues(router.query);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoadingInitialData,
|
||||||
|
isEmpty,
|
||||||
|
isLoadingMore,
|
||||||
|
isReachingEnd,
|
||||||
|
titles,
|
||||||
|
fetchMore,
|
||||||
|
error,
|
||||||
|
} = useDiscover<ReleaseResult | ArtistResult, unknown, FilterOptions>(
|
||||||
|
'/api/v1/discover/musics',
|
||||||
|
preparedFilters
|
||||||
|
);
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
|
if (error || !titles) {
|
||||||
|
return <Error statusCode={500} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = intl.formatMessage(messages.discovermusics);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle title={title} />
|
||||||
|
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
|
||||||
|
<Header>{title}</Header>
|
||||||
|
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||||
|
<FilterSlideover
|
||||||
|
type="music"
|
||||||
|
currentFilters={preparedFilters}
|
||||||
|
onClose={() => setShowFilters(false)}
|
||||||
|
show={showFilters}
|
||||||
|
/>
|
||||||
|
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
|
||||||
|
<Button onClick={() => setShowFilters(true)} className="w-full">
|
||||||
|
<FunnelIcon />
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(messages.activefilters, {
|
||||||
|
count: countActiveFilters(preparedFilters),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{Object.keys(preparedFilters).length === 0 && (
|
||||||
|
<RecentlyAddedSlider type="artist" />
|
||||||
|
)}
|
||||||
|
<div className="slider-header">
|
||||||
|
<div className="slider-title">
|
||||||
|
<span>{intl.formatMessage(messages.discovermoremusics)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
items={titles}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
isLoading={
|
||||||
|
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||||
|
}
|
||||||
|
isReachingEnd={isReachingEnd}
|
||||||
|
onScrollBottom={fetchMore}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverMusics;
|
@ -0,0 +1,321 @@
|
|||||||
|
import Button from '@app/components/Common/Button';
|
||||||
|
import CachedImage from '@app/components/Common/CachedImage';
|
||||||
|
import ListView from '@app/components/Common/ListView';
|
||||||
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
|
import type { PlayButtonLink } from '@app/components/Common/PlayButton';
|
||||||
|
import PlayButton from '@app/components/Common/PlayButton';
|
||||||
|
import Tag from '@app/components/Common/Tag';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
|
import IssueModal from '@app/components/IssueModal';
|
||||||
|
import RequestButton from '@app/components/RequestButton';
|
||||||
|
import Slider from '@app/components/Slider';
|
||||||
|
import StatusBadge from '@app/components/StatusBadge';
|
||||||
|
import FetchedDataTitleCard from '@app/components/TitleCard/FetchedDataTitleCard';
|
||||||
|
import useDeepLinks from '@app/hooks/useDeepLinks';
|
||||||
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
|
import Error from '@app/pages/_error';
|
||||||
|
import { ExclamationTriangleIcon, PlayIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { MediaStatus, SecondaryType } from '@server/constants/media';
|
||||||
|
import type { ArtistResult, ReleaseResult } from '@server/models/Search';
|
||||||
|
import 'country-flag-icons/3x2/flags.css';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
overview: 'Overview',
|
||||||
|
playonplex: 'Play on Plex',
|
||||||
|
reportissue: 'Report an Issue',
|
||||||
|
releases: 'Releases',
|
||||||
|
available: 'Available Albums',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ArtistDetailsProp {
|
||||||
|
artist: ArtistResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArtistDetails = ({ artist }: ArtistDetailsProp) => {
|
||||||
|
const { hasPermission } = useUser();
|
||||||
|
const router = useRouter();
|
||||||
|
const intl = useIntl();
|
||||||
|
const [showIssueModal, setShowIssueModal] = useState(false);
|
||||||
|
|
||||||
|
const data = artist;
|
||||||
|
|
||||||
|
const { plexUrl } = useDeepLinks({
|
||||||
|
plexUrl: data?.mediaInfo?.plexUrl,
|
||||||
|
iOSPlexUrl: data?.mediaInfo?.iOSPlexUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mediaLinks: PlayButtonLink[] = [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
plexUrl &&
|
||||||
|
hasPermission([Permission.REQUEST, Permission.REQUEST_MUSIC], {
|
||||||
|
type: 'or',
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
mediaLinks.push({
|
||||||
|
text: intl.formatMessage(messages.playonplex),
|
||||||
|
url: plexUrl,
|
||||||
|
svg: <PlayIcon />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const smartMerge = (a: ReleaseResult[], b: ReleaseResult[]) => {
|
||||||
|
const out = a;
|
||||||
|
b.forEach((item) => {
|
||||||
|
if (
|
||||||
|
!a.some((i) => i.id === item.id) &&
|
||||||
|
(!a.some(
|
||||||
|
(i) =>
|
||||||
|
i.releaseGroup?.id === item.releaseGroup?.id ||
|
||||||
|
i.title === item.title
|
||||||
|
) ||
|
||||||
|
item.mediaInfo?.status === MediaStatus.AVAILABLE)
|
||||||
|
) {
|
||||||
|
out.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanDate = (date: Date | string | undefined) => {
|
||||||
|
date = date ?? '';
|
||||||
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainDateDisplay: string =
|
||||||
|
data.beginDate && data.endDate
|
||||||
|
? `${cleanDate(data.beginDate)} - ${cleanDate(data.endDate)}`
|
||||||
|
: cleanDate(data.beginDate);
|
||||||
|
|
||||||
|
const filteredAvailableReleases = (releases: ReleaseResult[]) => {
|
||||||
|
return releases.filter((release) => release.mediaInfo);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [releases, setReleases] = useState<ReleaseResult[]>(
|
||||||
|
data.releases ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
const [availableReleases, setAvailableReleases] = useState<ReleaseResult[]>(
|
||||||
|
filteredAvailableReleases(releases)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAvailableReleases((prev) =>
|
||||||
|
smartMerge(prev, releases).filter(
|
||||||
|
(release) => release.mediaInfo?.status === MediaStatus.AVAILABLE
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [releases]);
|
||||||
|
|
||||||
|
const [currentOffset, setCurrentOffset] = useState(0);
|
||||||
|
|
||||||
|
const [isLoading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const getMore = useCallback(() => {
|
||||||
|
if (isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
fetch(
|
||||||
|
`/api/v1/music/artist/${router.query.mbId}?full=true&offset=${
|
||||||
|
currentOffset + 25
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((res) => {
|
||||||
|
if (res) {
|
||||||
|
const r = res.releases ?? [];
|
||||||
|
setReleases(smartMerge(releases, r));
|
||||||
|
setCurrentOffset(currentOffset + 25);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [currentOffset, isLoading, releases, router.query.mbId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
const bottom =
|
||||||
|
document.body.scrollHeight - window.scrollY - window.outerHeight <= 1;
|
||||||
|
if (bottom) {
|
||||||
|
getMore();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}, [getMore]);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <Error statusCode={404} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = data.name;
|
||||||
|
|
||||||
|
const tags: string[] = data.tags ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="media-page"
|
||||||
|
style={{
|
||||||
|
height: 493,
|
||||||
|
}}
|
||||||
|
key={data.id}
|
||||||
|
>
|
||||||
|
<div className="media-page-bg-image">
|
||||||
|
<CachedImage
|
||||||
|
alt=""
|
||||||
|
src={data.fanartPath ?? ''}
|
||||||
|
layout="fill"
|
||||||
|
objectFit="cover"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<PageTitle title={title} />
|
||||||
|
<IssueModal
|
||||||
|
onCancel={() => setShowIssueModal(false)}
|
||||||
|
show={showIssueModal}
|
||||||
|
mediaType="music"
|
||||||
|
mbId={data.id}
|
||||||
|
secondaryType={SecondaryType.ARTIST}
|
||||||
|
/>
|
||||||
|
<div className="media-header">
|
||||||
|
<div className="media-poster">
|
||||||
|
<CachedImage
|
||||||
|
src={data.posterPath ?? ''}
|
||||||
|
alt={title + ' poster'}
|
||||||
|
layout="responsive"
|
||||||
|
width={600}
|
||||||
|
height={600}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="media-title">
|
||||||
|
<div className="media-status">
|
||||||
|
<StatusBadge
|
||||||
|
status={data.mediaInfo?.status}
|
||||||
|
downloadItem={data.mediaInfo?.downloadStatus}
|
||||||
|
title={title}
|
||||||
|
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
|
||||||
|
tmdbId={data.mediaInfo?.tmdbId}
|
||||||
|
mediaType="music"
|
||||||
|
plexUrl={plexUrl}
|
||||||
|
serviceUrl={data.mediaInfo?.serviceUrl}
|
||||||
|
secondaryType={SecondaryType.ARTIST}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 data-testid="media-title">
|
||||||
|
{title}{' '}
|
||||||
|
{mainDateDisplay !== '' && (
|
||||||
|
<span className="media-year">({mainDateDisplay})</span>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
<span className="media-attributes"></span>
|
||||||
|
</div>
|
||||||
|
<div className="media-actions">
|
||||||
|
<PlayButton links={mediaLinks} />
|
||||||
|
<RequestButton
|
||||||
|
mediaType="music"
|
||||||
|
media={data.mediaInfo}
|
||||||
|
mbId={data.id}
|
||||||
|
secondaryType={SecondaryType.ARTIST}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
onUpdate={() => {}}
|
||||||
|
/>
|
||||||
|
{data.mediaInfo?.status === MediaStatus.AVAILABLE &&
|
||||||
|
hasPermission(
|
||||||
|
[Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES],
|
||||||
|
{
|
||||||
|
type: 'or',
|
||||||
|
}
|
||||||
|
) && (
|
||||||
|
<Tooltip content={intl.formatMessage(messages.reportissue)}>
|
||||||
|
<Button
|
||||||
|
buttonType="warning"
|
||||||
|
onClick={() => setShowIssueModal(true)}
|
||||||
|
className="ml-2 first:ml-0"
|
||||||
|
>
|
||||||
|
<ExclamationTriangleIcon />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
{tags.map((keyword, idx) => (
|
||||||
|
<Link
|
||||||
|
href={`/discover/music?keywords=${keyword}`}
|
||||||
|
key={`keyword-id-${idx}`}
|
||||||
|
>
|
||||||
|
<a className="mb-2 mr-2 inline-flex last:mr-0">
|
||||||
|
<Tag>{keyword}</Tag>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{availableReleases.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="slider-header">
|
||||||
|
<div className="slider-title">
|
||||||
|
<span>{intl.formatMessage(messages.available)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
sliderKey="Available Releases"
|
||||||
|
isLoading={false}
|
||||||
|
items={availableReleases.map((item) => {
|
||||||
|
return (
|
||||||
|
<FetchedDataTitleCard
|
||||||
|
key={`media-slider-item-${item.id}`}
|
||||||
|
data={item}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="slider-header">
|
||||||
|
<div className="slider-title">
|
||||||
|
<span>{intl.formatMessage(messages.releases)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
isLoading={false}
|
||||||
|
jsxItems={releases.map((item) => {
|
||||||
|
return (
|
||||||
|
<FetchedDataTitleCard
|
||||||
|
key={`media-slider-item-${item.id}`}
|
||||||
|
data={item}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
onScrollBottom={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ArtistDetails;
|
@ -0,0 +1,251 @@
|
|||||||
|
import Button from '@app/components/Common/Button';
|
||||||
|
import CachedImage from '@app/components/Common/CachedImage';
|
||||||
|
import List from '@app/components/Common/List';
|
||||||
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
|
import type { PlayButtonLink } from '@app/components/Common/PlayButton';
|
||||||
|
import PlayButton from '@app/components/Common/PlayButton';
|
||||||
|
import Tag from '@app/components/Common/Tag';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
|
import IssueModal from '@app/components/IssueModal';
|
||||||
|
import RequestButton from '@app/components/RequestButton';
|
||||||
|
import StatusBadge from '@app/components/StatusBadge';
|
||||||
|
import useDeepLinks from '@app/hooks/useDeepLinks';
|
||||||
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
|
import Error from '@app/pages/_error';
|
||||||
|
import { ExclamationTriangleIcon, PlayIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { MediaStatus, SecondaryType } from '@server/constants/media';
|
||||||
|
import type { RecordingResult, ReleaseResult } from '@server/models/Search';
|
||||||
|
import 'country-flag-icons/3x2/flags.css';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
originaltitle: 'Original Title',
|
||||||
|
overview: 'Overview',
|
||||||
|
recommendations: 'Recommendations',
|
||||||
|
playonplex: 'Play on Plex',
|
||||||
|
markavailable: 'Mark as Available',
|
||||||
|
showmore: 'Show More',
|
||||||
|
showless: 'Show Less',
|
||||||
|
digitalrelease: 'Digital Release',
|
||||||
|
physicalrelease: 'Physical Release',
|
||||||
|
reportissue: 'Report an Issue',
|
||||||
|
managemusic: 'Manage Music',
|
||||||
|
releases: 'Releases',
|
||||||
|
albums: 'Albums',
|
||||||
|
singles: 'Singles',
|
||||||
|
eps: 'EPs',
|
||||||
|
broadcasts: 'Broadcasts',
|
||||||
|
others: 'Others',
|
||||||
|
feats: 'Featured In',
|
||||||
|
tracks: 'Tracks',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ReleaseDetailsProp {
|
||||||
|
release: ReleaseResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReleaseDetails = ({ release }: ReleaseDetailsProp) => {
|
||||||
|
const { hasPermission } = useUser();
|
||||||
|
const router = useRouter();
|
||||||
|
const intl = useIntl();
|
||||||
|
const [showIssueModal, setShowIssueModal] = useState(false);
|
||||||
|
|
||||||
|
const { data: fetched } = useSWR<ReleaseResult>(
|
||||||
|
`/api/v1/music/release/${router.query.mbId}?full=true`
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = fetched ?? release;
|
||||||
|
|
||||||
|
const { plexUrl } = useDeepLinks({
|
||||||
|
plexUrl: data?.mediaInfo?.plexUrl,
|
||||||
|
iOSPlexUrl: data?.mediaInfo?.iOSPlexUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mediaLinks: PlayButtonLink[] = [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
plexUrl &&
|
||||||
|
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
|
||||||
|
type: 'or',
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
mediaLinks.push({
|
||||||
|
text: intl.formatMessage(messages.playonplex),
|
||||||
|
url: plexUrl,
|
||||||
|
svg: <PlayIcon />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanDate = (date: Date | string | undefined) => {
|
||||||
|
date = date ?? '';
|
||||||
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainDateDisplay: string = cleanDate(data.date);
|
||||||
|
|
||||||
|
const tracks: RecordingResult[] = data.tracks ?? [];
|
||||||
|
|
||||||
|
const lengthToTime = (length: number) => {
|
||||||
|
length /= 1000;
|
||||||
|
const minutes = Math.floor(length / 60);
|
||||||
|
const seconds = length - minutes * 60;
|
||||||
|
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds.toFixed(0)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <Error statusCode={404} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = data.title;
|
||||||
|
|
||||||
|
const tags: string[] = data.tags ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="media-page"
|
||||||
|
style={{
|
||||||
|
height: 493,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="media-page-bg-image">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<PageTitle title={title} />
|
||||||
|
<IssueModal
|
||||||
|
onCancel={() => setShowIssueModal(false)}
|
||||||
|
show={showIssueModal}
|
||||||
|
mediaType="music"
|
||||||
|
mbId={data.id}
|
||||||
|
secondaryType={SecondaryType.RELEASE}
|
||||||
|
/>
|
||||||
|
<div className="media-header">
|
||||||
|
<div className="media-poster">
|
||||||
|
<CachedImage
|
||||||
|
src={data.posterPath ?? ''}
|
||||||
|
alt=""
|
||||||
|
layout="responsive"
|
||||||
|
width={600}
|
||||||
|
height={600}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="media-title">
|
||||||
|
<div className="media-status">
|
||||||
|
<StatusBadge
|
||||||
|
status={data.mediaInfo?.status}
|
||||||
|
downloadItem={data.mediaInfo?.downloadStatus}
|
||||||
|
title={title}
|
||||||
|
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
|
||||||
|
tmdbId={data.mediaInfo?.tmdbId}
|
||||||
|
mediaType="music"
|
||||||
|
plexUrl={plexUrl}
|
||||||
|
serviceUrl={data.mediaInfo?.serviceUrl}
|
||||||
|
secondaryType={SecondaryType.RELEASE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 data-testid="media-title">
|
||||||
|
{title}{' '}
|
||||||
|
{mainDateDisplay !== '' && (
|
||||||
|
<span className="media-year">({mainDateDisplay})</span>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
<span className="media-attributes">
|
||||||
|
By
|
||||||
|
{data.artist.map((artist, index) => (
|
||||||
|
<div key={`artist-${index}`}>
|
||||||
|
{' '}
|
||||||
|
<Link href={`/music/artist/${artist.id}`}>
|
||||||
|
<a className="hover:underline">{artist.name}</a>
|
||||||
|
</Link>
|
||||||
|
{index < data.artist.length - 1 ? ', ' : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="media-actions">
|
||||||
|
<PlayButton links={mediaLinks} />
|
||||||
|
<RequestButton
|
||||||
|
mediaType="music"
|
||||||
|
media={data.mediaInfo}
|
||||||
|
mbId={data.id}
|
||||||
|
secondaryType={SecondaryType.RELEASE}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
onUpdate={() => {}}
|
||||||
|
/>
|
||||||
|
{data.mediaInfo?.status === MediaStatus.AVAILABLE &&
|
||||||
|
hasPermission(
|
||||||
|
[Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES],
|
||||||
|
{
|
||||||
|
type: 'or',
|
||||||
|
}
|
||||||
|
) && (
|
||||||
|
<Tooltip content={intl.formatMessage(messages.reportissue)}>
|
||||||
|
<Button
|
||||||
|
buttonType="warning"
|
||||||
|
onClick={() => setShowIssueModal(true)}
|
||||||
|
className="ml-2 first:ml-0"
|
||||||
|
>
|
||||||
|
<ExclamationTriangleIcon />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
{tags.map((keyword, idx) => (
|
||||||
|
<Link
|
||||||
|
href={`/discover/music?keywords=${keyword}`}
|
||||||
|
key={`keyword-id-${idx}`}
|
||||||
|
>
|
||||||
|
<a className="mb-2 mr-2 inline-flex last:mr-0">
|
||||||
|
<Tag>{keyword}</Tag>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<List title={intl.formatMessage(messages.tracks)}>
|
||||||
|
{tracks.map((track, index) => (
|
||||||
|
<div key={index}>
|
||||||
|
<div className="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||||
|
<dt className="block text-sm font-bold text-gray-400">
|
||||||
|
{track.title}
|
||||||
|
</dt>
|
||||||
|
<dd className="flex text-sm text-white sm:col-span-2 sm:mt-0">
|
||||||
|
<span className="flex-grow">
|
||||||
|
{lengthToTime(track.length)}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
<dd>
|
||||||
|
<span className="flex-grow">
|
||||||
|
{track.artist.map((artist, index) => (
|
||||||
|
<span key={index}>{artist.name}</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReleaseDetails;
|
@ -0,0 +1,203 @@
|
|||||||
|
import Button from '@app/components/Common/Button';
|
||||||
|
import CachedImage from '@app/components/Common/CachedImage';
|
||||||
|
import ListView from '@app/components/Common/ListView';
|
||||||
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
|
import type { PlayButtonLink } from '@app/components/Common/PlayButton';
|
||||||
|
import PlayButton from '@app/components/Common/PlayButton';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
|
import IssueModal from '@app/components/IssueModal';
|
||||||
|
import RequestButton from '@app/components/RequestButton';
|
||||||
|
import StatusBadge from '@app/components/StatusBadge';
|
||||||
|
import FetchedDataTitleCard from '@app/components/TitleCard/FetchedDataTitleCard';
|
||||||
|
import useDeepLinks from '@app/hooks/useDeepLinks';
|
||||||
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
|
import Error from '@app/pages/_error';
|
||||||
|
import { ExclamationTriangleIcon, PlayIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { MediaStatus, SecondaryType } from '@server/constants/media';
|
||||||
|
import type { ReleaseGroupResult } from '@server/models/Search';
|
||||||
|
import 'country-flag-icons/3x2/flags.css';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
overview: 'Overview',
|
||||||
|
recommendations: 'Recommendations',
|
||||||
|
playonplex: 'Play on Plex',
|
||||||
|
markavailable: 'Mark as Available',
|
||||||
|
showmore: 'Show More',
|
||||||
|
showless: 'Show Less',
|
||||||
|
reportissue: 'Report an Issue',
|
||||||
|
releases: 'Releases',
|
||||||
|
albums: 'Albums',
|
||||||
|
singles: 'Singles',
|
||||||
|
eps: 'EPs',
|
||||||
|
broadcasts: 'Broadcasts',
|
||||||
|
others: 'Others',
|
||||||
|
feats: 'Featured In',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ReleaseGroupDetailsProp {
|
||||||
|
releaseGroup: ReleaseGroupResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReleaseGroupDetails = ({ releaseGroup }: ReleaseGroupDetailsProp) => {
|
||||||
|
const { hasPermission } = useUser();
|
||||||
|
const intl = useIntl();
|
||||||
|
const [showIssueModal, setShowIssueModal] = useState(false);
|
||||||
|
|
||||||
|
const data = releaseGroup;
|
||||||
|
|
||||||
|
const { plexUrl } = useDeepLinks({
|
||||||
|
plexUrl: data?.mediaInfo?.plexUrl,
|
||||||
|
iOSPlexUrl: data?.mediaInfo?.iOSPlexUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mediaLinks: PlayButtonLink[] = [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
plexUrl &&
|
||||||
|
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
|
||||||
|
type: 'or',
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
mediaLinks.push({
|
||||||
|
text: intl.formatMessage(messages.playonplex),
|
||||||
|
url: plexUrl,
|
||||||
|
svg: <PlayIcon />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const releases = data.releases;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <Error statusCode={404} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
const cleanDate = (date: Date | string | undefined) => {
|
||||||
|
date = date ?? '';
|
||||||
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatedDate = cleanDate(data.firstReleaseDate ?? '');
|
||||||
|
*/
|
||||||
|
|
||||||
|
const title = data.title;
|
||||||
|
|
||||||
|
const tags: string[] = data.tags ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="media-page"
|
||||||
|
style={{
|
||||||
|
height: 493,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="media-page-bg-image">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<PageTitle title={title} />
|
||||||
|
<IssueModal
|
||||||
|
onCancel={() => setShowIssueModal(false)}
|
||||||
|
show={showIssueModal}
|
||||||
|
mediaType="music"
|
||||||
|
mbId={data.id}
|
||||||
|
secondaryType={SecondaryType.RELEASE_GROUP}
|
||||||
|
/>
|
||||||
|
<div className="media-header">
|
||||||
|
<div className="media-poster">
|
||||||
|
<CachedImage
|
||||||
|
src={data.posterPath ?? ''}
|
||||||
|
alt={title + ' album cover'}
|
||||||
|
layout="responsive"
|
||||||
|
width={600}
|
||||||
|
height={600}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="media-title">
|
||||||
|
<div className="media-status">
|
||||||
|
<StatusBadge
|
||||||
|
status={data.mediaInfo?.status}
|
||||||
|
downloadItem={data.mediaInfo?.downloadStatus}
|
||||||
|
title={title}
|
||||||
|
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
|
||||||
|
tmdbId={data.mediaInfo?.tmdbId}
|
||||||
|
mediaType="music"
|
||||||
|
plexUrl={plexUrl}
|
||||||
|
serviceUrl={data.mediaInfo?.serviceUrl}
|
||||||
|
secondaryType={SecondaryType.RELEASE_GROUP}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 data-testid="media-title">{title}</h1>
|
||||||
|
<h2 data-testid="media-subtitle">{data.type}</h2>
|
||||||
|
<span className="media-attributes">
|
||||||
|
{tags.map((t, k) => (
|
||||||
|
<span key={k}>{t}</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="media-actions">
|
||||||
|
<PlayButton links={mediaLinks} />
|
||||||
|
<RequestButton
|
||||||
|
mediaType="music"
|
||||||
|
media={data.mediaInfo}
|
||||||
|
mbId={data.id}
|
||||||
|
secondaryType={SecondaryType.RELEASE_GROUP}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
onUpdate={() => {}}
|
||||||
|
/>
|
||||||
|
{data.mediaInfo?.status === MediaStatus.AVAILABLE &&
|
||||||
|
hasPermission(
|
||||||
|
[Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES],
|
||||||
|
{
|
||||||
|
type: 'or',
|
||||||
|
}
|
||||||
|
) && (
|
||||||
|
<Tooltip content={intl.formatMessage(messages.reportissue)}>
|
||||||
|
<Button
|
||||||
|
buttonType="warning"
|
||||||
|
onClick={() => setShowIssueModal(true)}
|
||||||
|
className="ml-2 first:ml-0"
|
||||||
|
>
|
||||||
|
<ExclamationTriangleIcon />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{releases?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="slider-header">
|
||||||
|
<div className="slider-title">
|
||||||
|
<span>{intl.formatMessage(messages.releases)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
isLoading={false}
|
||||||
|
jsxItems={releases.map((item) => (
|
||||||
|
<FetchedDataTitleCard
|
||||||
|
key={`media-slider-item-${item.id}`}
|
||||||
|
data={item}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
onScrollBottom={() => {}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReleaseGroupDetails;
|
@ -0,0 +1,51 @@
|
|||||||
|
import ArtistDetails from '@app/components/MusicDetails/ArtistDetails';
|
||||||
|
import ReleaseDetails from '@app/components/MusicDetails/ReleaseDetails';
|
||||||
|
import ReleaseGroupDetails from '@app/components/MusicDetails/ReleaseGroupDetails';
|
||||||
|
import Error from '@app/pages/_error';
|
||||||
|
import { SecondaryType } from '@server/constants/media';
|
||||||
|
import type {
|
||||||
|
ArtistResult,
|
||||||
|
ReleaseGroupResult,
|
||||||
|
ReleaseResult,
|
||||||
|
} from '@server/models/Search';
|
||||||
|
import 'country-flag-icons/3x2/flags.css';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
interface MusicDetailsProps {
|
||||||
|
type: SecondaryType;
|
||||||
|
artist?: ArtistResult;
|
||||||
|
releaseGroup?: ReleaseGroupResult;
|
||||||
|
release?: ReleaseResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MusicDetails = ({
|
||||||
|
type,
|
||||||
|
artist,
|
||||||
|
releaseGroup,
|
||||||
|
release,
|
||||||
|
}: MusicDetailsProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: fetched } = useSWR<
|
||||||
|
ArtistResult | ReleaseGroupResult | ReleaseResult
|
||||||
|
>(
|
||||||
|
`/api/v1/music/${router.query.type}/${router.query.mbId}?full=true&maxElements=50`
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case SecondaryType.ARTIST:
|
||||||
|
return <ArtistDetails artist={(fetched ?? artist) as ArtistResult} />;
|
||||||
|
case SecondaryType.RELEASE_GROUP:
|
||||||
|
return (
|
||||||
|
<ReleaseGroupDetails
|
||||||
|
releaseGroup={(fetched ?? releaseGroup) as ReleaseGroupResult}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case SecondaryType.RELEASE:
|
||||||
|
return <ReleaseDetails release={(fetched ?? release) as ReleaseResult} />;
|
||||||
|
default:
|
||||||
|
return <Error statusCode={404} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MusicDetails;
|
@ -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;
|
@ -0,0 +1,346 @@
|
|||||||
|
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',
|
||||||
|
secondaryType: 'release',
|
||||||
|
...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;
|
@ -0,0 +1,682 @@
|
|||||||
|
import Modal from '@app/components/Common/Modal';
|
||||||
|
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||||
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import { Transition } from '@headlessui/react';
|
||||||
|
import type { LidarrSettings } from '@server/lib/settings';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Field, Formik } from 'formik';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import type { OnChangeValue } from 'react-select';
|
||||||
|
import Select from 'react-select';
|
||||||
|
import { useToasts } from 'react-toast-notifications';
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
|
type OptionType = {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
createlidarr: 'Add New Lidarr Server',
|
||||||
|
editlidarr: 'Edit Lidarr Server',
|
||||||
|
validationNameRequired: 'You must provide a server name',
|
||||||
|
validationHostnameRequired: 'You must provide a valid hostname or IP address',
|
||||||
|
validationPortRequired: 'You must provide a valid port number',
|
||||||
|
validationApiKeyRequired: 'You must provide an API key',
|
||||||
|
validationRootFolderRequired: 'You must select a root folder',
|
||||||
|
validationProfileRequired: 'You must select a quality profile',
|
||||||
|
toastLidarrTestSuccess: 'Lidarr connection established successfully!',
|
||||||
|
toastLidarrTestFailure: 'Failed to connect to Lidarr.',
|
||||||
|
add: 'Add Server',
|
||||||
|
defaultserver: 'Default Server',
|
||||||
|
servername: 'Server Name',
|
||||||
|
hostname: 'Hostname or IP Address',
|
||||||
|
port: 'Port',
|
||||||
|
ssl: 'Use SSL',
|
||||||
|
apiKey: 'API Key',
|
||||||
|
baseUrl: 'URL Base',
|
||||||
|
qualityprofile: 'Quality Profile',
|
||||||
|
rootfolder: 'Root Folder',
|
||||||
|
selectQualityProfile: 'Select quality profile',
|
||||||
|
selectRootFolder: 'Select root folder',
|
||||||
|
loadingprofiles: 'Loading quality profiles…',
|
||||||
|
testFirstQualityProfiles: 'Test connection to load quality profiles',
|
||||||
|
loadingrootfolders: 'Loading root folders…',
|
||||||
|
testFirstRootFolders: 'Test connection to load root folders',
|
||||||
|
loadingTags: 'Loading tags…',
|
||||||
|
testFirstTags: 'Test connection to load tags',
|
||||||
|
syncEnabled: 'Enable Scan',
|
||||||
|
externalUrl: 'External URL',
|
||||||
|
enableSearch: 'Enable Automatic Search',
|
||||||
|
tagRequests: 'Tag Requests',
|
||||||
|
tagRequestsInfo:
|
||||||
|
"Automatically add an additional tag with the requester's user ID & display name",
|
||||||
|
validationApplicationUrl: 'You must provide a valid URL',
|
||||||
|
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||||
|
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash',
|
||||||
|
validationBaseUrlTrailingSlash: 'Base URL must not end in a trailing slash',
|
||||||
|
tags: 'Tags',
|
||||||
|
notagoptions: 'No tags.',
|
||||||
|
selecttags: 'Select tags',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface TestResponse {
|
||||||
|
profiles: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
|
rootFolders: {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
}[];
|
||||||
|
tags: {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
}[];
|
||||||
|
urlBase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LidarrModalProps {
|
||||||
|
lidarr: LidarrSettings | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LidarrModal = ({ onClose, lidarr, onSave }: LidarrModalProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const initialLoad = useRef(false);
|
||||||
|
const { addToast } = useToasts();
|
||||||
|
const [isValidated, setIsValidated] = useState(lidarr ? true : false);
|
||||||
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
|
const [testResponse, setTestResponse] = useState<TestResponse>({
|
||||||
|
profiles: [],
|
||||||
|
rootFolders: [],
|
||||||
|
tags: [],
|
||||||
|
});
|
||||||
|
const LidarrSettingsSchema = Yup.object().shape({
|
||||||
|
name: Yup.string().required(
|
||||||
|
intl.formatMessage(messages.validationNameRequired)
|
||||||
|
),
|
||||||
|
hostname: Yup.string()
|
||||||
|
.required(intl.formatMessage(messages.validationHostnameRequired))
|
||||||
|
.matches(
|
||||||
|
/^(((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])):((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))@)?(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
|
||||||
|
intl.formatMessage(messages.validationHostnameRequired)
|
||||||
|
),
|
||||||
|
port: Yup.number()
|
||||||
|
.nullable()
|
||||||
|
.required(intl.formatMessage(messages.validationPortRequired)),
|
||||||
|
apiKey: Yup.string().required(
|
||||||
|
intl.formatMessage(messages.validationApiKeyRequired)
|
||||||
|
),
|
||||||
|
rootFolder: Yup.string().required(
|
||||||
|
intl.formatMessage(messages.validationRootFolderRequired)
|
||||||
|
),
|
||||||
|
activeProfileId: Yup.string().required(
|
||||||
|
intl.formatMessage(messages.validationProfileRequired)
|
||||||
|
),
|
||||||
|
externalUrl: Yup.string()
|
||||||
|
.url(intl.formatMessage(messages.validationApplicationUrl))
|
||||||
|
.test(
|
||||||
|
'no-trailing-slash',
|
||||||
|
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
||||||
|
(value) => !value || !value.endsWith('/')
|
||||||
|
),
|
||||||
|
baseUrl: Yup.string()
|
||||||
|
.test(
|
||||||
|
'leading-slash',
|
||||||
|
intl.formatMessage(messages.validationBaseUrlLeadingSlash),
|
||||||
|
(value) => !value || value.startsWith('/')
|
||||||
|
)
|
||||||
|
.test(
|
||||||
|
'no-trailing-slash',
|
||||||
|
intl.formatMessage(messages.validationBaseUrlTrailingSlash),
|
||||||
|
(value) => !value || !value.endsWith('/')
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const testConnection = useCallback(
|
||||||
|
async ({
|
||||||
|
hostname,
|
||||||
|
port,
|
||||||
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
useSsl = false,
|
||||||
|
}: {
|
||||||
|
hostname: string;
|
||||||
|
port: number;
|
||||||
|
apiKey: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
useSsl?: boolean;
|
||||||
|
}) => {
|
||||||
|
setIsTesting(true);
|
||||||
|
try {
|
||||||
|
const response = await axios.post<TestResponse>(
|
||||||
|
'/api/v1/settings/lidarr/test',
|
||||||
|
{
|
||||||
|
hostname,
|
||||||
|
apiKey,
|
||||||
|
port: Number(port),
|
||||||
|
baseUrl,
|
||||||
|
useSsl,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setIsValidated(true);
|
||||||
|
setTestResponse(response.data);
|
||||||
|
if (initialLoad.current) {
|
||||||
|
addToast(intl.formatMessage(messages.toastLidarrTestSuccess), {
|
||||||
|
appearance: 'success',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setIsValidated(false);
|
||||||
|
if (initialLoad.current) {
|
||||||
|
addToast(intl.formatMessage(messages.toastLidarrTestFailure), {
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsTesting(false);
|
||||||
|
initialLoad.current = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addToast, intl]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lidarr) {
|
||||||
|
testConnection({
|
||||||
|
apiKey: lidarr.apiKey,
|
||||||
|
hostname: lidarr.hostname,
|
||||||
|
port: lidarr.port,
|
||||||
|
baseUrl: lidarr.baseUrl,
|
||||||
|
useSsl: lidarr.useSsl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [lidarr, testConnection]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition
|
||||||
|
as="div"
|
||||||
|
appear
|
||||||
|
show
|
||||||
|
enter="transition-opacity ease-in-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="transition-opacity ease-in-out duration-300"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
name: lidarr?.name,
|
||||||
|
hostname: lidarr?.hostname,
|
||||||
|
port: lidarr?.port ?? 8686,
|
||||||
|
ssl: lidarr?.useSsl ?? false,
|
||||||
|
apiKey: lidarr?.apiKey,
|
||||||
|
baseUrl: lidarr?.baseUrl,
|
||||||
|
activeProfileId: lidarr?.activeProfileId,
|
||||||
|
rootFolder: lidarr?.activeDirectory,
|
||||||
|
isDefault: lidarr?.isDefault ?? false,
|
||||||
|
tags: lidarr?.tags ?? [],
|
||||||
|
externalUrl: lidarr?.externalUrl,
|
||||||
|
syncEnabled: lidarr?.syncEnabled ?? false,
|
||||||
|
enableSearch: !lidarr?.preventSearch,
|
||||||
|
tagRequests: lidarr?.tagRequests ?? false,
|
||||||
|
}}
|
||||||
|
validationSchema={LidarrSettingsSchema}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
try {
|
||||||
|
const profileName = testResponse.profiles.find(
|
||||||
|
(profile) => profile.id === Number(values.activeProfileId)
|
||||||
|
)?.name;
|
||||||
|
|
||||||
|
const submission = {
|
||||||
|
name: values.name,
|
||||||
|
hostname: values.hostname,
|
||||||
|
port: Number(values.port),
|
||||||
|
apiKey: values.apiKey,
|
||||||
|
useSsl: values.ssl,
|
||||||
|
baseUrl: values.baseUrl,
|
||||||
|
activeProfileId: Number(values.activeProfileId),
|
||||||
|
activeProfileName: profileName,
|
||||||
|
activeDirectory: values.rootFolder,
|
||||||
|
tags: values.tags,
|
||||||
|
isDefault: values.isDefault,
|
||||||
|
externalUrl: values.externalUrl,
|
||||||
|
syncEnabled: values.syncEnabled,
|
||||||
|
preventSearch: !values.enableSearch,
|
||||||
|
tagRequests: values.tagRequests,
|
||||||
|
};
|
||||||
|
if (!lidarr) {
|
||||||
|
await axios.post('/api/v1/settings/lidarr', submission);
|
||||||
|
} else {
|
||||||
|
await axios.put(
|
||||||
|
`/api/v1/settings/lidarr/${lidarr.id}`,
|
||||||
|
submission
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave();
|
||||||
|
} catch (e) {
|
||||||
|
// set error here
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
values,
|
||||||
|
handleSubmit,
|
||||||
|
setFieldValue,
|
||||||
|
isSubmitting,
|
||||||
|
isValid,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
onCancel={onClose}
|
||||||
|
okButtonType="primary"
|
||||||
|
okText={
|
||||||
|
isSubmitting
|
||||||
|
? intl.formatMessage(globalMessages.saving)
|
||||||
|
: lidarr
|
||||||
|
? intl.formatMessage(globalMessages.save)
|
||||||
|
: intl.formatMessage(messages.add)
|
||||||
|
}
|
||||||
|
secondaryButtonType="warning"
|
||||||
|
secondaryText={
|
||||||
|
isTesting
|
||||||
|
? intl.formatMessage(globalMessages.testing)
|
||||||
|
: intl.formatMessage(globalMessages.test)
|
||||||
|
}
|
||||||
|
onSecondary={() => {
|
||||||
|
if (values.apiKey && values.hostname && values.port) {
|
||||||
|
testConnection({
|
||||||
|
apiKey: values.apiKey,
|
||||||
|
baseUrl: values.baseUrl,
|
||||||
|
hostname: values.hostname,
|
||||||
|
port: values.port,
|
||||||
|
useSsl: values.ssl,
|
||||||
|
});
|
||||||
|
if (!values.baseUrl || values.baseUrl === '/') {
|
||||||
|
setFieldValue('baseUrl', testResponse.urlBase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
secondaryDisabled={
|
||||||
|
!values.apiKey ||
|
||||||
|
!values.hostname ||
|
||||||
|
!values.port ||
|
||||||
|
isTesting ||
|
||||||
|
isSubmitting
|
||||||
|
}
|
||||||
|
okDisabled={!isValidated || isSubmitting || isTesting || !isValid}
|
||||||
|
onOk={() => handleSubmit()}
|
||||||
|
title={
|
||||||
|
!lidarr
|
||||||
|
? intl.formatMessage(messages.createlidarr)
|
||||||
|
: intl.formatMessage(messages.editlidarr)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="isDefault" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.defaultserver)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field type="checkbox" id="isDefault" name="isDefault" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="name" className="text-label">
|
||||||
|
{intl.formatMessage(messages.servername)}
|
||||||
|
<span className="label-required">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setIsValidated(false);
|
||||||
|
setFieldValue('name', e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.name &&
|
||||||
|
touched.name &&
|
||||||
|
typeof errors.name === 'string' && (
|
||||||
|
<div className="error">{errors.name}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="hostname" className="text-label">
|
||||||
|
{intl.formatMessage(messages.hostname)}
|
||||||
|
<span className="label-required">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<span className="protocol">
|
||||||
|
{values.ssl ? 'https://' : 'http://'}
|
||||||
|
</span>
|
||||||
|
<Field
|
||||||
|
id="hostname"
|
||||||
|
name="hostname"
|
||||||
|
type="text"
|
||||||
|
inputMode="url"
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setIsValidated(false);
|
||||||
|
setFieldValue('hostname', e.target.value);
|
||||||
|
}}
|
||||||
|
className="rounded-r-only"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.hostname &&
|
||||||
|
touched.hostname &&
|
||||||
|
typeof errors.hostname === 'string' && (
|
||||||
|
<div className="error">{errors.hostname}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="port" className="text-label">
|
||||||
|
{intl.formatMessage(messages.port)}
|
||||||
|
<span className="label-required">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
id="port"
|
||||||
|
name="port"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
className="short"
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setIsValidated(false);
|
||||||
|
setFieldValue('port', e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{errors.port &&
|
||||||
|
touched.port &&
|
||||||
|
typeof errors.port === 'string' && (
|
||||||
|
<div className="error">{errors.port}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="ssl" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.ssl)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
type="checkbox"
|
||||||
|
id="ssl"
|
||||||
|
name="ssl"
|
||||||
|
onChange={() => {
|
||||||
|
setIsValidated(false);
|
||||||
|
setFieldValue('ssl', !values.ssl);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="apiKey" className="text-label">
|
||||||
|
{intl.formatMessage(messages.apiKey)}
|
||||||
|
<span className="label-required">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<SensitiveInput
|
||||||
|
as="field"
|
||||||
|
id="apiKey"
|
||||||
|
name="apiKey"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setIsValidated(false);
|
||||||
|
setFieldValue('apiKey', e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.apiKey &&
|
||||||
|
touched.apiKey &&
|
||||||
|
typeof errors.apiKey === 'string' && (
|
||||||
|
<div className="error">{errors.apiKey}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="baseUrl" className="text-label">
|
||||||
|
{intl.formatMessage(messages.baseUrl)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
id="baseUrl"
|
||||||
|
name="baseUrl"
|
||||||
|
type="text"
|
||||||
|
inputMode="url"
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setIsValidated(false);
|
||||||
|
setFieldValue('baseUrl', e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.baseUrl &&
|
||||||
|
touched.baseUrl &&
|
||||||
|
typeof errors.baseUrl === 'string' && (
|
||||||
|
<div className="error">{errors.baseUrl}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="activeProfileId" className="text-label">
|
||||||
|
{intl.formatMessage(messages.qualityprofile)}
|
||||||
|
<span className="label-required">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
as="select"
|
||||||
|
id="activeProfileId"
|
||||||
|
name="activeProfileId"
|
||||||
|
disabled={!isValidated || isTesting}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{isTesting
|
||||||
|
? intl.formatMessage(messages.loadingprofiles)
|
||||||
|
: !isValidated
|
||||||
|
? intl.formatMessage(
|
||||||
|
messages.testFirstQualityProfiles
|
||||||
|
)
|
||||||
|
: intl.formatMessage(messages.selectQualityProfile)}
|
||||||
|
</option>
|
||||||
|
{testResponse.profiles.length > 0 &&
|
||||||
|
testResponse.profiles.map((profile) => (
|
||||||
|
<option
|
||||||
|
key={`loaded-profile-${profile.id}`}
|
||||||
|
value={profile.id}
|
||||||
|
>
|
||||||
|
{profile.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{errors.activeProfileId &&
|
||||||
|
touched.activeProfileId &&
|
||||||
|
typeof errors.activeProfileId === 'string' && (
|
||||||
|
<div className="error">{errors.activeProfileId}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="rootFolder" className="text-label">
|
||||||
|
{intl.formatMessage(messages.rootfolder)}
|
||||||
|
<span className="label-required">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
as="select"
|
||||||
|
id="rootFolder"
|
||||||
|
name="rootFolder"
|
||||||
|
disabled={!isValidated || isTesting}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{isTesting
|
||||||
|
? intl.formatMessage(messages.loadingrootfolders)
|
||||||
|
: !isValidated
|
||||||
|
? intl.formatMessage(messages.testFirstRootFolders)
|
||||||
|
: intl.formatMessage(messages.selectRootFolder)}
|
||||||
|
</option>
|
||||||
|
{testResponse.rootFolders.length > 0 &&
|
||||||
|
testResponse.rootFolders.map((folder) => (
|
||||||
|
<option
|
||||||
|
key={`loaded-profile-${folder.id}`}
|
||||||
|
value={folder.path}
|
||||||
|
>
|
||||||
|
{folder.path}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{errors.rootFolder &&
|
||||||
|
touched.rootFolder &&
|
||||||
|
typeof errors.rootFolder === 'string' && (
|
||||||
|
<div className="error">{errors.rootFolder}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="tags" className="text-label">
|
||||||
|
{intl.formatMessage(messages.tags)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Select<OptionType, true>
|
||||||
|
options={
|
||||||
|
isValidated
|
||||||
|
? testResponse.tags.map((tag) => ({
|
||||||
|
label: tag.label,
|
||||||
|
value: tag.id,
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
isMulti
|
||||||
|
isDisabled={!isValidated || isTesting}
|
||||||
|
placeholder={
|
||||||
|
!isValidated
|
||||||
|
? intl.formatMessage(messages.testFirstTags)
|
||||||
|
: isTesting
|
||||||
|
? intl.formatMessage(messages.loadingTags)
|
||||||
|
: intl.formatMessage(messages.selecttags)
|
||||||
|
}
|
||||||
|
isLoading={isTesting}
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
value={
|
||||||
|
isTesting
|
||||||
|
? []
|
||||||
|
: (values.tags
|
||||||
|
.map((tagId) => {
|
||||||
|
const foundTag = testResponse.tags.find(
|
||||||
|
(tag) => tag.id === tagId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!foundTag) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: foundTag.id,
|
||||||
|
label: foundTag.label,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(option) => option !== undefined
|
||||||
|
) as OptionType[])
|
||||||
|
}
|
||||||
|
onChange={(value: OnChangeValue<OptionType, true>) => {
|
||||||
|
setFieldValue(
|
||||||
|
'tags',
|
||||||
|
value.map((option) => option.value)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
noOptionsMessage={() =>
|
||||||
|
intl.formatMessage(messages.notagoptions)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="externalUrl" className="text-label">
|
||||||
|
{intl.formatMessage(messages.externalUrl)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
id="externalUrl"
|
||||||
|
name="externalUrl"
|
||||||
|
type="text"
|
||||||
|
inputMode="url"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.externalUrl &&
|
||||||
|
touched.externalUrl &&
|
||||||
|
typeof errors.externalUrl === 'string' && (
|
||||||
|
<div className="error">{errors.externalUrl}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="syncEnabled" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.syncEnabled)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
type="checkbox"
|
||||||
|
id="syncEnabled"
|
||||||
|
name="syncEnabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="enableSearch" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.enableSearch)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
type="checkbox"
|
||||||
|
id="enableSearch"
|
||||||
|
name="enableSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="tagRequests" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.tagRequests)}
|
||||||
|
<span className="label-tip">
|
||||||
|
{intl.formatMessage(messages.tagRequestsInfo)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
type="checkbox"
|
||||||
|
id="tagRequests"
|
||||||
|
name="tagRequests"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LidarrModal;
|
@ -0,0 +1,65 @@
|
|||||||
|
import TitleCard from '@app/components/TitleCard';
|
||||||
|
import { MediaType } from '@server/constants/media';
|
||||||
|
import type {
|
||||||
|
ArtistResult,
|
||||||
|
RecordingResult,
|
||||||
|
ReleaseGroupResult,
|
||||||
|
ReleaseResult,
|
||||||
|
WorkResult,
|
||||||
|
} from '@server/models/Search';
|
||||||
|
|
||||||
|
export interface FetchedDataTitleCardProps {
|
||||||
|
data:
|
||||||
|
| ArtistResult
|
||||||
|
| ReleaseGroupResult
|
||||||
|
| ReleaseResult
|
||||||
|
| WorkResult
|
||||||
|
| RecordingResult;
|
||||||
|
canExpand?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FetchedDataTitleCard = ({
|
||||||
|
canExpand,
|
||||||
|
data,
|
||||||
|
}: FetchedDataTitleCardProps) => {
|
||||||
|
if (data.mediaType === 'artist') {
|
||||||
|
const newData = data as ArtistResult;
|
||||||
|
return (
|
||||||
|
<TitleCard
|
||||||
|
id={data.id}
|
||||||
|
image={data.posterPath}
|
||||||
|
status={newData.mediaInfo?.status}
|
||||||
|
title={newData.name}
|
||||||
|
mediaType={data.mediaType}
|
||||||
|
canExpand={canExpand}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (data.mediaType === 'release-group') {
|
||||||
|
return (
|
||||||
|
<TitleCard
|
||||||
|
id={data.id}
|
||||||
|
image={data.posterPath}
|
||||||
|
status={data.mediaInfo?.status}
|
||||||
|
title={data.title}
|
||||||
|
mediaType={data.mediaType}
|
||||||
|
canExpand={canExpand}
|
||||||
|
type={data.type ?? MediaType.MUSIC}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (data.mediaType === 'release') {
|
||||||
|
return (
|
||||||
|
<TitleCard
|
||||||
|
id={data.id}
|
||||||
|
image={data.posterPath}
|
||||||
|
status={data.mediaInfo?.status}
|
||||||
|
title={data.title}
|
||||||
|
mediaType={data.mediaType}
|
||||||
|
canExpand={canExpand}
|
||||||
|
type={data.releaseGroup?.type ?? MediaType.MUSIC}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FetchedDataTitleCard;
|
@ -0,0 +1,95 @@
|
|||||||
|
import TitleCard from '@app/components/TitleCard';
|
||||||
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
|
import type { SecondaryType } from '@server/constants/media';
|
||||||
|
import { MediaType } from '@server/constants/media';
|
||||||
|
import type {
|
||||||
|
ArtistResult,
|
||||||
|
RecordingResult,
|
||||||
|
ReleaseGroupResult,
|
||||||
|
ReleaseResult,
|
||||||
|
WorkResult,
|
||||||
|
} from '@server/models/Search';
|
||||||
|
import { useInView } from 'react-intersection-observer';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
export interface MusicBrainTitleCardProps {
|
||||||
|
id: number;
|
||||||
|
mbId: string;
|
||||||
|
type?: SecondaryType;
|
||||||
|
canExpand?: boolean;
|
||||||
|
displayType?: string;
|
||||||
|
preData?:
|
||||||
|
| ArtistResult
|
||||||
|
| ReleaseGroupResult
|
||||||
|
| ReleaseResult
|
||||||
|
| WorkResult
|
||||||
|
| RecordingResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MusicTitleCard = ({
|
||||||
|
id,
|
||||||
|
mbId,
|
||||||
|
canExpand,
|
||||||
|
type,
|
||||||
|
displayType,
|
||||||
|
}: MusicBrainTitleCardProps) => {
|
||||||
|
const { hasPermission } = useUser();
|
||||||
|
|
||||||
|
const { ref, inView } = useInView({
|
||||||
|
triggerOnce: true,
|
||||||
|
});
|
||||||
|
const url = `/api/v1/music/${type}/${mbId}`;
|
||||||
|
const { data, error } = useSWR<
|
||||||
|
| ArtistResult
|
||||||
|
| ReleaseGroupResult
|
||||||
|
| ReleaseResult
|
||||||
|
| WorkResult
|
||||||
|
| RecordingResult
|
||||||
|
>(inView ? `${url}` : null);
|
||||||
|
|
||||||
|
if (!data && !error) {
|
||||||
|
return (
|
||||||
|
<div ref={ref}>
|
||||||
|
<TitleCard.Placeholder canExpand={canExpand} type="music" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return hasPermission(Permission.ADMIN) ? (
|
||||||
|
<TitleCard.ErrorCard id={id} mbId={mbId} type="music" />
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.mediaType === 'artist') {
|
||||||
|
const newData = data as ArtistResult;
|
||||||
|
return (
|
||||||
|
<TitleCard
|
||||||
|
id={mbId}
|
||||||
|
image={data.posterPath}
|
||||||
|
status={newData.mediaInfo?.status}
|
||||||
|
title={newData.name}
|
||||||
|
mediaType={data.mediaType}
|
||||||
|
canExpand={canExpand}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
data.mediaType === 'release-group' ||
|
||||||
|
data.mediaType === 'release'
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<TitleCard
|
||||||
|
id={mbId}
|
||||||
|
image={data.posterPath}
|
||||||
|
status={data.mediaInfo?.status}
|
||||||
|
title={data.title}
|
||||||
|
mediaType={data.mediaType}
|
||||||
|
canExpand={canExpand}
|
||||||
|
type={displayType ?? MediaType.MUSIC}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MusicTitleCard;
|
@ -0,0 +1,8 @@
|
|||||||
|
import DiscoverMusics from '@app/components/Discover/DiscoverMusics';
|
||||||
|
import type { NextPage } from 'next';
|
||||||
|
|
||||||
|
const DiscoverMusicsPage: NextPage = () => {
|
||||||
|
return <DiscoverMusics />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverMusicsPage;
|
@ -0,0 +1,68 @@
|
|||||||
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||||
|
import MusicDetails from '@app/components/MusicDetails';
|
||||||
|
import Error from '@app/pages/_error';
|
||||||
|
import { SecondaryType } from '@server/constants/media';
|
||||||
|
import type {
|
||||||
|
ArtistResult,
|
||||||
|
RecordingResult,
|
||||||
|
ReleaseGroupResult,
|
||||||
|
ReleaseResult,
|
||||||
|
WorkResult,
|
||||||
|
} from '@server/models/Search';
|
||||||
|
import axios from 'axios';
|
||||||
|
import type { GetServerSideProps, NextPage } from 'next';
|
||||||
|
|
||||||
|
interface MusicPageProps {
|
||||||
|
music?:
|
||||||
|
| ArtistResult
|
||||||
|
| ReleaseGroupResult
|
||||||
|
| ReleaseResult
|
||||||
|
| RecordingResult
|
||||||
|
| WorkResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MusicPage: NextPage<MusicPageProps> = ({ music }) => {
|
||||||
|
if (!music) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
switch (music?.mediaType) {
|
||||||
|
case SecondaryType.ARTIST:
|
||||||
|
return <MusicDetails type={SecondaryType.ARTIST} artist={music} />;
|
||||||
|
case SecondaryType.RELEASE_GROUP:
|
||||||
|
return (
|
||||||
|
<MusicDetails type={SecondaryType.RELEASE_GROUP} releaseGroup={music} />
|
||||||
|
);
|
||||||
|
case SecondaryType.RELEASE:
|
||||||
|
return <MusicDetails type={SecondaryType.RELEASE} release={music} />;
|
||||||
|
default:
|
||||||
|
return <Error statusCode={404} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getServerSideProps: GetServerSideProps<MusicPageProps> = async (
|
||||||
|
ctx
|
||||||
|
) => {
|
||||||
|
const response = await axios.get<
|
||||||
|
| ArtistResult
|
||||||
|
| ReleaseGroupResult
|
||||||
|
| ReleaseResult
|
||||||
|
| RecordingResult
|
||||||
|
| WorkResult
|
||||||
|
>(
|
||||||
|
`http://localhost:${process.env.PORT || 5055}/api/v1/music/${
|
||||||
|
ctx.query.type
|
||||||
|
}/${ctx.query.mbId}`,
|
||||||
|
{
|
||||||
|
headers: ctx.req?.headers?.cookie
|
||||||
|
? { cookie: ctx.req.headers.cookie }
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
music: response.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MusicPage;
|
Loading…
Reference in new issue