diff --git a/server/src/modules/metadata-fetch/providers/itunes/itunes.provider.test.ts b/server/src/modules/metadata-fetch/providers/itunes/itunes.provider.test.ts index 8f3c718a2..877c1ec19 100644 --- a/server/src/modules/metadata-fetch/providers/itunes/itunes.provider.test.ts +++ b/server/src/modules/metadata-fetch/providers/itunes/itunes.provider.test.ts @@ -117,7 +117,7 @@ describe('ITunesProvider', () => { collectionId: 456, collectionName: 'Audiobook Title', artistName: 'Author', - kind: 'audiobook', + wrapperType: 'audiobook', }; global.fetch = vi.fn().mockResolvedValue({ ok: true, @@ -132,6 +132,38 @@ describe('ITunesProvider', () => { expect(result[0].title).toBe('Audiobook Title'); }); + it('should discard results whose media type does not match the requested entity', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + results: [ + { + trackId: 123, + trackName: 'Ebook Result', + artistName: 'Author', + kind: 'ebook', + }, + { + collectionId: 456, + collectionName: 'Audiobook Result', + artistName: 'Author', + wrapperType: 'audiobook', + }, + { + trackId: 789, + trackName: 'Unclassified Result', + artistName: 'Author', + }, + ], + }), + }); + + const result = await provider.search({ title: 'Test', isAudiobook: true }); + + expect(result).toHaveLength(1); + expect(result[0].providerId).toBe('456'); + }); + it('should skip invalid results', async () => { const mockResults = [ { @@ -198,6 +230,66 @@ describe('ITunesProvider', () => { expect(result?.title).toBe('Test Book'); }); + it('should accept a stored audiobook id when the lookup confirms the requested media type', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + results: [ + { + collectionId: 456, + collectionName: 'Audiobook Title', + artistName: 'Author', + wrapperType: 'audiobook', + }, + ], + }), + }); + + const result = await provider.lookupById('456', undefined, { title: 'Audiobook Title', isAudiobook: true }); + + expect(result?.providerId).toBe('456'); + }); + + it.each([ + { + label: 'ebook lookup for an audiobook', + expectedAudiobook: true, + result: { trackId: 123, trackName: 'Book', kind: 'ebook' }, + }, + { + label: 'audiobook lookup for an ebook', + expectedAudiobook: false, + result: { collectionId: 456, collectionName: 'Book', wrapperType: 'audiobook' }, + }, + { + label: 'unclassified lookup for a known media type', + expectedAudiobook: true, + result: { trackId: 789, trackName: 'Book' }, + }, + ])('should reject a stored $label', async ({ expectedAudiobook, result: lookupResult }) => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ results: [lookupResult] }), + }); + + const result = await provider.lookupById('stored-id', undefined, { title: 'Book', isAudiobook: expectedAudiobook }); + + expect(result).toBeNull(); + }); + + it('should preserve unconstrained direct lookups', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + results: [{ trackId: 123, trackName: 'Test Book' }], + }), + }); + + const result = await provider.lookupById('123'); + + expect(result?.providerId).toBe('123'); + }); + it('should return null if no results', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/server/src/modules/metadata-fetch/providers/itunes/itunes.provider.ts b/server/src/modules/metadata-fetch/providers/itunes/itunes.provider.ts index 01ae0198f..46ea6435c 100644 --- a/server/src/modules/metadata-fetch/providers/itunes/itunes.provider.ts +++ b/server/src/modules/metadata-fetch/providers/itunes/itunes.provider.ts @@ -13,6 +13,17 @@ import { ITunesResponse } from './itunes.types'; const SEARCH_URL = 'https://itunes.apple.com/search'; const LOOKUP_URL = 'https://itunes.apple.com/lookup'; +type ITunesMediaKind = 'ebook' | 'audiobook'; + +function mediaKindOf(result: ITunesResponse['results'][number]): ITunesMediaKind | null { + if (result.kind === 'ebook' || result.kind === 'audiobook') return result.kind; + if (result.wrapperType === 'audiobook') return 'audiobook'; + return null; +} + +function matchesMediaKind(result: ITunesResponse['results'][number], expected: ITunesMediaKind): boolean { + return mediaKindOf(result) === expected; +} @Injectable() export class ITunesProvider implements IdentifiableProvider { @@ -33,7 +44,8 @@ export class ITunesProvider implements IdentifiableProvider { const url = new URL(SEARCH_URL); url.searchParams.set('term', query); - url.searchParams.set('entity', params.isAudiobook ? 'audiobook' : 'ebook'); + const mediaKind: ITunesMediaKind = params.isAudiobook ? 'audiobook' : 'ebook'; + url.searchParams.set('entity', mediaKind); url.searchParams.set('limit', '10'); const requestUrl = url.toString(); const startedAt = Date.now(); @@ -49,6 +61,7 @@ export class ITunesProvider implements IdentifiableProvider { } const body = (await res.json()) as ITunesResponse; const results = body.results + .filter((result) => matchesMediaKind(result, mediaKind)) .map((r) => { try { return mapITunesResult(r, coverResolution); @@ -75,7 +88,7 @@ export class ITunesProvider implements IdentifiableProvider { } } - async lookupById(providerId: string, signal?: AbortSignal): Promise { + async lookupById(providerId: string, signal?: AbortSignal, params?: MetadataSearchParams): Promise { const { enabled, coverResolution } = await this.providerConfig.getConfig().then((c) => c.itunes); if (!enabled) return null; @@ -95,9 +108,12 @@ export class ITunesProvider implements IdentifiableProvider { } const body = (await res.json()) as ITunesResponse; let result: MetadataCandidate | null = null; - if (body.results.length > 0) { + const rawResult = body.results[0]; + const expectedMediaKind: ITunesMediaKind | undefined = + params?.isAudiobook === undefined ? undefined : params.isAudiobook ? 'audiobook' : 'ebook'; + if (rawResult && (expectedMediaKind === undefined || matchesMediaKind(rawResult, expectedMediaKind))) { try { - result = mapITunesResult(body.results[0], coverResolution); + result = mapITunesResult(rawResult, coverResolution); } catch (err) { this.logger.warn(`[itunes] failed to map lookup result providerId="${providerId}": ${err instanceof Error ? err.message : String(err)}`); } diff --git a/server/src/modules/metadata-fetch/providers/itunes/itunes.types.ts b/server/src/modules/metadata-fetch/providers/itunes/itunes.types.ts index 5f225d6c3..f2f68f2fe 100644 --- a/server/src/modules/metadata-fetch/providers/itunes/itunes.types.ts +++ b/server/src/modules/metadata-fetch/providers/itunes/itunes.types.ts @@ -8,7 +8,8 @@ export interface ITunesResult { releaseDate?: string; genres?: string[]; artworkUrl100?: string; - kind: 'ebook' | 'audiobook'; + kind?: string; + wrapperType?: string; trackViewUrl?: string; collectionViewUrl?: string; averageUserRating?: number;