diff --git a/overseerr-api.yml b/overseerr-api.yml index b75ca5867..a5312602f 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -6117,6 +6117,18 @@ paths: type: boolean example: false default: false + - in: query + name: maxElements + schema: + type: number + example: 50 + default: 25 + - in: query + name: offset + schema: + type: number + example: 25 + default: 0 responses: '200': description: Artist details diff --git a/server/api/musicbrainz/index.ts b/server/api/musicbrainz/index.ts index fc08ebc5f..4f2eda50e 100644 --- a/server/api/musicbrainz/index.ts +++ b/server/api/musicbrainz/index.ts @@ -485,7 +485,11 @@ class MusicBrainz extends BaseNodeBrainz { } }; - public getFullArtist = (artistId: string): Promise => { + public getFullArtist = ( + artistId: string, + maxElements = 25, + startOffset = 0 + ): Promise => { try { return new Promise((resolve, reject) => { this.artist( @@ -498,12 +502,26 @@ class MusicBrainz extends BaseNodeBrainz { reject(error); } else { const results = convertArtist(data as Artist); - results.releaseGroups = await this.getReleaseGroups(artistId); - /* - results.releases = await this.getReleases(artistId); - results.recordings = await this.getRecordings(artistId); - results.works = await this.getWorks(artistId); - */ + results.releaseGroups = await this.getReleaseGroups( + artistId, + maxElements, + startOffset + ); + results.releases = await this.getReleases( + artistId, + maxElements, + startOffset + ); + results.recordings = await this.getRecordings( + artistId, + maxElements, + startOffset + ); + results.works = await this.getWorks( + artistId, + maxElements, + startOffset + ); resolve(results); } } @@ -518,6 +536,88 @@ class MusicBrainz extends BaseNodeBrainz { } }; + public getRecordings = ( + artistId: string, + maxElements = 50, + startOffset = 0 + ): Promise => { + try { + return new Promise((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[] = await 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((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((resolve) => resolve([])); + } + }; + public getRecording = (recordingId: string): Promise => { try { return new Promise((resolve, reject) => { @@ -545,50 +645,74 @@ class MusicBrainz extends BaseNodeBrainz { } }; - public getReleaseGroups = (artistId: string): Promise => { + public getReleaseGroups = ( + artistId: string, + maxElements = 50, + startOffset = 0 + ): Promise => { try { return new Promise((resolve, reject) => { this.browse( 'release-group', - { artist: artistId }, + { artist: artistId, offset: startOffset }, async (error, data) => { if (error) { reject(error); } else { - const results = await new Promise( - (resolve2, reject2) => { - this.browse( - 'release-group', - { - artist: artistId, - limit: - ( - data as { - 'release-group-count': number; - 'release-group-offset': number; - 'release-groups': Group[]; - } - )['release-group-count'] ?? 25, - }, - (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); + 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[] = await 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((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); } } @@ -634,6 +758,88 @@ class MusicBrainz extends BaseNodeBrainz { } }; + public getReleases = ( + artistId: string, + maxElements = 50, + startOffset = 0 + ): Promise => { + try { + return new Promise((resolve, reject) => { + this.browse( + 'release', + { artist: artistId, offset: startOffset }, + 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[] = await 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((resolve2, reject2) => { + this.browse( + 'release', + { + artist: artistId, + offset: i, + limit: 100, + }, + (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((resolve) => resolve([])); + } + }; + public getRelease = (releaseId: string): Promise => { try { return new Promise((resolve, reject) => { @@ -661,6 +867,86 @@ class MusicBrainz extends BaseNodeBrainz { } }; + public getWorks = ( + artistId: string, + maxElements = 50, + startOffset = 0 + ): Promise => { + try { + return new Promise((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[] = await 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((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((resolve) => resolve([])); + } + }; + public getWork = (workId: string): Promise => { try { return new Promise((resolve, reject) => { diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index 3766668cb..a019189d1 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -204,6 +204,26 @@ class PlexAPI { }; } + public async getMusicLibraryContents( + id: string, + { offset = 0, size = 50 }: { offset?: number; size?: number } = {} + ): Promise<{ totalSize: number; items: PlexLibraryItem[] }> { + const response = await this.getLibraryContents(id, { offset, size }); + + const response2 = await this.plexClient.query({ + uri: `/library/sections/${id}/albums?includeGuids=1`, + extraHeaders: { + 'X-Plex-Container-Start': `${offset}`, + 'X-Plex-Container-Size': `${size}`, + }, + }); + + return { + totalSize: response.totalSize + response2.MediaContainer.totalSize, + items: response.items.concat(response2.MediaContainer.Metadata) ?? [], + }; + } + public async getMetadata( key: string, options: { includeChildren?: boolean } = {} @@ -241,7 +261,21 @@ class PlexAPI { 'X-Plex-Container-Size': `500`, }, }); - + if (mediaType === 'artist') { + const albumResponse = await this.plexClient.query({ + uri: `/library/sections/${id}/albums&sort=addedAt%3Adesc&addedAt>>=${Math.floor( + options.addedAt / 1000 + )}`, + extraHeaders: { + 'X-Plex-Container-Start': `0`, + 'X-Plex-Container-Size': `500`, + }, + }); + // concat the album and artist response mediacontainer.metadata + return response.MediaContainer.Metadata.concat( + albumResponse.MediaContainer.Metadata + ); + } return response.MediaContainer.Metadata; } } diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index ddc118c23..ffddf69ff 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -590,6 +590,93 @@ class BaseScanner { }); } + protected async processAlbum( + mbId: string, + { + mediaAddedAt, + ratingKey, + serviceId, + externalServiceId, + processing = false, + title = 'Unknown Title', + }: ProcessOptions = {} + ): Promise { + const mediaRepository = getRepository(Media); + + await this.asyncLock.dispatch(mbId, async () => { + const existing = await this.getExisting(mbId, MediaType.MUSIC); + + if (existing) { + let changedExisting = false; + + if (existing['status'] !== MediaStatus.AVAILABLE) { + existing['status'] = processing + ? MediaStatus.PROCESSING + : MediaStatus.AVAILABLE; + if (mediaAddedAt) { + existing.mediaAddedAt = mediaAddedAt; + } + changedExisting = true; + } + + if (!changedExisting && !existing.mediaAddedAt && mediaAddedAt) { + existing.mediaAddedAt = mediaAddedAt; + changedExisting = true; + } + + if (ratingKey && existing['ratingKey'] !== ratingKey) { + existing['ratingKey'] = ratingKey; + changedExisting = true; + } + + if (serviceId !== undefined && existing['serviceId'] !== serviceId) { + existing['serviceId'] = serviceId; + changedExisting = true; + } + + if ( + externalServiceId !== undefined && + existing['externalServiceId'] !== externalServiceId + ) { + existing['externalServiceId'] = externalServiceId; + changedExisting = true; + } + + if (changedExisting) { + await mediaRepository.save(existing); + this.log( + `Media for ${title} exists. Changes were detected and the title will be updated.`, + 'info' + ); + } else { + this.log(`Title already exists and no changes detected for ${title}`); + } + } else { + const newMedia = new Media(); + newMedia.mbId = mbId; + newMedia.secondaryType = SecondaryType.RELEASE; + newMedia.status = !processing + ? MediaStatus.AVAILABLE + : processing + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN; + newMedia.mediaType = MediaType.MUSIC; + newMedia.serviceId = serviceId; + newMedia.externalServiceId = externalServiceId; + + if (mediaAddedAt) { + newMedia.mediaAddedAt = mediaAddedAt; + } + + if (ratingKey) { + newMedia.ratingKey = ratingKey; + } + await mediaRepository.save(newMedia); + this.log(`Saved new media: ${title}`); + } + }); + } + /** * Call startRun from child class whenever a run is starting to * ensure required values are set diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index f14653807..3fc809428 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -136,7 +136,13 @@ class PlexScanner for (const library of this.libraries) { this.currentLibrary = library; this.log(`Beginning to process library: ${library.name}`, 'info'); - await this.paginateLibrary(library, { sessionId }); + try { + await this.paginateLibrary(library, { sessionId }); + } catch (e) { + this.log('Failed to paginate library', 'error', { + errorMessage: e.message, + }); + } } } this.log( @@ -165,12 +171,16 @@ class PlexScanner if (this.sessionId !== sessionId) { throw new Error('New session was started. Old session aborted.'); } - - const response = await this.plexClient.getLibraryContents(library.id, { - size: this.protectedBundleSize, - offset: start, - }); - + const response = + library.type === 'artist' + ? await this.plexClient.getMusicLibraryContents(library.id, { + size: this.protectedBundleSize, + offset: start, + }) + : await this.plexClient.getLibraryContents(library.id, { + size: this.protectedBundleSize, + offset: start, + }); this.progress = start; this.totalSize = response.totalSize; @@ -212,6 +222,8 @@ class PlexScanner await this.processPlexShow(plexitem); } else if (plexitem.type === 'artist') { await this.processPlexArtist(plexitem); + } else if (plexitem.type === 'album') { + await this.processPlexAlbum(plexitem); } } catch (e) { this.log('Failed to process Plex media', 'error', { @@ -359,6 +371,21 @@ class PlexScanner } } + private async processPlexAlbum(plexitem: PlexLibraryItem) { + const mediaIds = await this.getMediaIds(plexitem); + if (mediaIds.mbId) { + await this.processAlbum(mediaIds.mbId, { + mediaAddedAt: new Date(plexitem.addedAt * 1000), + ratingKey: plexitem.ratingKey, + title: plexitem.title, + }); + } else { + this.log('No MusicBrainz ID found for album', 'warn', { + title: plexitem.title, + }); + } + } + private async getMediaIds(plexitem: PlexLibraryItem): Promise { let mediaIds: Partial = {}; // Check if item is using new plex movie/tv agent diff --git a/server/routes/music.ts b/server/routes/music.ts index 342e7b0b5..c88017318 100644 --- a/server/routes/music.ts +++ b/server/routes/music.ts @@ -15,8 +15,12 @@ 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) + ? await mb.getFullArtist(req.params.id, maxElements, offset) : await mb.getArtist(req.params.id); const media = await Media.getMedia(artist.id, MediaType.MUSIC);