mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user