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