mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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',
|
||||
|
||||
+55
@@ -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()
|
||||
})
|
||||
})
|
||||
+13
-2
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user