}) {
+ return {
+ status: fetched.status,
+ headers: {
+ get: (name: string) => fetched.headers[name.toLowerCase()] ?? null,
+ },
+ };
+}
+
+function parseRetryAfterSeconds(retryAfter: string | undefined): number | undefined {
+ if (!retryAfter) return undefined;
+ const numeric = Number(retryAfter);
+ if (Number.isFinite(numeric) && numeric > 0) return Math.ceil(numeric);
+
+ const retryAtEpochMs = Date.parse(retryAfter);
+ if (Number.isNaN(retryAtEpochMs)) return undefined;
+ const remainingSeconds = Math.ceil((retryAtEpochMs - Date.now()) / 1000);
+ return remainingSeconds > 0 ? remainingSeconds : undefined;
+}
diff --git a/server/src/modules/metadata-fetch/providers/kobo/kobo.scraper.test.ts b/server/src/modules/metadata-fetch/providers/kobo/kobo.scraper.test.ts
new file mode 100644
index 000000000..83824b379
--- /dev/null
+++ b/server/src/modules/metadata-fetch/providers/kobo/kobo.scraper.test.ts
@@ -0,0 +1,140 @@
+import {
+ buildKoboBookUrl,
+ buildKoboSearchUrl,
+ extractKoboProviderId,
+ extractKoboSearchResults,
+ isKoboChallengePage,
+ normalizeKoboProviderId,
+ parseKoboBookPage,
+} from './kobo.scraper';
+
+describe('Kobo scraper', () => {
+ it('builds regional search and lookup URLs like the Kobo metadata plugin', () => {
+ expect(buildKoboSearchUrl('Fourth Wing Rebecca Yarros', 2, { country: 'au', language: 'en' })).toBe(
+ 'https://www.kobo.com/au/en/search?query=Fourth+Wing+Rebecca+Yarros&fcmedia=Book&pageNumber=2&fclanguages=en',
+ );
+ expect(buildKoboBookUrl('fourth-wing-1', { country: 'au', language: 'en' })).toBe('https://www.kobo.com/au/en/ebook/fourth-wing-1');
+ expect(buildKoboBookUrl('fourth-wing-1', { country: 'au', language: 'all' })).toBe('https://www.kobo.com/au/ebook/fourth-wing-1');
+ });
+
+ it('extracts and normalizes Kobo provider IDs', () => {
+ expect(extractKoboProviderId('https://www.kobo.com/us/en/ebook/fourth-wing-1?utm=1')).toBe('fourth-wing-1');
+ expect(extractKoboProviderId('/us/en/ebook/fourth-wing-1')).toBe('fourth-wing-1');
+ expect(normalizeKoboProviderId(' https://www.kobo.com/us/en/ebook/fourth-wing-1 ')).toBe('fourth-wing-1');
+ });
+
+ it('extracts search results from the current Kobo search layout and dedupes duplicate anchors', () => {
+ const html = `
+
+ `;
+
+ expect(extractKoboSearchResults(html, 10)).toEqual([
+ { providerId: 'fourth-wing-1', url: 'https://www.kobo.com/us/en/ebook/fourth-wing-1' },
+ { providerId: 'iron-flame-1', url: 'https://www.kobo.com/us/en/ebook/iron-flame-1' },
+ ]);
+ });
+
+ it('extracts search results from the legacy product field layout', () => {
+ const html = `
+
+ `;
+
+ expect(extractKoboSearchResults(html, 1)).toEqual([{ providerId: 'old-layout-book', url: 'https://www.kobo.com/au/en/ebook/old-layout-book' }]);
+ });
+
+ it('parses Kobo product pages using the Calibre plugin selectors', () => {
+ const html = `
+
+
+
+ Fourth Wing: Empyrean Book 1
+
+ Rebecca Yarros
+
+ Book 1 -
+ The Empyrean
+
+
+
+ A young dragon rider enters a war college.
+
+
+ `;
+
+ expect(parseKoboBookPage(html, 'https://www.kobo.com/us/en/ebook/fourth-wing-1')).toEqual({
+ providerId: 'fourth-wing-1',
+ title: 'Fourth Wing',
+ subtitle: 'Empyrean Book 1',
+ authors: ['Rebecca Yarros'],
+ description: 'A young dragon rider enters a war college.',
+ publisher: 'Entangled Publishing, LLC',
+ publishedYear: 2023,
+ language: 'English',
+ pageCount: 640,
+ isbn10: undefined,
+ isbn13: '9781649374042',
+ seriesName: 'The Empyrean',
+ seriesIndex: 1,
+ genres: ['Fantasy', 'Romance'],
+ coverUrl: 'https://cdn.kobo.com/book-images/44f0/1650/2200/100/False/cover.jpg',
+ sourceUrl: 'https://www.kobo.com/us/en/ebook/fourth-wing-1',
+ });
+ });
+
+ it('parses visible subtitle and page stats from the current Kobo layout', () => {
+ const html = `
+ Beautiful Ugly
+ A Novel
+ Alice Feeney
+
+
+ `;
+
+ expect(parseKoboBookPage(html, 'https://www.kobo.com/us/en/ebook/beautiful-ugly-3')).toEqual(
+ expect.objectContaining({
+ providerId: 'beautiful-ugly-3',
+ title: 'Beautiful Ugly',
+ subtitle: 'A Novel',
+ authors: ['Alice Feeney'],
+ publisher: 'Flatiron Books',
+ publishedYear: 2025,
+ language: 'English',
+ pageCount: 304,
+ isbn13: '9781250337795',
+ }),
+ );
+ });
+
+ it('detects Kobo Cloudflare challenge pages', () => {
+ const html = 'Challenged | Kobo.comEnable JavaScript and cookies';
+ expect(isKoboChallengePage(html)).toBe(true);
+ expect(isKoboChallengePage('ok', { headers: new Headers({ 'cf-mitigated': 'challenge' }) })).toBe(true);
+ });
+});
diff --git a/server/src/modules/metadata-fetch/providers/kobo/kobo.scraper.ts b/server/src/modules/metadata-fetch/providers/kobo/kobo.scraper.ts
new file mode 100644
index 000000000..64d4ace6b
--- /dev/null
+++ b/server/src/modules/metadata-fetch/providers/kobo/kobo.scraper.ts
@@ -0,0 +1,375 @@
+import * as cheerio from 'cheerio';
+import type { CheerioAPI } from 'cheerio';
+
+export interface KoboRegionConfig {
+ country: string;
+ language: string;
+}
+
+export interface KoboSearchResultLink {
+ providerId: string;
+ url: string;
+}
+
+export interface KoboBookData {
+ providerId?: string;
+ title?: string;
+ subtitle?: string;
+ authors?: string[];
+ description?: string;
+ publisher?: string;
+ publishedYear?: number;
+ language?: string;
+ pageCount?: number;
+ isbn10?: string;
+ isbn13?: string;
+ seriesName?: string;
+ seriesIndex?: number;
+ genres?: string[];
+ coverUrl?: string;
+ sourceUrl?: string;
+}
+
+type KoboDetails = {
+ publisher?: string;
+ publishedYear?: number;
+ language?: string;
+ pageCount?: number;
+ isbn10?: string;
+ isbn13?: string;
+ providerId?: string;
+ bookId?: string;
+};
+
+type ChallengeResponse = {
+ status?: number;
+ headers?: {
+ get(name: string): string | null;
+ };
+};
+
+const KOBO_BASE_URL = 'https://www.kobo.com';
+const CHALLENGE_PATTERNS = [/challenge-form/i, /challenge-error-text/i, /cf-chl/i, /cf_chl/i, /\s*Challenged\s*\|\s*Kobo\.com\s*<\/title>/i];
+
+export function buildKoboSearchUrl(query: string, page: number, config: KoboRegionConfig): string {
+ const country = normalizeKoboPathSegment(config.country, 'us');
+ const language = normalizeKoboPathSegment(config.language, 'en');
+ const pageNumber = Number.isFinite(page) ? Math.max(1, Math.floor(page)) : 1;
+ const url = new URL(`/${country}/${language}/search`, KOBO_BASE_URL);
+ url.searchParams.set('query', query);
+ url.searchParams.set('fcmedia', 'Book');
+ url.searchParams.set('pageNumber', String(pageNumber));
+ url.searchParams.set('fclanguages', language);
+ return url.toString();
+}
+
+export function buildKoboBookUrl(providerId: string, config: KoboRegionConfig): string {
+ const country = normalizeKoboPathSegment(config.country, 'us');
+ const language = normalizeKoboPathSegment(config.language, 'en');
+ const id = normalizeKoboProviderId(providerId);
+ const path = language === 'all' ? `/${country}/ebook/${encodeURIComponent(id)}` : `/${country}/${language}/ebook/${encodeURIComponent(id)}`;
+ return new URL(path, KOBO_BASE_URL).toString();
+}
+
+export function extractKoboSearchResults(html: string, limit: number): KoboSearchResultLink[] {
+ const $ = cheerio.load(html);
+ const results: KoboSearchResultLink[] = [];
+ const seen = new Set();
+ const anchors = [
+ ...$('div[data-testid="search-result-widget"] a[data-testid="title"]').toArray(),
+ ...$('h2.title.product-field a').toArray(),
+ ...$('[data-testid="search-result-widget"] a[href*="/ebook/"]').toArray(),
+ ];
+
+ for (const el of anchors) {
+ if (results.length >= limit) break;
+ const href = $(el).attr('href');
+ const providerId = href ? extractKoboProviderId(href) : undefined;
+ if (!href || !providerId || seen.has(providerId)) continue;
+ seen.add(providerId);
+ results.push({ providerId, url: resolveKoboUrl(href) });
+ }
+
+ return results;
+}
+
+export function parseKoboBookPage(html: string, sourceUrl?: string): KoboBookData {
+ const $ = cheerio.load(html);
+ const { title, subtitle } = extractTitle($);
+ const details = extractDetails($);
+ const sourceProviderId = sourceUrl ? extractKoboProviderId(sourceUrl) : undefined;
+ const bookId = details.bookId ? normalizeIsbn(details.bookId) || details.bookId : undefined;
+
+ return {
+ providerId: sourceProviderId ?? details.providerId ?? bookId,
+ title,
+ subtitle,
+ authors: extractAuthors($),
+ description: extractDescription($),
+ publisher: details.publisher,
+ publishedYear: details.publishedYear,
+ language: details.language,
+ pageCount: details.pageCount ?? extractPageCount($),
+ isbn10: details.isbn10,
+ isbn13: details.isbn13,
+ seriesName: extractSeriesName($),
+ seriesIndex: extractSeriesIndex($),
+ genres: extractGenres($),
+ coverUrl: extractCoverUrl($),
+ sourceUrl,
+ };
+}
+
+export function extractKoboProviderId(value: string): string | undefined {
+ const raw = value.trim();
+ if (!raw) return undefined;
+ let url: URL;
+ try {
+ url = new URL(raw, KOBO_BASE_URL);
+ } catch {
+ const fallbackSlug = raw.split('/ebook/').pop()?.split(/[?#/]/)[0];
+ return fallbackSlug ? normalizeKoboProviderId(fallbackSlug) || undefined : undefined;
+ }
+
+ const marker = '/ebook/';
+ const index = url.pathname.indexOf(marker);
+ if (index === -1) return undefined;
+ const remainder = url.pathname.slice(index + marker.length);
+ const slug = remainder.split('/')[0];
+ return normalizeKoboProviderId(decodeURIComponent(slug)) || undefined;
+}
+
+export function normalizeKoboProviderId(value: string): string {
+ const trimmed = value.trim();
+ if (!trimmed) return '';
+ const fromUrl = trimmed.includes('/ebook/') ? extractKoboProviderId(trimmed) : undefined;
+ const raw = fromUrl ?? trimmed;
+ return (
+ raw
+ .split(/[?#]/)[0]
+ ?.replace(/^\/+|\/+$/g, '')
+ .trim() ?? ''
+ );
+}
+
+export function isKoboProductUrl(url: string): boolean {
+ try {
+ return new URL(url, KOBO_BASE_URL).pathname.includes('/ebook/');
+ } catch {
+ return false;
+ }
+}
+
+export function isKoboChallengePage(html: string, response?: ChallengeResponse): boolean {
+ const cfMitigated = response?.headers?.get('cf-mitigated')?.toLowerCase() === 'challenge';
+ if (cfMitigated) return true;
+ if (response?.status === 403 && /cloudflare/i.test(html)) return true;
+ return CHALLENGE_PATTERNS.some((pattern) => pattern.test(html));
+}
+
+function extractTitle($: CheerioAPI): { title?: string; subtitle?: string } {
+ const explicitSubtitle =
+ cleanText($('span.subtitle.product-field').first().text()) ||
+ cleanText($('[data-testid="subtitle"]').first().text()) ||
+ cleanText($('meta[property="book:subtitle"]').attr('content'));
+ const raw =
+ cleanText($('h1.title.product-field').first().text()) ||
+ cleanText($('h1[data-testid="title"]').first().text()) ||
+ cleanText($('meta[property="og:title"]').attr('content')) ||
+ cleanText($('h1').first().text());
+ if (!raw) return {};
+
+ const normalized = raw.replace(/\s+eBook\s+by\s+.+$/i, '').trim();
+ const colon = normalized.indexOf(':');
+ if (colon > 0) {
+ return {
+ title: normalized.slice(0, colon).trim(),
+ subtitle: explicitSubtitle || normalized.slice(colon + 1).trim() || undefined,
+ };
+ }
+ return { title: normalized, subtitle: explicitSubtitle || undefined };
+}
+
+function extractAuthors($: CheerioAPI): string[] {
+ const authors: string[] = [];
+ const seen = new Set();
+ const selectors = ['span.visible-contributors a', '[data-testid="contributors"] a', 'a[href*="/author/"]'];
+
+ for (const selector of selectors) {
+ $(selector).each((_, el) => {
+ const name = cleanText($(el).text());
+ if (!name || seen.has(name)) return;
+ seen.add(name);
+ authors.push(name);
+ });
+ if (authors.length) break;
+ }
+
+ return authors;
+}
+
+function extractDescription($: CheerioAPI): string | undefined {
+ const synopsis = $('[data-full-synopsis]').first();
+ const full = cleanText(synopsis.attr('data-full-synopsis'));
+ if (full) return full;
+
+ const synopsisText = cleanText(synopsis.text());
+ if (synopsisText) return synopsisText;
+
+ const fallback =
+ cleanText($('[itemprop="description"]').first().text()) ||
+ cleanText($('meta[name="description"]').attr('content')) ||
+ cleanText($('meta[property="og:description"]').attr('content'));
+ return fallback || undefined;
+}
+
+function extractSeriesName($: CheerioAPI): string | undefined {
+ const series = $('span.series.product-field').last();
+ const name =
+ cleanText(series.find('span.product-sequence-field a').first().text()) ||
+ cleanText($('[data-testid="series"] a').first().text()) ||
+ cleanText($('[data-testid="series"]').first().text());
+ return name || undefined;
+}
+
+function extractSeriesIndex($: CheerioAPI): number | undefined {
+ const raw =
+ cleanText($('span.series.product-field').last().find('span.sequenced-name-prefix').first().text()) ||
+ cleanText($('[data-testid="series"]').first().text());
+ const match = raw.match(/Book\s+(\d+(?:\.\d+)?)\s*-/i) ?? raw.match(/#\s*(\d+(?:\.\d+)?)/);
+ if (!match) return undefined;
+ const value = parseFloat(match[1]);
+ return Number.isNaN(value) ? undefined : value;
+}
+
+function extractDetails($: CheerioAPI): KoboDetails {
+ const details: KoboDetails = {};
+ const items = $('div.bookitem-secondary-metadata ul li').toArray();
+ if (!items.length) return details;
+
+ const firstText = cleanText($(items[0]).text());
+ if (firstText && !/^(release date|isbn|book id|language|publisher):/i.test(firstText)) {
+ details.publisher = firstText;
+ }
+
+ for (const item of items) {
+ const label = cleanText($(item).clone().children().remove().end().text()).replace(/:$/, '');
+ const spanValue = cleanText($(item).find('span').first().text());
+ const inlineValue = cleanText($(item).text()).replace(new RegExp(`^${escapeRegExp(label)}:?\\s*`, 'i'), '');
+ const value = spanValue || inlineValue;
+ if (!label || !value) continue;
+
+ if (/^release date$/i.test(label)) {
+ details.publishedYear = parseYearFromText(value);
+ } else if (/^pages$/i.test(label)) {
+ details.pageCount = parseIntegerFromText(value);
+ } else if (/^(isbn|book id)$/i.test(label)) {
+ const normalized = normalizeIsbn(value);
+ if (normalized?.length === 13) details.isbn13 = normalized;
+ else if (normalized?.length === 10) details.isbn10 = normalized;
+ else if (/^book id$/i.test(label)) details.bookId = value;
+ } else if (/^language$/i.test(label)) {
+ details.language = value;
+ } else if (/^publisher$/i.test(label)) {
+ details.publisher = value;
+ }
+ }
+
+ return details;
+}
+
+function extractPageCount($: CheerioAPI): number | undefined {
+ for (const column of $('#about-this-book-widget .book-stats .column').toArray()) {
+ const label = cleanText($(column).find('span').first().text());
+ if (!/^pages$/i.test(label)) continue;
+ return parseIntegerFromText(cleanText($(column).find('strong').first().text()));
+ }
+
+ const configuredPageCount = extractConfiguredGoogleBookPageCount($);
+ if (configuredPageCount !== undefined) return configuredPageCount;
+
+ return parseIntegerFromText(cleanText($('meta[property="books:page_count"], meta[name="pageCount"]').attr('content')));
+}
+
+function extractGenres($: CheerioAPI): string[] {
+ const genres: string[] = [];
+ const seen = new Set();
+ const add = (value: string | undefined) => {
+ const genre = cleanText(value);
+ if (!genre || seen.has(genre)) return;
+ seen.add(genre);
+ genres.push(genre);
+ };
+
+ $('ul.category-rankings meta[property="genre"], meta[property="book:genre"], meta[property="genre"]').each((_, el) => add($(el).attr('content')));
+ return genres;
+}
+
+function extractCoverUrl($: CheerioAPI): string | undefined {
+ const raw =
+ $('img.cover-image').first().attr('src') ||
+ $('img[class*="cover-image"]').first().attr('src') ||
+ $('meta[property="og:image"]').attr('content') ||
+ $('link[rel="image_src"]').attr('href');
+ if (!raw) return undefined;
+ return normalizeKoboCoverUrl(resolveKoboUrl(raw));
+}
+
+function normalizeKoboCoverUrl(url: string): string {
+ return url.replace(/\/book-images\/([^/]+)\/\d+\/\d+\/\d+\/(False|false)\//, '/book-images/$1/1650/2200/100/$2/');
+}
+
+function resolveKoboUrl(value: string): string {
+ if (value.startsWith('//')) return `https:${value}`;
+ return new URL(value, KOBO_BASE_URL).toString();
+}
+
+function normalizeKoboPathSegment(value: string, fallback: string): string {
+ const normalized = value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9-]/g, '');
+ return normalized || fallback;
+}
+
+function cleanText(value: string | undefined): string {
+ return (value ?? '').replace(/\s+/g, ' ').trim();
+}
+
+function normalizeIsbn(value: string): string | undefined {
+ const normalized = value.replace(/[^0-9X]/gi, '').toUpperCase();
+ return normalized || undefined;
+}
+
+function parseYearFromText(text: string): number | undefined {
+ const match = text.match(/\b(1[0-9]{3}|2[0-9]{3})\b/);
+ return match ? parseInt(match[1], 10) : undefined;
+}
+
+function parseIntegerFromText(text: string | undefined): number | undefined {
+ if (!text) return undefined;
+ const match = text.replace(/,/g, '').match(/\d+/);
+ if (!match) return undefined;
+ const value = parseInt(match[0], 10);
+ return Number.isFinite(value) ? value : undefined;
+}
+
+function extractConfiguredGoogleBookPageCount($: CheerioAPI): number | undefined {
+ const rawConfig = $('#ratings-widget-details-wrapper').attr('data-kobo-gizmo-config');
+ if (!rawConfig) return undefined;
+
+ try {
+ const config = JSON.parse(rawConfig) as { googleBook?: string };
+ if (!config.googleBook) return undefined;
+ const book = JSON.parse(config.googleBook) as { numberOfPages?: unknown };
+ if (typeof book.numberOfPages === 'number') return book.numberOfPages;
+ if (typeof book.numberOfPages === 'string') return parseIntegerFromText(book.numberOfPages);
+ return undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
diff --git a/server/src/modules/metadata-fetch/providers/metadata-provider.ts b/server/src/modules/metadata-fetch/providers/metadata-provider.ts
index 6c089fa27..bef1e8a4a 100644
--- a/server/src/modules/metadata-fetch/providers/metadata-provider.ts
+++ b/server/src/modules/metadata-fetch/providers/metadata-provider.ts
@@ -6,6 +6,7 @@ export interface MetadataProvider {
readonly key: MetadataProviderKey;
readonly label: string;
readonly identifiable: boolean;
+ readonly timeoutMs?: number;
search(params: MetadataSearchParams): Promise;
}
diff --git a/server/src/modules/metadata-fetch/providers/open-library/open-library.provider.test.ts b/server/src/modules/metadata-fetch/providers/open-library/open-library.provider.test.ts
index 185d6e610..3268caf18 100644
--- a/server/src/modules/metadata-fetch/providers/open-library/open-library.provider.test.ts
+++ b/server/src/modules/metadata-fetch/providers/open-library/open-library.provider.test.ts
@@ -18,6 +18,7 @@ describe('OpenLibraryProvider', () => {
audnexus: { enabled: false },
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
+ kobo: { enabled: false, country: 'us', language: 'en' },
};
beforeEach(() => {
diff --git a/server/src/modules/metadata-fetch/providers/provider-constants.ts b/server/src/modules/metadata-fetch/providers/provider-constants.ts
index ff2dc3c5e..dda1e1581 100644
--- a/server/src/modules/metadata-fetch/providers/provider-constants.ts
+++ b/server/src/modules/metadata-fetch/providers/provider-constants.ts
@@ -1,6 +1,7 @@
export const PROVIDER_TIMEOUT_MS = {
DEFAULT: 10_000,
SCRAPE: 15_000,
+ KOBO_SCRAPE: 30_000,
} as const;
export const PROVIDER_LIMITS = {
@@ -8,6 +9,7 @@ export const PROVIDER_LIMITS = {
GOODREADS_MAX_RESULTS: 3,
COMICVINE_MAX_RESULTS: 10,
RANOBEDB_MAX_RESULTS: 5,
+ KOBO_MAX_RESULTS: 3,
DEFAULT_SEARCH_RESULTS: 10,
} as const;
@@ -17,4 +19,5 @@ export const PROVIDER_DELAYS_MS = {
HARDCOVER_RATE_LIMIT: 1_000,
COMICVINE_VELOCITY_GUARD: 1_000,
RANOBEDB_BETWEEN_REQUESTS: 500,
+ KOBO_BETWEEN_REQUESTS: 700,
} as const;
diff --git a/server/src/modules/metadata-fetch/providers/ranobedb/ranobedb.provider.test.ts b/server/src/modules/metadata-fetch/providers/ranobedb/ranobedb.provider.test.ts
index 9f13082b3..cbe836a9c 100644
--- a/server/src/modules/metadata-fetch/providers/ranobedb/ranobedb.provider.test.ts
+++ b/server/src/modules/metadata-fetch/providers/ranobedb/ranobedb.provider.test.ts
@@ -18,6 +18,7 @@ const enabledConfig: ProviderConfigurations = {
audnexus: { enabled: false },
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: true },
+ kobo: { enabled: false, country: 'us', language: 'en' },
};
const disabledConfig: ProviderConfigurations = {
diff --git a/server/src/modules/metadata-preferences/dto/update-provider-config.dto.ts b/server/src/modules/metadata-preferences/dto/update-provider-config.dto.ts
index 462d11b73..c07437529 100644
--- a/server/src/modules/metadata-preferences/dto/update-provider-config.dto.ts
+++ b/server/src/modules/metadata-preferences/dto/update-provider-config.dto.ts
@@ -37,6 +37,12 @@ export class ComicVineProviderConfigDto {
@IsOptional() @IsString() apiKey?: string;
}
+export class KoboProviderConfigDto {
+ @IsOptional() @IsBoolean() enabled?: boolean;
+ @IsOptional() @IsString() country?: string;
+ @IsOptional() @IsString() language?: string;
+}
+
export class UpdateProviderConfigDto {
@IsOptional() @ValidateNested() @Type(() => GoogleProviderConfigDto) google?: GoogleProviderConfigDto;
@IsOptional() @ValidateNested() @Type(() => AmazonProviderConfigDto) amazon?: AmazonProviderConfigDto;
@@ -48,4 +54,5 @@ export class UpdateProviderConfigDto {
@IsOptional() @ValidateNested() @Type(() => SimpleProviderConfigDto) audnexus?: SimpleProviderConfigDto;
@IsOptional() @ValidateNested() @Type(() => ComicVineProviderConfigDto) comicvine?: ComicVineProviderConfigDto;
@IsOptional() @ValidateNested() @Type(() => SimpleProviderConfigDto) ranobedb?: SimpleProviderConfigDto;
+ @IsOptional() @ValidateNested() @Type(() => KoboProviderConfigDto) kobo?: KoboProviderConfigDto;
}
diff --git a/server/src/modules/metadata-preferences/metadata-preference-resolver.test.ts b/server/src/modules/metadata-preferences/metadata-preference-resolver.test.ts
index de8d0b678..6209ea5f1 100644
--- a/server/src/modules/metadata-preferences/metadata-preference-resolver.test.ts
+++ b/server/src/modules/metadata-preferences/metadata-preference-resolver.test.ts
@@ -22,6 +22,7 @@ describe('MetadataPreferenceResolver', () => {
MetadataProviderKey.GOOGLE,
MetadataProviderKey.ITUNES,
MetadataProviderKey.AMAZON,
+ MetadataProviderKey.KOBO,
MetadataProviderKey.OPEN_LIBRARY,
]);
}
@@ -29,12 +30,18 @@ describe('MetadataPreferenceResolver', () => {
expect(defaults.fields.cover.providers).toEqual([
MetadataProviderKey.AMAZON,
MetadataProviderKey.ITUNES,
+ MetadataProviderKey.KOBO,
MetadataProviderKey.GOODREADS,
MetadataProviderKey.GOOGLE,
MetadataProviderKey.OPEN_LIBRARY,
]);
- expect(defaults.fields.genres.providers).toEqual([MetadataProviderKey.GOODREADS, MetadataProviderKey.GOOGLE, MetadataProviderKey.ITUNES]);
+ expect(defaults.fields.genres.providers).toEqual([
+ MetadataProviderKey.GOODREADS,
+ MetadataProviderKey.GOOGLE,
+ MetadataProviderKey.ITUNES,
+ MetadataProviderKey.KOBO,
+ ]);
const fieldsWithoutItunes: (keyof typeof defaults.fields)[] = [
'publisher',
@@ -50,6 +57,7 @@ describe('MetadataPreferenceResolver', () => {
MetadataProviderKey.GOODREADS,
MetadataProviderKey.GOOGLE,
MetadataProviderKey.AMAZON,
+ MetadataProviderKey.KOBO,
MetadataProviderKey.OPEN_LIBRARY,
]);
}
diff --git a/server/src/modules/metadata-preferences/metadata-preference-resolver.ts b/server/src/modules/metadata-preferences/metadata-preference-resolver.ts
index 78ad89d9a..cee3c12ac 100644
--- a/server/src/modules/metadata-preferences/metadata-preference-resolver.ts
+++ b/server/src/modules/metadata-preferences/metadata-preference-resolver.ts
@@ -18,6 +18,7 @@ const DEFAULT_PROVIDER_ORDER: MetadataProviderKey[] = [
MetadataProviderKey.GOODREADS,
MetadataProviderKey.GOOGLE,
MetadataProviderKey.AMAZON,
+ MetadataProviderKey.KOBO,
MetadataProviderKey.OPEN_LIBRARY,
];
@@ -30,6 +31,7 @@ const PROVIDERS_WITH_ITUNES: MetadataProviderKey[] = [
MetadataProviderKey.GOOGLE,
MetadataProviderKey.ITUNES,
MetadataProviderKey.AMAZON,
+ MetadataProviderKey.KOBO,
MetadataProviderKey.OPEN_LIBRARY,
];
@@ -41,13 +43,14 @@ const FIELD_DEFAULTS: Partial>> =
providers: [
MetadataProviderKey.AMAZON,
MetadataProviderKey.ITUNES,
+ MetadataProviderKey.KOBO,
MetadataProviderKey.GOODREADS,
MetadataProviderKey.GOOGLE,
MetadataProviderKey.OPEN_LIBRARY,
],
},
authors: { providers: PROVIDERS_WITH_ITUNES },
- genres: { providers: [MetadataProviderKey.GOODREADS, MetadataProviderKey.GOOGLE, MetadataProviderKey.ITUNES] },
+ genres: { providers: [MetadataProviderKey.GOODREADS, MetadataProviderKey.GOOGLE, MetadataProviderKey.ITUNES, MetadataProviderKey.KOBO] },
};
@Injectable()
diff --git a/server/src/modules/metadata-preferences/provider-config.service.test.ts b/server/src/modules/metadata-preferences/provider-config.service.test.ts
index 35fe93509..8535f8d3c 100644
--- a/server/src/modules/metadata-preferences/provider-config.service.test.ts
+++ b/server/src/modules/metadata-preferences/provider-config.service.test.ts
@@ -73,6 +73,7 @@ describe('ProviderConfigService', () => {
audnexus: { enabled: false },
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
+ kobo: { enabled: false, country: 'us', language: 'en' },
});
});
@@ -354,6 +355,7 @@ describe('ProviderConfigService', () => {
audnexus: { enabled: false },
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
+ kobo: { enabled: false, country: 'us', language: 'en' },
});
expect(statuses.map((s) => s.key)).toEqual([
@@ -367,6 +369,7 @@ describe('ProviderConfigService', () => {
MetadataProviderKey.AUDNEXUS,
MetadataProviderKey.COMICVINE,
MetadataProviderKey.RANOBEDB,
+ MetadataProviderKey.KOBO,
]);
expect(statuses.find((s) => s.key === MetadataProviderKey.GOOGLE)?.configured).toBe(false);
expect(statuses.find((s) => s.key === MetadataProviderKey.GOOGLE)?.hint).toContain('API key required');
@@ -390,6 +393,7 @@ describe('ProviderConfigService', () => {
audnexus: { enabled: false },
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
+ kobo: { enabled: false, country: 'us', language: 'en' },
});
expect(statuses.find((s) => s.key === MetadataProviderKey.HARDCOVER)?.configured).toBe(true);
diff --git a/server/src/modules/metadata-preferences/provider-config.service.ts b/server/src/modules/metadata-preferences/provider-config.service.ts
index 1d611cf80..02498cae0 100644
--- a/server/src/modules/metadata-preferences/provider-config.service.ts
+++ b/server/src/modules/metadata-preferences/provider-config.service.ts
@@ -43,6 +43,7 @@ const DEFAULT_CONFIG: ProviderConfigurations = {
audnexus: { enabled: false },
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
+ kobo: { enabled: false, country: 'us', language: 'en' },
};
function asObject(value: unknown): Record {
@@ -115,6 +116,15 @@ function mergeComicVineConfig(base: ProviderConfigurations['comicvine'], value:
};
}
+function mergeKoboConfig(base: ProviderConfigurations['kobo'], value: unknown): ProviderConfigurations['kobo'] {
+ const next = asObject(value);
+ return {
+ enabled: asBoolean(next.enabled, base.enabled),
+ country: asString(next.country, base.country),
+ language: asString(next.language, base.language),
+ };
+}
+
const PROVIDER_LABELS: Record = {
[MetadataProviderKey.GOOGLE]: 'Google Books',
[MetadataProviderKey.AMAZON]: 'Amazon',
@@ -126,6 +136,7 @@ const PROVIDER_LABELS: Record = {
[MetadataProviderKey.AUDNEXUS]: 'AudNexus',
[MetadataProviderKey.COMICVINE]: 'ComicVine',
[MetadataProviderKey.RANOBEDB]: 'RanobeDB',
+ [MetadataProviderKey.KOBO]: 'Kobo',
};
type ProviderEnableRule = {
@@ -170,6 +181,7 @@ export class ProviderConfigService {
audnexus: { ...DEFAULT_CONFIG.audnexus },
comicvine: { ...DEFAULT_CONFIG.comicvine },
ranobedb: { ...DEFAULT_CONFIG.ranobedb },
+ kobo: { ...DEFAULT_CONFIG.kobo },
};
}
@@ -186,6 +198,7 @@ export class ProviderConfigService {
audnexus: mergeSimpleConfig(base.audnexus, next.audnexus),
comicvine: mergeComicVineConfig(base.comicvine, next.comicvine),
ranobedb: mergeSimpleConfig(base.ranobedb, next.ranobedb),
+ kobo: mergeKoboConfig(base.kobo, next.kobo),
};
}
@@ -251,6 +264,11 @@ export class ProviderConfigService {
...config.comicvine,
apiKey: config.comicvine.apiKey.trim(),
},
+ kobo: {
+ ...config.kobo,
+ country: this.normalizeKoboPathSegment(config.kobo.country, DEFAULT_CONFIG.kobo.country),
+ language: this.normalizeKoboPathSegment(config.kobo.language, DEFAULT_CONFIG.kobo.language),
+ },
};
if (!normalized.google.enabled || normalized.google.apiKey) return normalized;
@@ -274,6 +292,14 @@ export class ProviderConfigService {
return normalized;
}
+ private normalizeKoboPathSegment(value: string, fallback: string): string {
+ const normalized = value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9-]/g, '');
+ return normalized || fallback;
+ }
+
private parsePersistedConfig(
rawValue: string,
fallback: ProviderConfigurations,
@@ -393,6 +419,13 @@ export class ProviderConfigService {
enabled: cfg.ranobedb.enabled,
configured: true,
},
+ {
+ key: MetadataProviderKey.KOBO,
+ label: PROVIDER_LABELS[MetadataProviderKey.KOBO],
+ enabled: cfg.kobo.enabled,
+ configured: true,
+ hint: 'Web scraping may be blocked by Kobo bot protection',
+ },
];
}
diff --git a/server/src/modules/metadata-score/dto/update-weights.dto.ts b/server/src/modules/metadata-score/dto/update-weights.dto.ts
index 1e5c86f13..53456985b 100644
--- a/server/src/modules/metadata-score/dto/update-weights.dto.ts
+++ b/server/src/modules/metadata-score/dto/update-weights.dto.ts
@@ -23,4 +23,5 @@ export class UpdateWeightsDto {
@IsInt() @Min(0) hardcoverId!: number;
@IsInt() @Min(0) openLibraryId!: number;
@IsInt() @Min(0) itunesId!: number;
+ @IsInt() @Min(0) koboId!: number;
}
diff --git a/server/src/modules/metadata-score/metadata-score.controller.test.ts b/server/src/modules/metadata-score/metadata-score.controller.test.ts
index 30eae44b4..53bc69908 100644
--- a/server/src/modules/metadata-score/metadata-score.controller.test.ts
+++ b/server/src/modules/metadata-score/metadata-score.controller.test.ts
@@ -57,6 +57,7 @@ describe('MetadataScoreController', () => {
hardcoverId: 1,
openLibraryId: 1,
itunesId: 1,
+ koboId: 1,
};
service.updateWeights.mockResolvedValue(dto);
diff --git a/server/src/modules/metadata-score/metadata-score.repository.ts b/server/src/modules/metadata-score/metadata-score.repository.ts
index a520986e2..9b9982b33 100644
--- a/server/src/modules/metadata-score/metadata-score.repository.ts
+++ b/server/src/modules/metadata-score/metadata-score.repository.ts
@@ -29,6 +29,7 @@ const METADATA_SCORE_BASE_SELECT = {
hardcoverId: bookMetadata.hardcoverId,
openLibraryId: bookMetadata.openLibraryId,
itunesId: bookMetadata.itunesId,
+ koboId: bookMetadata.koboId,
} as const;
export type ScorePage = {
diff --git a/server/src/modules/metadata-score/metadata-score.scorer.test.ts b/server/src/modules/metadata-score/metadata-score.scorer.test.ts
index bba4de2f1..5201801ea 100644
--- a/server/src/modules/metadata-score/metadata-score.scorer.test.ts
+++ b/server/src/modules/metadata-score/metadata-score.scorer.test.ts
@@ -23,6 +23,7 @@ function makeData(overrides: Partial = {}): ScoreData {
hardcoverId: null,
openLibraryId: null,
itunesId: null,
+ koboId: null,
authorCount: 0,
genreCount: 0,
tagCount: 0,
@@ -79,6 +80,7 @@ describe('MetadataScoreScorer', () => {
hardcoverId: 0,
openLibraryId: 0,
itunesId: 0,
+ koboId: 0,
};
expect(scorer.compute(data, weights)).toBe(100);
@@ -110,6 +112,7 @@ describe('MetadataScoreScorer', () => {
hardcoverId: 0,
openLibraryId: 0,
itunesId: 0,
+ koboId: 0,
};
expect(scorer.compute(makeData({ title: 'Dune', coverSource: 'extracted', authorCount: 10 }), weights)).toBe(100);
diff --git a/server/src/modules/metadata-score/metadata-score.scorer.ts b/server/src/modules/metadata-score/metadata-score.scorer.ts
index f5c8e5711..290a5cac9 100644
--- a/server/src/modules/metadata-score/metadata-score.scorer.ts
+++ b/server/src/modules/metadata-score/metadata-score.scorer.ts
@@ -21,6 +21,7 @@ export type ScoreData = {
hardcoverId: string | null;
openLibraryId: string | null;
itunesId: string | null;
+ koboId: string | null;
authorCount: number;
genreCount: number;
tagCount: number;
@@ -52,6 +53,7 @@ const SCORE_RULES: Record = {
hardcoverId: { kind: 'string', value: (data) => data.hardcoverId },
openLibraryId: { kind: 'string', value: (data) => data.openLibraryId },
itunesId: { kind: 'string', value: (data) => data.itunesId },
+ koboId: { kind: 'string', value: (data) => data.koboId },
};
@Injectable()
diff --git a/server/src/modules/metadata-score/metadata-score.service.test.ts b/server/src/modules/metadata-score/metadata-score.service.test.ts
index 4b6c87e36..10b8760e7 100644
--- a/server/src/modules/metadata-score/metadata-score.service.test.ts
+++ b/server/src/modules/metadata-score/metadata-score.service.test.ts
@@ -37,6 +37,7 @@ function makeWeights(): MetadataScoreWeights {
hardcoverId: 1,
openLibraryId: 1,
itunesId: 1,
+ koboId: 1,
};
}
@@ -102,6 +103,7 @@ describe('MetadataScoreService', () => {
hardcoverId: null,
openLibraryId: null,
itunesId: null,
+ koboId: null,
authorCount: 1,
genreCount: 0,
tagCount: 0,
@@ -157,6 +159,7 @@ describe('MetadataScoreService', () => {
hardcoverId: null,
openLibraryId: null,
itunesId: null,
+ koboId: null,
authorCount: 1,
genreCount: 0,
tagCount: 0,
@@ -184,6 +187,7 @@ describe('MetadataScoreService', () => {
hardcoverId: null,
openLibraryId: null,
itunesId: null,
+ koboId: null,
authorCount: 1,
genreCount: 0,
tagCount: 0,
@@ -216,6 +220,7 @@ describe('MetadataScoreService', () => {
hardcoverId: null,
openLibraryId: null,
itunesId: null,
+ koboId: null,
authorCount: 1,
genreCount: 0,
tagCount: 0,
diff --git a/server/src/modules/metadata/extractors/comic-format.extractor.ts b/server/src/modules/metadata/extractors/comic-format.extractor.ts
index 5a82600ec..244a7baae 100644
--- a/server/src/modules/metadata/extractors/comic-format.extractor.ts
+++ b/server/src/modules/metadata/extractors/comic-format.extractor.ts
@@ -47,6 +47,7 @@ export class ComicFormatExtractor implements FormatExtractor {
hardcoverId: comicMetadata?.hardcoverId ?? null,
openLibraryId: comicMetadata?.openLibraryId ?? null,
ranobedbId: comicMetadata?.ranobedbId ?? null,
+ koboId: comicMetadata?.koboId ?? null,
itunesId: comicMetadata?.itunesId ?? null,
cover: cover ?? null,
comicMetadata: comicMetadata?.comicMetadata ?? null,
diff --git a/server/src/modules/metadata/extractors/format-extractor.interface.ts b/server/src/modules/metadata/extractors/format-extractor.interface.ts
index 1c574b7cc..3fe165347 100644
--- a/server/src/modules/metadata/extractors/format-extractor.interface.ts
+++ b/server/src/modules/metadata/extractors/format-extractor.interface.ts
@@ -21,6 +21,7 @@ export interface ParsedBookData {
hardcoverId?: string | null;
openLibraryId?: string | null;
ranobedbId?: string | null;
+ koboId?: string | null;
itunesId?: string | null;
audibleId?: string | null;
cover: Buffer | null;
diff --git a/server/src/modules/metadata/extractors/opf-metadata.mapper.ts b/server/src/modules/metadata/extractors/opf-metadata.mapper.ts
index aa8297fff..5f7c5ddc8 100644
--- a/server/src/modules/metadata/extractors/opf-metadata.mapper.ts
+++ b/server/src/modules/metadata/extractors/opf-metadata.mapper.ts
@@ -24,6 +24,7 @@ export function hasOpfMetadata(metadata: ParsedOpf): boolean {
metadata.hardcoverId !== null ||
metadata.openLibraryId !== null ||
metadata.ranobedbId !== null ||
+ metadata.koboId !== null ||
metadata.itunesId !== null
);
}
@@ -51,6 +52,7 @@ export function mapOpfMetadata(metadata: ParsedOpf, cover: Buffer | null): Parse
hardcoverId: metadata.hardcoverId,
openLibraryId: metadata.openLibraryId,
ranobedbId: metadata.ranobedbId,
+ koboId: metadata.koboId,
itunesId: metadata.itunesId,
cover,
};
diff --git a/server/src/modules/metadata/extractors/pdf-format.extractor.ts b/server/src/modules/metadata/extractors/pdf-format.extractor.ts
index 3116c6fb0..57d9b97c9 100644
--- a/server/src/modules/metadata/extractors/pdf-format.extractor.ts
+++ b/server/src/modules/metadata/extractors/pdf-format.extractor.ts
@@ -36,6 +36,7 @@ export class PdfFormatExtractor implements FormatExtractor {
hardcoverId: pdf.hardcoverId,
openLibraryId: pdf.openLibraryId,
ranobedbId: pdf.ranobedbId,
+ koboId: pdf.koboId,
itunesId: pdf.itunesId,
cover: pdf.coverBuffer,
pageCount: pdf.pageCount,
diff --git a/server/src/modules/metadata/lib/cbz-metadata.ts b/server/src/modules/metadata/lib/cbz-metadata.ts
index e58c46e00..d24dc82f3 100644
--- a/server/src/modules/metadata/lib/cbz-metadata.ts
+++ b/server/src/modules/metadata/lib/cbz-metadata.ts
@@ -45,6 +45,7 @@ export interface ParsedCbzMetadata {
hardcoverId: string | null;
openLibraryId: string | null;
ranobedbId: string | null;
+ koboId: string | null;
itunesId: string | null;
comicMetadata: ParsedCbzComicMetadata | null;
}
@@ -155,7 +156,7 @@ function parseProjectxManagedNotes(notes: string | null): Map {
function parseProviderIdsFromWebUrl(
webUrl: string | null,
-): Partial> {
+): Partial> {
if (!webUrl) return {};
if (webUrl.startsWith('https://www.goodreads.com/book/show/')) {
@@ -173,6 +174,9 @@ function parseProviderIdsFromWebUrl(
if (webUrl.startsWith('https://openlibrary.org/works/')) {
return { openLibraryId: webUrl.slice('https://openlibrary.org/works/'.length) || '' };
}
+ if (webUrl.startsWith('https://www.kobo.com/us/en/ebook/')) {
+ return { koboId: webUrl.slice('https://www.kobo.com/us/en/ebook/'.length) || '' };
+ }
return {};
}
@@ -262,6 +266,7 @@ function parseComicInfoXml(xmlBuf: Buffer): ParsedCbzMetadata | null {
hardcoverId: managedNotes.get('hardcoverId') ?? providerIdsFromWeb.hardcoverId ?? null,
openLibraryId: managedNotes.get('openLibraryId') ?? providerIdsFromWeb.openLibraryId ?? null,
ranobedbId: managedNotes.get('ranobedbId') ?? null,
+ koboId: managedNotes.get('koboId') ?? providerIdsFromWeb.koboId ?? null,
itunesId: null,
comicMetadata: hasComicFields
? {
@@ -318,6 +323,7 @@ function parseComicBookInfoJson(comment: string): ParsedCbzMetadata | null {
hardcoverId: null,
openLibraryId: null,
ranobedbId: null,
+ koboId: null,
itunesId: null,
comicMetadata: null,
};
diff --git a/server/src/modules/metadata/lib/opf-parser.ts b/server/src/modules/metadata/lib/opf-parser.ts
index 51bd87bb8..1092e55d8 100644
--- a/server/src/modules/metadata/lib/opf-parser.ts
+++ b/server/src/modules/metadata/lib/opf-parser.ts
@@ -22,6 +22,7 @@ export interface ParsedOpf {
hardcoverId: string | null;
openLibraryId: string | null;
ranobedbId: string | null;
+ koboId: string | null;
itunesId: string | null;
}
@@ -205,6 +206,7 @@ export function parseOpf(xml: string): ParsedOpf {
let schemeHardcoverId: string | null = null;
let schemeOpenLibraryId: string | null = null;
let schemeRanobedbId: string | null = null;
+ let schemeKoboId: string | null = null;
let schemeItunesId: string | null = null;
let urnGoogleBooksId: string | null = null;
@@ -213,6 +215,7 @@ export function parseOpf(xml: string): ParsedOpf {
let urnHardcoverId: string | null = null;
let urnOpenLibraryId: string | null = null;
let urnRanobedbId: string | null = null;
+ let urnKoboId: string | null = null;
let urnItunesId: string | null = null;
for (const ident of toArray(metadata['identifier'])) {
@@ -235,6 +238,7 @@ export function parseOpf(xml: string): ParsedOpf {
if (scheme === 'hardcover') schemeHardcoverId ??= value || null;
if (scheme === 'openlibrary') schemeOpenLibraryId ??= value || null;
if (scheme === 'ranobedb') schemeRanobedbId ??= value || null;
+ if (scheme === 'kobo') schemeKoboId ??= value || null;
if (scheme === 'itunes') schemeItunesId ??= value || null;
// urn:-prefixed provider identifiers (legacy / backward-compat)
@@ -244,6 +248,7 @@ export function parseOpf(xml: string): ParsedOpf {
if (value.startsWith('urn:google:')) urnGoogleBooksId ??= value.slice('urn:google:'.length) || null;
if (value.startsWith('urn:openlibrary:')) urnOpenLibraryId ??= value.slice('urn:openlibrary:'.length) || null;
if (value.startsWith('urn:ranobedb:')) urnRanobedbId ??= value.slice('urn:ranobedb:'.length) || null;
+ if (value.startsWith('urn:kobo:')) urnKoboId ??= value.slice('urn:kobo:'.length) || null;
if (value.startsWith('urn:itunes:')) urnItunesId ??= value.slice('urn:itunes:'.length) || null;
}
@@ -254,6 +259,7 @@ export function parseOpf(xml: string): ParsedOpf {
const hardcoverId = schemeHardcoverId ?? urnHardcoverId;
const openLibraryId = schemeOpenLibraryId ?? urnOpenLibraryId;
const ranobedbId = schemeRanobedbId ?? urnRanobedbId;
+ const koboId = schemeKoboId ?? urnKoboId;
const itunesId = schemeItunesId ?? urnItunesId;
isbn10 ??= propertyMeta('bookorbit:isbn10') ?? namedMeta('bookorbit:isbn10');
@@ -321,6 +327,7 @@ export function parseOpf(xml: string): ParsedOpf {
hardcoverId,
openLibraryId,
ranobedbId,
+ koboId,
itunesId,
};
}
diff --git a/server/src/modules/metadata/lib/pdf-parser.ts b/server/src/modules/metadata/lib/pdf-parser.ts
index de6f71b6a..843b85ce8 100644
--- a/server/src/modules/metadata/lib/pdf-parser.ts
+++ b/server/src/modules/metadata/lib/pdf-parser.ts
@@ -28,6 +28,7 @@ export interface PdfParsed {
hardcoverId: string | null;
openLibraryId: string | null;
ranobedbId: string | null;
+ koboId: string | null;
itunesId: string | null;
coverBuffer: Buffer | null;
}
@@ -138,6 +139,7 @@ export async function parsePdfFile(absolutePath: string, options: PdfParseOption
hardcoverId: xmp?.hardcoverId ?? null,
openLibraryId: xmp?.openLibraryId ?? null,
ranobedbId: xmp?.ranobedbId ?? null,
+ koboId: xmp?.koboId ?? null,
itunesId: xmp?.itunesId ?? null,
coverBuffer,
};
diff --git a/server/src/modules/metadata/lib/pdf-xmp-reader.ts b/server/src/modules/metadata/lib/pdf-xmp-reader.ts
index 79a5161b4..2eac0e642 100644
--- a/server/src/modules/metadata/lib/pdf-xmp-reader.ts
+++ b/server/src/modules/metadata/lib/pdf-xmp-reader.ts
@@ -25,6 +25,7 @@ export interface XmpParsed {
hardcoverId: string | null;
openLibraryId: string | null;
ranobedbId: string | null;
+ koboId: string | null;
itunesId: string | null;
}
@@ -157,6 +158,7 @@ export function parseXmp(xmpXml: string): XmpParsed | null {
hardcoverId: str(merged[`${px}:hardcoverId`]),
openLibraryId: str(merged[`${px}:openLibraryId`]),
ranobedbId: str(merged[`${px}:ranobedbId`]),
+ koboId: str(merged[`${px}:koboId`]),
itunesId: str(merged[`${px}:itunesId`]),
};
}
diff --git a/server/src/modules/metadata/metadata.service.ts b/server/src/modules/metadata/metadata.service.ts
index 5f89be9e7..d0e4c6cba 100644
--- a/server/src/modules/metadata/metadata.service.ts
+++ b/server/src/modules/metadata/metadata.service.ts
@@ -571,6 +571,7 @@ export class MetadataService {
hardcoverId: data.hardcoverId,
openLibraryId: data.openLibraryId,
ranobedbId: data.ranobedbId,
+ koboId: data.koboId,
itunesId: data.itunesId,
comicMetadata: data.comicMetadata ?? undefined,
});
@@ -594,6 +595,7 @@ export class MetadataService {
if (filtered.hardcoverId !== undefined) scalarFields.hardcoverId = filtered.hardcoverId;
if (filtered.openLibraryId !== undefined) scalarFields.openLibraryId = filtered.openLibraryId;
if (filtered.ranobedbId !== undefined) scalarFields.ranobedbId = filtered.ranobedbId;
+ if (filtered.koboId !== undefined) scalarFields.koboId = filtered.koboId;
if (filtered.itunesId !== undefined) scalarFields.itunesId = filtered.itunesId;
if (Object.keys(scalarFields).length > 0) {
scalarFields.updatedAt = new Date();
diff --git a/server/src/modules/migration/adapters/source-adapter.types.ts b/server/src/modules/migration/adapters/source-adapter.types.ts
index a50992ac4..2b8cfb5d6 100644
--- a/server/src/modules/migration/adapters/source-adapter.types.ts
+++ b/server/src/modules/migration/adapters/source-adapter.types.ts
@@ -62,6 +62,7 @@ export interface SourceBook {
goodreadsId?: string | null;
amazonId?: string | null;
hardcoverId?: string | null;
+ koboId?: string | null;
audibleId?: string | null;
comicvineId?: string | null;
durationSeconds?: number | null;
diff --git a/server/src/modules/migration/executor/executor-utils.test.ts b/server/src/modules/migration/executor/executor-utils.test.ts
index 95754e54e..7857c7faf 100644
--- a/server/src/modules/migration/executor/executor-utils.test.ts
+++ b/server/src/modules/migration/executor/executor-utils.test.ts
@@ -81,6 +81,7 @@ describe('migration executor utils', () => {
goodreadsId: 'gr',
amazonId: 'amz',
hardcoverId: 'hc',
+ koboId: 'kobo',
audibleId: 'aud',
comicvineId: 'cv',
durationSeconds: 3601.7,
@@ -98,6 +99,23 @@ describe('migration executor utils', () => {
});
});
+ it('maps Kobo provider IDs when present in source metadata', () => {
+ const patch = buildMetadataPatch({
+ title: null,
+ subtitle: null,
+ isbn10: null,
+ isbn13: null,
+ description: null,
+ publisher: null,
+ publishedYear: null,
+ language: null,
+ koboId: 'kobo-book-slug',
+ presentFields: ['koboId'],
+ });
+
+ expect(patch).toEqual({ koboId: 'kobo-book-slug' });
+ });
+
it('builds contributor values with truncation and defaulted sort name', () => {
const values = buildContributorValues({
name: 'Name',
diff --git a/server/src/modules/migration/executor/executor-utils.ts b/server/src/modules/migration/executor/executor-utils.ts
index 839fa07c9..baec0645e 100644
--- a/server/src/modules/migration/executor/executor-utils.ts
+++ b/server/src/modules/migration/executor/executor-utils.ts
@@ -83,6 +83,7 @@ export function buildMetadataPatch(sourceBook: {
goodreadsId?: string | null;
amazonId?: string | null;
hardcoverId?: string | null;
+ koboId?: string | null;
audibleId?: string | null;
comicvineId?: string | null;
durationSeconds?: number | null;
@@ -109,6 +110,7 @@ export function buildMetadataPatch(sourceBook: {
goodreadsId: field('goodreadsId', truncateNullableText(sourceBook.goodreadsId, 50)),
amazonId: field('amazonId', truncateNullableText(sourceBook.amazonId, 20)),
hardcoverId: field('hardcoverId', truncateNullableText(sourceBook.hardcoverId, 255)),
+ koboId: field('koboId', truncateNullableText(sourceBook.koboId, 255)),
audibleId: field('audibleId', truncateNullableText(sourceBook.audibleId, 20)),
comicvineId: field('comicvineId', truncateNullableText(sourceBook.comicvineId, 50)),
durationSeconds: field('durationSeconds', sanitizeNonNegativeInteger(sourceBook.durationSeconds)),
diff --git a/server/src/modules/migration/executor/migration-import.repository.test.ts b/server/src/modules/migration/executor/migration-import.repository.test.ts
index 968de886d..c9ac161de 100644
--- a/server/src/modules/migration/executor/migration-import.repository.test.ts
+++ b/server/src/modules/migration/executor/migration-import.repository.test.ts
@@ -32,6 +32,7 @@ describe('MigrationImportRepository', () => {
title: 'Dune',
openLibraryId: 'OL1W',
itunesId: '123',
+ koboId: 'dune-kobo',
},
{
bookId: 2,
@@ -48,6 +49,7 @@ describe('MigrationImportRepository', () => {
title: expect.anything(),
openLibraryId: expect.anything(),
itunesId: expect.anything(),
+ koboId: expect.anything(),
updatedAt: expect.anything(),
}),
}),
diff --git a/server/src/modules/migration/executor/migration-import.repository.ts b/server/src/modules/migration/executor/migration-import.repository.ts
index 4cb324ce1..3654d1a8c 100644
--- a/server/src/modules/migration/executor/migration-import.repository.ts
+++ b/server/src/modules/migration/executor/migration-import.repository.ts
@@ -48,6 +48,7 @@ const BOOK_METADATA_COLUMN_BY_PROPERTY = {
goodreadsId: 'goodreads_id',
amazonId: 'amazon_id',
hardcoverId: 'hardcover_id',
+ koboId: 'kobo_id',
audibleId: 'audible_id',
comicvineId: 'comicvine_id',
openLibraryId: 'open_library_id',
diff --git a/server/src/modules/statistics/statistics.repository.ts b/server/src/modules/statistics/statistics.repository.ts
index cb3c8900f..02749a0dd 100644
--- a/server/src/modules/statistics/statistics.repository.ts
+++ b/server/src/modules/statistics/statistics.repository.ts
@@ -735,7 +735,9 @@ export class StatisticsRepository {
${bookMetadata.openLibraryId} is not null or
${bookMetadata.itunesId} is not null or
${bookMetadata.audibleId} is not null or
- ${bookMetadata.comicvineId} is not null
+ ${bookMetadata.koboId} is not null or
+ ${bookMetadata.comicvineId} is not null or
+ ${bookMetadata.ranobedbId} is not null
)`;
const [row] = await this.db
diff --git a/server/src/scripts/setup-kobo-cloudscraper.ts b/server/src/scripts/setup-kobo-cloudscraper.ts
new file mode 100644
index 000000000..df9a447c0
--- /dev/null
+++ b/server/src/scripts/setup-kobo-cloudscraper.ts
@@ -0,0 +1,59 @@
+import { execFile as execFileCallback } from 'child_process';
+import { existsSync } from 'fs';
+import { mkdir } from 'fs/promises';
+import { join } from 'path';
+import { promisify } from 'util';
+
+const execFile = promisify(execFileCallback);
+
+const SERVER_ROOT = process.cwd();
+const VENV_DIR = join(SERVER_ROOT, '.venv', 'kobo-cloudscraper');
+const PYTHON_BIN = process.platform === 'win32' ? join(VENV_DIR, 'Scripts', 'python.exe') : join(VENV_DIR, 'bin', 'python');
+const REQUIREMENTS_PATH = join(SERVER_ROOT, 'requirements', 'kobo-cloudscraper.txt');
+
+async function runCommand(command: string, args: string[]): Promise {
+ await execFile(command, args, {
+ cwd: SERVER_ROOT,
+ maxBuffer: 1024 * 1024,
+ });
+}
+
+async function hasCloudscraper(): Promise {
+ if (!existsSync(PYTHON_BIN)) return false;
+
+ try {
+ await runCommand(PYTHON_BIN, ['-c', 'import cloudscraper; assert getattr(cloudscraper, "__version__", "") == "3.0.0"']);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function createVenv(): Promise {
+ await mkdir(join(SERVER_ROOT, '.venv'), { recursive: true });
+
+ try {
+ await runCommand('python3', ['-m', 'venv', VENV_DIR]);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ throw new Error(`Could not create Kobo cloudscraper venv with python3: ${message}`, { cause: error });
+ }
+}
+
+async function run(): Promise {
+ if (await hasCloudscraper()) {
+ return;
+ }
+
+ await createVenv();
+ await runCommand(PYTHON_BIN, ['-m', 'pip', 'install', '--disable-pip-version-check', '--quiet', '-r', REQUIREMENTS_PATH]);
+
+ if (!(await hasCloudscraper())) {
+ throw new Error('Kobo cloudscraper setup completed, but cloudscraper 3.0.0 could not be imported');
+ }
+}
+
+void run().catch((error) => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+});