feat(metadata): add LubimyCzytac metadata provider (#324)

* feat(metadata): add LubimyCzytac metadata provider

- Add LubimyCzytac scraper and provider implementation
- Add lubimyczytacId field across types, schema, extractors, and metadata service
- Register lubimyczytac provider in module, pipeline, repository, and config service
- Add provider icon, color, link, and UI support in book detail views
- Add migration for lubimyczytac_id column

Closes #304

* fix(books): keep cover card read button opening reader when thumbnail click prefers details

The overlay primary button should always open the reader regardless of
the thumbnailClickAction setting. That setting only controls card click
behaviour.

* fix(books): add explicit read button to hover overlay when thumbnail click prefers details

When thumbnailClickAction is 'details', a dedicated Read button now
appears in the overlay top row so users can still open the reader
directly without changing their preference.
This commit is contained in:
Chris Mansell
2026-06-13 11:40:53 -06:00
committed by GitHub
parent d42abb3647
commit aed22712cc
66 changed files with 12097 additions and 4 deletions
@@ -0,0 +1,9 @@
<svg role="img" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<title>LubimyCzytac</title>
<rect width="48" height="48" rx="10" fill="#ffffff" />
<g fill="#E85621">
<path d="M24 9 L42 16 L42 19 L24 26 L6 19 L6 16 Z" />
<path d="M6 22 L24 29 L42 22 L42 26 L24 33 L6 26 Z" />
<path d="M6 29 L24 36 L42 29 L42 33 L24 40 L6 33 Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 370 B

@@ -261,6 +261,7 @@ const primaryOverlayActionIcon = computed(() => {
return isAudiobook.value ? Play : BookOpen
})
const primaryOverlayActionIconClass = computed(() => (thumbnailClickAction.value !== 'details' && isAudiobook.value ? 'ml-[2cqi]' : ''))
const showExplicitReadButton = computed(() => thumbnailClickAction.value === 'details' && primaryFile.value != null && !isMissing.value)
function handlePrimaryOverlayAction() {
if (thumbnailClickAction.value === 'details') {
@@ -271,6 +272,10 @@ function handlePrimaryOverlayAction() {
if (primaryFile.value && !isMissing.value) openFile(primaryFile.value)
}
function openPrimaryFileExplicit() {
if (primaryFile.value && !isMissing.value) openFile(primaryFile.value)
}
function handleCardClick(event: MouseEvent) {
const target = event.target
if (target instanceof Element && target.closest('button, [data-card-click-blocker]')) return
@@ -517,11 +522,18 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
showMobileOverlay ? 'opacity-100' : 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto',
]"
>
<!-- Top row: Quick View -->
<div class="shrink-0 flex justify-end">
<!-- Top row: Quick View + explicit Read (when thumbnail click prefers details) -->
<div class="shrink-0 flex items-center justify-end gap-1">
<button class="p-[3cqi] rounded-[2.5cqi] bg-black/50 hover:bg-black/30 transition-colors text-white" @click="openQuickView">
<PanelRight class="size-[12cqi]" />
</button>
<button
v-if="showExplicitReadButton"
class="p-[3cqi] rounded-[2.5cqi] bg-black/50 hover:bg-black/30 transition-colors text-white"
@click.stop="openPrimaryFileExplicit"
>
<BookOpen class="size-[12cqi]" />
</button>
</div>
<!-- Center: primary thumbnail action -->
@@ -9,6 +9,7 @@ import { DialogRoot, DialogContent, DialogPortal, DialogOverlay, DialogClose, Di
import { formatBytes } from '@/lib/formatting'
import { getProviderColor } from '@/lib/provider-colors'
import { providerIconPath } from '@/features/book/lib/provider-icons'
import { lubimyczytacBookUrl } from '@/features/book/lib/provider-links'
import { useBookDetail } from '../composables/useBookDetail'
import { useCoverVersions } from '../composables/useCoverVersions'
import { getFormatColor } from '../lib/format-colors'
@@ -144,6 +145,15 @@ const providerLinks = computed<ProviderLink[]>(() => {
fallback: 'RN',
})
}
if (ids.lubimyczytac) {
out.push({
key: 'lubimyczytac',
label: 'LubimyCzytac',
url: lubimyczytacBookUrl(ids.lubimyczytac),
iconUrl: providerIconPath('lubimyczytac'),
fallback: 'LC',
})
}
return out
})
@@ -21,6 +21,7 @@ import {
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot } from 'reka-ui'
import { getFormatColor } from '@/features/book/lib/format-colors'
import { providerIconPath } from '@/features/book/lib/provider-icons'
import { lubimyczytacBookUrl } from '@/features/book/lib/provider-links'
import { getProviderColor } from '@/lib/provider-colors'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
@@ -601,6 +602,15 @@ const providerLinks = computed<ProviderLink[]>(() => {
fallback: 'RN',
})
}
if (ids.lubimyczytac) {
out.push({
key: 'lubimyczytac',
label: 'LubimyCzytac',
url: lubimyczytacBookUrl(ids.lubimyczytac),
iconUrl: providerIconPath('lubimyczytac'),
fallback: 'LC',
})
}
return out
})
@@ -73,6 +73,7 @@ const DIRECT_PATCH_FIELDS = [
'koboId',
'comicvineId',
'ranobedbId',
'lubimyczytacId',
] as const
const COMIC_FIELD_MAP = {
@@ -169,6 +170,7 @@ const providerIdFields = [
{ field: 'koboId' as const, label: 'Kobo' },
{ field: 'comicvineId' as const, label: 'ComicVine' },
{ field: 'ranobedbId' as const, label: 'RanobeDB' },
{ field: 'lubimyczytacId' as const, label: 'LubimyCzytac' },
]
function setIntField(field: 'publishedYear' | 'pageCount' | 'durationSeconds', e: Event) {
@@ -421,6 +423,7 @@ function buildPreviewPatch(preview: MetadataRefreshPreview): MetadataPatch {
koboId: preview.koboId,
comicvineId: preview.comicvineId,
ranobedbId: preview.ranobedbId,
lubimyczytacId: preview.lubimyczytacId,
comicMetadata: preview.comicMetadata,
narrators: preview.audioMetadata?.narrators,
durationSeconds: preview.audioMetadata?.durationSeconds ?? undefined,
@@ -24,6 +24,7 @@ export interface FileMetadata {
koboId?: string | null
comicvineId?: string | null
ranobedbId?: string | null
lubimyczytacId?: string | null
authors?: string[]
genres?: string[]
narrators?: string[]
@@ -96,6 +96,7 @@ export interface MetadataPatch {
koboId?: string | null
comicvineId?: string | null
ranobedbId?: string | null
lubimyczytacId?: string | null
comicMetadata?: ComicMetadataFields
}
@@ -158,6 +159,7 @@ export type ProviderIdPatchField =
| 'koboId'
| 'comicvineId'
| 'ranobedbId'
| 'lubimyczytacId'
export const PROVIDER_ID_FIELD: Record<MetadataProviderKey, ProviderIdPatchField | undefined> = {
google: 'googleBooksId',
@@ -171,6 +173,7 @@ export const PROVIDER_ID_FIELD: Record<MetadataProviderKey, ProviderIdPatchField
comicvine: 'comicvineId',
ranobedb: 'ranobedbId',
kobo: 'koboId',
lubimyczytac: 'lubimyczytacId',
}
const PROVIDER_ID_PATCH_FIELDS = new Set<string>(Object.values(PROVIDER_ID_FIELD).filter((v): v is ProviderIdPatchField => v !== undefined))
@@ -191,6 +194,7 @@ export const PROVIDER_ID_LABEL: Record<MetadataProviderKey, string> = {
comicvine: 'ComicVine ID',
ranobedb: 'RanobeDB ID',
kobo: 'Kobo ID',
lubimyczytac: 'LubimyCzytac ID',
}
export function getCandidateValueFrom(candidate: MetadataCandidate, key: DiffFieldKey): string {
@@ -28,6 +28,7 @@ const ROOT_FIELDS = [
'koboId',
'comicvineId',
'ranobedbId',
'lubimyczytacId',
] as const
const COMIC_FIELDS = {
@@ -83,6 +84,7 @@ export function useMetadataEditor() {
koboId: null as string | null,
comicvineId: null as string | null,
ranobedbId: null as string | null,
lubimyczytacId: null as string | null,
comicIssueNumber: null as string | null,
comicVolumeName: null as string | null,
comicStoryArcs: [] as string[],
@@ -130,6 +132,7 @@ export function useMetadataEditor() {
form.koboId = book.providerIds.kobo ?? null
form.comicvineId = book.providerIds.comicvine ?? null
form.ranobedbId = book.providerIds.ranobedb ?? null
form.lubimyczytacId = book.providerIds.lubimyczytac ?? null
const cm = book.comicMetadata
form.comicIssueNumber = cm?.issueNumber ?? null
form.comicVolumeName = cm?.volumeName ?? null
@@ -23,6 +23,7 @@ const FILE_METADATA_PATCH_FIELDS = [
'koboId',
'comicvineId',
'ranobedbId',
'lubimyczytacId',
'authors',
'genres',
'narrators',
@@ -17,6 +17,7 @@ const PROVIDER_LABELS: Record<MetadataProviderKeyType, string> = {
[MetadataProviderKey.COMICVINE]: 'ComicVine',
[MetadataProviderKey.RANOBEDB]: 'RanobeDB',
[MetadataProviderKey.KOBO]: 'Kobo',
[MetadataProviderKey.LUBIMYCZYTAC]: 'LubimyCzytac',
}
export function metadataRefreshEmptyMessage(diagnostics: MetadataFetchDiagnostics, book: BookDetail): string {
@@ -10,6 +10,7 @@ const PROVIDER_ICON_PATHS = {
audible: `${PROVIDER_ICON_BASE_PATH}/audible.svg`,
kobo: `${PROVIDER_ICON_BASE_PATH}/kobo.svg`,
ranobedb: `${PROVIDER_ICON_BASE_PATH}/ranobedb.svg`,
lubimyczytac: `${PROVIDER_ICON_BASE_PATH}/lubimyczytac.svg`,
} as const
type ProviderIconKey = keyof typeof PROVIDER_ICON_PATHS
@@ -0,0 +1,7 @@
export function lubimyczytacBookUrl(id: string): string {
const value = String(id).trim()
// New data stores the canonical "id/slug" path. lubimyczytac.pl 404s on a slug-less id,
// so legacy bare-numeric ids get a placeholder slug segment to keep the link valid.
const path = value.includes('/') ? value : `${value}/-`
return `https://lubimyczytac.pl/ksiazka/${path.split('/').map(encodeURIComponent).join('/')}`
}
@@ -198,6 +198,12 @@ const rows: RowDef[] = [
},
},
{ key: 'ranobedb', label: 'RanobeDB', hint: 'Japanese light novel metadata from RanobeDB. No setup required.', fields: [] },
{
key: 'lubimyczytac',
label: 'LubimyCzytac',
hint: 'Polish book catalog (lubimyczytac.pl). Scrapes public book pages. No setup required.',
fields: [],
},
]
const TESTABLE_PROVIDERS: MetadataProviderKey[] = [MetadataProviderKey.AMAZON, MetadataProviderKey.HARDCOVER]
+2
View File
@@ -12,6 +12,7 @@ const PROVIDER_HEX: Record<string, string> = {
comicvine: '#ffdb0f',
ranobedb: '#a78cff',
kobo: '#e23434',
lubimyczytac: '#f47373',
auto: '#8B5CF6',
}
@@ -29,6 +30,7 @@ export const PROVIDER_SHORT_LABELS: Record<string, string> = {
comicvine: 'ComicVine',
ranobedb: 'RanobeDB',
kobo: 'Kobo',
lubimyczytac: 'LubimyCzytac',
auto: 'Fetched',
}
+1
View File
@@ -161,6 +161,7 @@ export type BookMetadataRefreshPreviewFields = {
koboId?: string | null;
comicvineId?: string | null;
ranobedbId?: string | null;
lubimyczytacId?: string | null;
audioMetadata?: {
narrators?: string[];
durationSeconds?: number | null;
+2
View File
@@ -24,6 +24,7 @@ export const COMMON_PROVIDER_BOOK_FILE_WRITE_FIELDS = [
"openLibraryId",
"ranobedbId",
"koboId",
"lubimyczytacId",
] as const;
export const COMIC_BOOK_FILE_WRITE_FIELDS = [
@@ -75,6 +76,7 @@ export const BOOK_FILE_WRITE_FIELD_LABELS = {
openLibraryId: "Open Library ID",
ranobedbId: "RanobeDB ID",
koboId: "Kobo ID",
lubimyczytacId: "LubimyCzytac ID",
comicIssueNumber: "Issue number",
comicVolumeName: "Volume",
comicPencillers: "Pencillers",
+1
View File
@@ -12,6 +12,7 @@ export const MetadataProviderKey = {
COMICVINE: "comicvine",
RANOBEDB: "ranobedb",
KOBO: "kobo",
LUBIMYCZYTAC: "lubimyczytac",
} as const;
export interface ComicMetadataFields {
+14 -1
View File
@@ -27,6 +27,7 @@ export const BOOK_METADATA_LOCK_FIELDS = [
"koboId",
"comicvineId",
"ranobedbId",
"lubimyczytacId",
"comicIssueNumber",
"comicVolumeName",
"comicStoryArcs",
@@ -75,7 +76,19 @@ export const BOOK_METADATA_LOCK_GROUPS: BookMetadataLockGroup[] = [
{
key: "providers",
label: "Provider IDs",
fields: ["googleBooksId", "goodreadsId", "amazonId", "hardcoverId", "openLibraryId", "itunesId", "audibleId", "koboId", "comicvineId", "ranobedbId"],
fields: [
"googleBooksId",
"goodreadsId",
"amazonId",
"hardcoverId",
"openLibraryId",
"itunesId",
"audibleId",
"koboId",
"comicvineId",
"ranobedbId",
"lubimyczytacId",
],
},
{
key: "comic",
@@ -127,6 +127,7 @@ export interface ProviderConfigurations {
comicvine: ComicVineProviderConfig;
ranobedb: SimpleProviderConfig;
kobo: KoboProviderConfig;
lubimyczytac: SimpleProviderConfig;
}
export interface ProviderStatus {
@@ -0,0 +1 @@
ALTER TABLE "book_metadata" ADD COLUMN "lubimyczytac_id" text;
File diff suppressed because it is too large Load Diff
@@ -134,6 +134,13 @@
"when": 1781214276180,
"tag": "0018_author_sort_key_and_listing_indexes",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1781330635947,
"tag": "0019_add_lubimyczytac_provider_id",
"breakpoints": true
}
]
}
+1
View File
@@ -68,6 +68,7 @@ export const bookMetadata = pgTable(
audibleId: varchar('audible_id', { length: 20 }),
comicvineId: varchar('comicvine_id', { length: 50 }),
ranobedbId: varchar('ranobedb_id', { length: 50 }),
lubimyczytacId: text('lubimyczytac_id'),
chapters: jsonb('chapters'),
lockedFields: text('locked_fields')
.array()
@@ -291,6 +291,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
if (filteredProviderIds[MetadataProviderKey.KOBO]) scalarFields.koboId = filteredProviderIds[MetadataProviderKey.KOBO];
if (filteredProviderIds[MetadataProviderKey.COMICVINE]) scalarFields.comicvineId = filteredProviderIds[MetadataProviderKey.COMICVINE];
if (filteredProviderIds[MetadataProviderKey.RANOBEDB]) scalarFields.ranobedbId = filteredProviderIds[MetadataProviderKey.RANOBEDB];
if (filteredProviderIds[MetadataProviderKey.LUBIMYCZYTAC]) scalarFields.lubimyczytacId = filteredProviderIds[MetadataProviderKey.LUBIMYCZYTAC];
const duration = this.asNullableNumber(filteredResolved.duration);
if (duration !== undefined) scalarFields.durationSeconds = duration;
@@ -380,6 +381,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
koboId?: string | null;
comicvineId?: string | null;
ranobedbId?: string | null;
lubimyczytacId?: string | null;
}): Partial<Record<MetadataProviderKey, string>> {
const ids: Partial<Record<MetadataProviderKey, string>> = {};
if (meta.googleBooksId) ids[MetadataProviderKey.GOOGLE] = meta.googleBooksId;
@@ -392,6 +394,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
if (meta.koboId) ids[MetadataProviderKey.KOBO] = meta.koboId;
if (meta.comicvineId) ids[MetadataProviderKey.COMICVINE] = meta.comicvineId;
if (meta.ranobedbId) ids[MetadataProviderKey.RANOBEDB] = meta.ranobedbId;
if (meta.lubimyczytacId) ids[MetadataProviderKey.LUBIMYCZYTAC] = meta.lubimyczytacId;
return ids;
}
@@ -19,6 +19,7 @@ const PROVIDER_ID_LOCK_FIELDS_BY_PROVIDER: Partial<Record<MetadataProviderKey, B
[MetadataProviderKey.KOBO]: 'koboId',
[MetadataProviderKey.COMICVINE]: 'comicvineId',
[MetadataProviderKey.RANOBEDB]: 'ranobedbId',
[MetadataProviderKey.LUBIMYCZYTAC]: 'lubimyczytacId',
};
const COMIC_METADATA_LOCK_FIELDS: Record<keyof ComicMetadataFields, BookMetadataLockField> = {
@@ -241,6 +242,7 @@ export class BookMetadataLockService {
this.copyUpdateField(filteredDto, dto, 'koboId', 'koboId', lockedSet, skippedFields);
this.copyUpdateField(filteredDto, dto, 'comicvineId', 'comicvineId', lockedSet, skippedFields);
this.copyUpdateField(filteredDto, dto, 'ranobedbId', 'ranobedbId', lockedSet, skippedFields);
this.copyUpdateField(filteredDto, dto, 'lubimyczytacId', 'lubimyczytacId', lockedSet, skippedFields);
if (dto.audioMetadata) {
const filteredAudioMetadata: NonNullable<UpdateBookMetadataDto['audioMetadata']> = {};
@@ -366,6 +368,7 @@ export class BookMetadataLockService {
this.addFieldIfPresent(fields, dto, 'koboId', 'koboId');
this.addFieldIfPresent(fields, dto, 'comicvineId', 'comicvineId');
this.addFieldIfPresent(fields, dto, 'ranobedbId', 'ranobedbId');
this.addFieldIfPresent(fields, dto, 'lubimyczytacId', 'lubimyczytacId');
if (dto.audioMetadata) {
this.addFieldIfPresent(fields, dto.audioMetadata, 'narrators', 'narrators');
+9
View File
@@ -273,6 +273,7 @@ export class BookService {
koboId?: string | null;
comicvineId?: string | null;
ranobedbId?: string | null;
lubimyczytacId?: string | null;
}): Partial<Record<MetadataProviderKey, string>> {
const providerIds: Partial<Record<MetadataProviderKey, string>> = {};
if (meta.googleBooksId) providerIds[MetadataProviderKey.GOOGLE] = meta.googleBooksId;
@@ -285,6 +286,7 @@ export class BookService {
if (meta.koboId) providerIds[MetadataProviderKey.KOBO] = meta.koboId;
if (meta.comicvineId) providerIds[MetadataProviderKey.COMICVINE] = meta.comicvineId;
if (meta.ranobedbId) providerIds[MetadataProviderKey.RANOBEDB] = meta.ranobedbId;
if (meta.lubimyczytacId) providerIds[MetadataProviderKey.LUBIMYCZYTAC] = meta.lubimyczytacId;
return providerIds;
}
@@ -301,6 +303,7 @@ export class BookService {
| 'koboId'
| 'comicvineId'
| 'ranobedbId'
| 'lubimyczytacId'
>,
providerIds: Partial<Record<MetadataProviderKey, string>>,
): void {
@@ -314,6 +317,7 @@ export class BookService {
if (providerIds[MetadataProviderKey.KOBO]) dto.koboId = providerIds[MetadataProviderKey.KOBO];
if (providerIds[MetadataProviderKey.COMICVINE]) dto.comicvineId = providerIds[MetadataProviderKey.COMICVINE];
if (providerIds[MetadataProviderKey.RANOBEDB]) dto.ranobedbId = providerIds[MetadataProviderKey.RANOBEDB];
if (providerIds[MetadataProviderKey.LUBIMYCZYTAC]) dto.lubimyczytacId = providerIds[MetadataProviderKey.LUBIMYCZYTAC];
}
private buildMetadataRefreshPreview(
@@ -1430,6 +1434,7 @@ export class BookService {
if (dto.koboId !== undefined) scalarFields.koboId = dto.koboId ?? null;
if (dto.comicvineId !== undefined) scalarFields.comicvineId = dto.comicvineId ?? null;
if (dto.ranobedbId !== undefined) scalarFields.ranobedbId = dto.ranobedbId ?? null;
if (dto.lubimyczytacId !== undefined) scalarFields.lubimyczytacId = dto.lubimyczytacId ?? null;
if (dto.rating !== undefined) scalarFields.rating = dto.rating ?? null;
if (dto.audioMetadata) {
if (dto.audioMetadata.durationSeconds !== undefined) scalarFields.durationSeconds = dto.audioMetadata.durationSeconds ?? null;
@@ -2648,6 +2653,7 @@ export class BookService {
[MetadataProviderKey.KOBO]: meta?.koboId ?? null,
[MetadataProviderKey.COMICVINE]: meta?.comicvineId ?? null,
[MetadataProviderKey.RANOBEDB]: meta?.ranobedbId ?? null,
[MetadataProviderKey.LUBIMYCZYTAC]: meta?.lubimyczytacId ?? null,
},
authors: authorRows,
genres: genreRows.map((g) => g.name),
@@ -2755,6 +2761,7 @@ export class BookService {
itunesId: parsed.itunesId,
koboId: parsed.koboId,
ranobedbId: parsed.ranobedbId,
lubimyczytacId: parsed.lubimyczytacId,
authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined,
genres: parsed.genres.length > 0 ? parsed.genres : undefined,
};
@@ -2785,6 +2792,7 @@ export class BookService {
itunesId: parsed.itunesId,
koboId: parsed.koboId,
ranobedbId: parsed.ranobedbId,
lubimyczytacId: parsed.lubimyczytacId,
authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined,
genres: parsed.genres.length > 0 ? parsed.genres : undefined,
};
@@ -2833,6 +2841,7 @@ export class BookService {
itunesId: parsed.itunesId,
koboId: parsed.koboId,
ranobedbId: parsed.ranobedbId,
lubimyczytacId: parsed.lubimyczytacId,
authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined,
genres: cbzGenres.length > 0 ? cbzGenres : undefined,
comicMetadata: parsed.comicMetadata ?? undefined,
@@ -55,5 +55,6 @@ export class UpdateBookMetadataDto {
@IsOptional() @ValidateNested() @Type(() => AudioMetadataDto) audioMetadata?: AudioMetadataDto;
@IsOptional() @IsString() @MaxLength(50) comicvineId?: string | null;
@IsOptional() @IsString() @MaxLength(50) ranobedbId?: string | null;
@IsOptional() @IsString() @MaxLength(512) lubimyczytacId?: string | null;
@IsOptional() @ValidateNested() @Type(() => ComicMetadataDto) comicMetadata?: ComicMetadataDto;
}
@@ -15,6 +15,7 @@ const baseConfig: ProviderConfigurations = {
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
};
function mockJsonResponse(body: unknown, status = 200): Response {
@@ -38,6 +38,7 @@ export const COMIC_INFO_MANAGED_NOTES_KEYS = [
'openLibraryId',
'ranobedbId',
'koboId',
'lubimyczytacId',
] as const satisfies readonly BookWritePayloadKey[];
type ComicInfoProviderKey = (typeof COMIC_INFO_PROVIDER_ID_KEYS)[number];
@@ -59,5 +60,6 @@ export const EPUB_PROVIDER_IDENTIFIER_SCHEMES = {
openLibraryId: 'OPENLIBRARY',
ranobedbId: 'RANOBEDB',
koboId: 'KOBO',
lubimyczytacId: 'LUBIMYCZYTAC',
itunesId: 'ITUNES',
} as const satisfies Partial<Record<BookWritePayloadKey, string>>;
@@ -185,6 +185,7 @@ export class FileWriteRepository {
openLibraryId: meta.openLibraryId,
ranobedbId: meta.ranobedbId,
koboId: meta.koboId,
lubimyczytacId: meta.lubimyczytacId,
itunesId: meta.itunesId,
audibleId: meta.audibleId,
rating: meta.rating,
@@ -23,6 +23,7 @@ export interface BookWritePayload {
openLibraryId?: string | null;
ranobedbId?: string | null;
koboId?: string | null;
lubimyczytacId?: string | null;
comicIssueNumber?: string | null;
comicVolumeName?: string | null;
comicPencillers?: string[];
@@ -130,6 +130,7 @@ describe('MetadataFetchPipeline', () => {
google: { enabled: false, apiKey: '' },
openLibrary: { enabled: false },
kobo: { enabled: true, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
}),
);
preferencesService.getGlobal.mockResolvedValue(global);
@@ -575,5 +576,6 @@ function makeProviderConfig(overrides: Partial<ProviderConfigurations> = {}): Pr
comicvine: { enabled: true, apiKey: 'comicvine-key', ...overrides.comicvine },
ranobedb: { enabled: true, ...overrides.ranobedb },
kobo: { enabled: true, country: 'us', language: 'en', ...overrides.kobo },
lubimyczytac: { enabled: false, ...overrides.lubimyczytac },
};
}
@@ -171,6 +171,7 @@ describe('MetadataFetchController', () => {
audible: { enabled: false, domain: 'com' },
audnexus: { enabled: false },
kobo: { enabled: true, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
}),
);
service.search.mockReturnValue(of({ provider: MetadataProviderKey.KOBO, providerId: 'dune-1', title: 'Dune' }));
@@ -313,5 +314,6 @@ function makeProviderConfig(overrides: Partial<ProviderConfigurations> = {}): Pr
comicvine: { enabled: false, apiKey: '', ...overrides.comicvine },
ranobedb: { enabled: false, ...overrides.ranobedb },
kobo: { enabled: true, country: 'us', language: 'en', ...overrides.kobo },
lubimyczytac: { enabled: false, ...overrides.lubimyczytac },
};
}
@@ -19,6 +19,7 @@ import { HardcoverProvider } from './providers/hardcover/hardcover.provider';
import { ComicVineProvider } from './providers/comicvine/comicvine.provider';
import { KoboProvider } from './providers/kobo/kobo.provider';
import { RanobeDbProvider } from './providers/ranobedb/ranobedb.provider';
import { LubimyczytacProvider } from './providers/lubimyczytac/lubimyczytac.provider';
describe('MetadataFetchModule', () => {
it('registers provider classes and factory wiring for METADATA_PROVIDERS', () => {
@@ -37,6 +38,7 @@ describe('MetadataFetchModule', () => {
ComicVineProvider,
RanobeDbProvider,
KoboProvider,
LubimyczytacProvider,
ProviderRegistry,
MetadataFetchRepository,
MetadataFetchService,
@@ -62,6 +64,7 @@ describe('MetadataFetchModule', () => {
ComicVineProvider,
RanobeDbProvider,
KoboProvider,
LubimyczytacProvider,
]);
const google = { key: 'google' };
@@ -23,6 +23,7 @@ import { ComicVineProvider } from './providers/comicvine/comicvine.provider';
import { KoboProvider } from './providers/kobo/kobo.provider';
import { RanobeDbClient } from './providers/ranobedb/ranobedb.client';
import { RanobeDbProvider } from './providers/ranobedb/ranobedb.provider';
import { LubimyczytacProvider } from './providers/lubimyczytac/lubimyczytac.provider';
const PROVIDER_CLASSES = [
GoogleProvider,
@@ -36,6 +37,7 @@ const PROVIDER_CLASSES = [
ComicVineProvider,
RanobeDbProvider,
KoboProvider,
LubimyczytacProvider,
];
@Module({
@@ -20,6 +20,7 @@ export interface StoredProviderIdsRow {
koboId: string | null;
comicvineId: string | null;
ranobedbId: string | null;
lubimyczytacId: string | null;
}
@Injectable()
@@ -40,6 +41,7 @@ export class MetadataFetchRepository {
koboId: bookMetadata.koboId,
comicvineId: bookMetadata.comicvineId,
ranobedbId: bookMetadata.ranobedbId,
lubimyczytacId: bookMetadata.lubimyczytacId,
})
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
@@ -72,6 +72,7 @@ export class MetadataFetchService {
[MetadataProviderKey.KOBO]: row.koboId ?? undefined,
[MetadataProviderKey.COMICVINE]: row.comicvineId ?? undefined,
[MetadataProviderKey.RANOBEDB]: row.ranobedbId ?? undefined,
[MetadataProviderKey.LUBIMYCZYTAC]: row.lubimyczytacId ?? undefined,
};
}
@@ -21,6 +21,7 @@ describe('AmazonProvider', () => {
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
};
beforeEach(async () => {
@@ -39,6 +39,7 @@ const baseConfig: ProviderConfigurations = {
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
};
function makeProvider(config: ProviderConfigurations = baseConfig): {
@@ -30,6 +30,7 @@ describe('AudnexusProvider', () => {
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
};
beforeEach(async () => {
@@ -20,6 +20,7 @@ const mockConfig: ProviderConfigurations = {
comicvine: { enabled: true, apiKey: 'test-key' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
};
const mockVolume = {
@@ -20,6 +20,7 @@ describe('GoodreadsProvider', () => {
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
};
function goodreadsBookHtml(bookId: string, title: string): string {
@@ -20,6 +20,7 @@ describe('GoogleProvider', () => {
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
};
beforeEach(async () => {
@@ -18,6 +18,7 @@ const mockConfig: ProviderConfigurations = {
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
};
const mockDocument: HardcoverSearchDocument = {
@@ -20,6 +20,7 @@ describe('ITunesProvider', () => {
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
};
beforeEach(async () => {
@@ -28,6 +28,7 @@ describe('KoboProvider', () => {
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: true, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
};
beforeEach(async () => {
@@ -62,6 +63,7 @@ describe('KoboProvider', () => {
vi.spyOn(providerConfig, 'getConfig').mockResolvedValue({
...mockConfig,
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
});
await expect(provider.search({ title: 'Fourth Wing' })).resolves.toEqual([]);
@@ -0,0 +1,216 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MetadataProviderKey, ProviderConfigurations } from '@bookorbit/types';
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
import { ProviderThrottleError } from '../../provider-throttle.error';
import { LubimyczytacProvider } from './lubimyczytac.provider';
const SEARCH_HTML = `
<div class="book-card">
<a class="book-card__title" href="/ksiazka/123456/ostatnie-zyczenie">Ostatnie życzenie</a>
</div>
`;
const BOOK_HTML = `
<h1 class="book__title">Ostatnie życzenie</h1>
<div class="book-cover"><img src="/upload/books/cover.jpg" /></div>
<a href="/wydawnictwo/123/superNowa">superNOWA</a>
<dl><dt>Język:</dt><dd>polski</dd></dl>
<meta property="books:isbn" content="9788375780635" />
<script type="application/ld+json">
{ "@type": "Book", "url": "https://lubimyczytac.pl/ksiazka/123456/ostatnie-zyczenie", "numberOfPages": 330, "datePublished": "2014-05-12", "author": { "@type": "Person", "name": "Andrzej Sapkowski" } }
</script>
`;
describe('LubimyczytacProvider', () => {
let provider: LubimyczytacProvider;
let providerConfig: ProviderConfigService;
const mockConfig: ProviderConfigurations = {
google: { enabled: true, apiKey: '' },
amazon: { enabled: true, domain: 'amazon.com', cookie: '' },
goodreads: { enabled: true },
hardcover: { enabled: false, apiKey: '' },
openLibrary: { enabled: true },
itunes: { enabled: true, 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: true },
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
LubimyczytacProvider,
{
provide: ProviderConfigService,
useValue: { getConfig: vi.fn().mockResolvedValue(mockConfig) },
},
],
}).compile();
provider = module.get<LubimyczytacProvider>(LubimyczytacProvider);
providerConfig = module.get<ProviderConfigService>(ProviderConfigService);
});
afterEach(() => {
vi.restoreAllMocks();
});
function mockFetchSequence(...bodies: string[]) {
const fetch = vi.fn();
for (const body of bodies) {
fetch.mockResolvedValueOnce({ ok: true, status: 200, text: () => Promise.resolve(body) } as Response);
}
global.fetch = fetch;
return fetch;
}
describe('search', () => {
it('returns empty array when disabled', async () => {
vi.spyOn(providerConfig, 'getConfig').mockResolvedValue({ ...mockConfig, lubimyczytac: { enabled: false } });
const result = await provider.search({ title: 'Wiedźmin' });
expect(result).toEqual([]);
});
it('searches by title then fetches book details', async () => {
mockFetchSequence(SEARCH_HTML, BOOK_HTML);
const result = await provider.search({ title: 'Ostatnie życzenie', author: 'Andrzej Sapkowski' });
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
provider: MetadataProviderKey.LUBIMYCZYTAC,
providerId: '123456/ostatnie-zyczenie',
title: 'Ostatnie życzenie',
authors: ['Andrzej Sapkowski'],
publisher: 'superNOWA',
language: 'pl',
pageCount: 330,
publishedYear: 2014,
isbn13: '9788375780635',
sourceUrl: 'https://lubimyczytac.pl/ksiazka/123456/ostatnie-zyczenie',
});
});
it('returns empty array when there is no query', async () => {
const fetch = mockFetchSequence();
const result = await provider.search({});
expect(result).toEqual([]);
expect(fetch).not.toHaveBeenCalled();
});
});
describe('lookupById', () => {
it('fetches a book by its provider id', async () => {
mockFetchSequence(BOOK_HTML);
const result = await provider.lookupById('123456');
expect(result?.providerId).toBe('123456/ostatnie-zyczenie');
expect(result?.title).toBe('Ostatnie życzenie');
});
it('returns null when disabled', async () => {
vi.spyOn(providerConfig, 'getConfig').mockResolvedValue({ ...mockConfig, lubimyczytac: { enabled: false } });
expect(await provider.lookupById('123456')).toBeNull();
});
it('returns null for an invalid providerId', async () => {
expect(await provider.lookupById('not-a-valid-id')).toBeNull();
});
it('returns null when book page has no title', async () => {
mockFetchSequence('<html><body></body></html>');
expect(await provider.lookupById('123456')).toBeNull();
});
it('returns null on non-ok HTTP response', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 503, text: () => Promise.resolve('') } as unknown as Response);
expect(await provider.lookupById('123456')).toBeNull();
});
it('rethrows ProviderThrottleError', async () => {
global.fetch = vi.fn().mockResolvedValue({
status: 429,
ok: false,
headers: { get: () => null },
} as unknown as Response);
await expect(provider.lookupById('123456')).rejects.toBeInstanceOf(ProviderThrottleError);
});
it('returns null on generic fetch error', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('network failure'));
expect(await provider.lookupById('123456')).toBeNull();
});
it('returns null on non-Error rejection', async () => {
global.fetch = vi.fn().mockRejectedValue('string rejection');
expect(await provider.lookupById('123456')).toBeNull();
});
});
describe('search — additional branches', () => {
it('searches by isbn when no title is given', async () => {
mockFetchSequence(SEARCH_HTML, BOOK_HTML);
const result = await provider.search({ isbn: '9788375780635' });
expect(result).toHaveLength(1);
});
it('returns empty array on non-ok search response', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 503, text: () => Promise.resolve('') } as unknown as Response);
expect(await provider.search({ title: 'Wiedźmin' })).toEqual([]);
});
it('skips book fetch when book page returns no title', async () => {
const SEARCH_TWO = `
<a class="book-card__title" href="/ksiazka/1/first">First</a>
<a class="book-card__title" href="/ksiazka/2/second">Second</a>
`;
mockFetchSequence(SEARCH_TWO, '<html><body></body></html>', '<html><body></body></html>');
expect(await provider.search({ title: 'Wiedźmin' })).toEqual([]);
});
it('omits authors and genres fields when empty', async () => {
const NO_AUTHORS_HTML = `
<h1 class="book__title">Test Book</h1>
<script type="application/ld+json">
{ "@type": "Book", "url": "https://lubimyczytac.pl/ksiazka/1/test" }
</script>
`;
mockFetchSequence(SEARCH_HTML, NO_AUTHORS_HTML);
const result = await provider.search({ title: 'Test' });
expect(result[0]?.authors).toBeUndefined();
expect(result[0]?.genres).toBeUndefined();
});
it('breaks the fetch loop when signal is pre-aborted', async () => {
const SEARCH_TWO = `
<a class="book-card__title" href="/ksiazka/1/first">First</a>
<a class="book-card__title" href="/ksiazka/2/second">Second</a>
`;
const fetch = mockFetchSequence(SEARCH_TWO);
const result = await provider.search({ title: 'Wiedźmin', signal: AbortSignal.abort() });
expect(result).toEqual([]);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('sleeps between multiple book fetches', async () => {
const SEARCH_TWO = `
<a class="book-card__title" href="/ksiazka/123456/ostatnie-zyczenie">Ostatnie życzenie</a>
<a class="book-card__title" href="/ksiazka/789012/miecz-przeznaczenia">Miecz przeznaczenia</a>
`;
const BOOK_HTML_2 = `
<h1 class="book__title">Miecz przeznaczenia</h1>
<script type="application/ld+json">
{ "@type": "Book", "url": "https://lubimyczytac.pl/ksiazka/789012/miecz-przeznaczenia", "numberOfPages": 280, "datePublished": "2015" }
</script>
`;
mockFetchSequence(SEARCH_TWO, BOOK_HTML, BOOK_HTML_2);
const result = await provider.search({ title: 'Sapkowski' });
expect(result).toHaveLength(2);
expect(result[1]?.title).toBe('Miecz przeznaczenia');
}, 10_000);
});
});
@@ -0,0 +1,144 @@
import { Injectable, Logger } from '@nestjs/common';
import { MetadataCandidate, MetadataProviderKey } 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 { IdentifiableProvider } from '../metadata-provider';
import { PROVIDER_DELAYS_MS, PROVIDER_LIMITS, PROVIDER_TIMEOUT_MS } from '../provider-constants';
import { MetadataSearchParams } from '../metadata-search-params';
import { buildRequestSignal, normalizeMaxCandidates, sleep } from '../provider-utils';
import {
buildLubimyczytacBookUrl,
buildLubimyczytacSearchUrl,
extractLubimyczytacPath,
extractLubimyczytacSearchResults,
LubimyczytacSearchResultLink,
parseLubimyczytacBookPage,
} from './lubimyczytac.scraper';
const HEADERS: HeadersInit = {
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'accept-language': 'pl-PL,pl;q=0.9,en;q=0.8',
};
type FetchContext = {
op: 'search' | 'lookup';
query?: string;
providerId?: string;
};
@Injectable()
export class LubimyczytacProvider implements IdentifiableProvider {
readonly key = MetadataProviderKey.LUBIMYCZYTAC;
readonly label = 'LubimyCzytac';
readonly identifiable = true as const;
readonly timeoutMs = PROVIDER_TIMEOUT_MS.SCRAPE;
private readonly logger = new Logger(LubimyczytacProvider.name);
constructor(private readonly providerConfig: ProviderConfigService) {}
async search(params: MetadataSearchParams): Promise<MetadataCandidate[]> {
const { enabled } = await this.providerConfig.getConfig().then((c) => c.lubimyczytac);
if (!enabled) return [];
const maxCandidates = normalizeMaxCandidates(params.maxCandidatesPerProvider, PROVIDER_LIMITS.LUBIMYCZYTAC_MAX_RESULTS);
const links = await this.searchBookLinks(params, maxCandidates, params.signal);
const results: MetadataCandidate[] = [];
for (const link of links.slice(0, maxCandidates)) {
if (params.signal?.aborted) break;
if (results.length > 0) {
await sleep(PROVIDER_DELAYS_MS.LUBIMYCZYTAC_BETWEEN_REQUESTS, params.signal);
}
const candidate = await this.fetchByUrl(link.url, link.providerId, params.signal);
if (candidate) results.push(candidate);
}
return results;
}
async lookupById(providerId: string, signal?: AbortSignal): Promise<MetadataCandidate | null> {
const { enabled } = await this.providerConfig.getConfig().then((c) => c.lubimyczytac);
if (!enabled) return null;
const normalized = extractLubimyczytacPath(providerId);
if (!normalized) return null;
return this.fetchByUrl(buildLubimyczytacBookUrl(normalized), normalized, signal);
}
private async searchBookLinks(params: MetadataSearchParams, limit: number, signal?: AbortSignal): Promise<LubimyczytacSearchResultLink[]> {
const query = params.title?.trim() || params.isbn?.trim();
if (!query) return [];
const url = buildLubimyczytacSearchUrl(query, params.author);
const html = await this.fetchHtml(url, { op: 'search', query }, signal);
return html ? extractLubimyczytacSearchResults(html, limit) : [];
}
private async fetchByUrl(url: string, fallbackProviderId: string, signal?: AbortSignal): Promise<MetadataCandidate | null> {
const html = await this.fetchHtml(url, { op: 'lookup', providerId: fallbackProviderId }, signal);
if (!html) return null;
const data = parseLubimyczytacBookPage(html, url);
const providerId = data.providerId || extractLubimyczytacPath(url) || fallbackProviderId;
if (!data.title || !providerId) return null;
return {
provider: MetadataProviderKey.LUBIMYCZYTAC,
providerId,
title: data.title,
authors: data.authors?.length ? data.authors : undefined,
description: data.description,
publisher: data.publisher,
publishedYear: data.publishedYear,
language: data.language,
pageCount: data.pageCount,
isbn10: data.isbn10,
isbn13: data.isbn13,
seriesName: data.seriesName,
seriesIndex: data.seriesIndex,
genres: data.genres?.length ? data.genres : undefined,
coverUrl: data.coverUrl,
sourceUrl: url,
};
}
private async fetchHtml(url: string, context: FetchContext, signal?: AbortSignal): Promise<string | null> {
const startedAt = Date.now();
const safeQuery = context.query ? sanitizeLogValue(context.query) : undefined;
const safeProviderId = context.providerId ? sanitizeLogValue(context.providerId) : undefined;
const subject = `${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''}`;
this.logger.log(`[lubimyczytac] [start] op=${context.op}${subject} - lubimyczytac fetch started`);
try {
const res = await fetchWithThrottle(url, { headers: HEADERS, signal: buildRequestSignal(PROVIDER_TIMEOUT_MS.SCRAPE, signal) });
if (!res.ok) {
this.logger.warn(
`[lubimyczytac] [fail] op=${context.op}${subject} status=${res.status} durationMs=${Date.now() - startedAt} errorClass=HttpError error="non-ok response" - lubimyczytac fetch failed`,
);
return null;
}
const html = await res.text();
this.logger.log(
`[lubimyczytac] [end] op=${context.op}${subject} status=${res.status} durationMs=${Date.now() - startedAt} bytes=${html.length} - lubimyczytac fetch completed`,
);
return html;
} catch (error) {
if (error instanceof ProviderThrottleError) {
this.logger.warn(
`[lubimyczytac] [fail] op=${context.op}${subject} durationMs=${Date.now() - startedAt} errorClass=ProviderThrottleError error="throttled" - lubimyczytac fetch throttled`,
);
throw error;
}
const errorClass = error instanceof Error ? error.name : 'UnknownError';
const message = sanitizeLogValue(error instanceof Error ? error.message : String(error));
this.logger.warn(
`[lubimyczytac] [fail] op=${context.op}${subject} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${message}" - lubimyczytac fetch failed`,
);
return null;
}
}
}
@@ -0,0 +1,439 @@
import {
buildLubimyczytacBookUrl,
buildLubimyczytacSearchUrl,
extractLubimyczytacId,
extractLubimyczytacPath,
extractLubimyczytacSearchResults,
parseLubimyczytacBookPage,
} from './lubimyczytac.scraper';
const BOOK_HTML = `
<h1 class="book__title">Ostatnie życzenie</h1>
<div class="book-cover"><img src="/upload/books/cover.jpg" /></div>
<a href="/wydawnictwo/123/superNowa">superNOWA</a>
<dl>
<dt>Język:</dt><dd>polski</dd>
</dl>
<div class="book__description text-collapse">Opis książki o wiedźminie Geralcie.</div>
<meta property="books:isbn" content="978-83-7578-063-5" />
<a class="book__category d-sm-block d-none" href="/ksiazki/k/64/fantasy-fantastyka">Fantasy, fantastyka</a>
<span class="d-none d-sm-block mt-1">Cykl: Wiedźmin (tom 1)</span>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Book",
"name": "Ostatnie życzenie",
"numberOfPages": 330,
"datePublished": "2014-05-12",
"author": [{ "@type": "Person", "name": "Andrzej Sapkowski" }],
"genre": "https://lubimyczytac.pl/ksiazki/k/64/fantasy-fantastyka"
}
</script>
`;
describe('LubimyczytacScraper', () => {
describe('buildLubimyczytacSearchUrl', () => {
it('encodes phrase and author', () => {
const url = buildLubimyczytacSearchUrl('Wiedźmin', 'Andrzej Sapkowski');
expect(url).toContain('https://lubimyczytac.pl/szukaj/ksiazki');
expect(url).toContain('phrase=Wied%C5%BAmin');
expect(url).toContain('author=Andrzej+Sapkowski');
});
it('omits author when not provided', () => {
expect(buildLubimyczytacSearchUrl('Lalka')).not.toContain('author=');
});
});
describe('extractLubimyczytacId', () => {
it('extracts the numeric id from a book url', () => {
expect(extractLubimyczytacId('https://lubimyczytac.pl/ksiazka/123456/ostatnie-zyczenie')).toBe('123456');
expect(extractLubimyczytacId('/ksiazka/789012')).toBe('789012');
});
it('accepts a bare numeric id', () => {
expect(extractLubimyczytacId('4815162342')).toBe('4815162342');
});
it('returns undefined for non-book urls', () => {
expect(extractLubimyczytacId('https://lubimyczytac.pl/autor/123/foo')).toBeUndefined();
});
});
describe('extractLubimyczytacPath', () => {
it('keeps the id and slug as the canonical path', () => {
expect(extractLubimyczytacPath('https://lubimyczytac.pl/ksiazka/123456/ostatnie-zyczenie')).toBe('123456/ostatnie-zyczenie');
expect(extractLubimyczytacPath('/ksiazka/789012/x?ref=1')).toBe('789012/x');
expect(extractLubimyczytacPath('123456/ostatnie-zyczenie')).toBe('123456/ostatnie-zyczenie');
});
it('returns the bare id when no slug is present', () => {
expect(extractLubimyczytacPath('/ksiazka/789012')).toBe('789012');
});
});
describe('buildLubimyczytacBookUrl', () => {
it('builds a url from an id/slug path', () => {
expect(buildLubimyczytacBookUrl('123456/ostatnie-zyczenie')).toBe('https://lubimyczytac.pl/ksiazka/123456/ostatnie-zyczenie');
});
it('appends a placeholder slug for a bare numeric id (slug-less urls 404)', () => {
expect(buildLubimyczytacBookUrl('123456')).toBe('https://lubimyczytac.pl/ksiazka/123456/-');
});
});
describe('extractLubimyczytacSearchResults', () => {
it('extracts unique book links from search results', () => {
const html = `
<div class="book-card">
<a class="book-card__cover-link" href="/ksiazka/123456/ostatnie-zyczenie"><img /></a>
<a class="book-card__title" href="/ksiazka/123456/ostatnie-zyczenie">Ostatnie życzenie</a>
</div>
<div class="book-card">
<a class="book-card__title" href="https://lubimyczytac.pl/ksiazka/789012/miecz-przeznaczenia">Miecz przeznaczenia</a>
</div>
`;
const results = extractLubimyczytacSearchResults(html, 5);
expect(results).toEqual([
{ providerId: '123456/ostatnie-zyczenie', url: 'https://lubimyczytac.pl/ksiazka/123456/ostatnie-zyczenie' },
{ providerId: '789012/miecz-przeznaczenia', url: 'https://lubimyczytac.pl/ksiazka/789012/miecz-przeznaczenia' },
]);
});
it('falls back to the legacy authorAllBooks layout', () => {
const html = `
<div class="authorAllBooks__single">
<a class="authorAllBooks__singleTextTitle" href="/ksiazka/555/legacy">Legacy</a>
</div>
`;
expect(extractLubimyczytacSearchResults(html, 5)).toEqual([{ providerId: '555/legacy', url: 'https://lubimyczytac.pl/ksiazka/555/legacy' }]);
});
it('respects the limit', () => {
const html = `
<a class="book-card__title" href="/ksiazka/1/a">A</a>
<a class="book-card__title" href="/ksiazka/2/b">B</a>
`;
expect(extractLubimyczytacSearchResults(html, 1)).toHaveLength(1);
});
});
describe('parseLubimyczytacBookPage', () => {
it('parses a complete book page', () => {
const data = parseLubimyczytacBookPage(BOOK_HTML, 'https://lubimyczytac.pl/ksiazka/123456/ostatnie-zyczenie');
expect(data.providerId).toBe('123456/ostatnie-zyczenie');
expect(data.title).toBe('Ostatnie życzenie');
expect(data.authors).toEqual(['Andrzej Sapkowski']);
expect(data.description).toBe('Opis książki o wiedźminie Geralcie.');
expect(data.publisher).toBe('superNOWA');
expect(data.language).toBe('pl');
expect(data.pageCount).toBe(330);
expect(data.publishedYear).toBe(2014);
expect(data.isbn13).toBe('9788375780635');
expect(data.seriesName).toBe('Wiedźmin');
expect(data.seriesIndex).toBe(1);
expect(data.genres).toEqual(['Fantasy', 'fantastyka']);
expect(data.coverUrl).toBe('https://lubimyczytac.pl/upload/books/cover.jpg');
});
it('falls back to the json-ld genre slug when no tags are present', () => {
const html = `
<h1 class="book__title">Bez tagów</h1>
<script type="application/ld+json">
{ "@type": "Book", "genre": "https://lubimyczytac.pl/ksiazki/k/69/poradniki" }
</script>
`;
expect(parseLubimyczytacBookPage(html).genres).toEqual(['poradniki']);
});
it('parses the series index from a trailing volume number', () => {
const html = `
<h1 class="book__title">Uwikłana</h1>
<span class="d-none d-sm-block mt-1">Cykl: <a href="/cykl/1/x">Komisarz Tymon Hanter 4</a></span>
`;
const data = parseLubimyczytacBookPage(html);
expect(data.seriesName).toBe('Komisarz Tymon Hanter');
expect(data.seriesIndex).toBe(4);
});
it('returns no title for an empty page', () => {
expect(parseLubimyczytacBookPage('<html><body></body></html>').title).toBeUndefined();
});
it('derives providerId from canonical url in json-ld', () => {
const html = `
<h1 class="book__title">Test</h1>
<script type="application/ld+json">
{ "@type": "Book", "url": "https://lubimyczytac.pl/ksiazka/999/canonical-slug" }
</script>
`;
expect(parseLubimyczytacBookPage(html).providerId).toBe('999/canonical-slug');
});
it('derives providerId from sourceUrl when no canonical url in json-ld', () => {
const html = `<h1 class="book__title">Test</h1>`;
expect(parseLubimyczytacBookPage(html, 'https://lubimyczytac.pl/ksiazka/777/from-source').providerId).toBe('777/from-source');
});
it('returns undefined providerId when called without sourceUrl and no json-ld url', () => {
const html = `<h1 class="book__title">Test</h1>`;
expect(parseLubimyczytacBookPage(html).providerId).toBeUndefined();
});
it('handles invalid JSON in ld+json script gracefully', () => {
const html = `
<h1 class="book__title">Test</h1>
<script type="application/ld+json">{ broken json }</script>
`;
const data = parseLubimyczytacBookPage(html);
expect(data.title).toBe('Test');
expect(data.pageCount).toBeUndefined();
});
it('parses numberOfPages from a string value in json-ld', () => {
const html = `
<h1 class="book__title">Test</h1>
<script type="application/ld+json">{ "@type": "Book", "numberOfPages": "330" }</script>
`;
expect(parseLubimyczytacBookPage(html).pageCount).toBe(330);
});
it('returns undefined pageCount for a non-numeric string in json-ld', () => {
const html = `
<h1 class="book__title">Test</h1>
<script type="application/ld+json">{ "@type": "Book", "numberOfPages": "brak danych" }</script>
`;
expect(parseLubimyczytacBookPage(html).pageCount).toBeUndefined();
});
it('returns undefined pageCount when numberOfPages is an object', () => {
const html = `
<h1 class="book__title">Test</h1>
<script type="application/ld+json">{ "@type": "Book", "numberOfPages": {"value": 300} }</script>
`;
expect(parseLubimyczytacBookPage(html).pageCount).toBeUndefined();
});
it('returns undefined publishedYear when datePublished is a number', () => {
const html = `
<h1 class="book__title">Test</h1>
<script type="application/ld+json">{ "@type": "Book", "datePublished": 2020 }</script>
`;
expect(parseLubimyczytacBookPage(html).publishedYear).toBeUndefined();
});
it('returns undefined publishedYear when datePublished string contains no year', () => {
const html = `
<h1 class="book__title">Test</h1>
<script type="application/ld+json">{ "@type": "Book", "datePublished": "unknown" }</script>
`;
expect(parseLubimyczytacBookPage(html).publishedYear).toBeUndefined();
});
it('extracts isbn10', () => {
const html = `
<h1 class="book__title">Test</h1>
<meta property="books:isbn" content="0-7432-7356-7" />
`;
const data = parseLubimyczytacBookPage(html);
expect(data.isbn10).toBe('0743273567');
expect(data.isbn13).toBeUndefined();
});
it('uses og:image as cover fallback', () => {
const html = `
<h1 class="book__title">Test</h1>
<meta property="og:image" content="https://example.com/cover.jpg" />
`;
expect(parseLubimyczytacBookPage(html).coverUrl).toBe('https://example.com/cover.jpg');
});
it('resolves a protocol-relative cover url', () => {
const html = `
<h1 class="book__title">Test</h1>
<div class="book-cover"><img src="//static.lubimyczytac.pl/cover.jpg" /></div>
`;
expect(parseLubimyczytacBookPage(html).coverUrl).toBe('https://static.lubimyczytac.pl/cover.jpg');
});
it('uses og:description as description fallback', () => {
const html = `
<h1 class="book__title">Test</h1>
<meta property="og:description" content="Opis z og." />
`;
expect(parseLubimyczytacBookPage(html).description).toBe('Opis z og.');
});
it('passes through unknown language values unchanged', () => {
const html = `
<h1 class="book__title">Test</h1>
<dl><dt>Język:</dt><dd>klingon</dd></dl>
`;
expect(parseLubimyczytacBookPage(html).language).toBe('klingon');
});
it('uses inLanguage from json-ld when no dt/dd language block', () => {
const html = `
<h1 class="book__title">Test</h1>
<script type="application/ld+json">{ "@type": "Book", "inLanguage": "pl" }</script>
`;
expect(parseLubimyczytacBookPage(html).language).toBe('pl');
});
it('parses a plain string author from json-ld', () => {
const html = `
<h1 class="book__title">Test</h1>
<script type="application/ld+json">{ "@type": "Book", "author": "Andrzej Sapkowski" }</script>
`;
expect(parseLubimyczytacBookPage(html).authors).toEqual(['Andrzej Sapkowski']);
});
it('ignores author object with a non-string name', () => {
const html = `
<h1 class="book__title">Test</h1>
<script type="application/ld+json">{ "@type": "Book", "author": { "name": 42 } }</script>
`;
expect(parseLubimyczytacBookPage(html).authors).toEqual([]);
});
it('ignores author object with a whitespace-only name', () => {
const html = `
<h1 class="book__title">Test</h1>
<script type="application/ld+json">{ "@type": "Book", "author": { "name": " " } }</script>
`;
expect(parseLubimyczytacBookPage(html).authors).toEqual([]);
});
it('ignores a whitespace-only string author', () => {
const html = `
<h1 class="book__title">Test</h1>
<script type="application/ld+json">{ "@type": "Book", "author": " " }</script>
`;
expect(parseLubimyczytacBookPage(html).authors).toEqual([]);
});
it('returns undefined pageCount when numberOfPages overflows to Infinity', () => {
const html = `
<h1 class="book__title">Test</h1>
<script type="application/ld+json">{ "@type": "Book", "numberOfPages": 1e309 }</script>
`;
expect(parseLubimyczytacBookPage(html).pageCount).toBeUndefined();
});
it('parses json-ld wrapped in a @graph node', () => {
const html = `
<h1 class="book__title">Test</h1>
<script type="application/ld+json">
{ "@graph": [{ "@type": "Book", "numberOfPages": 200, "datePublished": "2020-01-01" }] }
</script>
`;
const data = parseLubimyczytacBookPage(html);
expect(data.pageCount).toBe(200);
expect(data.publishedYear).toBe(2020);
});
it('parses json-ld from an array at root', () => {
const html = `
<h1 class="book__title">Test</h1>
<script type="application/ld+json">
[{ "@type": "WebPage" }, { "@type": "Book", "numberOfPages": 100 }]
</script>
`;
expect(parseLubimyczytacBookPage(html).pageCount).toBe(100);
});
it('uses second json-ld script block when first has no book node', () => {
const html = `
<h1 class="book__title">Test</h1>
<script type="application/ld+json">{ "@type": "WebSite" }</script>
<script type="application/ld+json">{ "@type": "Book", "numberOfPages": 150 }</script>
`;
expect(parseLubimyczytacBookPage(html).pageCount).toBe(150);
});
it('extracts genres from user tag links', () => {
const html = `
<h1 class="book__title">Test</h1>
<a href="/ksiazki/t/1/fantasy">Fantasy</a>
<a href="/ksiazki/t/2/kryminał">Kryminał</a>
`;
expect(parseLubimyczytacBookPage(html).genres).toEqual(['Fantasy', 'Kryminał']);
});
it('returns no series info when no cykl block is present', () => {
const data = parseLubimyczytacBookPage('<h1 class="book__title">Test</h1>');
expect(data.seriesName).toBeUndefined();
expect(data.seriesIndex).toBeUndefined();
});
it('parses a decimal series index with comma notation', () => {
const html = `
<h1 class="book__title">Test</h1>
<span class="d-none d-sm-block mt-1">Cykl: Wiedźmin (tom 2,5)</span>
`;
const data = parseLubimyczytacBookPage(html);
expect(data.seriesName).toBe('Wiedźmin');
expect(data.seriesIndex).toBe(2.5);
});
it('returns series name only when no volume number is present', () => {
const html = `
<h1 class="book__title">Test</h1>
<span class="d-none d-sm-block mt-1">Cykl: Wiedźmin</span>
`;
const data = parseLubimyczytacBookPage(html);
expect(data.seriesName).toBe('Wiedźmin');
expect(data.seriesIndex).toBeUndefined();
});
it('deduplicates authors from html', () => {
const html = `
<h1 class="book__title">Test</h1>
<a class="link-name" href="/autor/1/sapkowski">Andrzej Sapkowski</a>
<a href="/autor/1/sapkowski">Andrzej Sapkowski</a>
`;
const data = parseLubimyczytacBookPage(html);
expect(data.authors?.filter((a) => a === 'Andrzej Sapkowski').length).toBe(1);
});
it('returns no metadata from a json-ld array containing no book nodes', () => {
const html = `
<h1 class="book__title">Test</h1>
<script type="application/ld+json">
[{ "@type": "WebPage" }, { "@type": "WebSite" }]
</script>
`;
const data = parseLubimyczytacBookPage(html);
expect(data.pageCount).toBeUndefined();
expect(data.publishedYear).toBeUndefined();
});
it('parses an Audiobook type json-ld node', () => {
const html = `
<h1 class="book__title">Test Audio</h1>
<script type="application/ld+json">
{ "@type": "Audiobook", "numberOfPages": 400, "datePublished": "2021-03-15" }
</script>
`;
const data = parseLubimyczytacBookPage(html);
expect(data.pageCount).toBe(400);
expect(data.publishedYear).toBe(2021);
});
it('does not overwrite fields already parsed from an earlier json-ld block', () => {
const html = `
<h1 class="book__title">Test</h1>
<script type="application/ld+json">{ "@type": "Book", "numberOfPages": 100, "datePublished": "2019" }</script>
<script type="application/ld+json">{ "@type": "Book", "numberOfPages": 999, "datePublished": "2099" }</script>
`;
const data = parseLubimyczytacBookPage(html);
expect(data.pageCount).toBe(100);
expect(data.publishedYear).toBe(2019);
});
it('returns undefined for genres when no genre links or json-ld genre are present', () => {
const data = parseLubimyczytacBookPage('<h1 class="book__title">Test</h1>');
expect(data.genres).toEqual([]);
});
});
});
@@ -0,0 +1,345 @@
import * as cheerio from 'cheerio';
import type { CheerioAPI } from 'cheerio';
export interface LubimyczytacSearchResultLink {
providerId: string;
url: string;
}
export interface LubimyczytacBookData {
providerId?: string;
title?: 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;
}
const LUBIMYCZYTAC_BASE_URL = 'https://lubimyczytac.pl';
const LUBIMYCZYTAC_SEARCH_PATH = '/szukaj/ksiazki';
const BOOK_PATH_PATTERN = /\/ksiazka\/(\d+(?:\/[^/?#]+)?)/;
const SERIES_INDEX_PATTERN = /\(tom\s+(\d+(?:[.,]\d+)?)\)/i;
const MAX_GENRES = 10;
const LANGUAGE_MAP: Record<string, string> = {
polski: 'pl',
angielski: 'en',
niemiecki: 'de',
francuski: 'fr',
hiszpański: 'es',
włoski: 'it',
};
export function buildLubimyczytacSearchUrl(query: string, author?: string): string {
const url = new URL(LUBIMYCZYTAC_SEARCH_PATH, LUBIMYCZYTAC_BASE_URL);
url.searchParams.set('phrase', query);
if (author?.trim()) url.searchParams.set('author', author.trim());
return url.toString();
}
export function buildLubimyczytacBookUrl(providerId: string): string {
const path = extractLubimyczytacPath(providerId) ?? providerId.trim().replace(/^\/+|\/+$/g, '');
const segments = path.split('/').filter(Boolean);
// A bare numeric id (no slug) 404s; lubimyczytac.pl serves the book for any non-empty slug segment.
if (segments.length === 1) segments.push('-');
return `${LUBIMYCZYTAC_BASE_URL}/ksiazka/${segments.map(encodeURIComponent).join('/')}`;
}
// Canonical path after /ksiazka/, e.g. "123456/some-slug" (or just the numeric id when no slug is present).
export function extractLubimyczytacPath(value: string): string | undefined {
const match = BOOK_PATH_PATTERN.exec(value);
if (match) return match[1];
const trimmed = value.trim().replace(/^\/+|\/+$/g, '');
return /^\d+(?:\/[^/?#]+)?$/.test(trimmed) ? trimmed : undefined;
}
// Numeric book id only - used for de-duplication and validation.
export function extractLubimyczytacId(value: string): string | undefined {
const id = extractLubimyczytacPath(value)?.split('/')[0];
return id && /^\d+$/.test(id) ? id : undefined;
}
export function extractLubimyczytacSearchResults(html: string, limit: number): LubimyczytacSearchResultLink[] {
const $ = cheerio.load(html);
const results: LubimyczytacSearchResultLink[] = [];
const seen = new Set<string>();
const anchors = [...$('a.book-card__title').toArray(), ...$('.authorAllBooks__single .authorAllBooks__singleTextTitle').toArray()];
for (const el of anchors) {
if (results.length >= limit) break;
const href = $(el).attr('href');
if (!href) continue;
const id = extractLubimyczytacId(href);
const providerId = extractLubimyczytacPath(href);
if (!id || !providerId || seen.has(id)) continue;
seen.add(id);
results.push({ providerId, url: resolveUrl(href) });
}
return results;
}
export function parseLubimyczytacBookPage(html: string, sourceUrl?: string): LubimyczytacBookData {
const $ = cheerio.load(html);
const jsonLd = parseJsonLd($);
const canonicalPath = jsonLd.url ? extractLubimyczytacPath(jsonLd.url) : undefined;
const sourcePath = sourceUrl ? extractLubimyczytacPath(sourceUrl) : undefined;
const { isbn10, isbn13 } = extractIsbn($);
const series = extractSeries($);
return {
providerId: canonicalPath ?? sourcePath,
title: extractTitle($),
authors: jsonLd.authors?.length ? jsonLd.authors : extractAuthors($),
description: extractDescription($),
publisher: extractPublisher($),
publishedYear: jsonLd.publishedYear,
language: extractLanguage($) ?? (jsonLd.language ? mapLanguage(jsonLd.language) : undefined),
pageCount: jsonLd.pageCount,
isbn10,
isbn13,
seriesName: series.name,
seriesIndex: series.index,
genres: extractGenres($, jsonLd.genre),
coverUrl: extractCoverUrl($),
sourceUrl,
};
}
function extractTitle($: CheerioAPI): string | undefined {
return cleanText($('h1.book__title').first().text()) || undefined;
}
function extractAuthors($: CheerioAPI): string[] {
const authors: string[] = [];
const seen = new Set<string>();
$('a.link-name[href*="/autor/"], a[href*="/autor/"]').each((_, el) => {
const name = cleanText($(el).text());
if (!name || seen.has(name)) return;
seen.add(name);
authors.push(name);
});
return authors;
}
function extractDescription($: CheerioAPI): string | undefined {
// `.book__description` is the actual blurb; avoid `.collapse-content`, which the page also uses
// for user reviews and the ratings histogram.
return cleanText($('.book__description').first().text()) || cleanText($('meta[property="og:description"]').attr('content')) || undefined;
}
function extractPublisher($: CheerioAPI): string | undefined {
return cleanText($('a[href*="/wydawnictwo/"]').first().text()) || undefined;
}
function extractLanguage($: CheerioAPI): string | undefined {
let raw = '';
$('dt').each((_, el) => {
if (raw) return;
if (/język/i.test($(el).text())) raw = cleanText($(el).next('dd').text());
});
return raw ? mapLanguage(raw) : undefined;
}
function mapLanguage(raw: string): string {
const normalized = raw.trim().toLowerCase();
return LANGUAGE_MAP[normalized] ?? normalized;
}
function extractIsbn($: CheerioAPI): { isbn10?: string; isbn13?: string } {
const raw = cleanText($('meta[property="books:isbn"]').attr('content'));
const normalized = raw.replace(/[^0-9X]/gi, '').toUpperCase();
if (normalized.length === 13) return { isbn13: normalized };
if (normalized.length === 10) return { isbn10: normalized };
return {};
}
function extractSeries($: CheerioAPI): { name?: string; index?: number } {
let raw = '';
$('span.d-none.d-sm-block.mt-1').each((_, el) => {
if (raw) return;
const text = cleanText($(el).text());
if (text.startsWith('Cykl:')) raw = text;
});
if (!raw) return {};
const text = raw.slice('Cykl:'.length).trim();
// Preferred format: "Series Name (tom 4)"
const tomMatch = SERIES_INDEX_PATTERN.exec(text);
if (tomMatch) {
const index = parseFloat(tomMatch[1].replace(',', '.'));
return { name: text.replace(SERIES_INDEX_PATTERN, '').trim() || undefined, index: Number.isNaN(index) ? undefined : index };
}
// Fallback: "Series Name 4" (trailing standalone volume number)
const trailing = /\s(\d+(?:[.,]\d+)?)$/.exec(text);
if (trailing) {
const index = parseFloat(trailing[1].replace(',', '.'));
return { name: text.slice(0, trailing.index).trim() || undefined, index: Number.isNaN(index) ? undefined : index };
}
return { name: text || undefined };
}
function extractGenres($: CheerioAPI, jsonLdGenre?: string): string[] {
const genres: string[] = [];
const seen = new Set<string>();
const add = (value: string | undefined) => {
const genre = cleanText(value);
if (!genre || seen.has(genre) || genres.length >= MAX_GENRES) return;
seen.add(genre);
genres.push(genre);
};
// Category breadcrumb, e.g. "Literatura obyczajowa, romans" -> split into individual genres
$('a.book__category').each((_, el) => {
for (const part of $(el).text().split(',')) add(part);
});
// User tags (older layout / when present)
$('a[href*="/ksiazki/t/"]').each((_, el) => add($(el).text()));
if (!genres.length && jsonLdGenre) add(deslugify(jsonLdGenre));
return genres;
}
function extractCoverUrl($: CheerioAPI): string | undefined {
const raw = $('.book-cover img').first().attr('src') || $('meta[property="og:image"]').attr('content');
if (!raw) return undefined;
return resolveUrl(raw);
}
type JsonLdFields = {
authors?: string[];
publishedYear?: number;
pageCount?: number;
genre?: string;
language?: string;
url?: string;
};
function parseJsonLd($: CheerioAPI): JsonLdFields {
const fields: JsonLdFields = {};
$('script[type="application/ld+json"]').each((_, el) => {
const node = findBookNode(safeJsonParse($(el).contents().text()));
if (!node) return;
if (!fields.authors) {
const authors = parseJsonLdAuthors(node.author);
if (authors.length) fields.authors = authors;
}
if (fields.pageCount === undefined) {
const pages = parseInteger(node.numberOfPages);
if (pages !== undefined) fields.pageCount = pages;
}
if (fields.publishedYear === undefined) {
const year = parseYear(node.datePublished);
if (year !== undefined) fields.publishedYear = year;
}
if (!fields.genre && typeof node.genre === 'string') {
fields.genre = node.genre.split('/').filter(Boolean).pop();
}
if (!fields.language && typeof node.inLanguage === 'string') {
fields.language = node.inLanguage;
}
if (!fields.url && typeof node.url === 'string') {
fields.url = node.url;
}
});
return fields;
}
type JsonLdNode = {
author?: unknown;
numberOfPages?: unknown;
datePublished?: unknown;
genre?: unknown;
inLanguage?: unknown;
url?: unknown;
};
function findBookNode(value: unknown): JsonLdNode | undefined {
if (Array.isArray(value)) {
for (const item of value) {
const found = findBookNode(item);
if (found) return found;
}
return undefined;
}
if (typeof value !== 'object' || value === null) return undefined;
const node = value as Record<string, unknown>;
if ('@graph' in node) return findBookNode(node['@graph']);
const type = node['@type'];
if (type === 'Book' || type === 'Audiobook') return node as JsonLdNode;
if ('author' in node || 'numberOfPages' in node || 'datePublished' in node || 'genre' in node) return node as JsonLdNode;
return undefined;
}
function parseJsonLdAuthors(author: unknown): string[] {
const names: string[] = [];
const push = (entry: unknown) => {
if (typeof entry === 'string') {
const name = cleanText(entry);
if (name) names.push(name);
return;
}
if (typeof entry === 'object' && entry !== null && 'name' in entry) {
const rawName = (entry as { name?: unknown }).name;
if (typeof rawName === 'string') {
const name = cleanText(rawName);
if (name) names.push(name);
}
}
};
if (Array.isArray(author)) author.forEach(push);
else push(author);
return names;
}
function safeJsonParse(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return undefined;
}
}
function resolveUrl(value: string): string {
if (value.startsWith('//')) return `https:${value}`;
return new URL(value, LUBIMYCZYTAC_BASE_URL).toString();
}
function cleanText(value: string | undefined): string {
return (value ?? '').replace(/\s+/g, ' ').trim();
}
function deslugify(value: string): string {
return value.replace(/[-_]+/g, ' ').trim();
}
function parseInteger(value: unknown): number | undefined {
if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : undefined;
if (typeof value !== 'string') return undefined;
const match = value.replace(/,/g, '').match(/\d+/);
if (!match) return undefined;
const parsed = parseInt(match[0], 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
function parseYear(value: unknown): number | undefined {
if (typeof value !== 'string') return undefined;
const match = value.match(/\b(1[0-9]{3}|2[0-9]{3})\b/);
return match ? parseInt(match[1], 10) : undefined;
}
@@ -19,6 +19,7 @@ describe('OpenLibraryProvider', () => {
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
};
beforeEach(() => {
@@ -10,6 +10,7 @@ export const PROVIDER_LIMITS = {
COMICVINE_MAX_RESULTS: 10,
RANOBEDB_MAX_RESULTS: 5,
KOBO_MAX_RESULTS: 3,
LUBIMYCZYTAC_MAX_RESULTS: 5,
DEFAULT_SEARCH_RESULTS: 10,
} as const;
@@ -20,4 +21,5 @@ export const PROVIDER_DELAYS_MS = {
COMICVINE_VELOCITY_GUARD: 1_000,
RANOBEDB_BETWEEN_REQUESTS: 500,
KOBO_BETWEEN_REQUESTS: 700,
LUBIMYCZYTAC_BETWEEN_REQUESTS: 600,
} as const;
@@ -19,6 +19,7 @@ const enabledConfig: ProviderConfigurations = {
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: true },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
};
const disabledConfig: ProviderConfigurations = {
@@ -55,4 +55,5 @@ export class UpdateProviderConfigDto {
@IsOptional() @ValidateNested() @Type(() => ComicVineProviderConfigDto) comicvine?: ComicVineProviderConfigDto;
@IsOptional() @ValidateNested() @Type(() => SimpleProviderConfigDto) ranobedb?: SimpleProviderConfigDto;
@IsOptional() @ValidateNested() @Type(() => KoboProviderConfigDto) kobo?: KoboProviderConfigDto;
@IsOptional() @ValidateNested() @Type(() => SimpleProviderConfigDto) lubimyczytac?: SimpleProviderConfigDto;
}
@@ -74,6 +74,7 @@ describe('ProviderConfigService', () => {
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
});
});
@@ -356,6 +357,7 @@ describe('ProviderConfigService', () => {
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
});
expect(statuses.map((s) => s.key)).toEqual([
@@ -370,6 +372,7 @@ describe('ProviderConfigService', () => {
MetadataProviderKey.COMICVINE,
MetadataProviderKey.RANOBEDB,
MetadataProviderKey.KOBO,
MetadataProviderKey.LUBIMYCZYTAC,
]);
expect(statuses.find((s) => s.key === MetadataProviderKey.GOOGLE)?.configured).toBe(false);
expect(statuses.find((s) => s.key === MetadataProviderKey.GOOGLE)?.hint).toContain('API key required');
@@ -394,6 +397,7 @@ describe('ProviderConfigService', () => {
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
});
expect(statuses.find((s) => s.key === MetadataProviderKey.HARDCOVER)?.configured).toBe(true);
@@ -44,6 +44,7 @@ const DEFAULT_CONFIG: ProviderConfigurations = {
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
};
function asObject(value: unknown): Record<string, unknown> {
@@ -137,6 +138,7 @@ const PROVIDER_LABELS: Record<MetadataProviderKey, string> = {
[MetadataProviderKey.COMICVINE]: 'ComicVine',
[MetadataProviderKey.RANOBEDB]: 'RanobeDB',
[MetadataProviderKey.KOBO]: 'Kobo',
[MetadataProviderKey.LUBIMYCZYTAC]: 'LubimyCzytac',
};
type ProviderEnableRule = {
@@ -182,6 +184,7 @@ export class ProviderConfigService {
comicvine: { ...DEFAULT_CONFIG.comicvine },
ranobedb: { ...DEFAULT_CONFIG.ranobedb },
kobo: { ...DEFAULT_CONFIG.kobo },
lubimyczytac: { ...DEFAULT_CONFIG.lubimyczytac },
};
}
@@ -199,6 +202,7 @@ export class ProviderConfigService {
comicvine: mergeComicVineConfig(base.comicvine, next.comicvine),
ranobedb: mergeSimpleConfig(base.ranobedb, next.ranobedb),
kobo: mergeKoboConfig(base.kobo, next.kobo),
lubimyczytac: mergeSimpleConfig(base.lubimyczytac, next.lubimyczytac),
};
}
@@ -426,6 +430,13 @@ export class ProviderConfigService {
configured: true,
hint: 'Web scraping may be blocked by Kobo bot protection',
},
{
key: MetadataProviderKey.LUBIMYCZYTAC,
label: PROVIDER_LABELS[MetadataProviderKey.LUBIMYCZYTAC],
enabled: cfg.lubimyczytac.enabled,
configured: true,
hint: 'Polish book catalog (lubimyczytac.pl). Scrapes public pages.',
},
];
}
@@ -48,6 +48,7 @@ export class ComicFormatExtractor implements FormatExtractor {
openLibraryId: comicMetadata?.openLibraryId ?? null,
ranobedbId: comicMetadata?.ranobedbId ?? null,
koboId: comicMetadata?.koboId ?? null,
lubimyczytacId: comicMetadata?.lubimyczytacId ?? null,
itunesId: comicMetadata?.itunesId ?? null,
cover: cover ?? null,
comicMetadata: comicMetadata?.comicMetadata ?? null,
@@ -22,6 +22,7 @@ export interface ParsedBookData {
openLibraryId?: string | null;
ranobedbId?: string | null;
koboId?: string | null;
lubimyczytacId?: string | null;
itunesId?: string | null;
audibleId?: string | null;
cover: Buffer | null;
@@ -25,6 +25,7 @@ export function hasOpfMetadata(metadata: ParsedOpf): boolean {
metadata.openLibraryId !== null ||
metadata.ranobedbId !== null ||
metadata.koboId !== null ||
metadata.lubimyczytacId !== null ||
metadata.itunesId !== null
);
}
@@ -53,6 +54,7 @@ export function mapOpfMetadata(metadata: ParsedOpf, cover: Buffer | null): Parse
openLibraryId: metadata.openLibraryId,
ranobedbId: metadata.ranobedbId,
koboId: metadata.koboId,
lubimyczytacId: metadata.lubimyczytacId,
itunesId: metadata.itunesId,
cover,
};
@@ -37,6 +37,7 @@ export class PdfFormatExtractor implements FormatExtractor {
openLibraryId: pdf.openLibraryId,
ranobedbId: pdf.ranobedbId,
koboId: pdf.koboId,
lubimyczytacId: pdf.lubimyczytacId,
itunesId: pdf.itunesId,
cover: pdf.coverBuffer,
pageCount: pdf.pageCount,
@@ -46,6 +46,7 @@ export interface ParsedCbzMetadata {
openLibraryId: string | null;
ranobedbId: string | null;
koboId: string | null;
lubimyczytacId: string | null;
itunesId: string | null;
comicMetadata: ParsedCbzComicMetadata | null;
}
@@ -267,6 +268,7 @@ function parseComicInfoXml(xmlBuf: Buffer): ParsedCbzMetadata | null {
openLibraryId: managedNotes.get('openLibraryId') ?? providerIdsFromWeb.openLibraryId ?? null,
ranobedbId: managedNotes.get('ranobedbId') ?? null,
koboId: managedNotes.get('koboId') ?? providerIdsFromWeb.koboId ?? null,
lubimyczytacId: managedNotes.get('lubimyczytacId') ?? null,
itunesId: null,
comicMetadata: hasComicFields
? {
@@ -324,6 +326,7 @@ function parseComicBookInfoJson(comment: string): ParsedCbzMetadata | null {
openLibraryId: null,
ranobedbId: null,
koboId: null,
lubimyczytacId: null,
itunesId: null,
comicMetadata: null,
};
@@ -23,6 +23,7 @@ export interface ParsedOpf {
openLibraryId: string | null;
ranobedbId: string | null;
koboId: string | null;
lubimyczytacId: string | null;
itunesId: string | null;
coverHref: string | null;
}
@@ -208,6 +209,7 @@ export function parseOpf(xml: string): ParsedOpf {
let schemeOpenLibraryId: string | null = null;
let schemeRanobedbId: string | null = null;
let schemeKoboId: string | null = null;
let schemeLubimyczytacId: string | null = null;
let schemeItunesId: string | null = null;
let urnGoogleBooksId: string | null = null;
@@ -217,6 +219,7 @@ export function parseOpf(xml: string): ParsedOpf {
let urnOpenLibraryId: string | null = null;
let urnRanobedbId: string | null = null;
let urnKoboId: string | null = null;
let urnLubimyczytacId: string | null = null;
let urnItunesId: string | null = null;
for (const ident of toArray(metadata['identifier'])) {
@@ -240,6 +243,7 @@ export function parseOpf(xml: string): ParsedOpf {
if (scheme === 'openlibrary') schemeOpenLibraryId ??= value || null;
if (scheme === 'ranobedb') schemeRanobedbId ??= value || null;
if (scheme === 'kobo') schemeKoboId ??= value || null;
if (scheme === 'lubimyczytac') schemeLubimyczytacId ??= value || null;
if (scheme === 'itunes') schemeItunesId ??= value || null;
// urn:-prefixed provider identifiers (legacy / backward-compat)
@@ -250,6 +254,7 @@ export function parseOpf(xml: string): ParsedOpf {
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:lubimyczytac:')) urnLubimyczytacId ??= value.slice('urn:lubimyczytac:'.length) || null;
if (value.startsWith('urn:itunes:')) urnItunesId ??= value.slice('urn:itunes:'.length) || null;
}
@@ -261,6 +266,7 @@ export function parseOpf(xml: string): ParsedOpf {
const openLibraryId = schemeOpenLibraryId ?? urnOpenLibraryId;
const ranobedbId = schemeRanobedbId ?? urnRanobedbId;
const koboId = schemeKoboId ?? urnKoboId;
const lubimyczytacId = schemeLubimyczytacId ?? urnLubimyczytacId;
const itunesId = schemeItunesId ?? urnItunesId;
isbn10 ??= propertyMeta('bookorbit:isbn10') ?? namedMeta('bookorbit:isbn10');
@@ -381,6 +387,7 @@ export function parseOpf(xml: string): ParsedOpf {
openLibraryId,
ranobedbId,
koboId,
lubimyczytacId,
itunesId,
coverHref,
};
@@ -29,6 +29,7 @@ export interface PdfParsed {
openLibraryId: string | null;
ranobedbId: string | null;
koboId: string | null;
lubimyczytacId: string | null;
itunesId: string | null;
coverBuffer: Buffer | null;
}
@@ -140,6 +141,7 @@ export async function parsePdfFile(absolutePath: string, options: PdfParseOption
openLibraryId: xmp?.openLibraryId ?? null,
ranobedbId: xmp?.ranobedbId ?? null,
koboId: xmp?.koboId ?? null,
lubimyczytacId: xmp?.lubimyczytacId ?? null,
itunesId: xmp?.itunesId ?? null,
coverBuffer,
};
@@ -26,6 +26,7 @@ export interface XmpParsed {
openLibraryId: string | null;
ranobedbId: string | null;
koboId: string | null;
lubimyczytacId: string | null;
itunesId: string | null;
}
@@ -159,6 +160,7 @@ export function parseXmp(xmpXml: string): XmpParsed | null {
openLibraryId: str(merged[`${px}:openLibraryId`]),
ranobedbId: str(merged[`${px}:ranobedbId`]),
koboId: str(merged[`${px}:koboId`]),
lubimyczytacId: str(merged[`${px}:lubimyczytacId`]),
itunesId: str(merged[`${px}:itunesId`]),
};
}
@@ -578,6 +578,7 @@ export class MetadataService {
openLibraryId: data.openLibraryId,
ranobedbId: data.ranobedbId,
koboId: data.koboId,
lubimyczytacId: data.lubimyczytacId,
itunesId: data.itunesId,
comicMetadata: data.comicMetadata ?? undefined,
});
@@ -602,6 +603,7 @@ export class MetadataService {
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.lubimyczytacId !== undefined) scalarFields.lubimyczytacId = filtered.lubimyczytacId;
if (filtered.itunesId !== undefined) scalarFields.itunesId = filtered.itunesId;
if (Object.keys(scalarFields).length > 0) {
scalarFields.updatedAt = new Date();
@@ -737,7 +737,8 @@ export class StatisticsRepository {
${bookMetadata.audibleId} is not null or
${bookMetadata.koboId} is not null or
${bookMetadata.comicvineId} is not null or
${bookMetadata.ranobedbId} is not null
${bookMetadata.ranobedbId} is not null or
${bookMetadata.lubimyczytacId} is not null
)`;
const [row] = await this.db