fix(metadata): reject mismatched iTunes media lookups

Validate iTunes lookup identities against the book medium so stale ebook IDs fall back to audiobook search instead of preserving portrait covers.

Closes #1285
This commit is contained in:
neon
2026-09-06 11:40:18 +00:00
parent e3241eea3d
commit 5052f170a9
3 changed files with 115 additions and 6 deletions
@@ -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,
@@ -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<MetadataCandidate | null> {
async lookupById(providerId: string, signal?: AbortSignal, params?: MetadataSearchParams): Promise<MetadataCandidate | null> {
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)}`);
}
@@ -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;