Added the music tab

pull/3800/merge^2
Anatole Sot 4 months ago
parent 755b26e57f
commit 6fa4b36a3e

@ -648,6 +648,24 @@ components:
oneOf:
- $ref: '#/components/schemas/MovieResult'
- $ref: '#/components/schemas/TvResult'
MusicResult:
type: object
properties:
id:
type: string
example: 87f17f8a-c0e2-406c-a149-8c8e311bf330
mediaType:
type: string
posterPath:
type: string
title:
type: string
example: Album Name
releaseDate:
type: string
example: 19923-12-03
mediaInfo:
$ref: '#/components/schemas/MediaInfo'
Genre:
type: object
properties:
@ -4927,6 +4945,51 @@ paths:
type: array
items:
$ref: '#/components/schemas/TvResult'
/discover/musics:
get:
summary: Discover music
description: Returns a list of music in a JSON object.
tags:
- search
parameters:
- in: query
name: page
schema:
type: number
example: 1
default: 1
- in: query
name: keywords
schema:
type: string
example: 1,2
- in: query
name: sortBy
schema:
type: string
example: popularity.desc
responses:
'200':
description: Results
content:
application/json:
schema:
type: object
properties:
page:
type: number
example: 1
totalPages:
type: number
example: 20
totalResults:
type: number
example: 200
results:
type: array
items:
$ref: '#/components/schemas/MusicResult'
/discover/trending:
get:
summary: Trending movies and TV

