diff --git a/server/api/musicbrainz/index.ts b/server/api/musicbrainz/index.ts index 5908c2f6d..ab251cd09 100644 --- a/server/api/musicbrainz/index.ts +++ b/server/api/musicbrainz/index.ts @@ -103,6 +103,73 @@ class MusicBrainz extends ExternalAPI { } } + private static requestQueue: Promise = Promise.resolve(); + private lastRequestTime = 0; + private readonly RATE_LIMIT_DELAY = 1100; + + public async getReleaseGroup({ + releaseId, + }: { + releaseId: string; + }): Promise { + try { + await MusicBrainz.requestQueue; + + MusicBrainz.requestQueue = (async () => { + const now = Date.now(); + const timeSinceLastRequest = now - this.lastRequestTime; + if (timeSinceLastRequest < this.RATE_LIMIT_DELAY) { + await new Promise((resolve) => + setTimeout(resolve, this.RATE_LIMIT_DELAY - timeSinceLastRequest) + ); + } + this.lastRequestTime = Date.now(); + })(); + + await MusicBrainz.requestQueue; + + const data = await this.getRolling( + `/release/${releaseId}`, + { + inc: 'release-groups', + fmt: 'json', + }, + 43200, + { + headers: { + 'User-Agent': + 'Jellyseerr/1.0.0 (https://github.com/Fallenbagel/jellyseerr; hello@jellyseerr.com)', + Accept: 'application/json', + }, + }, + 'https://musicbrainz.org/ws/2' + ); + + return data['release-group']?.id || null; + } catch (e) { + if (e.message.includes('503')) { + await new Promise((resolve) => + setTimeout(resolve, this.RATE_LIMIT_DELAY * 2) + ); + + await MusicBrainz.requestQueue; + MusicBrainz.requestQueue = Promise.resolve(); + this.lastRequestTime = Date.now(); + + try { + return await this.getReleaseGroup({ releaseId }); + } catch (retryError) { + throw new Error( + `[MusicBrainz] Failed to fetch release group after retry: ${retryError.message}` + ); + } + } + throw new Error( + `[MusicBrainz] Failed to fetch release group: ${e.message}` + ); + } + } + public async getWikipediaExtract( id: string, language = 'en', diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index 388f10333..ceb11ecbe 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -28,7 +28,7 @@ interface PlexLibraryResponse { } export interface PlexLibrary { - type: 'show' | 'movie' | 'music'; + type: 'show' | 'movie' | 'artist'; key: string; title: string; agent: string; @@ -155,7 +155,7 @@ class PlexAPI { (library) => library.type === 'movie' || library.type === 'show' || - library.type === 'music' + library.type === 'artist' ) // Remove libraries that do not have a metadata agent set (usually personal video libraries) .filter((library) => library.agent !== 'com.plexapp.agents.none') @@ -168,7 +168,7 @@ class PlexAPI { id: library.key, name: library.title, enabled: existing?.enabled ?? false, - type: library.type, + type: library.type === 'artist' ? 'music' : library.type, lastScan: existing?.lastScan, }; }); @@ -230,7 +230,7 @@ class PlexAPI { options: { addedAt: number } = { addedAt: Date.now() - 1000 * 60 * 60, }, - mediaType: 'movie' | 'show' | 'music' + mediaType: 'movie' | 'show' | 'artist' ): Promise { const response = await this.plexClient.query({ uri: `/library/sections/${id}/all?type=${ diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index 24862e558..2aba8f6e9 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -1,5 +1,6 @@ import animeList from '@server/api/animelist'; import { getMetadataProvider } from '@server/api/metadata'; +import MusicBrainz from '@server/api/musicbrainz'; import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi'; import TheMovieDb from '@server/api/themoviedb'; @@ -26,6 +27,7 @@ const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/); const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/); const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/); const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/); +const mbRegex = new RegExp(/mbid:\/\/([0-9a-f-]+)/); const plexRegex = new RegExp(/plex:\/\//); // Hama agent uses ASS naming, see details here: // https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id @@ -95,6 +97,7 @@ class PlexScanner 'info', { lastScan: library.lastScan } ); + const mappedType = library.type === 'music' ? 'artist' : library.type; const libraryItems = await this.plexClient.getRecentlyAdded( library.id, library.lastScan @@ -103,7 +106,7 @@ class PlexScanner addedAt: library.lastScan - 1000 * 60 * 10, } : undefined, - library.type + mappedType ); // Bundle items up by rating keys @@ -215,6 +218,12 @@ class PlexScanner plexitem.type === 'season' ) { await this.processPlexShow(plexitem); + } else if ( + plexitem.type === 'artist' || + plexitem.type === 'album' || + plexitem.type === 'track' + ) { + await this.processPlexMusic(plexitem); } } catch (e) { this.log('Failed to process Plex media', 'error', { @@ -381,6 +390,60 @@ class PlexScanner } } + private async processPlexMusic(plexitem: PlexLibraryItem) { + const ratingKey = + plexitem.grandparentRatingKey ?? + plexitem.parentRatingKey ?? + plexitem.ratingKey; + + let metadata; + try { + metadata = await this.plexClient.getMetadata(ratingKey, { + includeChildren: true, + }); + + if (metadata.Children?.Metadata) { + const musicBrainz = new MusicBrainz(); + + for (const album of metadata.Children.Metadata) { + const albumMetadata = await this.plexClient.getMetadata( + album.ratingKey + ); + + const mbReleaseId = albumMetadata.Guid?.find((g) => { + const id = g.id.toLowerCase(); + return id.startsWith('mbid://'); + })?.id.replace('mbid://', ''); + + if (!mbReleaseId) { + this.log('No MusicBrainz ID found for album', 'debug', { + title: album.title, + artist: metadata.title, + }); + continue; + } + + const releaseGroupId = await musicBrainz.getReleaseGroup({ + releaseId: mbReleaseId, + }); + + if (releaseGroupId) { + await this.processMusic(releaseGroupId, { + mediaAddedAt: new Date(album.addedAt * 1000), + ratingKey: album.ratingKey, + title: album.title, + }); + } + } + } + } catch (e) { + this.log('Failed to process music media', 'error', { + errorMessage: e.message, + title: metadata?.title, + }); + } + } + private async getMediaIds(plexitem: PlexLibraryItem): Promise { let mediaIds: Partial = {}; // Check if item is using new plex movie/tv agent @@ -419,6 +482,8 @@ class PlexScanner } else if (ref.id.match(tvdbRegex)) { const tvdbMatch = ref.id.match(tvdbRegex)?.[1]; mediaIds.tvdbId = Number(tvdbMatch); + } else if (ref.id.match(mbRegex)) { + mediaIds.mbId = ref.id.match(mbRegex)?.[1] ?? undefined; } }); @@ -534,6 +599,12 @@ class PlexScanner } } } + // Check for MusicBrainz + } else if (plexitem.guid.match(mbRegex)) { + const mbMatch = plexitem.guid.match(mbRegex); + if (mbMatch) { + mediaIds.mbId = mbMatch[1]; + } } if (!mediaIds.tmdbId) {