feat(metadata): add Libro.fm as a metadata source

Introduce a metadata provider for Libro.fm to fetch audiobook details, offering an alternative to Audible and iTunes. Add server-side mapping, fetch controller endpoints, validation DTOs, and client-side UI configurations for user metadata preferences.

Closes #636
This commit is contained in:
neon
2026-07-12 05:52:55 +00:00
parent 00951219b2
commit 37d3df4089
29 changed files with 603 additions and 38 deletions
@@ -186,6 +186,7 @@ export const PROVIDER_ID_FIELD: Record<MetadataProviderKey, ProviderIdPatchField
itunes: 'itunesId',
audible: 'audibleId',
audnexus: undefined,
librofm: undefined,
comicvine: 'comicvineId',
ranobedb: 'ranobedbId',
kobo: 'koboId',
@@ -208,6 +209,7 @@ export const PROVIDER_ID_LABEL: Record<MetadataProviderKey, string> = {
itunes: 'iTunes ID',
audible: 'Audible ID',
audnexus: 'AudNexus ID',
librofm: 'Libro.fm ISBN',
comicvine: 'ComicVine ID',
ranobedb: 'RanobeDB ID',
kobo: 'Kobo ID',
@@ -99,6 +99,7 @@ export function useMetadataSearch() {
'amazon',
'audible',
'audnexus',
'librofm',
'goodreads',
'hardcover',
'google',
@@ -14,6 +14,7 @@ const PROVIDER_LABELS: Record<MetadataProviderKeyType, string> = {
[MetadataProviderKey.ITUNES]: 'iTunes',
[MetadataProviderKey.AUDIBLE]: 'Audible',
[MetadataProviderKey.AUDNEXUS]: 'AudNexus',
[MetadataProviderKey.LIBROFM]: 'Libro.fm',
[MetadataProviderKey.COMICVINE]: 'ComicVine',
[MetadataProviderKey.RANOBEDB]: 'RanobeDB',
[MetadataProviderKey.KOBO]: 'Kobo',
@@ -0,0 +1,55 @@
import { mount } from '@vue/test-utils'
import type { ProviderConfigurations } from '@bookorbit/types'
import { describe, expect, it } from 'vitest'
import ProviderConfigPanel from './ProviderConfigPanel.vue'
function legacyConfigWithoutLibroFm(): ProviderConfigurations {
return {
google: { enabled: false, apiKey: '' },
amazon: { enabled: false, domain: 'amazon.com', cookie: '' },
goodreads: { enabled: false },
hardcover: { enabled: false, apiKey: '' },
openLibrary: { enabled: false },
itunes: { enabled: false, coverResolution: 'high' },
audible: { enabled: false, domain: 'com' },
audnexus: { enabled: false },
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
aladin: { enabled: false, ttbKey: '' },
} as ProviderConfigurations
}
describe('ProviderConfigPanel', () => {
it('does not render or submit providers missing from the backend contract', async () => {
const wrapper = mount(ProviderConfigPanel, {
props: {
config: legacyConfigWithoutLibroFm(),
statuses: [],
saving: false,
},
})
expect(wrapper.text()).not.toContain('Libro.fm')
expect(wrapper.findAll('[role="switch"]')).toHaveLength(13)
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('save')?.[0]?.[0]).not.toHaveProperty('librofm')
wrapper.unmount()
})
it('renders Libro.fm when the backend includes its configuration section', () => {
const wrapper = mount(ProviderConfigPanel, {
props: {
config: { ...legacyConfigWithoutLibroFm(), librofm: { enabled: false } },
statuses: [],
saving: false,
},
})
expect(wrapper.text()).toContain('Libro.fm')
expect(wrapper.findAll('[role="switch"]')).toHaveLength(14)
wrapper.unmount()
})
})
@@ -41,7 +41,8 @@ onUnmounted(() => {
watch(
() => props.config,
(c) => {
if (c) draft.value = JSON.parse(JSON.stringify(c))
if (!c) return
draft.value = JSON.parse(JSON.stringify(c)) as ProviderConfigurations
},
{ immediate: true },
)
@@ -187,6 +188,12 @@ const rows: RowDef[] = [
fields: [{ key: 'domain', label: 'Region', type: 'select', options: AUDIBLE_DOMAINS }],
},
{ key: 'audnexus', label: 'AudNexus', hint: 'Community-driven audiobook metadata. No setup required.', fields: [] },
{
key: 'librofm',
label: 'Libro.fm',
hint: 'DRM-free audiobook catalog metadata. Uses an undocumented Libro.fm endpoint and is disabled by default.',
fields: [],
},
{
key: 'comicvine',
label: 'ComicVine',
@@ -360,6 +367,10 @@ function toggleProvider(row: RowDef) {
}
const draftReady = computed(() => draft.value !== null)
const visibleRows = computed(() => {
if (!draft.value) return []
return rows.filter((row) => Object.prototype.hasOwnProperty.call(draft.value, row.key))
})
</script>
<template>
@@ -377,7 +388,7 @@ const draftReady = computed(() => draft.value !== null)
<div v-if="draft" class="divide-y divide-border">
<div
v-for="row in rows"
v-for="row in visibleRows"
:key="row.key"
class="px-4 py-3.5 md:px-5 md:py-4 flex flex-col md:flex-row md:items-start gap-3 md:gap-4 bg-card transition-colors hover:bg-muted/30"
>
@@ -13,6 +13,10 @@ describe('getProviderColor', () => {
expect(getProviderColor('comicvine')).toBe('#ffdb0f')
})
it('returns the Libro.fm brand color', () => {
expect(getProviderColor('librofm')).toBe('#62B9B6')
})
it('returns default color for unknown provider', () => {
const result = getProviderColor('unknown-provider')
expect(result).toBeTruthy()
@@ -29,6 +33,7 @@ describe('getProviderColor', () => {
'itunes',
'audible',
'audnexus',
'librofm',
'comicvine',
'ranobedb',
'kobo',
@@ -56,6 +61,7 @@ describe('PROVIDER_SHORT_LABELS', () => {
'itunes',
'audible',
'audnexus',
'librofm',
'comicvine',
'ranobedb',
'kobo',
+2
View File
@@ -9,6 +9,7 @@ const PROVIDER_HEX: Record<string, string> = {
itunes: '#ff4f5d',
audible: '#FF8A00',
audnexus: '#FF5ADD',
librofm: '#62B9B6',
comicvine: '#ffdb0f',
ranobedb: '#a78cff',
kobo: '#e23434',
@@ -28,6 +29,7 @@ export const PROVIDER_SHORT_LABELS: Record<string, string> = {
itunes: 'iTunes',
audible: 'Audible',
audnexus: 'AudNexus',
librofm: 'Libro.fm',
comicvine: 'ComicVine',
ranobedb: 'RanobeDB',
kobo: 'Kobo',
+1 -8
View File
@@ -96,14 +96,7 @@ export interface BookDockFinalizeResult {
}
export type BookDockFinalizePreviewStatus =
| "ready"
| "duplicate"
| "destination_conflict"
| "missing_destination"
| "invalid_target"
| "access_denied"
| "invalid_format"
| "error";
"ready" | "duplicate" | "destination_conflict" | "missing_destination" | "invalid_target" | "access_denied" | "invalid_format" | "error";
export interface BookDockFinalizePreviewItem {
fileId: number;
+1 -5
View File
@@ -104,11 +104,7 @@ export type AudioMetadata = {
};
export type BookFileWriteDisabledReason =
| "library_disabled"
| "no_primary_file"
| "format_not_supported"
| "format_disabled"
| "file_exceeds_size_limit";
"library_disabled" | "no_primary_file" | "format_not_supported" | "format_disabled" | "file_exceeds_size_limit";
export type BookFileWriteStatus = {
enabled: boolean;
+1 -6
View File
@@ -2,12 +2,7 @@ export type HardcoverSyncDisabledReason = "permission_denied" | "missing_token"
export type HardcoverBookSyncMode = "all_eligible" | "selected_only";
export type HardcoverBookSyncOverride = "included" | "excluded" | null;
export type HardcoverBookSyncEffectiveReason =
| HardcoverSyncDisabledReason
| "global_disabled"
| "not_selected"
| "excluded"
| "unread"
| "unsupported_status";
HardcoverSyncDisabledReason | "global_disabled" | "not_selected" | "excluded" | "unread" | "unsupported_status";
export interface HardcoverSettings {
tokenConfigured: boolean;
+1 -9
View File
@@ -133,15 +133,7 @@ export interface TestKoreaderConnectionResult {
}
export type KoreaderCatalogSection =
| "libraries"
| "collections"
| "smart-scopes"
| "authors"
| "series"
| "search"
| "recent"
| "all-books"
| "continue-reading";
"libraries" | "collections" | "smart-scopes" | "authors" | "series" | "search" | "recent" | "all-books" | "continue-reading";
export type KoreaderCatalogSort = "title" | "author" | "recently_added" | "recently_updated" | "recently_read" | "series";
+1
View File
@@ -9,6 +9,7 @@ export const MetadataProviderKey = {
ITUNES: "itunes",
AUDIBLE: "audible",
AUDNEXUS: "audnexus",
LIBROFM: "librofm",
COMICVINE: "comicvine",
RANOBEDB: "ranobedb",
KOBO: "kobo",
@@ -131,6 +131,7 @@ export interface ProviderConfigurations {
itunes: ITunesProviderConfig;
audible: AudibleProviderConfig;
audnexus: AudnexusProviderConfig;
librofm: SimpleProviderConfig;
comicvine: ComicVineProviderConfig;
ranobedb: SimpleProviderConfig;
kobo: KoboProviderConfig;
+1 -1
View File
@@ -1,7 +1,7 @@
/** A single allowlisted (GitHub-hosted) media item attached to a highlight. */
export interface ReleaseMedia {
url: string;
type: 'image' | 'video';
type: "image" | "video";
}
export interface ReleaseHighlight {
+1 -6
View File
@@ -35,12 +35,7 @@ export interface StorygraphSyncPendingSummary {
export type StorygraphBookSyncOverride = "included" | "excluded" | null;
export type StorygraphBookSyncEffectiveReason =
| StorygraphSyncDisabledReason
| "global_disabled"
| "not_selected"
| "excluded"
| "unread"
| "unsupported_status";
StorygraphSyncDisabledReason | "global_disabled" | "not_selected" | "excluded" | "unread" | "unsupported_status";
export interface StorygraphBookSyncState {
bookId: number;
@@ -29,6 +29,7 @@ describe('MetadataFetchController', () => {
{ key: MetadataProviderKey.OPEN_LIBRARY, label: 'OpenLibrary', identifiable: false },
{ key: MetadataProviderKey.AUDIBLE, label: 'Audible', identifiable: true },
{ key: MetadataProviderKey.AUDNEXUS, label: 'AudNexus', identifiable: false },
{ key: MetadataProviderKey.LIBROFM, label: 'Libro.fm', identifiable: true },
{ key: MetadataProviderKey.KOBO, label: 'Kobo', identifiable: true },
];
@@ -243,6 +244,16 @@ describe('MetadataFetchController', () => {
);
});
it('infers audiobook search when Libro.fm is requested', async () => {
providerConfig.getConfig.mockResolvedValue(makeProviderConfig({ librofm: { enabled: true } }));
service.search.mockReturnValue(of({ provider: MetadataProviderKey.LIBROFM, providerId: '9781427201438', title: 'Dune' }));
const stream = await controller.stream({ title: 'Dune', providers: [MetadataProviderKey.LIBROFM] }, user);
await firstValueFrom(stream.pipe(toArray()));
expect(service.search).toHaveBeenCalledWith(expect.objectContaining({ title: 'Dune', isAudiobook: true }), [MetadataProviderKey.LIBROFM]);
});
it('infers audiobook search when the effective provider set is only audiobook providers', async () => {
service.getStoredProviderContext.mockResolvedValue({ libraryId: 5, providerIds: {} });
pipeline.getEffectiveProviderKeys.mockResolvedValue([MetadataProviderKey.AUDIBLE]);
@@ -378,6 +389,7 @@ function makeProviderConfig(overrides: Partial<ProviderConfigurations> = {}): Pr
itunes: { enabled: false, coverResolution: 'high', ...overrides.itunes },
audible: { enabled: true, domain: 'com', ...overrides.audible },
audnexus: { enabled: true, ...overrides.audnexus },
librofm: { enabled: false, ...overrides.librofm },
comicvine: { enabled: false, apiKey: '', ...overrides.comicvine },
ranobedb: { enabled: false, ...overrides.ranobedb },
kobo: { enabled: true, country: 'us', language: 'en', ...overrides.kobo },
@@ -24,7 +24,7 @@ function normalizeSearchTitle(title: string | undefined): string | undefined {
}
function isAudiobookProvider(providerKey: MetadataProviderKey): boolean {
return providerKey === MetadataProviderKey.AUDIBLE || providerKey === MetadataProviderKey.AUDNEXUS;
return providerKey === MetadataProviderKey.AUDIBLE || providerKey === MetadataProviderKey.AUDNEXUS || providerKey === MetadataProviderKey.LIBROFM;
}
@Controller('metadata-fetch')
@@ -15,6 +15,7 @@ import { OpenLibraryProvider } from './providers/open-library/open-library.provi
import { ITunesProvider } from './providers/itunes/itunes.provider';
import { AudibleProvider } from './providers/audible/audible.provider';
import { AudnexusProvider } from './providers/audnexus/audnexus.provider';
import { LibroFmProvider } from './providers/librofm/librofm.provider';
import { HardcoverProvider } from './providers/hardcover/hardcover.provider';
import { ComicVineProvider } from './providers/comicvine/comicvine.provider';
import { KoboProvider } from './providers/kobo/kobo.provider';
@@ -35,6 +36,7 @@ describe('MetadataFetchModule', () => {
ITunesProvider,
AudibleProvider,
AudnexusProvider,
LibroFmProvider,
HardcoverProvider,
ComicVineProvider,
RanobeDbProvider,
@@ -62,6 +64,7 @@ describe('MetadataFetchModule', () => {
ITunesProvider,
AudibleProvider,
AudnexusProvider,
LibroFmProvider,
HardcoverProvider,
ComicVineProvider,
RanobeDbProvider,
@@ -16,6 +16,7 @@ import { OpenLibraryProvider } from './providers/open-library/open-library.provi
import { ITunesProvider } from './providers/itunes/itunes.provider';
import { AudibleProvider } from './providers/audible/audible.provider';
import { AudnexusProvider } from './providers/audnexus/audnexus.provider';
import { LibroFmProvider } from './providers/librofm/librofm.provider';
import { HardcoverClient } from './providers/hardcover/hardcover.client';
import { HardcoverProvider } from './providers/hardcover/hardcover.provider';
import { ComicVineClient } from './providers/comicvine/comicvine.client';
@@ -34,6 +35,7 @@ const PROVIDER_CLASSES = [
ITunesProvider,
AudibleProvider,
AudnexusProvider,
LibroFmProvider,
HardcoverProvider,
ComicVineProvider,
RanobeDbProvider,
@@ -0,0 +1,81 @@
import { MetadataProviderKey } from '@bookorbit/types';
import { mapLibroFmAudiobook } from './librofm.mapper';
import type { LibroFmAudiobook } from './librofm.types';
function makeAudiobook(overrides: Partial<LibroFmAudiobook> = {}): LibroFmAudiobook {
return {
title: 'And Then She Vanished',
isbn: 9781982689711,
...overrides,
};
}
describe('mapLibroFmAudiobook', () => {
it('maps every compatible audiobook metadata field', () => {
const result = mapLibroFmAudiobook(
makeAudiobook({
subtitle: 'The Joseph Bridgeman Series, Book 1',
authors: [' Nick Jones '],
description: '<p>He only looked away for <strong>a second</strong>.</p>',
publisher: 'Blackstone Publishing',
publication_date: '2021-04-03T00:00:00.000Z',
audiobook_info: {
narrators: [' Ray Porter '],
duration: 32821,
audio_language: 'eng',
audio_language_display: 'English',
},
abridged: false,
series: 'The Joseph Bridgeman Series',
series_num: 1,
genres: [{ name: 'Fiction' }, { name: 'Mystery & Thriller' }, { name: 'Science Fiction' }],
cover_url: '//covers.libro.fm/9781982689711_1120.jpg',
}),
);
expect(result).toEqual({
provider: MetadataProviderKey.LIBROFM,
providerId: '9781982689711',
title: 'And Then She Vanished',
subtitle: 'The Joseph Bridgeman Series, Book 1',
authors: ['Nick Jones'],
narrators: ['Ray Porter'],
description: 'He only looked away for a second.',
publisher: 'Blackstone Publishing',
publishedDate: '2021-04-03',
publishedYear: 2021,
language: 'English',
isbn13: '9781982689711',
seriesName: 'The Joseph Bridgeman Series',
seriesIndex: 1,
seriesMemberships: [{ seriesName: 'The Joseph Bridgeman Series', seriesIndex: 1 }],
genres: ['Fiction', 'Mystery & Thriller', 'Science Fiction'],
coverUrl: 'https://covers.libro.fm/9781982689711_1120.jpg',
sourceUrl: 'https://libro.fm/audiobooks/9781982689711',
durationSeconds: 32821,
abridged: false,
});
});
it('falls back to the language code and omits invalid optional values', () => {
const result = mapLibroFmAudiobook(
makeAudiobook({
audiobook_info: { duration: -1, audio_language: 'eng' },
series: ' ',
series_num: 'unknown',
genres: [{ name: 'Fiction' }, { name: 'fiction' }, { name: ' ' }],
}),
);
expect(result.language).toBe('eng');
expect(result.durationSeconds).toBeUndefined();
expect(result.seriesMemberships).toBeUndefined();
expect(result.genres).toEqual(['Fiction']);
});
it('rejects responses without an ISBN or title', () => {
expect(() => mapLibroFmAudiobook(makeAudiobook({ isbn: null }))).toThrow('missing ISBN');
expect(() => mapLibroFmAudiobook(makeAudiobook({ title: null }))).toThrow('missing title');
});
});
@@ -0,0 +1,83 @@
import { MetadataProviderKey, type MetadataCandidate, type MetadataSeriesMembership } from '@bookorbit/types';
import { parsePublishedDateKey, publishedYearFromDateKey } from '../../../../common/utils/published-date.utils';
import { stripHtml } from '../provider-utils';
import type { LibroFmAudiobook } from './librofm.types';
function normalizeStrings(values: string[] | null | undefined): string[] | undefined {
if (!Array.isArray(values)) return undefined;
const normalized = values.map((value) => value.trim()).filter(Boolean);
return normalized.length ? normalized : undefined;
}
function normalizeCoverUrl(value: string | null | undefined): string | undefined {
const coverUrl = value?.trim();
if (!coverUrl) return undefined;
if (coverUrl.startsWith('//')) return `https:${coverUrl}`;
if (coverUrl.startsWith('/')) return `https://libro.fm${coverUrl}`;
return coverUrl;
}
function parseSeriesIndex(value: string | number | null | undefined): number | undefined {
if (value == null || String(value).trim() === '') return undefined;
const parsed = Number.parseFloat(String(value));
return Number.isFinite(parsed) ? parsed : undefined;
}
function normalizeGenres(values: LibroFmAudiobook['genres']): string[] | undefined {
if (!Array.isArray(values)) return undefined;
const seen = new Set<string>();
const genres: string[] = [];
for (const value of values) {
const name = value.name?.trim();
if (!name) continue;
const key = name.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
genres.push(name);
}
return genres.length ? genres : undefined;
}
export function mapLibroFmAudiobook(book: LibroFmAudiobook): MetadataCandidate {
const providerId = String(book.isbn ?? '').trim();
if (!providerId) throw new Error('Libro.fm audiobook missing ISBN');
const title = book.title?.trim();
if (!title) throw new Error('Libro.fm audiobook missing title');
const publishedDate = parsePublishedDateKey(book.publication_date ?? undefined);
const publishedYear = publishedDate ? publishedYearFromDateKey(publishedDate) : undefined;
const description = book.description ? stripHtml(book.description).replace(/\s+([.,!?;:])/g, '$1') : undefined;
const seriesName = book.series?.trim() || undefined;
const seriesIndex = parseSeriesIndex(book.series_num);
const seriesMemberships: MetadataSeriesMembership[] | undefined = seriesName
? [{ seriesName, ...(seriesIndex !== undefined ? { seriesIndex } : {}) }]
: undefined;
const duration = book.audiobook_info?.duration;
const durationSeconds = typeof duration === 'number' && Number.isFinite(duration) && duration >= 0 ? Math.round(duration) : undefined;
const language = book.audiobook_info?.audio_language_display?.trim() || book.audiobook_info?.audio_language?.trim() || undefined;
return {
provider: MetadataProviderKey.LIBROFM,
providerId,
title,
subtitle: book.subtitle?.trim() || undefined,
authors: normalizeStrings(book.authors),
narrators: normalizeStrings(book.audiobook_info?.narrators),
description,
publisher: book.publisher?.trim() || undefined,
publishedDate,
publishedYear,
language,
isbn13: /^\d{13}$/.test(providerId) ? providerId : undefined,
seriesName,
seriesIndex,
seriesMemberships,
genres: normalizeGenres(book.genres),
coverUrl: normalizeCoverUrl(book.cover_url),
sourceUrl: `https://libro.fm/audiobooks/${encodeURIComponent(providerId)}`,
durationSeconds,
abridged: typeof book.abridged === 'boolean' ? book.abridged : undefined,
};
}
@@ -0,0 +1,113 @@
import type { ProviderConfigurations } from '@bookorbit/types';
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
import { fetchWithThrottle } from '../../fetch-with-throttle';
import { ProviderThrottleError } from '../../provider-throttle.error';
import { LibroFmProvider } from './librofm.provider';
vi.mock('../../fetch-with-throttle', () => ({
fetchWithThrottle: vi.fn(),
}));
const mockFetchWithThrottle = fetchWithThrottle as MockedFunction<typeof fetchWithThrottle>;
const baseConfig: ProviderConfigurations = {
google: { enabled: false, apiKey: '' },
amazon: { enabled: false, domain: 'amazon.com', cookie: '' },
goodreads: { enabled: false },
hardcover: { enabled: false, apiKey: '' },
openLibrary: { enabled: false },
itunes: { enabled: false, coverResolution: 'high' },
audible: { enabled: false, domain: 'com' },
audnexus: { enabled: false },
librofm: { enabled: true },
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
aladin: { enabled: false, ttbKey: '' },
};
function makeProvider(config: ProviderConfigurations = baseConfig): LibroFmProvider {
const providerConfig = { getConfig: vi.fn().mockResolvedValue(config) };
return new LibroFmProvider(providerConfig as unknown as ProviderConfigService);
}
function response(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: vi.fn().mockResolvedValue(body),
} as unknown as Response;
}
describe('LibroFmProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('does not search when disabled or when the book is not an audiobook', async () => {
const disabled = makeProvider({ ...baseConfig, librofm: { enabled: false } });
await expect(disabled.search({ title: 'Dune', isAudiobook: true })).resolves.toEqual([]);
await expect(makeProvider().search({ title: 'Dune', isAudiobook: false })).resolves.toEqual([]);
expect(mockFetchWithThrottle).not.toHaveBeenCalled();
});
it('uses an ISBN for a direct details lookup', async () => {
mockFetchWithThrottle.mockResolvedValue(
response({
data: {
audiobook: {
title: 'Dune',
isbn: 9781427201438,
audiobook_info: { narrators: ['Simon Vance'], duration: 75840 },
},
},
}),
);
const result = await makeProvider().search({ isbn: '9781427201438', isAudiobook: true });
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ providerId: '9781427201438', narrators: ['Simon Vance'], durationSeconds: 75840 });
expect(mockFetchWithThrottle).toHaveBeenCalledTimes(1);
expect(mockFetchWithThrottle).toHaveBeenCalledWith(
expect.stringContaining('/audiobook_details/9781427201438'),
expect.objectContaining({
headers: expect.objectContaining({ 'user-agent': 'okhttp/4.12.0', 'x-librofm-appver': '7.37.4' }),
}),
);
});
it('searches by title and author, limits candidates, and enriches each result', async () => {
mockFetchWithThrottle
.mockResolvedValueOnce(
response({
audiobook_collection: {
audiobooks: [{ isbn: 1111111111111 }, { isbn: 2222222222222 }, { isbn: 3333333333333 }, { isbn: 4444444444444 }],
},
}),
)
.mockResolvedValueOnce(response({ data: { audiobook: { isbn: 1111111111111, title: 'First' } } }))
.mockResolvedValueOnce(response({ data: { audiobook: { isbn: 2222222222222, title: 'Second' } } }));
const result = await makeProvider().search({
title: 'Project Hail Mary',
author: 'Andy Weir',
isAudiobook: true,
maxCandidatesPerProvider: 2,
});
expect(result.map((candidate) => candidate.title)).toEqual(['First', 'Second']);
expect(mockFetchWithThrottle).toHaveBeenCalledTimes(3);
expect(String(mockFetchWithThrottle.mock.calls[0]?.[0])).toContain('q=Project+Hail+Mary+Andy+Weir');
});
it('returns null for missing details and rethrows throttling errors', async () => {
mockFetchWithThrottle.mockResolvedValueOnce(response({}, 404));
await expect(makeProvider().lookupById('missing')).resolves.toBeNull();
mockFetchWithThrottle.mockRejectedValueOnce(new ProviderThrottleError(60));
await expect(makeProvider().lookupById('9781427201438')).rejects.toBeInstanceOf(ProviderThrottleError);
});
});
@@ -0,0 +1,150 @@
import { Injectable, Logger } from '@nestjs/common';
import { MetadataProviderKey, type MetadataCandidate } from '@bookorbit/types';
import { sanitizeLogValue } from '../../../../common/utils/log-sanitize.utils';
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
import { fetchWithThrottle } from '../../fetch-with-throttle';
import { ProviderThrottleError } from '../../provider-throttle.error';
import type { IdentifiableProvider } from '../metadata-provider';
import type { MetadataSearchParams } from '../metadata-search-params';
import { PROVIDER_DELAYS_MS, PROVIDER_LIMITS, PROVIDER_TIMEOUT_MS } from '../provider-constants';
import { buildRequestSignal, normalizeMaxCandidates, sleep } from '../provider-utils';
import { mapLibroFmAudiobook } from './librofm.mapper';
import type { LibroFmDetailsResponse, LibroFmSearchResponse } from './librofm.types';
const API_BASE_URL = 'https://libro.fm/api/v12/explore';
const REQUEST_HEADERS: HeadersInit = {
accept: 'application/json',
'user-agent': 'okhttp/4.12.0',
'x-librofm-appver': '7.37.4',
};
@Injectable()
export class LibroFmProvider implements IdentifiableProvider {
readonly key = MetadataProviderKey.LIBROFM;
readonly label = 'Libro.fm';
readonly identifiable = true as const;
private readonly logger = new Logger(LibroFmProvider.name);
constructor(private readonly providerConfig: ProviderConfigService) {}
async search(params: MetadataSearchParams): Promise<MetadataCandidate[]> {
const { enabled } = await this.providerConfig.getConfig().then((config) => config.librofm);
if (!enabled || !params.isAudiobook) return [];
const directId = params.existingProviderIds?.[MetadataProviderKey.LIBROFM] ?? params.isbn?.trim();
if (directId) {
const candidate = await this.fetchByIsbn(directId, params.signal);
return candidate ? [candidate] : [];
}
const query = [params.title, params.author]
.filter((part): part is string => typeof part === 'string' && part.trim().length > 0)
.join(' ')
.trim();
if (!query) return [];
const maxCandidates = normalizeMaxCandidates(params.maxCandidatesPerProvider, PROVIDER_LIMITS.LIBROFM_MAX_RESULTS);
const isbns = await this.searchIsbns(query, maxCandidates, params.signal);
const candidates: MetadataCandidate[] = [];
for (const isbn of isbns) {
if (candidates.length > 0) await sleep(PROVIDER_DELAYS_MS.LIBROFM_BETWEEN_REQUESTS, params.signal);
const candidate = await this.fetchByIsbn(isbn, params.signal);
if (candidate) candidates.push(candidate);
}
return candidates;
}
async lookupById(providerId: string, signal?: AbortSignal): Promise<MetadataCandidate | null> {
const { enabled } = await this.providerConfig.getConfig().then((config) => config.librofm);
if (!enabled) return null;
return this.fetchByIsbn(providerId, signal);
}
private async searchIsbns(query: string, limit: number, signal?: AbortSignal): Promise<string[]> {
const url = new URL(`${API_BASE_URL}/search`);
url.searchParams.set('page', '1');
url.searchParams.set('q', query);
url.searchParams.set('searchby', 'all');
const startedAt = Date.now();
const safeQuery = sanitizeLogValue(query);
this.logger.log(`[librofm.metadata] [start] operation=search query="${safeQuery}" limit=${limit} - Libro.fm search started`);
try {
const response = await fetchWithThrottle(url, {
headers: REQUEST_HEADERS,
signal: buildRequestSignal(PROVIDER_TIMEOUT_MS.DEFAULT, signal),
});
if (!response.ok) {
const errorMessage = sanitizeLogValue(`status ${response.status}`);
this.logger.warn(
`[librofm.metadata] [fail] operation=search query="${safeQuery}" durationMs=${Date.now() - startedAt} errorClass=HttpError error="${errorMessage}" - Libro.fm search failed`,
);
return [];
}
const body = (await response.json()) as LibroFmSearchResponse;
const isbns = (body.audiobook_collection?.audiobooks ?? [])
.map((book) => String(book.isbn ?? '').trim())
.filter((isbn, index, values) => isbn.length > 0 && values.indexOf(isbn) === index)
.slice(0, limit);
this.logger.log(
`[librofm.metadata] [end] operation=search query="${safeQuery}" durationMs=${Date.now() - startedAt} resultCount=${isbns.length} - Libro.fm search completed`,
);
return isbns;
} catch (error) {
if (error instanceof ProviderThrottleError) throw error;
const errorClass = error instanceof Error ? error.name : 'UnknownError';
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error));
this.logger.warn(
`[librofm.metadata] [fail] operation=search query="${safeQuery}" durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - Libro.fm search failed`,
);
return [];
}
}
private async fetchByIsbn(isbn: string, signal?: AbortSignal): Promise<MetadataCandidate | null> {
const providerId = isbn.trim();
if (!providerId) return null;
const safeProviderId = sanitizeLogValue(providerId);
const startedAt = Date.now();
this.logger.log(`[librofm.metadata] [start] operation=lookup providerId="${safeProviderId}" - Libro.fm lookup started`);
try {
const response = await fetchWithThrottle(`${API_BASE_URL}/audiobook_details/${encodeURIComponent(providerId)}`, {
headers: REQUEST_HEADERS,
signal: buildRequestSignal(PROVIDER_TIMEOUT_MS.DEFAULT, signal),
});
if (!response.ok) {
const errorMessage = sanitizeLogValue(`status ${response.status}`);
this.logger.warn(
`[librofm.metadata] [fail] operation=lookup providerId="${safeProviderId}" durationMs=${Date.now() - startedAt} errorClass=HttpError error="${errorMessage}" - Libro.fm lookup failed`,
);
return null;
}
const body = (await response.json()) as LibroFmDetailsResponse;
const book = body.data?.audiobook;
if (!book) {
this.logger.log(
`[librofm.metadata] [end] operation=lookup providerId="${safeProviderId}" durationMs=${Date.now() - startedAt} found=false - Libro.fm lookup completed`,
);
return null;
}
const candidate = mapLibroFmAudiobook(book);
this.logger.log(
`[librofm.metadata] [end] operation=lookup providerId="${safeProviderId}" durationMs=${Date.now() - startedAt} found=true - Libro.fm lookup completed`,
);
return candidate;
} catch (error) {
if (error instanceof ProviderThrottleError) throw error;
const errorClass = error instanceof Error ? error.name : 'UnknownError';
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error));
this.logger.warn(
`[librofm.metadata] [fail] operation=lookup providerId="${safeProviderId}" durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - Libro.fm lookup failed`,
);
return null;
}
}
}
@@ -0,0 +1,42 @@
export interface LibroFmSearchAudiobook {
title?: string | null;
isbn?: string | number | null;
authors?: string[] | null;
cover_url?: string | null;
audiobook_info?: {
narrators?: string[] | null;
} | null;
}
export interface LibroFmSearchResponse {
audiobook_collection?: {
audiobooks?: LibroFmSearchAudiobook[] | null;
} | null;
}
export interface LibroFmAudiobook {
title?: string | null;
subtitle?: string | null;
isbn?: string | number | null;
description?: string | null;
abridged?: boolean | null;
series?: string | null;
series_num?: string | number | null;
genres?: Array<{ name?: string | null }> | null;
audiobook_info?: {
narrators?: string[] | null;
duration?: number | null;
audio_language?: string | null;
audio_language_display?: string | null;
} | null;
authors?: string[] | null;
cover_url?: string | null;
publisher?: string | null;
publication_date?: string | null;
}
export interface LibroFmDetailsResponse {
data?: {
audiobook?: LibroFmAudiobook | null;
} | null;
}
@@ -11,6 +11,7 @@ export const PROVIDER_LIMITS = {
RANOBEDB_MAX_RESULTS: 5,
KOBO_MAX_RESULTS: 3,
LUBIMYCZYTAC_MAX_RESULTS: 5,
LIBROFM_MAX_RESULTS: 3,
DEFAULT_SEARCH_RESULTS: 10,
} as const;
@@ -22,4 +23,5 @@ export const PROVIDER_DELAYS_MS = {
RANOBEDB_BETWEEN_REQUESTS: 500,
KOBO_BETWEEN_REQUESTS: 700,
LUBIMYCZYTAC_BETWEEN_REQUESTS: 600,
LIBROFM_BETWEEN_REQUESTS: 300,
} as const;
@@ -1,6 +1,7 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { ValidationPipe } from '@nestjs/common';
import { UpdateProviderConfigDto } from './update-provider-config.dto';
@@ -17,6 +18,7 @@ describe('UpdateProviderConfigDto', () => {
amazon: { cookie: 'session-cookie' },
hardcover: { apiKey: 'hardcover-key' },
itunes: { coverResolution: 'standard' },
librofm: { enabled: true },
aladin: { ttbKey: 'ttb-test-key' },
});
@@ -29,6 +31,14 @@ describe('UpdateProviderConfigDto', () => {
expect(errors).toHaveLength(0);
});
it('accepts Libro.fm through the production whitelist validation path', async () => {
const pipe = new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true });
await expect(
pipe.transform({ librofm: { enabled: true } }, { type: 'body', metatype: UpdateProviderConfigDto, data: undefined }),
).resolves.toMatchObject({ librofm: { enabled: true } });
});
it('rejects invalid nested property types', async () => {
const { errors } = await validateInput({
google: { enabled: 'yes' },
@@ -57,6 +57,7 @@ export class UpdateProviderConfigDto {
@IsOptional() @ValidateNested() @Type(() => ITunesProviderConfigDto) itunes?: ITunesProviderConfigDto;
@IsOptional() @ValidateNested() @Type(() => AudibleProviderConfigDto) audible?: AudibleProviderConfigDto;
@IsOptional() @ValidateNested() @Type(() => SimpleProviderConfigDto) audnexus?: SimpleProviderConfigDto;
@IsOptional() @ValidateNested() @Type(() => SimpleProviderConfigDto) librofm?: SimpleProviderConfigDto;
@IsOptional() @ValidateNested() @Type(() => ComicVineProviderConfigDto) comicvine?: ComicVineProviderConfigDto;
@IsOptional() @ValidateNested() @Type(() => SimpleProviderConfigDto) ranobedb?: SimpleProviderConfigDto;
@IsOptional() @ValidateNested() @Type(() => KoboProviderConfigDto) kobo?: KoboProviderConfigDto;
@@ -71,6 +71,7 @@ describe('ProviderConfigService', () => {
itunes: { enabled: true, coverResolution: 'high' },
audible: { enabled: false, domain: 'com' },
audnexus: { enabled: false },
librofm: { enabled: false },
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
@@ -355,6 +356,7 @@ describe('ProviderConfigService', () => {
itunes: { enabled: true, coverResolution: 'high' },
audible: { enabled: false, domain: 'com' },
audnexus: { enabled: false },
librofm: { enabled: false },
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
@@ -371,6 +373,7 @@ describe('ProviderConfigService', () => {
MetadataProviderKey.ITUNES,
MetadataProviderKey.AUDIBLE,
MetadataProviderKey.AUDNEXUS,
MetadataProviderKey.LIBROFM,
MetadataProviderKey.COMICVINE,
MetadataProviderKey.RANOBEDB,
MetadataProviderKey.KOBO,
@@ -397,6 +400,7 @@ describe('ProviderConfigService', () => {
itunes: { enabled: true, coverResolution: 'high' },
audible: { enabled: false, domain: 'com' },
audnexus: { enabled: false },
librofm: { enabled: false },
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
@@ -41,6 +41,7 @@ const DEFAULT_CONFIG: ProviderConfigurations = {
itunes: { enabled: true, coverResolution: 'high' },
audible: { enabled: false, domain: 'com' },
audnexus: { enabled: false },
librofm: { enabled: false },
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
@@ -144,6 +145,7 @@ const PROVIDER_LABELS: Record<MetadataProviderKey, string> = {
[MetadataProviderKey.ITUNES]: 'iTunes',
[MetadataProviderKey.AUDIBLE]: 'Audible',
[MetadataProviderKey.AUDNEXUS]: 'AudNexus',
[MetadataProviderKey.LIBROFM]: 'Libro.fm',
[MetadataProviderKey.COMICVINE]: 'ComicVine',
[MetadataProviderKey.RANOBEDB]: 'RanobeDB',
[MetadataProviderKey.KOBO]: 'Kobo',
@@ -196,6 +198,7 @@ export class ProviderConfigService {
itunes: { ...DEFAULT_CONFIG.itunes },
audible: { ...DEFAULT_CONFIG.audible },
audnexus: { ...DEFAULT_CONFIG.audnexus },
librofm: { ...DEFAULT_CONFIG.librofm },
comicvine: { ...DEFAULT_CONFIG.comicvine },
ranobedb: { ...DEFAULT_CONFIG.ranobedb },
kobo: { ...DEFAULT_CONFIG.kobo },
@@ -215,6 +218,7 @@ export class ProviderConfigService {
itunes: mergeITunesConfig(base.itunes, next.itunes),
audible: mergeAudibleConfig(base.audible, next.audible),
audnexus: mergeSimpleConfig(base.audnexus, next.audnexus),
librofm: mergeSimpleConfig(base.librofm, next.librofm),
comicvine: mergeComicVineConfig(base.comicvine, next.comicvine),
ranobedb: mergeSimpleConfig(base.ranobedb, next.ranobedb),
kobo: mergeKoboConfig(base.kobo, next.kobo),
@@ -431,6 +435,13 @@ export class ProviderConfigService {
enabled: cfg.audnexus.enabled,
configured: true,
},
{
key: MetadataProviderKey.LIBROFM,
label: PROVIDER_LABELS[MetadataProviderKey.LIBROFM],
enabled: cfg.librofm.enabled,
configured: true,
hint: 'Uses an undocumented Libro.fm catalog endpoint',
},
{
key: MetadataProviderKey.COMICVINE,
label: PROVIDER_LABELS[MetadataProviderKey.COMICVINE],