From 23fbd2ed5dbe45a6c7015a8f8b2df4fec615bf3c Mon Sep 17 00:00:00 2001 From: Digital Star System Date: Mon, 21 Sep 2026 15:35:39 -0700 Subject: [PATCH] fix(applemusic): backfill ISRC for library tracks via catalog lookup Apple Music's recently-played history endpoint only returns ISRC for full catalog "songs" resources. Tracks played from the user's personal library come back as "library-songs" and omit ISRC entirely, which silently degraded downstream metadata matching (e.g. ISRC-based lookups in transformers) for most personally-owned library plays. When a track is missing ISRC and is a library item linked to a catalog song (playParams.catalogId), make one additional catalog lookup to backfill it, mirroring the existing Spotify Source's enrichIsrc pattern. Result is cached (including negative caching) so repeat polls don't re-fetch. Storefront is resolved once and cached on the instance. Adds options.enrichIsrc (default true, ENV APPLEMUSIC_ENRICH_ISRC) to allow disabling the extra calls. --- config/applemusic.json.example | 3 +- .../config/source/applemusic.ts | 21 +++++- src/backend/sources/AppleMusicSource.ts | 66 ++++++++++++++++++- 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/config/applemusic.json.example b/config/applemusic.json.example index 33b21cc4..e85392cc 100644 --- a/config/applemusic.json.example +++ b/config/applemusic.json.example @@ -23,7 +23,8 @@ "options": { "logDiff": true, "recoverUnchangedTopHistory": true, - "normalizeAlbum": true + "normalizeAlbum": true, + "enrichIsrc": true } } ] diff --git a/src/backend/common/infrastructure/config/source/applemusic.ts b/src/backend/common/infrastructure/config/source/applemusic.ts index 9b49c287..df5d8d76 100644 --- a/src/backend/common/infrastructure/config/source/applemusic.ts +++ b/src/backend/common/infrastructure/config/source/applemusic.ts @@ -70,6 +70,21 @@ export const appleMusicOptions = z.object({ default: true, examples: [true, false] }), + /** + * Backfill ISRC data if it is missing + * + * Apple Music's recently-played history endpoint does not return an ISRC for tracks that come from the + * user's personal library rather than the Apple Music catalog. If this is enabled and a track is missing + * an ISRC, MS makes an additional catalog lookup (using the track's catalog ID, if one exists) to backfill it. + * + * @default true + * @examples [true] + */ + enrichIsrc: z.boolean().optional().meta({ + description: "Backfill ISRC data with an additional catalog lookup when the recently-played endpoint omits it", + default: true, + examples: [true] + }), }); export type AppleMusicOptions = z.infer; @@ -93,7 +108,8 @@ const envDataSchema = z.object({ APPLEMUSIC_TOKEN: appleMusicDataSchema.shape.token.optional(), APPLEMUSIC_ORIGIN_HEADER: appleMusicDataSchema.shape.origin, APPLEMUSIC_RECOVER_UNCHANGED_TOP_HISTORY: z.stringbool().optional().meta(appleMusicOptions.shape.recoverUnchangedTopHistory.meta()), - APPLEMUSIC_NORMALIZE_ALBUM: z.stringbool().optional().meta(appleMusicOptions.shape.normalizeAlbum.meta()) + APPLEMUSIC_NORMALIZE_ALBUM: z.stringbool().optional().meta(appleMusicOptions.shape.normalizeAlbum.meta()), + APPLEMUSIC_ENRICH_ISRC: z.stringbool().optional().meta(appleMusicOptions.shape.enrichIsrc.meta()) }); export const envSchemas: EnvSourceSchema = { @@ -134,7 +150,8 @@ export const envSchemas: EnvSourceSchema AppleMusicSource.formatPlayObj(track, {normalizeAlbum: this.config?.options?.normalizeAlbum})); + const tracks = result.data as Song[]; + const plays = tracks.map(track => AppleMusicSource.formatPlayObj(track, {normalizeAlbum: this.config?.options?.normalizeAlbum})); + return pMap(plays, (play, i) => this.enrichIsrc(play, tracks[i]), {concurrency: 3}); + } + + /** + * Backfill ISRC if it is not present in Play + * + * The recently-played history endpoint returns full catalog data (including ISRC) for tracks of type + * "songs", but tracks played from the user's personal library ("library-songs") often omit it. When those + * library tracks are linked to a catalog song (`playParams.catalogId`) this makes one extra call to + * `/catalog/{storefront}/songs/{catalogId}` to backfill and cache the ISRC from the catalog counterpart. + */ + protected enrichIsrc = async (play: PlayObject, track: Song): Promise => { + if (this.config.options?.enrichIsrc === false || play.data.isrc !== undefined) { + return play; + } + + // library-song resources aren't typed with `catalogId` but Apple's API includes it on `playParams` + // when the library item is linked to a matching catalog song + const catalogId = track.type === 'songs' ? track.id : (track.playParams as { catalogId?: string } | undefined)?.catalogId; + if (catalogId === undefined) { + return play; + } + + const cacheKey = `applemusic-isrc-${catalogId}`; + try { + let isrc = await this.cache.cacheApi.get(cacheKey); + if (isrc === undefined) { + const storefront = await this.getStorefront(); + if (storefront === undefined) { + return play; + } + const res = await this.musicKit.songs.get(storefront, catalogId); + isrc = (!res.error && res.data && res.data.length > 0) ? (res.data[0].isrc ?? null) : null; + await this.cache.cacheApi.set(cacheKey, isrc, '7d'); + } + if (isrc !== null) { + play.data.isrc = isrc; + } + } catch (e) { + this.logger.debug(new Error(`Failed to backfill ISRC for Apple Music track ${catalogId} from catalog endpoint`, { cause: e })); + // set to null on failure so we don't make consecutive calls that result in failure on every poll attempt + await this.cache.cacheApi.set(cacheKey, null, '7d'); + } + return play; + } + + private getStorefront = async (): Promise => { + if (this.storefront !== undefined) { + return this.storefront; + } + try { + const res = await this.musicKit.me.getStorefront(); + if (res.error || !res.data || res.data.length === 0) { + throw new Error(res.error ?? 'No storefront returned'); + } + this.storefront = res.data[0].id; + return this.storefront; + } catch (e) { + this.logger.warn(new Error('Could not determine Apple Music storefront, ISRC enrichment for library tracks will be skipped', { cause: e })); + return undefined; + } } getIncomingHistoryConsistencyResult = (plays: PlayObject[]): HistoryConsistencyResult => { -- 2.51.2