From 23fbd2ed5dbe45a6c7015a8f8b2df4fec615bf3c Mon Sep 17 00:00:00 2001 From: Digital Star System Date: Mon, 21 Sep 2026 15:35:39 -0700 Subject: [PATCH 1/2] 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 From 3e3ebea52c5048e0587b42d0830d7a1976cf69e0 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 22 Sep 2026 13:36:02 +0000 Subject: [PATCH 2/2] feat(applemusic): Cleanup enrich isrc behavior * reduce ttl on cached items * guard against consecutive errors on storefront fetch failure * fix log level for enrich error * use SimpleError for cleaner logging --- src/backend/sources/AppleMusicSource.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/backend/sources/AppleMusicSource.ts b/src/backend/sources/AppleMusicSource.ts index 75d44ad9..8c6d5bad 100644 --- a/src/backend/sources/AppleMusicSource.ts +++ b/src/backend/sources/AppleMusicSource.ts @@ -17,6 +17,7 @@ import { playsAreBumpedOnly, playsAreSortConsistent } from "../utils/PlayComparisonUtils.ts"; +import { SimpleError } from "../common/errors/MSErrors.ts"; export type AppleMusicHistoryDiffType = 'bump' | 'added' | 'top-rebound'; @@ -42,7 +43,7 @@ export default class AppleMusicSource extends AbstractSource { recentlyPlayed: PlayObject[] = []; musicKit!: MusicKit; - private storefront?: string; + private storefront?: string | null; recentChangedHistoryResponses: {ts: Dayjs, plays: PlayObject[]}[] = []; @@ -199,20 +200,20 @@ export default class AppleMusicSource extends AbstractSource { let isrc = await this.cache.cacheApi.get(cacheKey); if (isrc === undefined) { const storefront = await this.getStorefront(); - if (storefront === undefined) { + if (storefront === undefined || storefront === null) { 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'); + await this.cache.cacheApi.set(cacheKey, isrc, '3h'); } 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 })); + this.logger.warn(new SimpleError(`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'); + await this.cache.cacheApi.set(cacheKey, null, '3h'); } return play; } @@ -227,11 +228,12 @@ export default class AppleMusicSource extends AbstractSource { 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; + this.logger.warn(new SimpleError('Could not determine Apple Music storefront, ISRC enrichment for library tracks will be skipped', { cause: e })); + // store failure as non-undefined so we don't make consecutive calls to try to get storefront on failure + this.storefront = null; } + return this.storefront; } getIncomingHistoryConsistencyResult = (plays: PlayObject[]): HistoryConsistencyResult => { -- 2.51.2