feat(metadata): add Aladin Korean book metadata provider (#351)

* feat(metadata): add Aladin Korean book metadata provider

Add support for fetching Korean book metadata from Aladin OpenAPI.
Includes search by title/author/ISBN and lookup by ISBN-10/13.

- Add ALADIN to MetadataProviderKey in @bookorbit/types
- Add AladinProviderConfig to ProviderConfigurations
- Create AladinProvider with IdentifiableProvider interface
- Add mapper for Aladin API response to MetadataCandidate
- Register provider in MetadataFetchModule
- Add provider config merge, validation, and connection test
- Include 24 unit tests (provider + mapper)

* feat(client): add Aladin provider UI integration

* refactor(metadata): use 'Aladin' label and favicon-based icon

* feat(metadata): complete Aladin provider end-to-end wiring

Persist the Aladin ItemId across fetch, scoring, locks, and file metadata round-trip, and update the affected tests.

Co-authored-by: Kyungwook Park <288273+kyungw00k@users.noreply.github.com>

---------

Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
Co-authored-by: Kyungwook Park <288273+kyungw00k@users.noreply.github.com>
This commit is contained in:
Kyungwook Park
2026-06-18 04:33:35 +09:00
committed by GitHub
parent 9afddc34d2
commit 6677f2f5ea
63 changed files with 11843 additions and 12 deletions
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><title>Aladin</title><image width="48" height="48" href="data:image/png;base64,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"/></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -154,6 +154,15 @@ const providerLinks = computed<ProviderLink[]>(() => {
fallback: 'LC',
})
}
if (ids.aladin) {
out.push({
key: 'aladin',
label: 'Aladin',
url: `https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=${ids.aladin}`,
iconUrl: providerIconPath('aladin'),
fallback: '알',
})
}
return out
})
@@ -612,6 +612,15 @@ const providerLinks = computed<ProviderLink[]>(() => {
fallback: 'LC',
})
}
if (ids.aladin) {
out.push({
key: 'aladin',
label: 'Aladin',
url: `https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=${ids.aladin}`,
iconUrl: providerIconPath('aladin'),
fallback: '알',
})
}
return out
})
@@ -75,6 +75,7 @@ const DIRECT_PATCH_FIELDS = [
'comicvineId',
'ranobedbId',
'lubimyczytacId',
'aladinId',
] as const
const COMIC_FIELD_MAP = {
@@ -173,6 +174,7 @@ const providerIdFields = [
{ field: 'comicvineId' as const, label: 'ComicVine' },
{ field: 'ranobedbId' as const, label: 'RanobeDB' },
{ field: 'lubimyczytacId' as const, label: 'LubimyCzytac' },
{ field: 'aladinId' as const, label: 'Aladin' },
]
function setIntField(field: 'publishedYear' | 'pageCount' | 'durationSeconds', e: Event) {
@@ -463,6 +465,7 @@ function buildPreviewPatch(preview: MetadataRefreshPreview): MetadataPatch {
comicvineId: preview.comicvineId,
ranobedbId: preview.ranobedbId,
lubimyczytacId: preview.lubimyczytacId,
aladinId: preview.aladinId,
comicMetadata: preview.comicMetadata,
narrators: preview.audioMetadata?.narrators,
durationSeconds: preview.audioMetadata?.durationSeconds ?? undefined,
@@ -25,6 +25,7 @@ export interface FileMetadata {
comicvineId?: string | null
ranobedbId?: string | null
lubimyczytacId?: string | null
aladinId?: string | null
authors?: string[]
genres?: string[]
narrators?: string[]
@@ -97,6 +97,7 @@ export interface MetadataPatch {
comicvineId?: string | null
ranobedbId?: string | null
lubimyczytacId?: string | null
aladinId?: string | null
comicMetadata?: ComicMetadataFields
}
@@ -160,6 +161,7 @@ export type ProviderIdPatchField =
| 'comicvineId'
| 'ranobedbId'
| 'lubimyczytacId'
| 'aladinId'
export const PROVIDER_ID_FIELD: Record<MetadataProviderKey, ProviderIdPatchField | undefined> = {
google: 'googleBooksId',
@@ -174,6 +176,7 @@ export const PROVIDER_ID_FIELD: Record<MetadataProviderKey, ProviderIdPatchField
ranobedb: 'ranobedbId',
kobo: 'koboId',
lubimyczytac: 'lubimyczytacId',
aladin: 'aladinId',
}
const PROVIDER_ID_PATCH_FIELDS = new Set<string>(Object.values(PROVIDER_ID_FIELD).filter((v): v is ProviderIdPatchField => v !== undefined))
@@ -195,6 +198,7 @@ export const PROVIDER_ID_LABEL: Record<MetadataProviderKey, string> = {
ranobedb: 'RanobeDB ID',
kobo: 'Kobo ID',
lubimyczytac: 'LubimyCzytac ID',
aladin: 'Aladin ID',
}
export function getCandidateValueFrom(candidate: MetadataCandidate, key: DiffFieldKey): string {
@@ -32,6 +32,7 @@ const ROOT_FIELDS = [
'comicvineId',
'ranobedbId',
'lubimyczytacId',
'aladinId',
] as const
const COMIC_FIELDS = {
@@ -115,6 +116,7 @@ export function useMetadataEditor() {
comicvineId: null as string | null,
ranobedbId: null as string | null,
lubimyczytacId: null as string | null,
aladinId: null as string | null,
comicIssueNumber: null as string | null,
comicVolumeName: null as string | null,
comicStoryArcs: [] as string[],
@@ -164,6 +166,7 @@ export function useMetadataEditor() {
form.comicvineId = book.providerIds.comicvine ?? null
form.ranobedbId = book.providerIds.ranobedb ?? null
form.lubimyczytacId = book.providerIds.lubimyczytac ?? null
form.aladinId = book.providerIds.aladin ?? null
const cm = book.comicMetadata
form.comicIssueNumber = cm?.issueNumber ?? null
form.comicVolumeName = cm?.volumeName ?? null
@@ -96,6 +96,7 @@ export function useMetadataSearch() {
'itunes',
'kobo',
'openLibrary',
'aladin',
]
function sortResults(list: MetadataCandidate[]): MetadataCandidate[] {
@@ -24,6 +24,7 @@ const FILE_METADATA_PATCH_FIELDS = [
'comicvineId',
'ranobedbId',
'lubimyczytacId',
'aladinId',
'authors',
'genres',
'narrators',
@@ -18,6 +18,7 @@ const PROVIDER_LABELS: Record<MetadataProviderKeyType, string> = {
[MetadataProviderKey.RANOBEDB]: 'RanobeDB',
[MetadataProviderKey.KOBO]: 'Kobo',
[MetadataProviderKey.LUBIMYCZYTAC]: 'LubimyCzytac',
[MetadataProviderKey.ALADIN]: 'Aladin',
}
export function metadataRefreshEmptyMessage(diagnostics: MetadataFetchDiagnostics, book: BookDetail): string {
@@ -11,6 +11,7 @@ const PROVIDER_ICON_PATHS = {
kobo: `${PROVIDER_ICON_BASE_PATH}/kobo.svg`,
ranobedb: `${PROVIDER_ICON_BASE_PATH}/ranobedb.svg`,
lubimyczytac: `${PROVIDER_ICON_BASE_PATH}/lubimyczytac.svg`,
aladin: `${PROVIDER_ICON_BASE_PATH}/aladin.svg`,
} as const
type ProviderIconKey = keyof typeof PROVIDER_ICON_PATHS
@@ -64,6 +64,8 @@ function isFieldFilled(field: MetadataScoreField): boolean {
return !!b.providerIds.openLibrary?.trim()
case 'itunesId':
return !!b.providerIds.itunes?.trim()
case 'aladinId':
return !!b.providerIds.aladin?.trim()
default:
return false
}
@@ -204,9 +204,19 @@ const rows: RowDef[] = [
hint: 'Polish book catalog (lubimyczytac.pl). Scrapes public book pages. No setup required.',
fields: [],
},
{
key: 'aladin',
label: 'Aladin',
hint: 'Korean book metadata from Aladin (알라딘). A TTB Key from aladin.co.kr is required.',
fields: [{ key: 'ttbKey', label: 'TTB Key', type: 'password', placeholder: 'ttb...', alwaysEditable: true }],
enableRequirement: {
isConfigured: (c) => !!c.aladin.ttbKey.trim(),
blockedMessage: 'Aladin requires a TTB Key before it can be enabled',
},
},
]
const TESTABLE_PROVIDERS: MetadataProviderKey[] = [MetadataProviderKey.AMAZON, MetadataProviderKey.HARDCOVER]
const TESTABLE_PROVIDERS: MetadataProviderKey[] = [MetadataProviderKey.AMAZON, MetadataProviderKey.HARDCOVER, MetadataProviderKey.ALADIN]
function statusFor(key: string) {
return props.statuses.find((s) => s.key === key)
@@ -32,6 +32,7 @@ describe('getProviderColor', () => {
'comicvine',
'ranobedb',
'kobo',
'aladin',
'auto',
]
for (const provider of providers) {
@@ -46,7 +47,20 @@ describe('PROVIDER_SHORT_LABELS', () => {
})
it('has labels for all providers', () => {
const providers = ['google', 'amazon', 'goodreads', 'hardcover', 'openLibrary', 'itunes', 'audible', 'audnexus', 'comicvine', 'ranobedb', 'kobo']
const providers = [
'google',
'amazon',
'goodreads',
'hardcover',
'openLibrary',
'itunes',
'audible',
'audnexus',
'comicvine',
'ranobedb',
'kobo',
'aladin',
]
for (const provider of providers) {
expect(PROVIDER_SHORT_LABELS[provider]).toBeTruthy()
}
+2
View File
@@ -13,6 +13,7 @@ const PROVIDER_HEX: Record<string, string> = {
ranobedb: '#a78cff',
kobo: '#e23434',
lubimyczytac: '#f47373',
aladin: '#3E7FFF',
auto: '#8B5CF6',
}
@@ -31,6 +32,7 @@ export const PROVIDER_SHORT_LABELS: Record<string, string> = {
ranobedb: 'RanobeDB',
kobo: 'Kobo',
lubimyczytac: 'LubimyCzytac',
aladin: 'Aladin',
auto: 'Fetched',
}
+2 -1
View File
@@ -85,7 +85,8 @@
"@esbuild-kit/core-utils>esbuild": "^0.25.12",
"fastify": "^5.8.5",
"fast-uri": "^3.1.1",
"fast-xml-builder": "^1.1.7"
"fast-xml-builder": "^1.1.7",
"ws": "^8.21.0"
},
"onlyBuiltDependencies": [
"@nestjs/core",
+1
View File
@@ -177,6 +177,7 @@ export type BookMetadataRefreshPreviewFields = {
comicvineId?: string | null;
ranobedbId?: string | null;
lubimyczytacId?: string | null;
aladinId?: string | null;
audioMetadata?: {
narrators?: string[];
durationSeconds?: number | null;
+2
View File
@@ -25,6 +25,7 @@ export const COMMON_PROVIDER_BOOK_FILE_WRITE_FIELDS = [
"ranobedbId",
"koboId",
"lubimyczytacId",
"aladinId",
] as const;
export const COMIC_BOOK_FILE_WRITE_FIELDS = [
@@ -77,6 +78,7 @@ export const BOOK_FILE_WRITE_FIELD_LABELS = {
ranobedbId: "RanobeDB ID",
koboId: "Kobo ID",
lubimyczytacId: "LubimyCzytac ID",
aladinId: "Aladin ID",
comicIssueNumber: "Issue number",
comicVolumeName: "Volume",
comicPencillers: "Pencillers",
+1
View File
@@ -13,6 +13,7 @@ export const MetadataProviderKey = {
RANOBEDB: "ranobedb",
KOBO: "kobo",
LUBIMYCZYTAC: "lubimyczytac",
ALADIN: "aladin",
} as const;
export interface ComicMetadataFields {
+2
View File
@@ -28,6 +28,7 @@ export const BOOK_METADATA_LOCK_FIELDS = [
"comicvineId",
"ranobedbId",
"lubimyczytacId",
"aladinId",
"comicIssueNumber",
"comicVolumeName",
"comicStoryArcs",
@@ -88,6 +89,7 @@ export const BOOK_METADATA_LOCK_GROUPS: BookMetadataLockGroup[] = [
"comicvineId",
"ranobedbId",
"lubimyczytacId",
"aladinId",
],
},
{
@@ -115,6 +115,11 @@ export interface KoboProviderConfig {
language: string;
}
export interface AladinProviderConfig {
enabled: boolean;
ttbKey: string;
}
export interface ProviderConfigurations {
google: GoogleProviderConfig;
amazon: AmazonProviderConfig;
@@ -128,6 +133,7 @@ export interface ProviderConfigurations {
ranobedb: SimpleProviderConfig;
kobo: KoboProviderConfig;
lubimyczytac: SimpleProviderConfig;
aladin: AladinProviderConfig;
}
export interface ProviderStatus {
+3 -1
View File
@@ -21,7 +21,8 @@ export type MetadataScoreField =
| "hardcoverId"
| "openLibraryId"
| "itunesId"
| "koboId";
| "koboId"
| "aladinId";
export type MetadataScoreGroup = "core" | "publishing" | "classification" | "enrichment" | "providers";
@@ -55,6 +56,7 @@ export const METADATA_SCORE_FIELDS: Record<MetadataScoreField, MetadataScoreFiel
openLibraryId: { group: "providers", label: "Open Library ID", defaultWeight: 1 },
itunesId: { group: "providers", label: "iTunes ID", defaultWeight: 1 },
koboId: { group: "providers", label: "Kobo ID", defaultWeight: 1 },
aladinId: { group: "providers", label: "Aladin ID", defaultWeight: 1 },
};
export const METADATA_SCORE_GROUP_LABELS: Record<MetadataScoreGroup, string> = {
+7 -6
View File
@@ -9,6 +9,7 @@ overrides:
fastify: ^5.8.5
fast-uri: ^3.1.1
fast-xml-builder: ^1.1.7
ws: ^8.21.0
importers:
@@ -9204,8 +9205,8 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -14438,7 +14439,7 @@ snapshots:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3
engine.io-parser: 5.2.3
ws: 8.18.3
ws: 8.21.0
xmlhttprequest-ssl: 2.1.2
transitivePeerDependencies:
- bufferutil
@@ -14458,7 +14459,7 @@ snapshots:
cors: 2.8.6
debug: 4.4.3
engine.io-parser: 5.2.3
ws: 8.18.3
ws: 8.21.0
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -17139,7 +17140,7 @@ snapshots:
socket.io-adapter@2.5.6:
dependencies:
debug: 4.4.3
ws: 8.18.3
ws: 8.21.0
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -18339,7 +18340,7 @@ snapshots:
wrappy@1.0.2: {}
ws@8.18.3: {}
ws@8.21.0: {}
wsl-utils@0.1.0:
dependencies:
@@ -0,0 +1 @@
ALTER TABLE "book_metadata" ADD COLUMN "aladin_id" varchar(20);
File diff suppressed because it is too large Load Diff
@@ -155,6 +155,13 @@
"when": 1781675709588,
"tag": "0021_add_book_series_memberships",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1781716753204,
"tag": "0022_add_aladin_id",
"breakpoints": true
}
]
}
+1
View File
@@ -69,6 +69,7 @@ export const bookMetadata = pgTable(
comicvineId: varchar('comicvine_id', { length: 50 }),
ranobedbId: varchar('ranobedb_id', { length: 50 }),
lubimyczytacId: text('lubimyczytac_id'),
aladinId: varchar('aladin_id', { length: 20 }),
chapters: jsonb('chapters'),
lockedFields: text('locked_fields')
.array()
@@ -292,6 +292,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
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];
if (filteredProviderIds[MetadataProviderKey.ALADIN]) scalarFields.aladinId = filteredProviderIds[MetadataProviderKey.ALADIN];
const duration = this.asNullableNumber(filteredResolved.duration);
if (duration !== undefined) scalarFields.durationSeconds = duration;
@@ -382,6 +383,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
comicvineId?: string | null;
ranobedbId?: string | null;
lubimyczytacId?: string | null;
aladinId?: string | null;
}): Partial<Record<MetadataProviderKey, string>> {
const ids: Partial<Record<MetadataProviderKey, string>> = {};
if (meta.googleBooksId) ids[MetadataProviderKey.GOOGLE] = meta.googleBooksId;
@@ -395,6 +397,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
if (meta.comicvineId) ids[MetadataProviderKey.COMICVINE] = meta.comicvineId;
if (meta.ranobedbId) ids[MetadataProviderKey.RANOBEDB] = meta.ranobedbId;
if (meta.lubimyczytacId) ids[MetadataProviderKey.LUBIMYCZYTAC] = meta.lubimyczytacId;
if (meta.aladinId) ids[MetadataProviderKey.ALADIN] = meta.aladinId;
return ids;
}
@@ -20,6 +20,7 @@ const PROVIDER_ID_LOCK_FIELDS_BY_PROVIDER: Partial<Record<MetadataProviderKey, B
[MetadataProviderKey.COMICVINE]: 'comicvineId',
[MetadataProviderKey.RANOBEDB]: 'ranobedbId',
[MetadataProviderKey.LUBIMYCZYTAC]: 'lubimyczytacId',
[MetadataProviderKey.ALADIN]: 'aladinId',
};
const COMIC_METADATA_LOCK_FIELDS: Record<keyof ComicMetadataFields, BookMetadataLockField> = {
@@ -248,6 +249,7 @@ export class BookMetadataLockService {
this.copyUpdateField(filteredDto, dto, 'comicvineId', 'comicvineId', lockedSet, skippedFields);
this.copyUpdateField(filteredDto, dto, 'ranobedbId', 'ranobedbId', lockedSet, skippedFields);
this.copyUpdateField(filteredDto, dto, 'lubimyczytacId', 'lubimyczytacId', lockedSet, skippedFields);
this.copyUpdateField(filteredDto, dto, 'aladinId', 'aladinId', lockedSet, skippedFields);
if (dto.audioMetadata) {
const filteredAudioMetadata: NonNullable<UpdateBookMetadataDto['audioMetadata']> = {};
@@ -378,6 +380,7 @@ export class BookMetadataLockService {
this.addFieldIfPresent(fields, dto, 'comicvineId', 'comicvineId');
this.addFieldIfPresent(fields, dto, 'ranobedbId', 'ranobedbId');
this.addFieldIfPresent(fields, dto, 'lubimyczytacId', 'lubimyczytacId');
this.addFieldIfPresent(fields, dto, 'aladinId', 'aladinId');
if (dto.audioMetadata) {
this.addFieldIfPresent(fields, dto.audioMetadata, 'narrators', 'narrators');
+9
View File
@@ -278,6 +278,7 @@ export class BookService {
comicvineId?: string | null;
ranobedbId?: string | null;
lubimyczytacId?: string | null;
aladinId?: string | null;
}): Partial<Record<MetadataProviderKey, string>> {
const providerIds: Partial<Record<MetadataProviderKey, string>> = {};
if (meta.googleBooksId) providerIds[MetadataProviderKey.GOOGLE] = meta.googleBooksId;
@@ -291,6 +292,7 @@ export class BookService {
if (meta.comicvineId) providerIds[MetadataProviderKey.COMICVINE] = meta.comicvineId;
if (meta.ranobedbId) providerIds[MetadataProviderKey.RANOBEDB] = meta.ranobedbId;
if (meta.lubimyczytacId) providerIds[MetadataProviderKey.LUBIMYCZYTAC] = meta.lubimyczytacId;
if (meta.aladinId) providerIds[MetadataProviderKey.ALADIN] = meta.aladinId;
return providerIds;
}
@@ -308,6 +310,7 @@ export class BookService {
| 'comicvineId'
| 'ranobedbId'
| 'lubimyczytacId'
| 'aladinId'
>,
providerIds: Partial<Record<MetadataProviderKey, string>>,
): void {
@@ -322,6 +325,7 @@ export class BookService {
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];
if (providerIds[MetadataProviderKey.ALADIN]) dto.aladinId = providerIds[MetadataProviderKey.ALADIN];
}
private buildMetadataRefreshPreview(
@@ -1447,6 +1451,7 @@ export class BookService {
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.aladinId !== undefined) scalarFields.aladinId = dto.aladinId ?? null;
if (dto.rating !== undefined) scalarFields.rating = dto.rating ?? null;
if (dto.audioMetadata) {
if (dto.audioMetadata.durationSeconds !== undefined) scalarFields.durationSeconds = dto.audioMetadata.durationSeconds ?? null;
@@ -2689,6 +2694,7 @@ export class BookService {
[MetadataProviderKey.COMICVINE]: meta?.comicvineId ?? null,
[MetadataProviderKey.RANOBEDB]: meta?.ranobedbId ?? null,
[MetadataProviderKey.LUBIMYCZYTAC]: meta?.lubimyczytacId ?? null,
[MetadataProviderKey.ALADIN]: meta?.aladinId ?? null,
},
authors: authorRows,
genres: genreRows.map((g) => g.name),
@@ -2797,6 +2803,7 @@ export class BookService {
koboId: parsed.koboId,
ranobedbId: parsed.ranobedbId,
lubimyczytacId: parsed.lubimyczytacId,
aladinId: parsed.aladinId,
authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined,
genres: parsed.genres.length > 0 ? parsed.genres : undefined,
};
@@ -2828,6 +2835,7 @@ export class BookService {
koboId: parsed.koboId,
ranobedbId: parsed.ranobedbId,
lubimyczytacId: parsed.lubimyczytacId,
aladinId: parsed.aladinId,
authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined,
genres: parsed.genres.length > 0 ? parsed.genres : undefined,
};
@@ -2877,6 +2885,7 @@ export class BookService {
koboId: parsed.koboId,
ranobedbId: parsed.ranobedbId,
lubimyczytacId: parsed.lubimyczytacId,
aladinId: parsed.aladinId,
authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined,
genres: cbzGenres.length > 0 ? cbzGenres : undefined,
comicMetadata: parsed.comicMetadata ?? undefined,
@@ -174,6 +174,12 @@ describe('Book DTO validation', () => {
expect((await errorsFor(UpdateBookMetadataDto, { isbn10: '12345678901' })).length).toBeGreaterThan(0);
});
it('accepts aladinId and enforces its length bound', async () => {
expect((await errorsFor(UpdateBookMetadataDto, { aladinId: '12345' })).length).toBe(0);
expect((await errorsFor(UpdateBookMetadataDto, { aladinId: null })).length).toBe(0);
expect((await errorsFor(UpdateBookMetadataDto, { aladinId: 'a'.repeat(21) })).length).toBeGreaterThan(0);
});
it('normalizes a zero page count to null and validates other page count values', async () => {
// A provider can return a page count of 0 (issue #329); it must normalize to null instead of failing validation.
const zeroed = plainToInstance(UpdateBookMetadataDto, { pageCount: 0 });
@@ -63,5 +63,6 @@ export class UpdateBookMetadataDto {
@IsOptional() @IsString() @MaxLength(50) comicvineId?: string | null;
@IsOptional() @IsString() @MaxLength(50) ranobedbId?: string | null;
@IsOptional() @IsString() @MaxLength(512) lubimyczytacId?: string | null;
@IsOptional() @IsString() @MaxLength(20) aladinId?: string | null;
@IsOptional() @ValidateNested() @Type(() => ComicMetadataDto) comicMetadata?: ComicMetadataDto;
}
@@ -39,6 +39,7 @@ export const COMIC_INFO_MANAGED_NOTES_KEYS = [
'ranobedbId',
'koboId',
'lubimyczytacId',
'aladinId',
] as const satisfies readonly BookWritePayloadKey[];
type ComicInfoProviderKey = (typeof COMIC_INFO_PROVIDER_ID_KEYS)[number];
@@ -61,5 +62,6 @@ export const EPUB_PROVIDER_IDENTIFIER_SCHEMES = {
ranobedbId: 'RANOBEDB',
koboId: 'KOBO',
lubimyczytacId: 'LUBIMYCZYTAC',
aladinId: 'ALADIN',
itunesId: 'ITUNES',
} as const satisfies Partial<Record<BookWritePayloadKey, string>>;
@@ -186,6 +186,7 @@ export class FileWriteRepository {
ranobedbId: meta.ranobedbId,
koboId: meta.koboId,
lubimyczytacId: meta.lubimyczytacId,
aladinId: meta.aladinId,
itunesId: meta.itunesId,
audibleId: meta.audibleId,
rating: meta.rating,
@@ -24,6 +24,7 @@ export interface BookWritePayload {
ranobedbId?: string | null;
koboId?: string | null;
lubimyczytacId?: string | null;
aladinId?: string | null;
comicIssueNumber?: string | null;
comicVolumeName?: string | null;
comicPencillers?: string[];
@@ -20,6 +20,7 @@ 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';
import { AladinProvider } from './providers/aladin/aladin.provider';
describe('MetadataFetchModule', () => {
it('registers provider classes and factory wiring for METADATA_PROVIDERS', () => {
@@ -39,6 +40,7 @@ describe('MetadataFetchModule', () => {
RanobeDbProvider,
KoboProvider,
LubimyczytacProvider,
AladinProvider,
ProviderRegistry,
MetadataFetchRepository,
MetadataFetchService,
@@ -65,6 +67,7 @@ describe('MetadataFetchModule', () => {
RanobeDbProvider,
KoboProvider,
LubimyczytacProvider,
AladinProvider,
]);
const google = { key: 'google' };
@@ -24,6 +24,7 @@ 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';
import { AladinProvider } from './providers/aladin/aladin.provider';
const PROVIDER_CLASSES = [
GoogleProvider,
@@ -38,6 +39,7 @@ const PROVIDER_CLASSES = [
RanobeDbProvider,
KoboProvider,
LubimyczytacProvider,
AladinProvider,
];
@Module({
@@ -21,6 +21,7 @@ export interface StoredProviderIdsRow {
comicvineId: string | null;
ranobedbId: string | null;
lubimyczytacId: string | null;
aladinId: string | null;
}
@Injectable()
@@ -42,6 +43,7 @@ export class MetadataFetchRepository {
comicvineId: bookMetadata.comicvineId,
ranobedbId: bookMetadata.ranobedbId,
lubimyczytacId: bookMetadata.lubimyczytacId,
aladinId: bookMetadata.aladinId,
})
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
@@ -73,6 +73,7 @@ export class MetadataFetchService {
[MetadataProviderKey.COMICVINE]: row.comicvineId ?? undefined,
[MetadataProviderKey.RANOBEDB]: row.ranobedbId ?? undefined,
[MetadataProviderKey.LUBIMYCZYTAC]: row.lubimyczytacId ?? undefined,
[MetadataProviderKey.ALADIN]: row.aladinId ?? undefined,
};
}
@@ -0,0 +1,125 @@
import { MetadataProviderKey } from '@bookorbit/types';
import { mapAladinItem } from './aladin.mapper';
import { AladinItem } from './aladin.types';
describe('AladinMapper', () => {
const baseItem: AladinItem = {
title: '테스트 도서',
link: 'https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=12345',
author: '테스트 저자',
pubDate: '2024-01-15',
description: '테스트 설명',
isbn: '8912345678',
isbn13: '9788912345678',
priceSales: 15000,
priceStandard: 18000,
mallType: 'BOOK',
stockStatus: '',
mileage: 450,
cover: 'https://image.aladin.co.kr/product/12345/cover.jpg',
publisher: '테스트 출판사',
salesPoint: 100,
adult: false,
customerReviewRank: 8,
};
it('should map basic fields correctly', () => {
const result = mapAladinItem(baseItem);
expect(result.provider).toBe(MetadataProviderKey.ALADIN);
expect(result.providerId).toBe('12345');
expect(result.title).toBe('테스트 도서');
expect(result.authors).toEqual(['테스트 저자']);
expect(result.publisher).toBe('테스트 출판사');
expect(result.publishedYear).toBe(2024);
expect(result.isbn10).toBe('8912345678');
expect(result.isbn13).toBe('9788912345678');
expect(result.language).toBe('ko');
expect(result.coverUrl).toBe('https://image.aladin.co.kr/product/12345/cover.jpg');
expect(result.sourceUrl).toBe('https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=12345');
});
it('should handle multiple authors', () => {
const item = { ...baseItem, author: '저자1, 저자2, 저자3' };
const result = mapAladinItem(item);
expect(result.authors).toEqual(['저자1', '저자2', '저자3']);
});
it('should handle missing author', () => {
const item = { ...baseItem, author: '' };
const result = mapAladinItem(item);
expect(result.authors).toBeUndefined();
});
it('should parse page count from subInfo', () => {
const item = { ...baseItem, subInfo: { itemPage: 350 } };
const result = mapAladinItem(item);
expect(result.pageCount).toBe(350);
});
it('should handle missing subInfo', () => {
const item = { ...baseItem };
delete (item as Partial<AladinItem>).subInfo;
const result = mapAladinItem(item);
expect(result.pageCount).toBeUndefined();
});
it('should use fullDescription when available', () => {
const item = { ...baseItem, fullDescription: '전체 설명입니다.' };
const result = mapAladinItem(item);
expect(result.description).toBe('전체 설명입니다.');
});
it('should strip HTML tags from description', () => {
const item = { ...baseItem, description: '<p>HTML <b>설명</b></p>' };
const result = mapAladinItem(item);
expect(result.description).toBe('HTML 설명');
});
it('should parse genres from categoryIdList', () => {
const item = {
...baseItem,
categoryIdList: [
{ categoryId: 100, categoryName: '소설/시/희곡' },
{ categoryId: 200, categoryName: '인문학' },
],
};
const result = mapAladinItem(item);
expect(result.genres).toEqual(['소설/시/희곡', '인문학']);
});
it('should handle missing categoryIdList', () => {
const item = { ...baseItem };
delete (item as Partial<AladinItem>).categoryIdList;
const result = mapAladinItem(item);
expect(result.genres).toBeUndefined();
});
it('should parse series name without fabricating an index', () => {
const item = { ...baseItem, seriesInfo: { seriesId: 1, seriesName: '테스트 시리즈', seriesLink: 'https://aladin.co.kr/series/1' } };
const result = mapAladinItem(item);
expect(result.seriesName).toBe('테스트 시리즈');
expect(result.seriesIndex).toBeUndefined();
});
it('should handle missing series info', () => {
const item = { ...baseItem };
delete (item as Partial<AladinItem>).seriesInfo;
const result = mapAladinItem(item);
expect(result.seriesName).toBeUndefined();
expect(result.seriesIndex).toBeUndefined();
});
it('should use the Aladin ItemId from the link for providerId', () => {
const result = mapAladinItem(baseItem);
expect(result.providerId).toBe('12345');
});
it('should leave providerId empty when the link has no ItemId (ISBNs still mapped to their own fields)', () => {
const item = { ...baseItem, link: 'https://www.aladin.co.kr/shop/wproduct.aspx' };
const result = mapAladinItem(item);
expect(result.providerId).toBe('');
expect(result.isbn10).toBe('8912345678');
expect(result.isbn13).toBe('9788912345678');
});
});
@@ -0,0 +1,68 @@
import { MetadataCandidate, MetadataProviderKey } from '@bookorbit/types';
import { AladinItem } from './aladin.types';
function parseAuthors(authorString: string): string[] {
if (!authorString) return [];
return authorString
.split(',')
.map((a) => a.trim())
.filter((a) => a.length > 0);
}
function parseYear(pubDate: string): number | undefined {
if (!pubDate) return undefined;
const match = pubDate.match(/^(\d{4})/);
return match ? parseInt(match[1], 10) : undefined;
}
function parsePageCount(subInfo?: AladinItem['subInfo']): number | undefined {
if (!subInfo?.itemPage) return undefined;
return subInfo.itemPage;
}
function parseDescription(description: string, fullDescription?: string): string | undefined {
const desc = fullDescription ?? description;
if (!desc) return undefined;
return desc.replace(/<[^>]*>/g, '').trim();
}
function parseGenres(categoryIdList?: AladinItem['categoryIdList']): string[] | undefined {
if (!categoryIdList?.length) return undefined;
return categoryIdList.map((c) => c.categoryName).filter((c) => c.length > 0);
}
function parseSeriesName(seriesInfo?: AladinItem['seriesInfo']): string | undefined {
return seriesInfo?.seriesName || undefined;
}
function parseItemId(link: string): string | undefined {
const match = link.match(/ItemId=(\d+)/i);
return match ? match[1] : undefined;
}
export function mapAladinItem(item: AladinItem): MetadataCandidate {
const authors = parseAuthors(item.author);
const genres = parseGenres(item.categoryIdList);
const publishedYear = parseYear(item.pubDate);
const pageCount = parsePageCount(item.subInfo);
const itemId = parseItemId(item.link);
return {
provider: MetadataProviderKey.ALADIN,
providerId: itemId ?? '',
title: item.title,
subtitle: undefined,
authors: authors.length > 0 ? authors : undefined,
description: parseDescription(item.description, item.fullDescription),
publisher: item.publisher || undefined,
publishedYear,
language: 'ko',
pageCount,
isbn10: item.isbn || undefined,
isbn13: item.isbn13 || undefined,
seriesName: parseSeriesName(item.seriesInfo),
genres,
coverUrl: item.cover || undefined,
sourceUrl: item.link || undefined,
};
}
@@ -0,0 +1,232 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ProviderConfigurations } from '@bookorbit/types';
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
import { AladinProvider } from './aladin.provider';
describe('AladinProvider', () => {
let provider: AladinProvider;
let providerConfig: ProviderConfigService;
const mockConfig: ProviderConfigurations = {
google: { enabled: true, apiKey: 'test-api-key' },
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 },
aladin: { enabled: true, ttbKey: 'test-ttb-key' },
};
const mockAladinItem = {
title: '테스트 도서',
link: 'https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=12345',
author: '테스트 저자',
pubDate: '2024-01-15',
description: '테스트 설명',
isbn: '8912345678',
isbn13: '9788912345678',
priceSales: 15000,
priceStandard: 18000,
mallType: 'BOOK',
stockStatus: '',
mileage: 450,
cover: 'https://image.aladin.co.kr/product/12345/cover.jpg',
publisher: '테스트 출판사',
salesPoint: 100,
adult: false,
customerReviewRank: 8,
fullDescription: '전체 설명',
categoryIdList: [{ categoryId: 100, categoryName: '소설/시/희곡' }],
seriesInfo: { seriesId: 1, seriesName: '테스트 시리즈', seriesLink: 'https://aladin.co.kr/series/1' },
subInfo: { itemPage: 300, toc: '목차', authors: [{ name: '테스트 저자', link: 'https://aladin.co.kr/author/1' }] },
};
const mockAladinSearchResponse = {
version: 20131101,
title: '알라딘 검색 결과',
link: 'https://www.aladin.co.kr',
pubDate: '2024-01-15T00:00:00',
totalResults: 1,
startIndex: 1,
itemsPerPage: 10,
query: '테스트',
searchCategoryId: 0,
searchCategoryName: '',
item: [mockAladinItem],
};
const mockAladinLookupResponse = {
version: 20131101,
title: '알라딘 상품 조회',
link: 'https://www.aladin.co.kr',
pubDate: '2024-01-15T00:00:00',
totalResults: 1,
startIndex: 1,
itemsPerPage: 1,
query: '9788912345678',
searchCategoryId: 0,
searchCategoryName: '',
item: [mockAladinItem],
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AladinProvider,
{
provide: ProviderConfigService,
useValue: {
getConfig: vi.fn().mockResolvedValue(mockConfig),
},
},
],
}).compile();
provider = module.get<AladinProvider>(AladinProvider);
providerConfig = module.get<ProviderConfigService>(ProviderConfigService);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('search', () => {
it('should return empty array if disabled', async () => {
vi.spyOn(providerConfig, 'getConfig').mockResolvedValue({
...mockConfig,
aladin: { enabled: false, ttbKey: '' },
});
const result = await provider.search({ title: 'Test' });
expect(result).toEqual([]);
});
it('should return empty array if TTB key is missing', async () => {
global.fetch = vi.fn();
vi.spyOn(providerConfig, 'getConfig').mockResolvedValue({
...mockConfig,
aladin: { enabled: true, ttbKey: '' },
});
const result = await provider.search({ title: 'Test' });
expect(result).toEqual([]);
expect(global.fetch).not.toHaveBeenCalled();
});
it('should fetch from Aladin and return mapped items', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(mockAladinSearchResponse),
});
const result = await provider.search({ title: '테스트 도서' });
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('https://www.aladin.co.kr/ttb/api/ItemSearch.aspx'), expect.any(Object));
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('TTBKey=test-ttb-key'), expect.any(Object));
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('Query=%ED%85%8C%EC%8A%A4%ED%8A%B8+%EB%8F%84%EC%84%9C'), expect.any(Object));
expect(result).toHaveLength(1);
expect(result[0].title).toBe('테스트 도서');
expect(result[0].providerId).toBe('12345');
expect(result[0].provider).toBe('aladin');
expect(result[0].authors).toEqual(['테스트 저자']);
expect(result[0].publisher).toBe('테스트 출판사');
expect(result[0].publishedYear).toBe(2024);
expect(result[0].isbn13).toBe('9788912345678');
expect(result[0].language).toBe('ko');
expect(result[0].genres).toEqual(['소설/시/희곡']);
});
it('should handle search with ISBN', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ item: [] }),
});
await provider.search({ isbn: '9788912345678' });
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('Query=9788912345678'), expect.any(Object));
});
it('should return empty array on fetch error', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
const result = await provider.search({ title: 'Test' });
expect(result).toEqual([]);
});
it('should return empty array if no results', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({}),
});
const result = await provider.search({ title: 'Test' });
expect(result).toEqual([]);
});
});
describe('lookupById', () => {
it('should return null if TTB key is missing', async () => {
global.fetch = vi.fn();
vi.spyOn(providerConfig, 'getConfig').mockResolvedValue({
...mockConfig,
aladin: { enabled: true, ttbKey: '' },
});
const result = await provider.lookupById('9788912345678');
expect(result).toBeNull();
expect(global.fetch).not.toHaveBeenCalled();
});
it('should look up by Aladin ItemId and return the mapped item', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(mockAladinLookupResponse),
});
const result = await provider.lookupById('12345');
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('https://www.aladin.co.kr/ttb/api/ItemLookUp.aspx'), expect.any(Object));
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('ItemIdType=ItemId'), expect.any(Object));
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('ItemId=12345'), expect.any(Object));
expect(result).not.toBeNull();
expect(result?.title).toBe('테스트 도서');
expect(result?.providerId).toBe('12345');
});
it('should pass the providerId through unchanged as the ItemId', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(mockAladinLookupResponse),
});
const result = await provider.lookupById('9788912345678');
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('ItemIdType=ItemId'), expect.any(Object));
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('ItemId=9788912345678'), expect.any(Object));
expect(result).not.toBeNull();
});
it('should return null on fetch error', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
const result = await provider.lookupById('9788912345678');
expect(result).toBeNull();
});
it('should return null if item not found', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ item: [] }),
});
const result = await provider.lookupById('9788912345678');
expect(result).toBeNull();
});
});
});
@@ -0,0 +1,151 @@
import { Injectable, Logger } from '@nestjs/common';
import { MetadataCandidate, MetadataProviderKey } from '@bookorbit/types';
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
import { sanitizeLogValue } from '../../../../common/utils/log-sanitize.utils';
import { fetchWithThrottle } from '../../fetch-with-throttle';
import { ProviderThrottleError } from '../../provider-throttle.error';
import { IdentifiableProvider } from '../metadata-provider';
import { MetadataSearchParams } from '../metadata-search-params';
import { PROVIDER_TIMEOUT_MS } from '../provider-constants';
import { buildRequestSignal } from '../provider-utils';
import { mapAladinItem } from './aladin.mapper';
import { AladinLookupResponse, AladinSearchResponse } from './aladin.types';
const BASE_URL = 'https://www.aladin.co.kr/ttb/api';
const API_VERSION = '20131101';
@Injectable()
export class AladinProvider implements IdentifiableProvider {
readonly key = MetadataProviderKey.ALADIN;
readonly label = 'Aladin';
readonly identifiable = true as const;
private readonly logger = new Logger(AladinProvider.name);
constructor(private readonly providerConfig: ProviderConfigService) {}
async search(params: MetadataSearchParams): Promise<MetadataCandidate[]> {
const { enabled, ttbKey } = await this.providerConfig.getConfig().then((c) => c.aladin);
if (!enabled || !ttbKey.trim()) return [];
const query = this.buildQuery(params);
if (!query) return [];
return this.searchItems(query, ttbKey, params.signal);
}
async lookupById(providerId: string, signal?: AbortSignal): Promise<MetadataCandidate | null> {
const { enabled, ttbKey } = await this.providerConfig.getConfig().then((c) => c.aladin);
if (!enabled || !ttbKey.trim()) return null;
return this.lookupItem(providerId, ttbKey, signal);
}
private buildQuery(params: MetadataSearchParams): string | null {
if (params.isbn) {
return params.isbn;
}
const parts: string[] = [];
if (params.title) parts.push(params.title);
if (params.author) parts.push(params.author);
return parts.length ? parts.join(' ') : null;
}
private async searchItems(query: string, ttbKey: string, signal?: AbortSignal): Promise<MetadataCandidate[]> {
const url = this.buildSearchUrl(query, ttbKey);
const startedAt = Date.now();
const safeQuery = sanitizeLogValue(query);
this.logger.log(`[aladin.search] [start] query="${safeQuery}"`);
try {
const res = await fetchWithThrottle(url, { signal: buildRequestSignal(PROVIDER_TIMEOUT_MS.DEFAULT, signal) });
if (!res.ok) {
this.logger.warn(
`[aladin.search] [fail] query="${safeQuery}" status=${res.status} durationMs=${Date.now() - startedAt} errorClass=HttpError error="non-ok response"`,
);
return [];
}
const body = (await res.json()) as AladinSearchResponse;
const items = (body.item ?? []).map(mapAladinItem);
this.logger.log(
`[aladin.search] [end] query="${safeQuery}" status=${res.status} resultCount=${items.length} durationMs=${Date.now() - startedAt}`,
);
return items;
} catch (err) {
if (err instanceof ProviderThrottleError) {
this.logger.warn(
`[aladin.search] [fail] query="${safeQuery}" durationMs=${Date.now() - startedAt} errorClass=ProviderThrottleError error="throttled"`,
);
throw err;
}
this.logger.warn(
`[aladin.search] [fail] query="${safeQuery}" durationMs=${Date.now() - startedAt} errorClass=${err instanceof Error ? err.name : 'UnknownError'} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}"`,
);
throw err;
}
}
private async lookupItem(providerId: string, ttbKey: string, signal?: AbortSignal): Promise<MetadataCandidate | null> {
const url = this.buildLookupUrl(providerId, ttbKey);
const startedAt = Date.now();
const safeId = sanitizeLogValue(providerId);
this.logger.log(`[aladin.lookup] [start] providerId="${safeId}"`);
try {
const res = await fetchWithThrottle(url, { signal: buildRequestSignal(PROVIDER_TIMEOUT_MS.DEFAULT, signal) });
if (!res.ok) {
this.logger.warn(
`[aladin.lookup] [fail] providerId="${safeId}" status=${res.status} durationMs=${Date.now() - startedAt} errorClass=HttpError error="non-ok response"`,
);
return null;
}
const body = (await res.json()) as AladinLookupResponse;
const item = body.item?.[0];
if (!item) {
this.logger.log(`[aladin.lookup] [end] providerId="${safeId}" status=${res.status} found=false durationMs=${Date.now() - startedAt}`);
return null;
}
const mapped = mapAladinItem(item);
this.logger.log(`[aladin.lookup] [end] providerId="${safeId}" status=${res.status} found=true durationMs=${Date.now() - startedAt}`);
return mapped;
} catch (err) {
if (err instanceof ProviderThrottleError) {
this.logger.warn(
`[aladin.lookup] [fail] providerId="${safeId}" durationMs=${Date.now() - startedAt} errorClass=ProviderThrottleError error="throttled"`,
);
throw err;
}
this.logger.warn(
`[aladin.lookup] [fail] providerId="${safeId}" durationMs=${Date.now() - startedAt} errorClass=${err instanceof Error ? err.name : 'UnknownError'} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}"`,
);
throw err;
}
}
private buildSearchUrl(query: string, ttbKey: string): string {
const params = new URLSearchParams({
TTBKey: ttbKey,
Query: query,
QueryType: 'Keyword',
MaxResults: '10',
start: '1',
SearchTarget: 'Book',
output: 'JS',
Version: API_VERSION,
Cover: 'Big',
OptResult: 'ebookList,usedList,reviewList,fullDescription,categoryIdList,seriesInfo,authors',
});
return `${BASE_URL}/ItemSearch.aspx?${params.toString()}`;
}
private buildLookupUrl(providerId: string, ttbKey: string): string {
const params = new URLSearchParams({
TTBKey: ttbKey,
ItemId: providerId,
ItemIdType: 'ItemId',
output: 'JS',
Version: API_VERSION,
Cover: 'Big',
OptResult: 'ebookList,usedList,reviewList,fullDescription,categoryIdList,seriesInfo,authors,toc,packing,subbarcode',
});
return `${BASE_URL}/ItemLookUp.aspx?${params.toString()}`;
}
}
@@ -0,0 +1,70 @@
export interface AladinItem {
title: string;
link: string;
author: string;
pubDate: string;
description: string;
isbn: string;
isbn13: string;
priceSales: number;
priceStandard: number;
mallType: string;
stockStatus: string;
mileage: number;
cover: string;
publisher: string;
salesPoint: number;
adult: boolean;
customerReviewRank: number;
bestRank?: number;
fullDescription?: string;
fullDescription2?: string;
categoryIdList?: Array<{ categoryId: number; categoryName: string }>;
seriesInfo?: { seriesId: number; seriesName: string; seriesLink: string };
subInfo?: {
itemPage: number;
toc?: string;
itemWeight?: number;
itemVolume?: string;
editionName?: string;
itemOrigin?: string;
translateAuthor?: string;
itemBindingType?: string;
packgeGb?: string;
giftYn?: string;
ebookList?: unknown[];
usedList?: unknown[];
reviewList?: unknown[];
authors?: Array<{ name: string; link: string }>;
phraseList?: string[];
tocList?: string[];
};
}
export interface AladinSearchResponse {
version: number;
title: string;
link: string;
pubDate: string;
totalResults: number;
startIndex: number;
itemsPerPage: number;
query: string;
searchCategoryId: number;
searchCategoryName: string;
item: AladinItem[];
}
export interface AladinLookupResponse {
version: number;
title: string;
link: string;
pubDate: string;
totalResults: number;
startIndex: number;
itemsPerPage: number;
query: string;
searchCategoryId: number;
searchCategoryName: string;
item: AladinItem[];
}
@@ -0,0 +1,3 @@
export { AladinProvider } from './aladin.provider';
export { mapAladinItem } from './aladin.mapper';
export type { AladinItem, AladinSearchResponse, AladinLookupResponse } from './aladin.types';
@@ -17,6 +17,7 @@ describe('UpdateProviderConfigDto', () => {
amazon: { cookie: 'session-cookie' },
hardcover: { apiKey: 'hardcover-key' },
itunes: { coverResolution: 'standard' },
aladin: { ttbKey: 'ttb-test-key' },
});
expect(errors).toHaveLength(0);
@@ -35,6 +36,7 @@ describe('UpdateProviderConfigDto', () => {
hardcover: { apiKey: false },
openLibrary: { enabled: 'true' },
itunes: { coverResolution: 'ultra' },
aladin: { ttbKey: 123 },
});
expect(errors).toEqual(
@@ -44,6 +46,7 @@ describe('UpdateProviderConfigDto', () => {
expect.objectContaining({ property: 'hardcover' }),
expect.objectContaining({ property: 'openLibrary' }),
expect.objectContaining({ property: 'itunes' }),
expect.objectContaining({ property: 'aladin' }),
]),
);
});
@@ -43,6 +43,11 @@ export class KoboProviderConfigDto {
@IsOptional() @IsString() language?: string;
}
export class AladinProviderConfigDto {
@IsOptional() @IsBoolean() enabled?: boolean;
@IsOptional() @IsString() ttbKey?: string;
}
export class UpdateProviderConfigDto {
@IsOptional() @ValidateNested() @Type(() => GoogleProviderConfigDto) google?: GoogleProviderConfigDto;
@IsOptional() @ValidateNested() @Type(() => AmazonProviderConfigDto) amazon?: AmazonProviderConfigDto;
@@ -56,4 +61,5 @@ export class UpdateProviderConfigDto {
@IsOptional() @ValidateNested() @Type(() => SimpleProviderConfigDto) ranobedb?: SimpleProviderConfigDto;
@IsOptional() @ValidateNested() @Type(() => KoboProviderConfigDto) kobo?: KoboProviderConfigDto;
@IsOptional() @ValidateNested() @Type(() => SimpleProviderConfigDto) lubimyczytac?: SimpleProviderConfigDto;
@IsOptional() @ValidateNested() @Type(() => AladinProviderConfigDto) aladin?: AladinProviderConfigDto;
}
@@ -75,6 +75,7 @@ describe('ProviderConfigService', () => {
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
aladin: { enabled: false, ttbKey: '' },
});
});
@@ -358,6 +359,7 @@ describe('ProviderConfigService', () => {
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
aladin: { enabled: false, ttbKey: '' },
});
expect(statuses.map((s) => s.key)).toEqual([
@@ -373,6 +375,7 @@ describe('ProviderConfigService', () => {
MetadataProviderKey.RANOBEDB,
MetadataProviderKey.KOBO,
MetadataProviderKey.LUBIMYCZYTAC,
MetadataProviderKey.ALADIN,
]);
expect(statuses.find((s) => s.key === MetadataProviderKey.GOOGLE)?.configured).toBe(false);
expect(statuses.find((s) => s.key === MetadataProviderKey.GOOGLE)?.hint).toContain('API key required');
@@ -398,6 +401,7 @@ describe('ProviderConfigService', () => {
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
aladin: { enabled: false, ttbKey: '' },
});
expect(statuses.find((s) => s.key === MetadataProviderKey.HARDCOVER)?.configured).toBe(true);
@@ -45,6 +45,7 @@ const DEFAULT_CONFIG: ProviderConfigurations = {
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
aladin: { enabled: false, ttbKey: '' },
};
function asObject(value: unknown): Record<string, unknown> {
@@ -126,6 +127,14 @@ function mergeKoboConfig(base: ProviderConfigurations['kobo'], value: unknown):
};
}
function mergeAladinConfig(base: ProviderConfigurations['aladin'], value: unknown): ProviderConfigurations['aladin'] {
const next = asObject(value);
return {
enabled: asBoolean(next.enabled, base.enabled),
ttbKey: asString(next.ttbKey, base.ttbKey),
};
}
const PROVIDER_LABELS: Record<MetadataProviderKey, string> = {
[MetadataProviderKey.GOOGLE]: 'Google Books',
[MetadataProviderKey.AMAZON]: 'Amazon',
@@ -139,6 +148,7 @@ const PROVIDER_LABELS: Record<MetadataProviderKey, string> = {
[MetadataProviderKey.RANOBEDB]: 'RanobeDB',
[MetadataProviderKey.KOBO]: 'Kobo',
[MetadataProviderKey.LUBIMYCZYTAC]: 'LubimyCzytac',
[MetadataProviderKey.ALADIN]: 'Aladin',
};
type ProviderEnableRule = {
@@ -163,6 +173,11 @@ const PROVIDER_ENABLE_RULES = {
blockedMessage: 'ComicVine requires an API key before it can be enabled',
setupHint: 'API key required',
},
aladin: {
canEnable: (config) => !!config.aladin.ttbKey.trim(),
blockedMessage: 'Aladin requires a TTB Key before it can be enabled',
setupHint: 'TTB Key required',
},
} satisfies Partial<Record<keyof ProviderConfigurations, ProviderEnableRule>>;
@Injectable()
@@ -185,6 +200,7 @@ export class ProviderConfigService {
ranobedb: { ...DEFAULT_CONFIG.ranobedb },
kobo: { ...DEFAULT_CONFIG.kobo },
lubimyczytac: { ...DEFAULT_CONFIG.lubimyczytac },
aladin: { ...DEFAULT_CONFIG.aladin },
};
}
@@ -203,6 +219,7 @@ export class ProviderConfigService {
ranobedb: mergeSimpleConfig(base.ranobedb, next.ranobedb),
kobo: mergeKoboConfig(base.kobo, next.kobo),
lubimyczytac: mergeSimpleConfig(base.lubimyczytac, next.lubimyczytac),
aladin: mergeAladinConfig(base.aladin, next.aladin),
};
}
@@ -273,6 +290,10 @@ export class ProviderConfigService {
country: this.normalizeKoboPathSegment(config.kobo.country, DEFAULT_CONFIG.kobo.country),
language: this.normalizeKoboPathSegment(config.kobo.language, DEFAULT_CONFIG.kobo.language),
},
aladin: {
...config.aladin,
ttbKey: config.aladin.ttbKey.trim(),
},
};
if (!normalized.google.enabled || normalized.google.apiKey) return normalized;
@@ -437,6 +458,13 @@ export class ProviderConfigService {
configured: true,
hint: 'Polish book catalog (lubimyczytac.pl). Scrapes public pages.',
},
{
key: MetadataProviderKey.ALADIN,
label: PROVIDER_LABELS[MetadataProviderKey.ALADIN],
enabled: cfg.aladin.enabled,
configured: !!this.getEnableRule('aladin')?.canEnable(cfg),
hint: !this.getEnableRule('aladin')?.canEnable(cfg) ? this.getEnableRule('aladin')?.setupHint : undefined,
},
];
}
@@ -470,6 +498,8 @@ export class ProviderConfigService {
return this.testAmazonProvider(config.amazon);
case MetadataProviderKey.HARDCOVER:
return this.testHardcoverProvider(config.hardcover.apiKey);
case MetadataProviderKey.ALADIN:
return this.testAladinProvider(config.aladin.ttbKey);
default:
throw new BadRequestException(`Provider test not supported for ${key}`);
}
@@ -581,4 +611,57 @@ export class ProviderConfigService {
message: `Connected as ${username}.`,
};
}
private async testAladinProvider(ttbKey: string): Promise<ProviderConnectionTestResult> {
const key = ttbKey.trim();
if (!key) {
return {
key: MetadataProviderKey.ALADIN,
ok: false,
status: 'fail',
message: 'Aladin TTB Key is required.',
};
}
const testUrl = `https://www.aladin.co.kr/ttb/api/ItemSearch.aspx?TTBKey=${encodeURIComponent(key)}&Query=%ED%8C%8C%EC%9D%B4%EC%8D%AC&QueryType=Keyword&MaxResults=1&start=1&SearchTarget=Book&output=JS&Version=20131101`;
try {
const response = await fetch(testUrl, { method: 'GET', signal: AbortSignal.timeout(PROVIDER_TEST_TIMEOUT_MS) });
if (!response.ok) {
return {
key: MetadataProviderKey.ALADIN,
ok: false,
status: 'fail',
message: `Aladin request failed with HTTP ${response.status}.`,
};
}
const body = (await response.json()) as { item?: unknown[]; errorCode?: number; errorMessage?: string };
if (body.errorCode) {
return {
key: MetadataProviderKey.ALADIN,
ok: false,
status: 'fail',
message: `Aladin API error: ${body.errorMessage ?? body.errorCode}`,
};
}
return {
key: MetadataProviderKey.ALADIN,
ok: true,
status: 'success',
message: 'Aladin API connection successful.',
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
key: MetadataProviderKey.ALADIN,
ok: false,
status: 'fail',
message: `Aladin connection test failed: ${message}`,
};
}
}
}
@@ -24,4 +24,5 @@ export class UpdateWeightsDto {
@IsInt() @Min(0) openLibraryId!: number;
@IsInt() @Min(0) itunesId!: number;
@IsInt() @Min(0) koboId!: number;
@IsInt() @Min(0) aladinId!: number;
}
@@ -30,6 +30,7 @@ const METADATA_SCORE_BASE_SELECT = {
openLibraryId: bookMetadata.openLibraryId,
itunesId: bookMetadata.itunesId,
koboId: bookMetadata.koboId,
aladinId: bookMetadata.aladinId,
} as const;
export type ScorePage = {
@@ -24,6 +24,7 @@ function makeData(overrides: Partial<ScoreData> = {}): ScoreData {
openLibraryId: null,
itunesId: null,
koboId: null,
aladinId: null,
authorCount: 0,
genreCount: 0,
tagCount: 0,
@@ -81,6 +82,7 @@ describe('MetadataScoreScorer', () => {
openLibraryId: 0,
itunesId: 0,
koboId: 0,
aladinId: 0,
};
expect(scorer.compute(data, weights)).toBe(100);
@@ -113,6 +115,7 @@ describe('MetadataScoreScorer', () => {
openLibraryId: 0,
itunesId: 0,
koboId: 0,
aladinId: 0,
};
expect(scorer.compute(makeData({ title: 'Dune', coverSource: 'extracted', authorCount: 10 }), weights)).toBe(100);
@@ -22,6 +22,7 @@ export type ScoreData = {
openLibraryId: string | null;
itunesId: string | null;
koboId: string | null;
aladinId: string | null;
authorCount: number;
genreCount: number;
tagCount: number;
@@ -54,6 +55,7 @@ const SCORE_RULES: Record<MetadataScoreField, ScoreRule> = {
openLibraryId: { kind: 'string', value: (data) => data.openLibraryId },
itunesId: { kind: 'string', value: (data) => data.itunesId },
koboId: { kind: 'string', value: (data) => data.koboId },
aladinId: { kind: 'string', value: (data) => data.aladinId },
};
@Injectable()
@@ -49,6 +49,7 @@ export class ComicFormatExtractor implements FormatExtractor {
ranobedbId: comicMetadata?.ranobedbId ?? null,
koboId: comicMetadata?.koboId ?? null,
lubimyczytacId: comicMetadata?.lubimyczytacId ?? null,
aladinId: comicMetadata?.aladinId ?? null,
itunesId: comicMetadata?.itunesId ?? null,
cover: cover ?? null,
comicMetadata: comicMetadata?.comicMetadata ?? null,
@@ -23,6 +23,7 @@ export interface ParsedBookData {
ranobedbId?: string | null;
koboId?: string | null;
lubimyczytacId?: string | null;
aladinId?: string | null;
itunesId?: string | null;
audibleId?: string | null;
cover: Buffer | null;
@@ -26,6 +26,7 @@ export function hasOpfMetadata(metadata: ParsedOpf): boolean {
metadata.ranobedbId !== null ||
metadata.koboId !== null ||
metadata.lubimyczytacId !== null ||
metadata.aladinId !== null ||
metadata.itunesId !== null
);
}
@@ -55,6 +56,7 @@ export function mapOpfMetadata(metadata: ParsedOpf, cover: Buffer | null): Parse
ranobedbId: metadata.ranobedbId,
koboId: metadata.koboId,
lubimyczytacId: metadata.lubimyczytacId,
aladinId: metadata.aladinId,
itunesId: metadata.itunesId,
cover,
};
@@ -38,6 +38,7 @@ export class PdfFormatExtractor implements FormatExtractor {
ranobedbId: pdf.ranobedbId,
koboId: pdf.koboId,
lubimyczytacId: pdf.lubimyczytacId,
aladinId: pdf.aladinId,
itunesId: pdf.itunesId,
cover: pdf.coverBuffer,
pageCount: pdf.pageCount,
@@ -47,6 +47,7 @@ export interface ParsedCbzMetadata {
ranobedbId: string | null;
koboId: string | null;
lubimyczytacId: string | null;
aladinId: string | null;
itunesId: string | null;
comicMetadata: ParsedCbzComicMetadata | null;
}
@@ -269,6 +270,7 @@ function parseComicInfoXml(xmlBuf: Buffer): ParsedCbzMetadata | null {
ranobedbId: managedNotes.get('ranobedbId') ?? null,
koboId: managedNotes.get('koboId') ?? providerIdsFromWeb.koboId ?? null,
lubimyczytacId: managedNotes.get('lubimyczytacId') ?? null,
aladinId: managedNotes.get('aladinId') ?? null,
itunesId: null,
comicMetadata: hasComicFields
? {
@@ -327,6 +329,7 @@ function parseComicBookInfoJson(comment: string): ParsedCbzMetadata | null {
ranobedbId: null,
koboId: null,
lubimyczytacId: null,
aladinId: null,
itunesId: null,
comicMetadata: null,
};
@@ -24,6 +24,7 @@ export interface ParsedOpf {
ranobedbId: string | null;
koboId: string | null;
lubimyczytacId: string | null;
aladinId: string | null;
itunesId: string | null;
coverHref: string | null;
}
@@ -86,7 +87,7 @@ function parseBookOrbitTags(raw: string | null): string[] {
}
}
type ProviderKey = 'google' | 'amazon' | 'goodreads' | 'hardcover' | 'openlibrary' | 'ranobedb' | 'kobo' | 'lubimyczytac' | 'itunes';
type ProviderKey = 'google' | 'amazon' | 'goodreads' | 'hardcover' | 'openlibrary' | 'ranobedb' | 'kobo' | 'lubimyczytac' | 'aladin' | 'itunes';
// Calibre 9.x (opf3) writes provider identifiers as bare `prefix:value` text inside <dc:identifier>.
// Only these known prefixes are recognized, as a lowest-priority fallback after opf:scheme and urn:.
@@ -102,6 +103,7 @@ const PREFIX_TO_PROVIDER: Record<string, ProviderKey> = {
itunes: 'itunes',
lubimyczytac: 'lubimyczytac',
ranobedb: 'ranobedb',
aladin: 'aladin',
};
// Calibre stores custom-column values in a `calibre:user_metadata` JSON blob keyed by column name,
@@ -262,6 +264,7 @@ export function parseOpf(xml: string): ParsedOpf {
let schemeRanobedbId: string | null = null;
let schemeKoboId: string | null = null;
let schemeLubimyczytacId: string | null = null;
let schemeAladinId: string | null = null;
let schemeItunesId: string | null = null;
let urnGoogleBooksId: string | null = null;
@@ -272,6 +275,7 @@ export function parseOpf(xml: string): ParsedOpf {
let urnRanobedbId: string | null = null;
let urnKoboId: string | null = null;
let urnLubimyczytacId: string | null = null;
let urnAladinId: string | null = null;
let urnItunesId: string | null = null;
// Calibre prefix:value identifiers — lowest priority, resolved after scheme and urn.
@@ -299,6 +303,7 @@ export function parseOpf(xml: string): ParsedOpf {
if (scheme === 'ranobedb') schemeRanobedbId ??= value || null;
if (scheme === 'kobo') schemeKoboId ??= value || null;
if (scheme === 'lubimyczytac') schemeLubimyczytacId ??= value || null;
if (scheme === 'aladin') schemeAladinId ??= value || null;
if (scheme === 'itunes') schemeItunesId ??= value || null;
// urn:-prefixed provider identifiers (legacy / backward-compat)
@@ -310,6 +315,7 @@ export function parseOpf(xml: string): ParsedOpf {
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:aladin:')) urnAladinId ??= value.slice('urn:aladin:'.length) || null;
if (value.startsWith('urn:itunes:')) urnItunesId ??= value.slice('urn:itunes:'.length) || null;
// Calibre prefix:value — only when there is no opf:scheme attribute and the value is not a urn:
@@ -334,6 +340,7 @@ export function parseOpf(xml: string): ParsedOpf {
const ranobedbId = schemeRanobedbId ?? urnRanobedbId ?? prefixIds.ranobedb ?? null;
const koboId = schemeKoboId ?? urnKoboId ?? prefixIds.kobo ?? null;
const lubimyczytacId = schemeLubimyczytacId ?? urnLubimyczytacId ?? prefixIds.lubimyczytac ?? null;
const aladinId = schemeAladinId ?? urnAladinId ?? prefixIds.aladin ?? null;
const itunesId = schemeItunesId ?? urnItunesId ?? prefixIds.itunes ?? null;
isbn10 ??= propertyMeta('bookorbit:isbn10') ?? namedMeta('bookorbit:isbn10');
@@ -456,6 +463,7 @@ export function parseOpf(xml: string): ParsedOpf {
ranobedbId,
koboId,
lubimyczytacId,
aladinId,
itunesId,
coverHref,
};
@@ -30,6 +30,7 @@ export interface PdfParsed {
ranobedbId: string | null;
koboId: string | null;
lubimyczytacId: string | null;
aladinId: string | null;
itunesId: string | null;
coverBuffer: Buffer | null;
}
@@ -142,6 +143,7 @@ export async function parsePdfFile(absolutePath: string, options: PdfParseOption
ranobedbId: xmp?.ranobedbId ?? null,
koboId: xmp?.koboId ?? null,
lubimyczytacId: xmp?.lubimyczytacId ?? null,
aladinId: xmp?.aladinId ?? null,
itunesId: xmp?.itunesId ?? null,
coverBuffer,
};
@@ -27,6 +27,7 @@ export interface XmpParsed {
ranobedbId: string | null;
koboId: string | null;
lubimyczytacId: string | null;
aladinId: string | null;
itunesId: string | null;
}
@@ -161,6 +162,7 @@ export function parseXmp(xmpXml: string): XmpParsed | null {
ranobedbId: str(merged[`${px}:ranobedbId`]),
koboId: str(merged[`${px}:koboId`]),
lubimyczytacId: str(merged[`${px}:lubimyczytacId`]),
aladinId: str(merged[`${px}:aladinId`]),
itunesId: str(merged[`${px}:itunesId`]),
};
}
@@ -591,6 +591,7 @@ export class MetadataService {
ranobedbId: data.ranobedbId,
koboId: data.koboId,
lubimyczytacId: data.lubimyczytacId,
aladinId: data.aladinId,
itunesId: data.itunesId,
comicMetadata: data.comicMetadata ?? undefined,
});
@@ -616,6 +617,7 @@ export class MetadataService {
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.aladinId !== undefined) scalarFields.aladinId = filtered.aladinId;
if (filtered.itunesId !== undefined) scalarFields.itunesId = filtered.itunesId;
if (Object.keys(scalarFields).length > 0) {
const shouldSyncSeries =
@@ -738,7 +738,8 @@ export class StatisticsRepository {
${bookMetadata.koboId} is not null or
${bookMetadata.comicvineId} is not null or
${bookMetadata.ranobedbId} is not null or
${bookMetadata.lubimyczytacId} is not null
${bookMetadata.lubimyczytacId} is not null or
${bookMetadata.aladinId} is not null
)`;
const [row] = await this.db