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',
}