mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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
|
||||
}
|
||||
|
||||
+11
-1
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -13,6 +13,7 @@ export const MetadataProviderKey = {
|
||||
RANOBEDB: "ranobedb",
|
||||
KOBO: "kobo",
|
||||
LUBIMYCZYTAC: "lubimyczytac",
|
||||
ALADIN: "aladin",
|
||||
} as const;
|
||||
|
||||
export interface ComicMetadataFields {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
Generated
+7
-6
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user