@ -1,5 +1,6 @@
import BaseNodeBrainz from 'nodebrainz';
import type {
Artist,
ArtistCredit,
Group,
mbArtist,
@ -13,6 +14,7 @@ import type {
Release,
SearchOptions,
Tag,
Track,
Work,
} from './interfaces';
import { mbArtistType, mbReleaseGroupType, mbWorkType } from './interfaces';
@ -172,6 +174,114 @@ function searchOptionstoWorkSearchOptions(
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),
};
}
function convertReleaseGroup(releaseGroup: Group): mbReleaseGroup {
return {
media_type: 'release-group',
id: releaseGroup.id,
title: releaseGroup.title,
artist: releaseGroup['artist-credit'].map(convertArtistCredit),
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),
};
}
class MusicBrainz extends BaseNodeBrainz {
constructor() {
super({
@ -180,7 +290,7 @@ class MusicBrainz extends BaseNodeBrainz {
});
}
public searchMulti = async (search: SearchOptions) => {
public searchMulti = (search: SearchOptions) => {
try {
const artistSearch = searchOptionstoArtistSearchOptions(search);
const recordingSearch = searchOptionstoRecordingSearchOptions(search);
@ -188,13 +298,11 @@ class MusicBrainz extends BaseNodeBrainz {
searchOptionstoReleaseGroupSearchOptions(search);
const releaseSearch = searchOptionstoReleaseSearchOptions(search);
const workSearch = searchOptionstoWorkSearchOptions(search);
const artistResults = await this.searchArtists(artistSearch);
const recordingResults = await this.searchRecordings(recordingSearch);
const releaseGroupResults = await this.searchReleaseGroups(
releaseGroupSearch
);
const releaseResults = await this.searchReleases(releaseSearch);
const workResults = await this.searchWorks(workSearch);
const artistResults = this.searchArtists(artistSearch);
const recordingResults = this.searchRecordings(recordingSearch);
const releaseGroupResults = this.searchReleaseGroups(releaseGroupSearch);
const releaseResults = this.searchReleases(releaseSearch);
const workResults = this.searchWorks(workSearch);
const combinedResults = {
status: 'ok',
@ -218,138 +326,63 @@ class MusicBrainz extends BaseNodeBrainz {
}
};
public searchArtists = async (search: ArtistSearchOptions) => {
public searchArtists = (search: ArtistSearchOptions) => {
try {
const results = await this.search('artist', search);
const rawResults = this.search('artist', search) as Artist[];
const results = rawResults.map(convertArtist);
return results;
} catch (e) {
return [];
}
};
public searchRecordings = async (search: RecordingSearchOptions) => {
public searchRecordings = (search: RecordingSearchOptions) => {
try {
const results = await this.search('recording', search);
const rawResults = this.search('recording', search) as Recording[];
const results = rawResults.map(convertRecording);
return results;
} catch (e) {
return [];
}
};
public searchReleaseGroups = async (search: ReleaseGroupSearchOptions) => {
public searchReleaseGroups = (
search: ReleaseGroupSearchOptions
): mbReleaseGroup[] => {
try {
const results = await this.search('release-group', search);
const rawResults = this.search('release-group', search) as Group[];
const results = rawResults.map(convertReleaseGroup);
return results;
} catch (e) {
return [];
}
};
public searchReleases = async (search: ReleaseSearchOptions) => {
public searchReleases = (search: ReleaseSearchOptions): mbRelease[] => {
try {
const results = await this.search('release', search);
const rawResults = this.search('release', search) as Release[];
const results = rawResults.map(convertRelease);
return results;
} catch (e) {
return [];
}
};
public searchWorks = async (search: WorkSearchOptions) => {
public searchWorks = (search: WorkSearchOptions) => {
try {
const results = await this.search('work', search);
const results = this.search('work', search);
return results;
} catch (e) {
return [];
}
};
public getArtist = async (artistId: string): Promise<mbArtist> => {
public getArtist = (artistId: string): mbArtist => {
try {
const rawData = this.artist(artistId, {
inc: 'tags+recordings+releases+release-groups+works',
});
const artist: mbArtist = {
id: rawData.id,
name: rawData.name,
sortName: rawData['sort-name'],
type: (rawData.type as mbArtistType) || mbArtistType.OTHER,
recordings: rawData.recordings.map(
(recording: Recording): mbRecording => {
return {
id: recording.id,
artist: [
{
id: rawData.id,
name: rawData.name,
sortName: rawData['sort-name'],
type: (rawData.type as mbArtistType) || mbArtistType.OTHER,
tags: rawData.tags.map((tag: Tag) => tag.name),
},
],
title: recording.title,
length: recording.length,
tags: recording.tags.map((tag: Tag) => tag.name),
};
}
),
releases: rawData.releases.map((release: Release): mbRelease => {
return {
id: release.id,
artist: [
{
id: rawData.id,
name: rawData.name,
sortName: rawData['sort-name'],
type: (rawData.type as mbArtistType) || mbArtistType.OTHER,
tags: rawData.tags.map((tag: Tag) => tag.name),
},
],
title: release.title,
date: new Date(String(release.date)),
tags: release.tags.map((tag: Tag) => tag.name),
};
}),
releaseGroups: rawData['release-groups'].map(
(releaseGroup: Group): mbReleaseGroup => {
return {
id: releaseGroup.id,
artist: [
{
id: rawData.id,
name: rawData.name,
sortName: rawData['sort-name'],
type: (rawData.type as mbArtistType) || mbArtistType.OTHER,
tags: rawData.tags.map((tag: Tag) => tag.name),
},
],
title: releaseGroup.title,
type:
(releaseGroup['primary-type'] as mbReleaseGroupType) ||
mbReleaseGroupType.OTHER,
firstReleased: new Date(releaseGroup['first-release-date']),
tags: releaseGroup.tags.map((tag: Tag) => tag.name),
};
}
),
works: rawData.works.map((work: Work): mbWork => {
return {
id: work.id,
title: work.title,
type: (work.type as mbWorkType) || mbWorkType.OTHER,
artist: [
{
id: rawData.id,
name: rawData.name,
sortName: rawData['sort-name'],
type: (rawData.type as mbArtistType) || mbArtistType.OTHER,
tags: rawData.tags.map((tag: Tag) => tag.name),
},
],
tags: work.tags.map((tag: Tag) => tag.name),
};
}),
tags: rawData.tags.map((tag: Tag) => tag.name),
};
const artist: mbArtist = convertArtist(rawData);
return artist;
} catch (e) {
throw new Error(
@ -358,29 +391,12 @@ class MusicBrainz extends BaseNodeBrainz {
}
};
public getRecording = async (recordingId: string): Promise<mbRecording> => {
public getRecording = (recordingId: string): mbRecording => {
try {
const rawData = this.recording(recordingId, {
inc: 'tags+artists+releases',
});
const recording: mbRecording = {
id: rawData.id,
title: rawData.title,
artist: rawData['artist-credit'].map(
(artist: ArtistCredit): mbArtist => {
return {
id: artist.artist.id,
name: artist.artist.name,
sortName: artist.artist['sort-name'],
type: (artist.artist.type as mbArtistType) || mbArtistType.OTHER,
tags: artist.artist.tags.map((tag: Tag) => tag.name),
};
}
),
length: rawData.length,
firstReleased: new Date(rawData['first-release-date']),
tags: rawData.tags.map((tag: Tag) => tag.name),
};
const recording: mbRecording = convertRecording(rawData);
return recording;
} catch (e) {
throw new Error(
@ -389,33 +405,12 @@ class MusicBrainz extends BaseNodeBrainz {
}
};
public async getReleaseGroup(
releaseGroupId: string
): Promise<mbReleaseGroup> {
public getReleaseGroup(releaseGroupId: string): mbReleaseGroup {
try {
const rawData = this.releaseGroup(releaseGroupId, {
inc: 'tags+artists+releases',
});
const releaseGroup: mbReleaseGroup = {
id: rawData.id,
title: rawData.title,
artist: rawData['artist-credit'].map(
(artist: ArtistCredit): mbArtist => {
return {
id: artist.artist.id,
name: artist.artist.name,
sortName: artist.artist['sort-name'],
type: (artist.artist.type as mbArtistType) || mbArtistType.OTHER,
tags: artist.artist.tags.map((tag: Tag) => tag.name),
};
}
),
type:
(rawData['primary-type'] as mbReleaseGroupType) ||
mbReleaseGroupType.OTHER,
firstReleased: new Date(rawData['first-release-date']),
tags: rawData.tags.map((tag: Tag) => tag.name),
};
const releaseGroup: mbReleaseGroup = convertReleaseGroup(rawData);
return releaseGroup;
} catch (e) {
throw new Error(
@ -424,51 +419,12 @@ class MusicBrainz extends BaseNodeBrainz {
}
}
public async getRelease(releaseId: string): Promise<mbRelease> {
public getRelease(releaseId: string): mbRelease {
try {
const rawData = this.release(releaseId, {
inc: 'tags+artists+recordings',
});
const release: mbRelease = {
id: rawData.id,
title: rawData.title,
artist: rawData['artist-credit'].map(
(artist: ArtistCredit): mbArtist => {
return {
id: artist.artist.id,
name: artist.artist.name,
sortName: artist.artist['sort-name'],
type: (artist.artist.type as mbArtistType) || mbArtistType.OTHER,
tags: artist.artist.tags.map((tag: Tag) => tag.name),
};
}
),
date:
rawData['release-events'] && rawData['release-events'].length > 0
? new Date(String(rawData['release-events'][0].date))
: undefined,
tracks: rawData.media.flatMap((media: Medium): mbRecording[] => {
return (media.tracks ?? []).map((track): mbRecording => {
return {
id: track.id,
title: track.title,
artist: track.recording['artist-credit'].map((artist) => {
return {
id: artist.artist.id,
name: artist.artist.name,
sortName: artist.artist['sort-name'],
type:
(artist.artist.type as mbArtistType) || mbArtistType.OTHER,
tags: artist.artist.tags.map((tag: Tag) => tag.name),
};
}),
length: track.recording.length,
tags: track.recording.tags.map((tag: Tag) => tag.name),
};
});
}),
tags: rawData.tags.map((tag: Tag) => tag.name),
};
const release: mbRelease = convertRelease(rawData);
return release;
} catch (e) {
throw new Error(
@ -477,24 +433,10 @@ class MusicBrainz extends BaseNodeBrainz {
}
}
public async getWork(workId: string): Promise<mbWork> {
public getWork(workId: string): mbWork {
try {
const rawData = this.work(workId, { inc: 'tags+artist-rels' });
const work: mbWork = {
id: rawData.id,
title: rawData.title,
type: (rawData.type as mbWorkType) || mbWorkType.OTHER,
artist: rawData.relations.map((relation: Relation): mbArtist => {
return {
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((tag: Tag) => tag.name),
};
}),
tags: rawData.tags.map((tag: Tag) => tag.name),
};
const work: mbWork = convertWork(rawData);
return work;
} catch (e) {
throw new Error(
@ -505,3 +447,11 @@ class MusicBrainz extends BaseNodeBrainz {
}
export default MusicBrainz;
export type {
SearchOptions,
ArtistSearchOptions,
RecordingSearchOptions,
ReleaseSearchOptions,
ReleaseGroupSearchOptions,
WorkSearchOptions,
};

@ -1,5 +1,11 @@
// Purpose: Interfaces for MusicBrainz data.
export interface mbDefaultType {
media_type: string;
id: string;
tags: string[];
}
export enum mbArtistType {
PERSON = 'Person',
GROUP = 'Group',
@ -9,8 +15,8 @@ export enum mbArtistType {
OTHER = 'Other',
}
export interface mbArtist {
id: string;
export interface mbArtist extends mbDefaultType {
media_type: 'artist';
name: string;
sortName: string;
type: mbArtistType;
@ -22,25 +28,22 @@ export interface mbArtist {
area?: string;
beginDate?: string;
endDate?: string;
tags: string[];
}
export interface mbRecording {
id: string;
export interface mbRecording extends mbDefaultType {
media_type: 'recording';
title: string;
artist: mbArtist[];
length: number;
firstReleased?: Date;
tags: string[];
}
export interface mbRelease {
id: string;
export interface mbRelease extends mbDefaultType {
media_type: 'release';
title: string;
artist: mbArtist[];
date?: Date;
tracks?: mbRecording[];
tags: string[];
}
export enum mbReleaseGroupType {
@ -51,14 +54,13 @@ export enum mbReleaseGroupType {
OTHER = 'Other',
}
export interface mbReleaseGroup {
id: string;
export interface mbReleaseGroup extends mbDefaultType {
media_type: 'release-group';
title: string;
artist: mbArtist[];
type: mbReleaseGroupType;
firstReleased?: Date;
releases?: mbRelease[];
tags: string[];
}
export enum mbWorkType {
@ -94,12 +96,11 @@ export enum mbWorkType {
OTHER = 'Other',
}
export interface mbWork {
id: string;
export interface mbWork extends mbDefaultType {
media_type: 'work';
title: string;
type: mbWorkType;
artist: mbArtist[];
tags: string[];
}
export interface Artist {

@ -0,0 +1,6 @@
import type { mbRelease, mbReleaseGroup } from './interfaces';
function getPosterFromMB(element: mbRelease | mbReleaseGroup): string {
return `https://coverartarchive.org/${element.media_type}/${element.id}/front-250.jpg`;
}
export default getPosterFromMB;

@ -25,20 +25,31 @@ import Season from './Season';
@Entity()
class Media {
public static async getRelatedMedia(
tmdbIds: number | number[]
tmdbIds: number | number[] = [],
mbIds: string | string[] = []
): Promise<Media[]> {
const mediaRepository = getRepository(Media);
try {
let finalIds: number[];
let finalTmdbIds: number[] = [];
if (!Array.isArray(tmdbIds)) {
finalIds = [tmdbIds];
finalTmdbIds = [tmdbIds];
} else {
finalIds = tmdbIds;
finalTmdbIds = tmdbIds;
}
let finalMusicBrainzIds: string[] = [];
if (!Array.isArray(mbIds)) {
finalMusicBrainzIds = [mbIds];
} else {
finalMusicBrainzIds = mbIds;
}
const media = await mediaRepository.find({
where: { tmdbId: In(finalIds) },
where: [
{ tmdbId: In(finalTmdbIds) },
{ mbId: In(finalMusicBrainzIds) },
],
});
return media;

@ -1,3 +1,13 @@
import type {
mbArtist,
mbArtistType,
mbRecording,
mbRelease,
mbReleaseGroup,
mbReleaseGroupType,
mbWork,
} from '@server/api/musicbrainz/interfaces';
import getPosterFromMB from '@server/api/musicbrainz/poster';
import type {
TmdbCollectionResult,
TmdbMovieDetails,
@ -10,7 +20,17 @@ import type {
import { MediaType as MainMediaType } from '@server/constants/media';
import type Media from '@server/entity/Media';
export type MediaType = 'tv' | 'movie' | 'person' | 'collection';
export type MediaType =
| 'tv'
| 'movie'
| 'music'
| 'person'
| 'collection'
| 'release-group'
| 'release'
| 'recording'
| 'work'
| 'artist';
interface SearchResult {
id: number;
@ -44,6 +64,14 @@ export interface TvResult extends SearchResult {
firstAirDate: string;
}
export interface MusicResult extends SearchResult {
mediaType: 'music';
title: string;
originalTitle: string;
releaseDate: string;
mediaInfo?: Media;
}
export interface CollectionResult {
id: number;
mediaType: 'collection';
@ -66,7 +94,74 @@ export interface PersonResult {
knownFor: (MovieResult | TvResult)[];
}
export type Results = MovieResult | TvResult | PersonResult | CollectionResult;
export interface ReleaseGroupResult {
id: string;
mediaType: 'release-group';
type: mbReleaseGroupType;
posterPath?: string;
title: string;
artist: ArtistResult[];
tags: string[];
mediaInfo?: Media;
}
export interface ReleaseResult {
id: string;
mediaType: 'release';
title: string;
artist: ArtistResult[];
posterPath?: string;
date?: Date;
tracks?: RecordingResult[];
tags: string[];
mediaInfo?: Media;
}
export interface RecordingResult {
id: string;
mediaType: 'recording';
title: string;
artist: ArtistResult[];
length: number;
firstReleased?: Date;
tags: string[];
}
export interface WorkResult {
id: string;
mediaType: 'work';
title: string;
artist: ArtistResult[];
tags: string[];
}
export interface ArtistResult {
id: string;
mediaType: 'artist';
name: string;
type: mbArtistType;
releases: ReleaseResult[];
recordings: RecordingResult[];
releaseGroups: ReleaseGroupResult[];
works: WorkResult[];
gender?: string;
area?: string;
beginDate?: string;
endDate?: string;
tags: string[];
}
export type Results =
| MovieResult
| TvResult
| MusicResult
| PersonResult
| CollectionResult
| ReleaseGroupResult
| ReleaseResult
| RecordingResult
| WorkResult
| ArtistResult;
export const mapMovieResult = (
movieResult: TmdbMovieResult,
@ -144,12 +239,90 @@ export const mapPersonResult = (
}),
});
export const mapReleaseGroupResult = (
releaseGroupResult: mbReleaseGroup,
media?: Media
): ReleaseGroupResult => ({
id: releaseGroupResult.id,
mediaType: releaseGroupResult.media_type,
type: releaseGroupResult.type,
title: releaseGroupResult.title,
artist: releaseGroupResult.artist.map((artist) => mapArtistResult(artist)),
tags: releaseGroupResult.tags,
posterPath: getPosterFromMB(releaseGroupResult),
mediaInfo: media,
});
export const mapArtistResult = (artist: mbArtist): ArtistResult => ({
id: artist.id,
mediaType: 'artist',
name: artist.name,
type: artist.type,
releases: Array.isArray(artist.releases)
? artist.releases.map((release) => mapReleaseResult(release))
: [],
recordings: Array.isArray(artist.recordings)
? artist.recordings.map((recording) => mapRecordingResult(recording))
: [],
releaseGroups: Array.isArray(artist.releaseGroups)
? artist.releaseGroups.map((releaseGroup) =>
mapReleaseGroupResult(releaseGroup)
)
: [],
works: Array.isArray(artist.works)
? artist.works.map((work) => mapWorkResult(work))
: [],
tags: artist.tags,
});
export const mapReleaseResult = (
release: mbRelease,
media?: Media
): ReleaseResult => ({
id: release.id,
mediaType: release.media_type,
title: release.title,
posterPath: getPosterFromMB(release),
artist: release.artist.map((artist) => mapArtistResult(artist)),
date: release.date,
tracks: Array.isArray(release.tracks)
? release.tracks.map((track) => mapRecordingResult(track))
: [],
tags: release.tags,
mediaInfo: media,
});
export const mapRecordingResult = (
recording: mbRecording
): RecordingResult => ({
id: recording.id,
mediaType: recording.media_type,
title: recording.title,
artist: recording.artist.map((artist) => mapArtistResult(artist)),
length: recording.length,
firstReleased: recording.firstReleased,
tags: recording.tags,
});
export const mapWorkResult = (work: mbWork): WorkResult => ({
id: work.id,
mediaType: work.media_type,
title: work.title,
artist: work.artist.map((artist) => mapArtistResult(artist)),
tags: work.tags,
});
export const mapSearchResults = (
results: (
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
| mbArtist
| mbRecording
| mbRelease
| mbReleaseGroup
| mbWork
)[],
media?: Media[]
): Results[] =>
@ -173,8 +346,26 @@ export const mapSearchResults = (
);
case 'collection':
return mapCollectionResult(result);
default:
case 'person':
return mapPersonResult(result);
case 'release-group':
return mapReleaseGroupResult(
result,
media?.find((req) => req.mbId === result.id)
);
case 'release':
return mapReleaseResult(
result,
media?.find((req) => req.mbId === result.id)
);
case 'recording':
return mapRecordingResult(result);
case 'work':
return mapWorkResult(result);
case 'artist':
return mapArtistResult(result);
default:
return result;
}
});

@ -1,3 +1,4 @@
import MusicBrainz from '@server/api/musicbrainz';
import PlexTvAPI from '@server/api/plextv';
import type { SortOptions } from '@server/api/themoviedb';
import TheMovieDb from '@server/api/themoviedb';
@ -17,6 +18,7 @@ import {
mapCollectionResult,
mapMovieResult,
mapPersonResult,
mapReleaseGroupResult,
mapTvResult,
} from '@server/models/Search';
import { mapNetwork } from '@server/models/Tv';
@ -850,4 +852,40 @@ discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
}
);
discoverRoutes.get('/musics', async (req, res, next) => {
const mb = new MusicBrainz();
try {
const query = QueryFilterOptions.parse(req.query);
const keywords = query.keywords;
const results = mb.searchReleaseGroups({
query: keywords ?? '',
limit: 20,
offset: (Number(query.page) - 1) * 20,
});
const tmdbIds = [] as number[];
const mbIds = results.map((result) => result.id);
const media = await Media.getRelatedMedia(tmdbIds, mbIds);
return res.status(200).json({
page: query.page,
results: results.map((result) => {
mapReleaseGroupResult(
result,
media.find(
(med) => med.mbId === result.id && med.mediaType === MediaType.MUSIC
)
);
}),
});
} catch (e) {
logger.debug('Something went wrong retrieving popular series', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve popular series.',
});
}
});
export default discoverRoutes;

@ -1,9 +1 @@
<svg viewBox="0 0 216.7 216.9" xmlns="http://www.w3.org/2000/svg"><path d="M216.7 108.45c0 29.833-10.533 55.4-31.6 76.7-.7.833-1.483 1.6-2.35 2.3-3.466 3.4-7.133 6.484-11 9.25-18.267 13.467-39.367 20.2-63.3 20.2-23.967 0-45.033-6.733-63.2-20.2-4.8-3.4-9.3-7.25-13.5-11.55-16.367-16.266-26.417-35.167-30.15-56.7-.733-4.2-1.217-8.467-1.45-12.8-.1-2.4-.15-4.8-.15-7.2 0-2.533.05-4.95.15-7.25 0-.233.066-.467.2-.7 1.567-26.6 12.033-49.583 31.4-68.95C53.05 10.517 78.617 0 108.45 0c29.933 0 55.484 10.517 76.65 31.55 21.067 21.433 31.6 47.067 31.6 76.9z" clip-rule="evenodd" fill="#EEE" fill-rule="evenodd"></path><path d="M194.65 42.5l-22.4 22.4C159.152 77.998 158 89.4 158 109.5c0 17.934 2.852 34.352 16.2 47.7 9.746 9.746 19 18.95 19 18.95-2.5 3.067-5.2 6.067-8.1 9-.7.833-1.483 1.6-2.35 2.3-2.533 2.5-5.167 4.817-7.9 6.95l-17.55-17.55c-15.598-15.6-27.996-17.1-48.6-17.1-19.77 0-33.223 1.822-47.7 16.3-8.647 8.647-18.55 18.6-18.55 18.6-3.767-2.867-7.333-6.034-10.7-9.5-2.8-2.8-5.417-5.667-7.85-8.6 0 0 9.798-9.848 19.15-19.2 13.852-13.853 16.1-29.916 16.1-47.85 0-17.5-2.874-33.823-15.6-46.55-8.835-8.836-21.05-21-21.05-21 2.833-3.6 5.917-7.067 9.25-10.4 2.934-2.867 5.934-5.55 9-8.05L61.1 43.85C74.102 56.852 90.767 60.2 108.7 60.2c18.467 0 35.077-3.577 48.6-17.1 8.32-8.32 19.3-19.25 19.3-19.25 2.9 2.367 5.733 4.933 8.5 7.7 3.467 3.533 6.65 7.183 9.55 10.95z" clip-rule="evenodd" fill="#3A3F51" fill-rule="evenodd"></path><g clip-rule="evenodd" style="
fill: #FFEB3B;
"><path d="M78.7 114c-.2-1.167-.332-2.35-.4-3.55-.032-.667-.05-1.333-.05-2 0-.7.018-1.367.05-2 0-.067.018-.133.05-.2.435-7.367 3.334-13.733 8.7-19.1 5.9-5.833 12.984-8.75 21.25-8.75 8.3 0 15.384 2.917 21.25 8.75 5.834 5.934 8.75 13.033 8.75 21.3 0 8.267-2.916 15.35-8.75 21.25-.2.233-.416.45-.65.65-.966.933-1.982 1.783-3.05 2.55-5.065 3.733-10.916 5.6-17.55 5.6s-12.466-1.866-17.5-5.6c-1.332-.934-2.582-2-3.75-3.2-4.532-4.5-7.316-9.734-8.35-15.7z" fill="#0CF" fill-rule="evenodd" style="
fill: #FFEB3B;
"></path><path d="M157.8 59.75l-15 14.65M30.785 32.526L71.65 73.25m84.6 84.25l27.808 28.78m1.855-153.894L157.8 59.75m-125.45 126l27.35-27.4" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="2" style="
stroke: #FFEB3B;
"></path><path d="M157.8 59.75l-16.95 17.2M58.97 60.604l17.2 17.15M59.623 158.43l16.75-17.4m61.928-1.396l18.028 17.945" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="7" style="
stroke: #FFEB3B;
"></path></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 flex-shrink-0" viewBox="0 0 1024 1024"><style>.lidarr_svg__a{fill:#989898;stroke-width:24}.lidarr_svg__b{fill:none;stroke-width:16;stroke:#009252}</style><path fill="none" d="M-1-1h1026v1026H-1z"></path><circle cx="512" cy="512" r="410" stroke-width="1.8"></circle><circle cx="512" cy="512" r="460" style="fill: none; stroke-width: 99; stroke: rgb(229, 229, 229);"></circle><circle cx="512" cy="512" r="270" style="fill: rgb(229, 229, 229); stroke-width: 76; stroke: rgb(229, 229, 229);"></circle><circle cy="512" cx="512" r="410" style="fill: none; stroke-width: 12; stroke: rgb(0, 146, 82);"></circle><path d="M512 636V71L182 636h330zM512 388v565l330-565H512z"></path><path d="M512 636V71L182 636h330zM512 388v565l330-565H512z" class="lidarr_svg__b"></path><circle cx="512" cy="512" r="150" style="fill: rgb(0, 146, 82);"></circle></svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 897 B

@ -9,11 +9,17 @@ import type {
MovieResult,
PersonResult,
TvResult,
MusicResult,
ArtistResult,
ReleaseResult,
ReleaseGroupResult,
WorkResult,
RecordingResult,
} from '@server/models/Search';
import { useIntl } from 'react-intl';
type ListViewProps = {
items?: (TvResult | MovieResult | PersonResult | CollectionResult)[];
items?: (TvResult | MovieResult | PersonResult | CollectionResult | MusicResult | ArtistResult | ReleaseResult | ReleaseGroupResult | WorkResult | RecordingResult)[];
plexItems?: WatchlistItem[];
isEmpty?: boolean;
isLoading?: boolean;
@ -113,6 +119,58 @@ const ListView = ({
/>
);
break;
case 'artist':
titleCard = (
<TitleCard
id={title.id}
title={title.name}
mediaType={title.mediaType}
canExpand
/>
);
break;
case 'release':
titleCard = (
<TitleCard
id={title.id}
image={title.posterPath}
title={title.title}
mediaType={title.mediaType}
canExpand
/>
);
break;
case 'release-group':
titleCard = (
<TitleCard
id={title.id}
image={title.posterPath}
title={title.title}
mediaType={title.mediaType}
canExpand
/>
);
break;
case 'work':
titleCard = (
<TitleCard
id={title.id}
title={title.title}
mediaType={title.mediaType}
canExpand
/>
);
break;
case 'recording':
titleCard = (
<TitleCard
id={title.id}
title={title.title}
mediaType={title.mediaType}
canExpand
/>
);
break;
}
return <li key={`${title.id}-${index}`}>{titleCard}</li>;

@ -0,0 +1,94 @@
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 useDiscover from '@app/hooks/useDiscover';
import Error from '@app/pages/_error';
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
import type { MusicResult } 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}}',
});
const DiscoverMusics = () => {
const intl = useIntl();
const router = useRouter();
const preparedFilters = prepareFilterValues(router.query);
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<MusicResult, unknown, FilterOptions>(
'/api/v1/discover/musics',
preparedFilters
);
const [showFilters, setShowFilters] = useState(false);
if (error) {
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">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
<BarsArrowDownIcon className="h-6 w-6" />
</span>
</div>
<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>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverMusics;

@ -44,7 +44,7 @@ const messages = defineMessages({
type FilterSlideoverProps = {
show: boolean;
onClose: () => void;
type: 'movie' | 'tv';
type: 'movie' | 'tv' | 'music';
currentFilters: FilterOptions;
};
@ -290,27 +290,29 @@ const FilterSlideover = ({
<span className="text-lg font-semibold">
{intl.formatMessage(messages.streamingservices)}
</span>
<WatchProviderSelector
type={type}
region={currentFilters.watchRegion}
activeProviders={
currentFilters.watchProviders?.split('|').map((v) => Number(v)) ??
[]
}
onChange={(region, providers) => {
if (providers.length) {
batchUpdateQueryParams({
watchRegion: region,
watchProviders: providers.join('|'),
});
} else {
batchUpdateQueryParams({
watchRegion: undefined,
watchProviders: undefined,
});
}
}}
/>
{type in ['movie', 'tv']
?(<WatchProviderSelector
type={type as 'movie' | 'tv'}
region={currentFilters.watchRegion}
activeProviders={
currentFilters.watchProviders?.split('|').map((v) => Number(v)) ??
[]
}
onChange={(region, providers) => {
if (providers.length) {
batchUpdateQueryParams({
watchRegion: region,
watchProviders: providers.join('|'),
});
} else {
batchUpdateQueryParams({
watchRegion: undefined,
watchProviders: undefined,
});
}
}}
/>)
: null}
<div className="pt-4">
<Button
className="w-full"

@ -9,6 +9,7 @@ import {
FilmIcon,
SparklesIcon,
TvIcon,
MusicalNoteIcon,
UsersIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
@ -25,6 +26,7 @@ export const menuMessages = defineMessages({
issues: 'Issues',
users: 'Users',
settings: 'Settings',
music: 'Music',
});
interface SidebarProps {
@ -62,6 +64,12 @@ const SidebarLinks: SidebarLinkProps[] = [
svgIcon: <TvIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/discover\/tv$/,
},
{
href: '/discover/music',
messagesKey: 'music',
svgIcon: <MusicalNoteIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/discover\/music$/,
},
{
href: '/requests',
messagesKey: 'requests',

@ -7,8 +7,9 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
interface RequestModalProps {
show: boolean;
type: 'movie' | 'tv' | 'collection';
type: 'movie' | 'tv' | 'collection' | 'music';
tmdbId: number;
mbId: string;
is4k?: boolean;
editRequest?: MediaRequest;
onComplete?: (newStatus: MediaStatus) => void;
@ -20,6 +21,7 @@ const RequestModal = ({
type,
show,
tmdbId,
mbId,
is4k,
editRequest,
onComplete,
@ -55,7 +57,7 @@ const RequestModal = ({
is4k={is4k}
editRequest={editRequest}
/>
) : (
) : type === 'collection' ? (
<CollectionRequestModal
onComplete={onComplete}
onCancel={onCancel}
@ -63,7 +65,9 @@ const RequestModal = ({
onUpdating={onUpdating}
is4k={is4k}
/>
)}
) : type === 'music' ? (
<div>Music:{mbId}</div>
) : null}
</Transition>
);
};

@ -131,7 +131,7 @@ export const CompanySelector = ({
};
type GenreSelectorProps = (BaseSelectorMultiProps | BaseSelectorSingleProps) & {
type: 'movie' | 'tv';
type: 'movie' | 'tv' | 'music';
};
export const GenreSelector = ({

@ -26,7 +26,6 @@ const messages = defineMessages({
validationApiKeyRequired: 'You must provide an API key',
validationRootFolderRequired: 'You must select a root folder',
validationProfileRequired: 'You must select a quality profile',
validationLanguageProfileRequired: 'You must select a language profile',
toastLidarrTestSuccess: 'Lidarr connection established successfully!',
toastLidarrTestFailure: 'Failed to connect to Lidarr.',
add: 'Add Server',
@ -38,11 +37,9 @@ const messages = defineMessages({
apiKey: 'API Key',
baseUrl: 'URL Base',
qualityprofile: 'Quality Profile',
languageprofile: 'Language Profile',
rootfolder: 'Root Folder',
selectQualityProfile: 'Select quality profile',
selectRootFolder: 'Select root folder',
selectLanguageProfile: 'Select language profile',
loadingprofiles: 'Loading quality profiles…',
testFirstQualityProfiles: 'Test connection to load quality profiles',
loadingrootfolders: 'Loading root folders…',
@ -73,10 +70,6 @@ interface TestResponse {
id: number;
path: string;
}[];
languageProfiles: {
id: number;
name: string;
}[];
tags: {
id: number;
label: string;
@ -99,7 +92,6 @@ const LidarrModal = ({ onClose, lidarr, onSave }: LidarrModalProps) => {
const [testResponse, setTestResponse] = useState<TestResponse>({
profiles: [],
rootFolders: [],
languageProfiles: [],
tags: [],
});
const LidarrSettingsSchema = Yup.object().shape({
@ -124,9 +116,6 @@ const LidarrModal = ({ onClose, lidarr, onSave }: LidarrModalProps) => {
activeProfileId: Yup.string().required(
intl.formatMessage(messages.validationProfileRequired)
),
activeLanguageProfileId: Yup.number().required(
intl.formatMessage(messages.validationLanguageProfileRequired)
),
externalUrl: Yup.string()
.url(intl.formatMessage(messages.validationApplicationUrl))
.test(
@ -226,7 +215,7 @@ const LidarrModal = ({ onClose, lidarr, onSave }: LidarrModalProps) => {
initialValues={{
name: lidarr?.name,
hostname: lidarr?.hostname,
port: lidarr?.port ?? 8989,
port: lidarr?.port ?? 8686,
ssl: lidarr?.useSsl ?? false,
apiKey: lidarr?.apiKey,
baseUrl: lidarr?.baseUrl,
@ -562,15 +551,6 @@ const LidarrModal = ({ onClose, lidarr, onSave }: LidarrModalProps) => {
)}
</div>
</div>
<div className="form-row">
<label
htmlFor="activeLanguageProfileId"
className="text-label"
>
{intl.formatMessage(messages.languageprofile)}
<span className="label-required">*</span>
</label>
</div>
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}

@ -18,7 +18,7 @@ import { Fragment, useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
interface TitleCardProps {
id: number;
id: number|string;
image?: string;
summary?: string;
year?: string;
@ -79,21 +79,24 @@ const TitleCard = ({
],
{ type: 'or' }
);
const tmdbOrMbId: boolean = mediaType in ['movie', 'tv', 'collection'];
return (
<div
className={canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'}
data-testid="title-card"
>
<RequestModal
tmdbId={id}
tmdbId={tmdbOrMbId ? (id as number) : -1}
mbId={tmdbOrMbId ? "" : (id as string)}
show={showRequestModal}
type={
mediaType === 'movie'
? 'movie'
: mediaType === 'collection'
? 'collection'
: 'tv'
: mediaType === 'tv'
? 'tv'
: 'music'
}
onComplete={requestComplete}
onUpdating={requestUpdating}
@ -188,13 +191,7 @@ const TitleCard = ({
>
<div className="absolute inset-0 overflow-hidden rounded-xl">
<Link
href={
mediaType === 'movie'
? `/movie/${id}`
: mediaType === 'collection'
? `/collection/${id}`
: `/tv/${id}`
}
href={`/${mediaType}/${id}`}
>
<a
className="absolute inset-0 h-full w-full cursor-pointer overflow-hidden text-left"

@ -0,0 +1,8 @@
import DiscoverMusics from '@app/components/Discover/DiscoverMusics';
import type { NextPage } from 'next';
const DiscoverMusicsPage: NextPage = () => {
return <DiscoverMusics />;
};
export default DiscoverMusicsPage;
Loading…
Cancel
Save