mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(metadata): add RanobeDB as metadata provider (#185)
* feat(metadata): add RanobeDB metadata provider Introduces a new metadata provider to fetch book metadata from RanobeDB. - Adds `ranobedb.client` to interact with the RanobeDB API. - Implements `ranobedb.mapper` to map RanobeDB data to the internal metadata schema. - Maps native Japanese titles to subtitles. - Uses the original language as a fallback for the language field. - Parses specific release dates to determine the original publication year. - Registers RanobeDB as an available provider in the preferences UI. - Adds `ranobedb_id` to the metadata database schema. - Removes the `bookId` override when manually performing a metadata search in the UI. Closes #114
This commit is contained in:
@@ -53,6 +53,7 @@ const DIRECT_PATCH_FIELDS = [
|
|||||||
'itunesId',
|
'itunesId',
|
||||||
'audibleId',
|
'audibleId',
|
||||||
'comicvineId',
|
'comicvineId',
|
||||||
|
'ranobedbId',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const COMIC_FIELD_MAP = {
|
const COMIC_FIELD_MAP = {
|
||||||
@@ -112,6 +113,7 @@ const providerIdFields = [
|
|||||||
{ field: 'itunesId' as const, label: 'iTunes' },
|
{ field: 'itunesId' as const, label: 'iTunes' },
|
||||||
{ field: 'audibleId' as const, label: 'Audible' },
|
{ field: 'audibleId' as const, label: 'Audible' },
|
||||||
{ field: 'comicvineId' as const, label: 'ComicVine' },
|
{ field: 'comicvineId' as const, label: 'ComicVine' },
|
||||||
|
{ field: 'ranobedbId' as const, label: 'RanobeDB' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function setIntField(field: 'publishedYear' | 'pageCount' | 'durationSeconds', e: Event) {
|
function setIntField(field: 'publishedYear' | 'pageCount' | 'durationSeconds', e: Event) {
|
||||||
@@ -310,6 +312,7 @@ function buildPreviewPatch(preview: MetadataRefreshPreview): MetadataPatch {
|
|||||||
itunesId: preview.itunesId,
|
itunesId: preview.itunesId,
|
||||||
audibleId: preview.audibleId,
|
audibleId: preview.audibleId,
|
||||||
comicvineId: preview.comicvineId,
|
comicvineId: preview.comicvineId,
|
||||||
|
ranobedbId: preview.ranobedbId,
|
||||||
comicMetadata: preview.comicMetadata,
|
comicMetadata: preview.comicMetadata,
|
||||||
narrators: preview.audioMetadata?.narrators,
|
narrators: preview.audioMetadata?.narrators,
|
||||||
durationSeconds: preview.audioMetadata?.durationSeconds ?? undefined,
|
durationSeconds: preview.audioMetadata?.durationSeconds ?? undefined,
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ function handleSearch(params: { title: string; author: string; isbn: string }) {
|
|||||||
const isAudiobook = props.book.files.some((f) => f.format != null && isAudioFormat(f.format))
|
const isAudiobook = props.book.files.some((f) => f.format != null && isAudioFormat(f.format))
|
||||||
selectedCandidate.value = null
|
selectedCandidate.value = null
|
||||||
view.value = 'search'
|
view.value = 'search'
|
||||||
search({ ...params, bookId: props.book.id, isAudiobook })
|
search({ ...params, isAudiobook })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelect(candidate: MetadataCandidate) {
|
function handleSelect(candidate: MetadataCandidate) {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export interface MetadataPatch {
|
|||||||
itunesId?: string | null
|
itunesId?: string | null
|
||||||
audibleId?: string | null
|
audibleId?: string | null
|
||||||
comicvineId?: string | null
|
comicvineId?: string | null
|
||||||
|
ranobedbId?: string | null
|
||||||
comicMetadata?: ComicMetadataFields
|
comicMetadata?: ComicMetadataFields
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +155,7 @@ export type ProviderIdPatchField =
|
|||||||
| 'itunesId'
|
| 'itunesId'
|
||||||
| 'audibleId'
|
| 'audibleId'
|
||||||
| 'comicvineId'
|
| 'comicvineId'
|
||||||
|
| 'ranobedbId'
|
||||||
|
|
||||||
export const PROVIDER_ID_FIELD: Record<MetadataProviderKey, ProviderIdPatchField | undefined> = {
|
export const PROVIDER_ID_FIELD: Record<MetadataProviderKey, ProviderIdPatchField | undefined> = {
|
||||||
google: 'googleBooksId',
|
google: 'googleBooksId',
|
||||||
@@ -165,6 +167,7 @@ export const PROVIDER_ID_FIELD: Record<MetadataProviderKey, ProviderIdPatchField
|
|||||||
audible: 'audibleId',
|
audible: 'audibleId',
|
||||||
audnexus: undefined,
|
audnexus: undefined,
|
||||||
comicvine: 'comicvineId',
|
comicvine: 'comicvineId',
|
||||||
|
ranobedb: 'ranobedbId',
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROVIDER_ID_PATCH_FIELDS = new Set<string>(Object.values(PROVIDER_ID_FIELD).filter((v): v is ProviderIdPatchField => v !== undefined))
|
const PROVIDER_ID_PATCH_FIELDS = new Set<string>(Object.values(PROVIDER_ID_FIELD).filter((v): v is ProviderIdPatchField => v !== undefined))
|
||||||
@@ -183,6 +186,7 @@ export const PROVIDER_ID_LABEL: Record<MetadataProviderKey, string> = {
|
|||||||
audible: 'Audible ID',
|
audible: 'Audible ID',
|
||||||
audnexus: 'AudNexus ID',
|
audnexus: 'AudNexus ID',
|
||||||
comicvine: 'ComicVine ID',
|
comicvine: 'ComicVine ID',
|
||||||
|
ranobedb: 'RanobeDB ID',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCandidateValueFrom(candidate: MetadataCandidate, key: DiffFieldKey): string {
|
export function getCandidateValueFrom(candidate: MetadataCandidate, key: DiffFieldKey): string {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const ROOT_FIELDS = [
|
|||||||
'itunesId',
|
'itunesId',
|
||||||
'audibleId',
|
'audibleId',
|
||||||
'comicvineId',
|
'comicvineId',
|
||||||
|
'ranobedbId',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const COMIC_FIELDS = {
|
const COMIC_FIELDS = {
|
||||||
@@ -79,6 +80,7 @@ export function useMetadataEditor() {
|
|||||||
itunesId: null as string | null,
|
itunesId: null as string | null,
|
||||||
audibleId: null as string | null,
|
audibleId: null as string | null,
|
||||||
comicvineId: null as string | null,
|
comicvineId: null as string | null,
|
||||||
|
ranobedbId: null as string | null,
|
||||||
comicIssueNumber: null as string | null,
|
comicIssueNumber: null as string | null,
|
||||||
comicVolumeName: null as string | null,
|
comicVolumeName: null as string | null,
|
||||||
comicStoryArcs: [] as string[],
|
comicStoryArcs: [] as string[],
|
||||||
@@ -124,6 +126,7 @@ export function useMetadataEditor() {
|
|||||||
form.itunesId = book.providerIds.itunes ?? null
|
form.itunesId = book.providerIds.itunes ?? null
|
||||||
form.audibleId = book.providerIds.audible ?? null
|
form.audibleId = book.providerIds.audible ?? null
|
||||||
form.comicvineId = book.providerIds.comicvine ?? null
|
form.comicvineId = book.providerIds.comicvine ?? null
|
||||||
|
form.ranobedbId = book.providerIds.ranobedb ?? null
|
||||||
const cm = book.comicMetadata
|
const cm = book.comicMetadata
|
||||||
form.comicIssueNumber = cm?.issueNumber ?? null
|
form.comicIssueNumber = cm?.issueNumber ?? null
|
||||||
form.comicVolumeName = cm?.volumeName ?? null
|
form.comicVolumeName = cm?.volumeName ?? null
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface MetadataRefreshPreview {
|
|||||||
itunesId?: string | null
|
itunesId?: string | null
|
||||||
audibleId?: string | null
|
audibleId?: string | null
|
||||||
comicvineId?: string | null
|
comicvineId?: string | null
|
||||||
|
ranobedbId?: string | null
|
||||||
audioMetadata?: {
|
audioMetadata?: {
|
||||||
narrators?: string[]
|
narrators?: string[]
|
||||||
durationSeconds?: number | null
|
durationSeconds?: number | null
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ const rows: RowDef[] = [
|
|||||||
blockedMessage: 'ComicVine requires an API key before it can be enabled',
|
blockedMessage: 'ComicVine requires an API key before it can be enabled',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{ key: 'ranobedb', label: 'RanobeDB', hint: 'Japanese light novel metadata from RanobeDB. No setup required.', fields: [] },
|
||||||
]
|
]
|
||||||
|
|
||||||
const TESTABLE_PROVIDERS: MetadataProviderKey[] = [MetadataProviderKey.AMAZON, MetadataProviderKey.HARDCOVER]
|
const TESTABLE_PROVIDERS: MetadataProviderKey[] = [MetadataProviderKey.AMAZON, MetadataProviderKey.HARDCOVER]
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { getProviderColor, providerBadgeStyle, providerChipStyle, PROVIDER_SHORT_LABELS } from '../provider-colors'
|
||||||
|
|
||||||
|
const RANOBEDB_COLOR = '#a78cff'
|
||||||
|
|
||||||
|
describe('getProviderColor', () => {
|
||||||
|
it('returns correct color for ranobedb', () => {
|
||||||
|
expect(getProviderColor('ranobedb')).toBe(RANOBEDB_COLOR)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns correct color for comicvine', () => {
|
||||||
|
expect(getProviderColor('comicvine')).toBe('#ffdb0f')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns default color for unknown provider', () => {
|
||||||
|
const result = getProviderColor('unknown-provider')
|
||||||
|
expect(result).toBeTruthy()
|
||||||
|
expect(result).not.toBe(RANOBEDB_COLOR)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a color for all known providers', () => {
|
||||||
|
const providers = ['google', 'amazon', 'goodreads', 'hardcover', 'openLibrary', 'itunes', 'audible', 'audnexus', 'comicvine', 'ranobedb', 'auto']
|
||||||
|
for (const provider of providers) {
|
||||||
|
expect(getProviderColor(provider)).toBeTruthy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('PROVIDER_SHORT_LABELS', () => {
|
||||||
|
it('has a label for ranobedb', () => {
|
||||||
|
expect(PROVIDER_SHORT_LABELS['ranobedb']).toBe('RanobeDB')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has labels for all providers', () => {
|
||||||
|
const providers = ['google', 'amazon', 'goodreads', 'hardcover', 'openLibrary', 'itunes', 'audible', 'audnexus', 'comicvine', 'ranobedb']
|
||||||
|
for (const provider of providers) {
|
||||||
|
expect(PROVIDER_SHORT_LABELS[provider]).toBeTruthy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('providerBadgeStyle', () => {
|
||||||
|
it('returns a style object for ranobedb', () => {
|
||||||
|
const style = providerBadgeStyle('ranobedb')
|
||||||
|
expect(style).toHaveProperty('backgroundColor')
|
||||||
|
expect(style).toHaveProperty('color')
|
||||||
|
expect(style).toHaveProperty('outlineColor')
|
||||||
|
expect(style.color).toBe(RANOBEDB_COLOR)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a style object for unknown providers without throwing', () => {
|
||||||
|
expect(() => providerBadgeStyle('unknown')).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('providerChipStyle', () => {
|
||||||
|
it('returns a style object for ranobedb when not disabled', () => {
|
||||||
|
const style = providerChipStyle('ranobedb', false)
|
||||||
|
expect(style).toHaveProperty('backgroundColor')
|
||||||
|
expect(style.color).toBe(RANOBEDB_COLOR)
|
||||||
|
expect(style.opacity).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets opacity when disabled', () => {
|
||||||
|
const style = providerChipStyle('ranobedb', true)
|
||||||
|
expect(style.opacity).toBe('0.75')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,6 +10,7 @@ const PROVIDER_HEX: Record<string, string> = {
|
|||||||
audible: '#FF8A00',
|
audible: '#FF8A00',
|
||||||
audnexus: '#FF5ADD',
|
audnexus: '#FF5ADD',
|
||||||
comicvine: '#ffdb0f',
|
comicvine: '#ffdb0f',
|
||||||
|
ranobedb: '#a78cff',
|
||||||
auto: '#8B5CF6',
|
auto: '#8B5CF6',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export const PROVIDER_SHORT_LABELS: Record<string, string> = {
|
|||||||
audible: 'Audible',
|
audible: 'Audible',
|
||||||
audnexus: 'AudNexus',
|
audnexus: 'AudNexus',
|
||||||
comicvine: 'ComicVine',
|
comicvine: 'ComicVine',
|
||||||
|
ranobedb: 'RanobeDB',
|
||||||
auto: 'Fetched',
|
auto: 'Fetched',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const MetadataProviderKey = {
|
|||||||
AUDIBLE: "audible",
|
AUDIBLE: "audible",
|
||||||
AUDNEXUS: "audnexus",
|
AUDNEXUS: "audnexus",
|
||||||
COMICVINE: "comicvine",
|
COMICVINE: "comicvine",
|
||||||
|
RANOBEDB: "ranobedb",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export interface ComicMetadataFields {
|
export interface ComicMetadataFields {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const BOOK_METADATA_LOCK_FIELDS = [
|
|||||||
"itunesId",
|
"itunesId",
|
||||||
"audibleId",
|
"audibleId",
|
||||||
"comicvineId",
|
"comicvineId",
|
||||||
|
"ranobedbId",
|
||||||
"comicIssueNumber",
|
"comicIssueNumber",
|
||||||
"comicVolumeName",
|
"comicVolumeName",
|
||||||
"comicStoryArcs",
|
"comicStoryArcs",
|
||||||
@@ -73,7 +74,7 @@ export const BOOK_METADATA_LOCK_GROUPS: BookMetadataLockGroup[] = [
|
|||||||
{
|
{
|
||||||
key: "providers",
|
key: "providers",
|
||||||
label: "Provider IDs",
|
label: "Provider IDs",
|
||||||
fields: ["googleBooksId", "goodreadsId", "amazonId", "hardcoverId", "openLibraryId", "itunesId", "audibleId", "comicvineId"],
|
fields: ["googleBooksId", "goodreadsId", "amazonId", "hardcoverId", "openLibraryId", "itunesId", "audibleId", "comicvineId", "ranobedbId"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "comic",
|
key: "comic",
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export interface ProviderConfigurations {
|
|||||||
audible: AudibleProviderConfig;
|
audible: AudibleProviderConfig;
|
||||||
audnexus: AudnexusProviderConfig;
|
audnexus: AudnexusProviderConfig;
|
||||||
comicvine: ComicVineProviderConfig;
|
comicvine: ComicVineProviderConfig;
|
||||||
|
ranobedb: SimpleProviderConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderStatus {
|
export interface ProviderStatus {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "book_metadata" ADD COLUMN "ranobedb_id" varchar(50);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,13 @@
|
|||||||
"when": 1780190394625,
|
"when": 1780190394625,
|
||||||
"tag": "0010_fix_reading_threshold",
|
"tag": "0010_fix_reading_threshold",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780269274384,
|
||||||
|
"tag": "0011_add_ranobedb_id",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const bookMetadata = pgTable(
|
|||||||
abridged: boolean('abridged').notNull().default(false),
|
abridged: boolean('abridged').notNull().default(false),
|
||||||
audibleId: varchar('audible_id', { length: 20 }),
|
audibleId: varchar('audible_id', { length: 20 }),
|
||||||
comicvineId: varchar('comicvine_id', { length: 50 }),
|
comicvineId: varchar('comicvine_id', { length: 50 }),
|
||||||
|
ranobedbId: varchar('ranobedb_id', { length: 50 }),
|
||||||
chapters: jsonb('chapters'),
|
chapters: jsonb('chapters'),
|
||||||
lockedFields: text('locked_fields')
|
lockedFields: text('locked_fields')
|
||||||
.array()
|
.array()
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
|||||||
if (filteredProviderIds[MetadataProviderKey.ITUNES]) scalarFields.itunesId = filteredProviderIds[MetadataProviderKey.ITUNES];
|
if (filteredProviderIds[MetadataProviderKey.ITUNES]) scalarFields.itunesId = filteredProviderIds[MetadataProviderKey.ITUNES];
|
||||||
if (filteredProviderIds[MetadataProviderKey.AUDIBLE]) scalarFields.audibleId = filteredProviderIds[MetadataProviderKey.AUDIBLE];
|
if (filteredProviderIds[MetadataProviderKey.AUDIBLE]) scalarFields.audibleId = filteredProviderIds[MetadataProviderKey.AUDIBLE];
|
||||||
if (filteredProviderIds[MetadataProviderKey.COMICVINE]) scalarFields.comicvineId = filteredProviderIds[MetadataProviderKey.COMICVINE];
|
if (filteredProviderIds[MetadataProviderKey.COMICVINE]) scalarFields.comicvineId = filteredProviderIds[MetadataProviderKey.COMICVINE];
|
||||||
|
if (filteredProviderIds[MetadataProviderKey.RANOBEDB]) scalarFields.ranobedbId = filteredProviderIds[MetadataProviderKey.RANOBEDB];
|
||||||
|
|
||||||
const duration = this.asNullableNumber(filteredResolved.duration);
|
const duration = this.asNullableNumber(filteredResolved.duration);
|
||||||
if (duration !== undefined) scalarFields.durationSeconds = duration;
|
if (duration !== undefined) scalarFields.durationSeconds = duration;
|
||||||
@@ -375,6 +376,8 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
|||||||
openLibraryId?: string | null;
|
openLibraryId?: string | null;
|
||||||
itunesId?: string | null;
|
itunesId?: string | null;
|
||||||
audibleId?: string | null;
|
audibleId?: string | null;
|
||||||
|
comicvineId?: string | null;
|
||||||
|
ranobedbId?: string | null;
|
||||||
}): Partial<Record<MetadataProviderKey, string>> {
|
}): Partial<Record<MetadataProviderKey, string>> {
|
||||||
const ids: Partial<Record<MetadataProviderKey, string>> = {};
|
const ids: Partial<Record<MetadataProviderKey, string>> = {};
|
||||||
if (meta.googleBooksId) ids[MetadataProviderKey.GOOGLE] = meta.googleBooksId;
|
if (meta.googleBooksId) ids[MetadataProviderKey.GOOGLE] = meta.googleBooksId;
|
||||||
@@ -384,6 +387,8 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
|||||||
if (meta.openLibraryId) ids[MetadataProviderKey.OPEN_LIBRARY] = meta.openLibraryId;
|
if (meta.openLibraryId) ids[MetadataProviderKey.OPEN_LIBRARY] = meta.openLibraryId;
|
||||||
if (meta.itunesId) ids[MetadataProviderKey.ITUNES] = meta.itunesId;
|
if (meta.itunesId) ids[MetadataProviderKey.ITUNES] = meta.itunesId;
|
||||||
if (meta.audibleId) ids[MetadataProviderKey.AUDIBLE] = meta.audibleId;
|
if (meta.audibleId) ids[MetadataProviderKey.AUDIBLE] = meta.audibleId;
|
||||||
|
if (meta.comicvineId) ids[MetadataProviderKey.COMICVINE] = meta.comicvineId;
|
||||||
|
if (meta.ranobedbId) ids[MetadataProviderKey.RANOBEDB] = meta.ranobedbId;
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const PROVIDER_ID_LOCK_FIELDS_BY_PROVIDER: Partial<Record<MetadataProviderKey, B
|
|||||||
[MetadataProviderKey.ITUNES]: 'itunesId',
|
[MetadataProviderKey.ITUNES]: 'itunesId',
|
||||||
[MetadataProviderKey.AUDIBLE]: 'audibleId',
|
[MetadataProviderKey.AUDIBLE]: 'audibleId',
|
||||||
[MetadataProviderKey.COMICVINE]: 'comicvineId',
|
[MetadataProviderKey.COMICVINE]: 'comicvineId',
|
||||||
|
[MetadataProviderKey.RANOBEDB]: 'ranobedbId',
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMIC_METADATA_LOCK_FIELDS: Record<keyof ComicMetadataFields, BookMetadataLockField> = {
|
const COMIC_METADATA_LOCK_FIELDS: Record<keyof ComicMetadataFields, BookMetadataLockField> = {
|
||||||
@@ -237,6 +238,7 @@ export class BookMetadataLockService {
|
|||||||
this.copyUpdateField(filteredDto, dto, 'itunesId', 'itunesId', lockedSet, skippedFields);
|
this.copyUpdateField(filteredDto, dto, 'itunesId', 'itunesId', lockedSet, skippedFields);
|
||||||
this.copyUpdateField(filteredDto, dto, 'audibleId', 'audibleId', lockedSet, skippedFields);
|
this.copyUpdateField(filteredDto, dto, 'audibleId', 'audibleId', lockedSet, skippedFields);
|
||||||
this.copyUpdateField(filteredDto, dto, 'comicvineId', 'comicvineId', lockedSet, skippedFields);
|
this.copyUpdateField(filteredDto, dto, 'comicvineId', 'comicvineId', lockedSet, skippedFields);
|
||||||
|
this.copyUpdateField(filteredDto, dto, 'ranobedbId', 'ranobedbId', lockedSet, skippedFields);
|
||||||
|
|
||||||
if (dto.audioMetadata) {
|
if (dto.audioMetadata) {
|
||||||
const filteredAudioMetadata: NonNullable<UpdateBookMetadataDto['audioMetadata']> = {};
|
const filteredAudioMetadata: NonNullable<UpdateBookMetadataDto['audioMetadata']> = {};
|
||||||
@@ -360,6 +362,7 @@ export class BookMetadataLockService {
|
|||||||
this.addFieldIfPresent(fields, dto, 'itunesId', 'itunesId');
|
this.addFieldIfPresent(fields, dto, 'itunesId', 'itunesId');
|
||||||
this.addFieldIfPresent(fields, dto, 'audibleId', 'audibleId');
|
this.addFieldIfPresent(fields, dto, 'audibleId', 'audibleId');
|
||||||
this.addFieldIfPresent(fields, dto, 'comicvineId', 'comicvineId');
|
this.addFieldIfPresent(fields, dto, 'comicvineId', 'comicvineId');
|
||||||
|
this.addFieldIfPresent(fields, dto, 'ranobedbId', 'ranobedbId');
|
||||||
|
|
||||||
if (dto.audioMetadata) {
|
if (dto.audioMetadata) {
|
||||||
this.addFieldIfPresent(fields, dto.audioMetadata, 'narrators', 'narrators');
|
this.addFieldIfPresent(fields, dto.audioMetadata, 'narrators', 'narrators');
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ export class BookService {
|
|||||||
itunesId?: string | null;
|
itunesId?: string | null;
|
||||||
audibleId?: string | null;
|
audibleId?: string | null;
|
||||||
comicvineId?: string | null;
|
comicvineId?: string | null;
|
||||||
|
ranobedbId?: string | null;
|
||||||
}): Partial<Record<MetadataProviderKey, string>> {
|
}): Partial<Record<MetadataProviderKey, string>> {
|
||||||
const providerIds: Partial<Record<MetadataProviderKey, string>> = {};
|
const providerIds: Partial<Record<MetadataProviderKey, string>> = {};
|
||||||
if (meta.googleBooksId) providerIds[MetadataProviderKey.GOOGLE] = meta.googleBooksId;
|
if (meta.googleBooksId) providerIds[MetadataProviderKey.GOOGLE] = meta.googleBooksId;
|
||||||
@@ -255,13 +256,14 @@ export class BookService {
|
|||||||
if (meta.itunesId) providerIds[MetadataProviderKey.ITUNES] = meta.itunesId;
|
if (meta.itunesId) providerIds[MetadataProviderKey.ITUNES] = meta.itunesId;
|
||||||
if (meta.audibleId) providerIds[MetadataProviderKey.AUDIBLE] = meta.audibleId;
|
if (meta.audibleId) providerIds[MetadataProviderKey.AUDIBLE] = meta.audibleId;
|
||||||
if (meta.comicvineId) providerIds[MetadataProviderKey.COMICVINE] = meta.comicvineId;
|
if (meta.comicvineId) providerIds[MetadataProviderKey.COMICVINE] = meta.comicvineId;
|
||||||
|
if (meta.ranobedbId) providerIds[MetadataProviderKey.RANOBEDB] = meta.ranobedbId;
|
||||||
return providerIds;
|
return providerIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyResolvedProviderIds(
|
private applyResolvedProviderIds(
|
||||||
dto: Pick<
|
dto: Pick<
|
||||||
UpdateBookMetadataDto,
|
UpdateBookMetadataDto,
|
||||||
'googleBooksId' | 'goodreadsId' | 'amazonId' | 'hardcoverId' | 'openLibraryId' | 'itunesId' | 'audibleId' | 'comicvineId'
|
'googleBooksId' | 'goodreadsId' | 'amazonId' | 'hardcoverId' | 'openLibraryId' | 'itunesId' | 'audibleId' | 'comicvineId' | 'ranobedbId'
|
||||||
>,
|
>,
|
||||||
providerIds: Partial<Record<MetadataProviderKey, string>>,
|
providerIds: Partial<Record<MetadataProviderKey, string>>,
|
||||||
): void {
|
): void {
|
||||||
@@ -273,6 +275,7 @@ export class BookService {
|
|||||||
if (providerIds[MetadataProviderKey.ITUNES]) dto.itunesId = providerIds[MetadataProviderKey.ITUNES];
|
if (providerIds[MetadataProviderKey.ITUNES]) dto.itunesId = providerIds[MetadataProviderKey.ITUNES];
|
||||||
if (providerIds[MetadataProviderKey.AUDIBLE]) dto.audibleId = providerIds[MetadataProviderKey.AUDIBLE];
|
if (providerIds[MetadataProviderKey.AUDIBLE]) dto.audibleId = providerIds[MetadataProviderKey.AUDIBLE];
|
||||||
if (providerIds[MetadataProviderKey.COMICVINE]) dto.comicvineId = providerIds[MetadataProviderKey.COMICVINE];
|
if (providerIds[MetadataProviderKey.COMICVINE]) dto.comicvineId = providerIds[MetadataProviderKey.COMICVINE];
|
||||||
|
if (providerIds[MetadataProviderKey.RANOBEDB]) dto.ranobedbId = providerIds[MetadataProviderKey.RANOBEDB];
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyBookAccess(bookId: number, user: RequestUser): Promise<void> {
|
async verifyBookAccess(bookId: number, user: RequestUser): Promise<void> {
|
||||||
@@ -1177,6 +1180,7 @@ export class BookService {
|
|||||||
if (dto.itunesId !== undefined) scalarFields.itunesId = dto.itunesId ?? null;
|
if (dto.itunesId !== undefined) scalarFields.itunesId = dto.itunesId ?? null;
|
||||||
if (dto.audibleId !== undefined) scalarFields.audibleId = dto.audibleId ?? null;
|
if (dto.audibleId !== undefined) scalarFields.audibleId = dto.audibleId ?? null;
|
||||||
if (dto.comicvineId !== undefined) scalarFields.comicvineId = dto.comicvineId ?? null;
|
if (dto.comicvineId !== undefined) scalarFields.comicvineId = dto.comicvineId ?? null;
|
||||||
|
if (dto.ranobedbId !== undefined) scalarFields.ranobedbId = dto.ranobedbId ?? null;
|
||||||
if (dto.rating !== undefined) scalarFields.rating = dto.rating ?? null;
|
if (dto.rating !== undefined) scalarFields.rating = dto.rating ?? null;
|
||||||
if (dto.audioMetadata) {
|
if (dto.audioMetadata) {
|
||||||
if (dto.audioMetadata.durationSeconds !== undefined) scalarFields.durationSeconds = dto.audioMetadata.durationSeconds ?? null;
|
if (dto.audioMetadata.durationSeconds !== undefined) scalarFields.durationSeconds = dto.audioMetadata.durationSeconds ?? null;
|
||||||
@@ -2312,6 +2316,7 @@ export class BookService {
|
|||||||
[MetadataProviderKey.ITUNES]: meta?.itunesId ?? null,
|
[MetadataProviderKey.ITUNES]: meta?.itunesId ?? null,
|
||||||
[MetadataProviderKey.AUDIBLE]: meta?.audibleId ?? null,
|
[MetadataProviderKey.AUDIBLE]: meta?.audibleId ?? null,
|
||||||
[MetadataProviderKey.COMICVINE]: meta?.comicvineId ?? null,
|
[MetadataProviderKey.COMICVINE]: meta?.comicvineId ?? null,
|
||||||
|
[MetadataProviderKey.RANOBEDB]: meta?.ranobedbId ?? null,
|
||||||
},
|
},
|
||||||
authors: authorRows,
|
authors: authorRows,
|
||||||
genres: genreRows.map((g) => g.name),
|
genres: genreRows.map((g) => g.name),
|
||||||
|
|||||||
@@ -53,5 +53,6 @@ export class UpdateBookMetadataDto {
|
|||||||
@IsOptional() @IsString() @MaxLength(20) audibleId?: string | null;
|
@IsOptional() @IsString() @MaxLength(20) audibleId?: string | null;
|
||||||
@IsOptional() @ValidateNested() @Type(() => AudioMetadataDto) audioMetadata?: AudioMetadataDto;
|
@IsOptional() @ValidateNested() @Type(() => AudioMetadataDto) audioMetadata?: AudioMetadataDto;
|
||||||
@IsOptional() @IsString() @MaxLength(50) comicvineId?: string | null;
|
@IsOptional() @IsString() @MaxLength(50) comicvineId?: string | null;
|
||||||
|
@IsOptional() @IsString() @MaxLength(50) ranobedbId?: string | null;
|
||||||
@IsOptional() @ValidateNested() @Type(() => ComicMetadataDto) comicMetadata?: ComicMetadataDto;
|
@IsOptional() @ValidateNested() @Type(() => ComicMetadataDto) comicMetadata?: ComicMetadataDto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const baseConfig: ProviderConfigurations = {
|
|||||||
audible: { enabled: false, domain: 'com' },
|
audible: { enabled: false, domain: 'com' },
|
||||||
audnexus: { enabled: false },
|
audnexus: { enabled: false },
|
||||||
comicvine: { enabled: false, apiKey: '' },
|
comicvine: { enabled: false, apiKey: '' },
|
||||||
|
ranobedb: { enabled: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
function mockJsonResponse(body: unknown, status = 200): Response {
|
function mockJsonResponse(body: unknown, status = 200): Response {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { AudibleProvider } from './providers/audible/audible.provider';
|
|||||||
import { AudnexusProvider } from './providers/audnexus/audnexus.provider';
|
import { AudnexusProvider } from './providers/audnexus/audnexus.provider';
|
||||||
import { HardcoverProvider } from './providers/hardcover/hardcover.provider';
|
import { HardcoverProvider } from './providers/hardcover/hardcover.provider';
|
||||||
import { ComicVineProvider } from './providers/comicvine/comicvine.provider';
|
import { ComicVineProvider } from './providers/comicvine/comicvine.provider';
|
||||||
|
import { RanobeDbProvider } from './providers/ranobedb/ranobedb.provider';
|
||||||
|
|
||||||
describe('MetadataFetchModule', () => {
|
describe('MetadataFetchModule', () => {
|
||||||
it('registers provider classes and factory wiring for METADATA_PROVIDERS', () => {
|
it('registers provider classes and factory wiring for METADATA_PROVIDERS', () => {
|
||||||
@@ -33,6 +34,7 @@ describe('MetadataFetchModule', () => {
|
|||||||
AudnexusProvider,
|
AudnexusProvider,
|
||||||
HardcoverProvider,
|
HardcoverProvider,
|
||||||
ComicVineProvider,
|
ComicVineProvider,
|
||||||
|
RanobeDbProvider,
|
||||||
ProviderRegistry,
|
ProviderRegistry,
|
||||||
MetadataFetchRepository,
|
MetadataFetchRepository,
|
||||||
MetadataFetchService,
|
MetadataFetchService,
|
||||||
@@ -56,6 +58,7 @@ describe('MetadataFetchModule', () => {
|
|||||||
AudnexusProvider,
|
AudnexusProvider,
|
||||||
HardcoverProvider,
|
HardcoverProvider,
|
||||||
ComicVineProvider,
|
ComicVineProvider,
|
||||||
|
RanobeDbProvider,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const google = { key: 'google' };
|
const google = { key: 'google' };
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { HardcoverClient } from './providers/hardcover/hardcover.client';
|
|||||||
import { HardcoverProvider } from './providers/hardcover/hardcover.provider';
|
import { HardcoverProvider } from './providers/hardcover/hardcover.provider';
|
||||||
import { ComicVineClient } from './providers/comicvine/comicvine.client';
|
import { ComicVineClient } from './providers/comicvine/comicvine.client';
|
||||||
import { ComicVineProvider } from './providers/comicvine/comicvine.provider';
|
import { ComicVineProvider } from './providers/comicvine/comicvine.provider';
|
||||||
|
import { RanobeDbClient } from './providers/ranobedb/ranobedb.client';
|
||||||
|
import { RanobeDbProvider } from './providers/ranobedb/ranobedb.provider';
|
||||||
|
|
||||||
const PROVIDER_CLASSES = [
|
const PROVIDER_CLASSES = [
|
||||||
GoogleProvider,
|
GoogleProvider,
|
||||||
@@ -31,6 +33,7 @@ const PROVIDER_CLASSES = [
|
|||||||
AudnexusProvider,
|
AudnexusProvider,
|
||||||
HardcoverProvider,
|
HardcoverProvider,
|
||||||
ComicVineProvider,
|
ComicVineProvider,
|
||||||
|
RanobeDbProvider,
|
||||||
];
|
];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -44,6 +47,7 @@ const PROVIDER_CLASSES = [
|
|||||||
},
|
},
|
||||||
HardcoverClient,
|
HardcoverClient,
|
||||||
ComicVineClient,
|
ComicVineClient,
|
||||||
|
RanobeDbClient,
|
||||||
ProviderRegistry,
|
ProviderRegistry,
|
||||||
ProviderThrottleTracker,
|
ProviderThrottleTracker,
|
||||||
MetadataFetchRepository,
|
MetadataFetchRepository,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface StoredProviderIdsRow {
|
|||||||
itunesId: string | null;
|
itunesId: string | null;
|
||||||
audibleId: string | null;
|
audibleId: string | null;
|
||||||
comicvineId: string | null;
|
comicvineId: string | null;
|
||||||
|
ranobedbId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -36,6 +37,7 @@ export class MetadataFetchRepository {
|
|||||||
itunesId: bookMetadata.itunesId,
|
itunesId: bookMetadata.itunesId,
|
||||||
audibleId: bookMetadata.audibleId,
|
audibleId: bookMetadata.audibleId,
|
||||||
comicvineId: bookMetadata.comicvineId,
|
comicvineId: bookMetadata.comicvineId,
|
||||||
|
ranobedbId: bookMetadata.ranobedbId,
|
||||||
})
|
})
|
||||||
.from(books)
|
.from(books)
|
||||||
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export class MetadataFetchService {
|
|||||||
[MetadataProviderKey.ITUNES]: row.itunesId ?? undefined,
|
[MetadataProviderKey.ITUNES]: row.itunesId ?? undefined,
|
||||||
[MetadataProviderKey.AUDIBLE]: row.audibleId ?? undefined,
|
[MetadataProviderKey.AUDIBLE]: row.audibleId ?? undefined,
|
||||||
[MetadataProviderKey.COMICVINE]: row.comicvineId ?? undefined,
|
[MetadataProviderKey.COMICVINE]: row.comicvineId ?? undefined,
|
||||||
|
[MetadataProviderKey.RANOBEDB]: row.ranobedbId ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ describe('AmazonProvider', () => {
|
|||||||
audible: { enabled: false, domain: 'com' },
|
audible: { enabled: false, domain: 'com' },
|
||||||
audnexus: { enabled: false },
|
audnexus: { enabled: false },
|
||||||
comicvine: { enabled: false, apiKey: '' },
|
comicvine: { enabled: false, apiKey: '' },
|
||||||
|
ranobedb: { enabled: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const baseConfig: ProviderConfigurations = {
|
|||||||
audible: { enabled: true, domain: 'com' },
|
audible: { enabled: true, domain: 'com' },
|
||||||
audnexus: { enabled: false },
|
audnexus: { enabled: false },
|
||||||
comicvine: { enabled: false, apiKey: '' },
|
comicvine: { enabled: false, apiKey: '' },
|
||||||
|
ranobedb: { enabled: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
function makeProvider(config: ProviderConfigurations = baseConfig): {
|
function makeProvider(config: ProviderConfigurations = baseConfig): {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ describe('AudnexusProvider', () => {
|
|||||||
audible: { enabled: false, domain: 'audible.com' },
|
audible: { enabled: false, domain: 'audible.com' },
|
||||||
audnexus: { enabled: true },
|
audnexus: { enabled: true },
|
||||||
comicvine: { enabled: false, apiKey: '' },
|
comicvine: { enabled: false, apiKey: '' },
|
||||||
|
ranobedb: { enabled: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ describe('ComicVineProvider', () => {
|
|||||||
it('returns empty array when provider is disabled', async () => {
|
it('returns empty array when provider is disabled', async () => {
|
||||||
vi.spyOn(providerConfig, 'getConfig').mockResolvedValue({
|
vi.spyOn(providerConfig, 'getConfig').mockResolvedValue({
|
||||||
...mockConfig,
|
...mockConfig,
|
||||||
comicvine: { enabled: false, apiKey: 'test-key' },
|
comicvine: { enabled: false, apiKey: '' },
|
||||||
|
ranobedb: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await provider.search({ title: 'Batman #1' })).toEqual([]);
|
expect(await provider.search({ title: 'Batman #1' })).toEqual([]);
|
||||||
@@ -308,7 +309,8 @@ describe('ComicVineProvider', () => {
|
|||||||
it('returns null when disabled', async () => {
|
it('returns null when disabled', async () => {
|
||||||
vi.spyOn(providerConfig, 'getConfig').mockResolvedValue({
|
vi.spyOn(providerConfig, 'getConfig').mockResolvedValue({
|
||||||
...mockConfig,
|
...mockConfig,
|
||||||
comicvine: { enabled: false, apiKey: 'test-key' },
|
comicvine: { enabled: false, apiKey: '' },
|
||||||
|
ranobedb: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await provider.lookupById('100')).toBeNull();
|
expect(await provider.lookupById('100')).toBeNull();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ describe('GoodreadsProvider', () => {
|
|||||||
audible: { enabled: false, domain: 'com' },
|
audible: { enabled: false, domain: 'com' },
|
||||||
audnexus: { enabled: false },
|
audnexus: { enabled: false },
|
||||||
comicvine: { enabled: false, apiKey: '' },
|
comicvine: { enabled: false, apiKey: '' },
|
||||||
|
ranobedb: { enabled: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ describe('GoogleProvider', () => {
|
|||||||
audible: { enabled: false, domain: 'com' },
|
audible: { enabled: false, domain: 'com' },
|
||||||
audnexus: { enabled: false },
|
audnexus: { enabled: false },
|
||||||
comicvine: { enabled: false, apiKey: '' },
|
comicvine: { enabled: false, apiKey: '' },
|
||||||
|
ranobedb: { enabled: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const mockConfig: ProviderConfigurations = {
|
|||||||
audible: { enabled: false, domain: 'com' },
|
audible: { enabled: false, domain: 'com' },
|
||||||
audnexus: { enabled: false },
|
audnexus: { enabled: false },
|
||||||
comicvine: { enabled: false, apiKey: '' },
|
comicvine: { enabled: false, apiKey: '' },
|
||||||
|
ranobedb: { enabled: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockDocument: HardcoverSearchDocument = {
|
const mockDocument: HardcoverSearchDocument = {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ describe('ITunesProvider', () => {
|
|||||||
audible: { enabled: false, domain: 'com' },
|
audible: { enabled: false, domain: 'com' },
|
||||||
audnexus: { enabled: false },
|
audnexus: { enabled: false },
|
||||||
comicvine: { enabled: false, apiKey: '' },
|
comicvine: { enabled: false, apiKey: '' },
|
||||||
|
ranobedb: { enabled: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ describe('OpenLibraryProvider', () => {
|
|||||||
audible: { enabled: false, domain: 'com' },
|
audible: { enabled: false, domain: 'com' },
|
||||||
audnexus: { enabled: false },
|
audnexus: { enabled: false },
|
||||||
comicvine: { enabled: false, apiKey: '' },
|
comicvine: { enabled: false, apiKey: '' },
|
||||||
|
ranobedb: { enabled: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const PROVIDER_LIMITS = {
|
|||||||
AMAZON_MAX_RESULTS: 3,
|
AMAZON_MAX_RESULTS: 3,
|
||||||
GOODREADS_MAX_RESULTS: 3,
|
GOODREADS_MAX_RESULTS: 3,
|
||||||
COMICVINE_MAX_RESULTS: 10,
|
COMICVINE_MAX_RESULTS: 10,
|
||||||
|
RANOBEDB_MAX_RESULTS: 5,
|
||||||
DEFAULT_SEARCH_RESULTS: 10,
|
DEFAULT_SEARCH_RESULTS: 10,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -15,4 +16,5 @@ export const PROVIDER_DELAYS_MS = {
|
|||||||
GOODREADS_BETWEEN_REQUESTS: 600,
|
GOODREADS_BETWEEN_REQUESTS: 600,
|
||||||
HARDCOVER_RATE_LIMIT: 1_000,
|
HARDCOVER_RATE_LIMIT: 1_000,
|
||||||
COMICVINE_VELOCITY_GUARD: 1_000,
|
COMICVINE_VELOCITY_GUARD: 1_000,
|
||||||
|
RANOBEDB_BETWEEN_REQUESTS: 500,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import * as fetchWithThrottleModule from '../../fetch-with-throttle';
|
||||||
|
import { ProviderThrottleError } from '../../provider-throttle.error';
|
||||||
|
import { RanobeDbClient } from './ranobedb.client';
|
||||||
|
|
||||||
|
vi.mock('../../fetch-with-throttle', () => ({
|
||||||
|
fetchWithThrottle: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../provider-utils', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../provider-utils')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
sleep: vi.fn().mockResolvedValue(undefined),
|
||||||
|
buildRequestSignal: vi.fn().mockReturnValue(new AbortController().signal),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RanobeDbClient', () => {
|
||||||
|
let client: RanobeDbClient;
|
||||||
|
let mockFetch: ReturnType<typeof vi.mocked<typeof fetchWithThrottleModule.fetchWithThrottle>>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = new RanobeDbClient();
|
||||||
|
mockFetch = vi.mocked(fetchWithThrottleModule.fetchWithThrottle);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('search()', () => {
|
||||||
|
it('returns array of book IDs on success', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({ books: [{ id: 1 }, { id: 2 }, { id: 3 }], count: 3, currentPage: 1, totalPages: 1 }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await client.search('Sword Art Online');
|
||||||
|
expect(result).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds query parameters correctly', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({ books: [], count: 0, currentPage: 1, totalPages: 0 }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await client.search('Re:Zero');
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('q=Re%3AZero'), expect.any(Object));
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('limit=5'), expect.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends correct User-Agent header', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({ books: [], count: 0, currentPage: 1, totalPages: 0 }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await client.search('test');
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
'User-Agent': expect.stringContaining('bookorbit'),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when response is not ok', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await client.search('query');
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when fetch throws a generic error', async () => {
|
||||||
|
mockFetch.mockRejectedValue(new Error('network failure'));
|
||||||
|
|
||||||
|
const result = await client.search('query');
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rethrows ProviderThrottleError', async () => {
|
||||||
|
mockFetch.mockRejectedValue(new ProviderThrottleError('ranobedb', 1000));
|
||||||
|
|
||||||
|
await expect(client.search('query')).rejects.toThrow(ProviderThrottleError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when books field is missing from response', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await client.search('query');
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchBook()', () => {
|
||||||
|
it('returns book response on success', async () => {
|
||||||
|
const mockBook = {
|
||||||
|
id: 42,
|
||||||
|
title: 'Test Book',
|
||||||
|
romaji: null,
|
||||||
|
lang: 'ja',
|
||||||
|
c_release_date: 20220101,
|
||||||
|
image: null,
|
||||||
|
rating: null,
|
||||||
|
titles: [],
|
||||||
|
editions: [],
|
||||||
|
releases: [],
|
||||||
|
publishers: [],
|
||||||
|
series: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({ book: mockBook }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await client.fetchBook(42);
|
||||||
|
expect(result).toEqual({ book: mockBook });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls correct URL', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({ book: { id: 42 } }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await client.fetchBook(42);
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/book/42'), expect.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when response is not ok', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await client.fetchBook(999);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when fetch throws a generic error', async () => {
|
||||||
|
mockFetch.mockRejectedValue(new Error('timeout'));
|
||||||
|
|
||||||
|
const result = await client.fetchBook(1);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rethrows ProviderThrottleError', async () => {
|
||||||
|
mockFetch.mockRejectedValue(new ProviderThrottleError('ranobedb', 1000));
|
||||||
|
|
||||||
|
await expect(client.fetchBook(1)).rejects.toThrow(ProviderThrottleError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { fetchWithThrottle } from '../../fetch-with-throttle';
|
||||||
|
import { ProviderThrottleError } from '../../provider-throttle.error';
|
||||||
|
import { PROVIDER_DELAYS_MS, PROVIDER_TIMEOUT_MS } from '../provider-constants';
|
||||||
|
import { buildRequestSignal, sanitizeLogError, sleep } from '../provider-utils';
|
||||||
|
import { RanobeDbBookResponse, RanobeDbSearchResponse } from './ranobedb.types';
|
||||||
|
|
||||||
|
const BASE_URL = 'https://ranobedb.org/api/v0';
|
||||||
|
const USER_AGENT = 'bookorbit/1.0 (+https://github.com/bookorbit/bookorbit)';
|
||||||
|
|
||||||
|
class RateLimiter {
|
||||||
|
private nextAllowedTime = 0;
|
||||||
|
|
||||||
|
async throttle(signal?: AbortSignal): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
const scheduled = Math.max(now, this.nextAllowedTime);
|
||||||
|
this.nextAllowedTime = scheduled + PROVIDER_DELAYS_MS.RANOBEDB_BETWEEN_REQUESTS;
|
||||||
|
const wait = scheduled - now;
|
||||||
|
if (wait > 0) {
|
||||||
|
await sleep(wait, signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RanobeDbClient {
|
||||||
|
private readonly logger = new Logger(RanobeDbClient.name);
|
||||||
|
private readonly rateLimiter = new RateLimiter();
|
||||||
|
|
||||||
|
async search(query: string, signal?: AbortSignal): Promise<number[]> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
q: query,
|
||||||
|
limit: '5',
|
||||||
|
});
|
||||||
|
const body = await this.get<RanobeDbSearchResponse>('search', `/books?${params.toString()}`, signal);
|
||||||
|
return body?.books?.map((b) => b.id) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchBook(id: number, signal?: AbortSignal): Promise<RanobeDbBookResponse | null> {
|
||||||
|
return this.get<RanobeDbBookResponse>('fetchBook', `/book/${id}`, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async get<T>(op: string, path: string, signal?: AbortSignal): Promise<T | null> {
|
||||||
|
await this.rateLimiter.throttle(signal);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
this.logger.log(`[ranobedb] [start] op=${op} path=${path}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetchWithThrottle(`${BASE_URL}${path}`, {
|
||||||
|
headers: { 'User-Agent': USER_AGENT },
|
||||||
|
signal: buildRequestSignal(PROVIDER_TIMEOUT_MS.DEFAULT, signal),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
this.logger.warn(`[ranobedb] [fail] op=${op} status=${res.status} durationMs=${Date.now() - startedAt} error="non-ok response"`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await res.json()) as T;
|
||||||
|
this.logger.log(`[ranobedb] [end] op=${op} status=${res.status} durationMs=${Date.now() - startedAt}`);
|
||||||
|
return body;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ProviderThrottleError) {
|
||||||
|
this.logger.warn(`[ranobedb] [fail] op=${op} durationMs=${Date.now() - startedAt} error="throttled"`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
this.logger.warn(`[ranobedb] [fail] op=${op} durationMs=${Date.now() - startedAt} error="${sanitizeLogError(err)}"`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import { MetadataProviderKey } from '@bookorbit/types';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { mapRanobeDbBook, parseDateInt } from './ranobedb.mapper';
|
||||||
|
import { RanobeDbBook } from './ranobedb.types';
|
||||||
|
|
||||||
|
const baseBook: RanobeDbBook = {
|
||||||
|
id: 1287,
|
||||||
|
title: 'ソードアート・オンライン',
|
||||||
|
romaji: 'Sword Art Online',
|
||||||
|
lang: 'ja',
|
||||||
|
description: 'A virtual reality adventure.',
|
||||||
|
description_ja: '仮想現実の冒険。',
|
||||||
|
c_release_date: 20120710,
|
||||||
|
image: { id: 10, filename: 'covers/sao.jpg', width: 300, height: 450, nsfw: false, spoiler: false },
|
||||||
|
rating: { score: 8.5, count: 1200 },
|
||||||
|
titles: [
|
||||||
|
{ book_id: 1287, lang: 'en', official: true, title: 'Sword Art Online Vol. 1', romaji: null },
|
||||||
|
{ book_id: 1287, lang: 'ja', official: true, title: 'ソードアート・オンライン', romaji: 'Sword Art Online' },
|
||||||
|
],
|
||||||
|
editions: [
|
||||||
|
{
|
||||||
|
book_id: 1287,
|
||||||
|
lang: 'en',
|
||||||
|
title: 'English Edition',
|
||||||
|
eid: 1,
|
||||||
|
staff: [
|
||||||
|
{ role_type: 'author', romaji: 'Reki Kawahara', name: '川原礫', staff_id: 1 },
|
||||||
|
{ role_type: 'artist', romaji: 'abec', name: 'abec', staff_id: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
book_id: 1287,
|
||||||
|
lang: 'ja',
|
||||||
|
title: 'Japanese Edition',
|
||||||
|
eid: 2,
|
||||||
|
staff: [
|
||||||
|
{ role_type: 'author', romaji: null, name: '川原礫', staff_id: 1 },
|
||||||
|
{ role_type: 'translator', romaji: 'Some Translator', name: 'Some Translator', staff_id: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
releases: [
|
||||||
|
{
|
||||||
|
lang: 'en',
|
||||||
|
id: 100,
|
||||||
|
title: 'Sword Art Online Vol. 1',
|
||||||
|
release_date: 20121210,
|
||||||
|
isbn13: '9780316371247',
|
||||||
|
pages: 240,
|
||||||
|
format: 'print',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: 'en',
|
||||||
|
id: 101,
|
||||||
|
title: 'Sword Art Online Vol. 1 Digital',
|
||||||
|
release_date: 20121215,
|
||||||
|
isbn13: null,
|
||||||
|
pages: null,
|
||||||
|
format: 'digital',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: 'ja',
|
||||||
|
id: 200,
|
||||||
|
title: 'ソードアート・オンライン',
|
||||||
|
release_date: 20120710,
|
||||||
|
isbn13: '9784048913102',
|
||||||
|
pages: 248,
|
||||||
|
format: 'print',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
publishers: [
|
||||||
|
{ lang: 'en', id: 10, romaji: null, name: 'Yen Press', publisher_type: 'publisher' },
|
||||||
|
{ lang: 'en', id: 11, romaji: null, name: 'Yen On', publisher_type: 'imprint' },
|
||||||
|
{ lang: 'ja', id: 20, romaji: null, name: 'KADOKAWA', publisher_type: 'publisher' },
|
||||||
|
],
|
||||||
|
series: {
|
||||||
|
id: 50,
|
||||||
|
title: 'Sword Art Online',
|
||||||
|
romaji: 'Sword Art Online',
|
||||||
|
books: [
|
||||||
|
{ id: 1287, lang: 'en', title: 'Vol. 1', romaji: null, image: null },
|
||||||
|
{ id: 1288, lang: 'en', title: 'Vol. 2', romaji: null, image: null },
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{ id: 1, name: 'Sci-Fi', ttype: 'genre' },
|
||||||
|
{ id: 2, name: 'Action', ttype: 'genre' },
|
||||||
|
{ id: 3, name: 'LGBTQ', ttype: 'genre' },
|
||||||
|
{ id: 4, name: 'Isekai', ttype: 'tag' },
|
||||||
|
{ id: 5, name: 'Shonen', ttype: 'demographic' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('parseDateInt', () => {
|
||||||
|
it('extracts year from valid date integer', () => {
|
||||||
|
expect(parseDateInt(20220624)).toBe(2022);
|
||||||
|
expect(parseDateInt(19991231)).toBe(1999);
|
||||||
|
expect(parseDateInt(20000101)).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for null', () => {
|
||||||
|
expect(parseDateInt(null)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for undefined', () => {
|
||||||
|
expect(parseDateInt(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for zero', () => {
|
||||||
|
expect(parseDateInt(0)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for years out of plausible range', () => {
|
||||||
|
expect(parseDateInt(9990101)).toBeUndefined();
|
||||||
|
expect(parseDateInt(9990101)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts year at boundary', () => {
|
||||||
|
expect(parseDateInt(10000101)).toBe(1000);
|
||||||
|
expect(parseDateInt(22000101)).toBe(2200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapRanobeDbBook', () => {
|
||||||
|
it('maps a complete book correctly', () => {
|
||||||
|
const result = mapRanobeDbBook(baseBook);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
provider: MetadataProviderKey.RANOBEDB,
|
||||||
|
providerId: '1287',
|
||||||
|
title: 'Sword Art Online Vol. 1',
|
||||||
|
authors: ['Reki Kawahara'],
|
||||||
|
publisher: 'Yen Press',
|
||||||
|
publishedYear: 2012,
|
||||||
|
language: 'en',
|
||||||
|
pageCount: 240,
|
||||||
|
isbn13: '9780316371247',
|
||||||
|
seriesName: 'Sword Art Online',
|
||||||
|
seriesIndex: 1,
|
||||||
|
coverUrl: 'https://images.ranobedb.org/covers/sao.jpg',
|
||||||
|
sourceUrl: 'https://ranobedb.org/book/1287',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes only genre-type tags (not tag/demographic)', () => {
|
||||||
|
const result = mapRanobeDbBook(baseBook);
|
||||||
|
expect(result?.genres).toEqual(['Sci-Fi', 'Action', 'LGBTQ']);
|
||||||
|
expect(result?.genres).not.toContain('Isekai');
|
||||||
|
expect(result?.genres).not.toContain('Shonen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves original casing of genre names', () => {
|
||||||
|
const result = mapRanobeDbBook(baseBook);
|
||||||
|
expect(result?.genres).toContain('Sci-Fi');
|
||||||
|
expect(result?.genres).toContain('LGBTQ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates authors across editions', () => {
|
||||||
|
const result = mapRanobeDbBook(baseBook);
|
||||||
|
const authorCount = result?.authors?.filter((a) => a === 'Reki Kawahara' || a === '川原礫').length ?? 0;
|
||||||
|
expect(authorCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers romaji over native name for authors', () => {
|
||||||
|
const result = mapRanobeDbBook(baseBook);
|
||||||
|
expect(result?.authors).toContain('Reki Kawahara');
|
||||||
|
expect(result?.authors).not.toContain('川原礫');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only includes authors (not artists/translators)', () => {
|
||||||
|
const result = mapRanobeDbBook(baseBook);
|
||||||
|
expect(result?.authors).not.toContain('abec');
|
||||||
|
expect(result?.authors).not.toContain('Some Translator');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers English print release over English digital for isbn/pages', () => {
|
||||||
|
const result = mapRanobeDbBook(baseBook);
|
||||||
|
expect(result?.isbn13).toBe('9780316371247');
|
||||||
|
expect(result?.pageCount).toBe(240);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers English release publishedYear over c_release_date', () => {
|
||||||
|
const result = mapRanobeDbBook(baseBook);
|
||||||
|
expect(result?.publishedYear).toBe(2012);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to c_release_date year when no English release has a date', () => {
|
||||||
|
const book: RanobeDbBook = {
|
||||||
|
...baseBook,
|
||||||
|
releases: [{ lang: 'en', id: 100, title: null, release_date: null, isbn13: null, pages: null, format: 'print' }],
|
||||||
|
c_release_date: 20180501,
|
||||||
|
};
|
||||||
|
const result = mapRanobeDbBook(book);
|
||||||
|
expect(result?.publishedYear).toBe(2018);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when book has no id', () => {
|
||||||
|
const book = { ...baseBook, id: 0 };
|
||||||
|
expect(mapRanobeDbBook(book)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets language to "en" when any release has lang en', () => {
|
||||||
|
const result = mapRanobeDbBook(baseBook);
|
||||||
|
expect(result?.language).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to book.lang when no English releases exist', () => {
|
||||||
|
const book: RanobeDbBook = {
|
||||||
|
...baseBook,
|
||||||
|
lang: 'ja',
|
||||||
|
releases: [{ lang: 'ja', id: 200, title: null, release_date: 20120710, isbn13: null, pages: null, format: 'print' }],
|
||||||
|
};
|
||||||
|
const result = mapRanobeDbBook(book);
|
||||||
|
expect(result?.language).toBe('ja');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to romaji title when no official English title', () => {
|
||||||
|
const book: RanobeDbBook = {
|
||||||
|
...baseBook,
|
||||||
|
titles: [{ book_id: 1287, lang: 'ja', official: true, title: 'ソードアート・オンライン', romaji: null }],
|
||||||
|
romaji: 'Sword Art Online',
|
||||||
|
};
|
||||||
|
const result = mapRanobeDbBook(book);
|
||||||
|
expect(result?.title).toBe('Sword Art Online');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to native title when no romaji and no English title', () => {
|
||||||
|
const book: RanobeDbBook = {
|
||||||
|
...baseBook,
|
||||||
|
titles: [],
|
||||||
|
romaji: null,
|
||||||
|
title: 'ソードアート・オンライン',
|
||||||
|
};
|
||||||
|
const result = mapRanobeDbBook(book);
|
||||||
|
expect(result?.title).toBe('ソードアート・オンライン');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves publisher from English publisher (not imprint)', () => {
|
||||||
|
const result = mapRanobeDbBook(baseBook);
|
||||||
|
expect(result?.publisher).toBe('Yen Press');
|
||||||
|
expect(result?.publisher).not.toBe('Yen On');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined publisher when no English publisher exists', () => {
|
||||||
|
const book: RanobeDbBook = {
|
||||||
|
...baseBook,
|
||||||
|
publishers: [{ lang: 'ja', id: 20, romaji: null, name: 'KADOKAWA', publisher_type: 'publisher' }],
|
||||||
|
};
|
||||||
|
const result = mapRanobeDbBook(book);
|
||||||
|
expect(result?.publisher).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes seriesIndex as 1-based position', () => {
|
||||||
|
const result = mapRanobeDbBook(baseBook);
|
||||||
|
expect(result?.seriesIndex).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined seriesIndex when book is not in series.books', () => {
|
||||||
|
const book: RanobeDbBook = {
|
||||||
|
...baseBook,
|
||||||
|
series: { ...baseBook.series!, books: [{ id: 9999, lang: null, title: null, romaji: null, image: null }] },
|
||||||
|
};
|
||||||
|
const result = mapRanobeDbBook(book);
|
||||||
|
expect(result?.seriesIndex).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined seriesIndex when series is null', () => {
|
||||||
|
const book: RanobeDbBook = { ...baseBook, series: null };
|
||||||
|
const result = mapRanobeDbBook(book);
|
||||||
|
expect(result?.seriesIndex).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty genres array when series is null', () => {
|
||||||
|
const book: RanobeDbBook = { ...baseBook, series: null };
|
||||||
|
const result = mapRanobeDbBook(book);
|
||||||
|
expect(result?.genres).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no coverUrl when image is null', () => {
|
||||||
|
const book: RanobeDbBook = { ...baseBook, image: null };
|
||||||
|
const result = mapRanobeDbBook(book);
|
||||||
|
expect(result?.coverUrl).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty authors array when no editions have authors', () => {
|
||||||
|
const book: RanobeDbBook = {
|
||||||
|
...baseBook,
|
||||||
|
editions: [{ book_id: 1287, lang: 'en', title: null, eid: 1, staff: [{ role_type: 'artist', romaji: 'abec', name: 'abec', staff_id: 2 }] }],
|
||||||
|
};
|
||||||
|
const result = mapRanobeDbBook(book);
|
||||||
|
expect(result?.authors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses digital release when no print release is available', () => {
|
||||||
|
const book: RanobeDbBook = {
|
||||||
|
...baseBook,
|
||||||
|
releases: [{ lang: 'en', id: 101, title: null, release_date: 20221201, isbn13: '9780000000001', pages: 300, format: 'digital' }],
|
||||||
|
};
|
||||||
|
const result = mapRanobeDbBook(book);
|
||||||
|
expect(result?.isbn13).toBe('9780000000001');
|
||||||
|
expect(result?.pageCount).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not use audio release for isbn/pages selection', () => {
|
||||||
|
const book: RanobeDbBook = {
|
||||||
|
...baseBook,
|
||||||
|
releases: [{ lang: 'en', id: 102, title: null, release_date: 20221201, isbn13: '9780000000002', pages: 999, format: 'audio' }],
|
||||||
|
};
|
||||||
|
const result = mapRanobeDbBook(book);
|
||||||
|
expect(result?.isbn13).toBeUndefined();
|
||||||
|
expect(result?.pageCount).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates genres', () => {
|
||||||
|
const book: RanobeDbBook = {
|
||||||
|
...baseBook,
|
||||||
|
series: {
|
||||||
|
...baseBook.series!,
|
||||||
|
tags: [
|
||||||
|
{ id: 1, name: 'Action', ttype: 'genre' },
|
||||||
|
{ id: 2, name: 'Action', ttype: 'genre' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = mapRanobeDbBook(book);
|
||||||
|
expect(result?.genres).toEqual(['Action']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes sourceUrl', () => {
|
||||||
|
const result = mapRanobeDbBook(baseBook);
|
||||||
|
expect(result?.sourceUrl).toBe('https://ranobedb.org/book/1287');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps English description when present', () => {
|
||||||
|
const result = mapRanobeDbBook(baseBook);
|
||||||
|
expect(result?.description).toBe('A virtual reality adventure.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to description_ja when English description is empty', () => {
|
||||||
|
const book: RanobeDbBook = { ...baseBook, description: '', description_ja: '仮想現実の冒険。' };
|
||||||
|
const result = mapRanobeDbBook(book);
|
||||||
|
expect(result?.description).toBe('仮想現実の冒険。');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to description_ja when English description is null', () => {
|
||||||
|
const book: RanobeDbBook = { ...baseBook, description: null, description_ja: '仮想現実の冒険。' };
|
||||||
|
const result = mapRanobeDbBook(book);
|
||||||
|
expect(result?.description).toBe('仮想現実の冒険。');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined description when both descriptions are empty', () => {
|
||||||
|
const book: RanobeDbBook = { ...baseBook, description: '', description_ja: '' };
|
||||||
|
const result = mapRanobeDbBook(book);
|
||||||
|
expect(result?.description).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined description when both descriptions are null', () => {
|
||||||
|
const book: RanobeDbBook = { ...baseBook, description: null, description_ja: null };
|
||||||
|
const result = mapRanobeDbBook(book);
|
||||||
|
expect(result?.description).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { MetadataCandidate, MetadataProviderKey } from '@bookorbit/types';
|
||||||
|
|
||||||
|
import { RanobeDbBook, RanobeDbRelease } from './ranobedb.types';
|
||||||
|
|
||||||
|
const IMAGE_BASE_URL = 'https://images.ranobedb.org';
|
||||||
|
const RANOBEDB_BASE_URL = 'https://ranobedb.org';
|
||||||
|
|
||||||
|
export function parseDateInt(value: number | null | undefined): number | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const year = Math.floor(value / 10000);
|
||||||
|
return year >= 1000 && year <= 2200 ? year : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectEnglishRelease(releases: RanobeDbRelease[]): RanobeDbRelease | undefined {
|
||||||
|
const english = releases.filter((r) => r.lang === 'en' && r.format !== 'audio');
|
||||||
|
return english.find((r) => r.format === 'print') ?? english.find((r) => r.format === 'digital');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTitle(book: RanobeDbBook): string {
|
||||||
|
const englishTitle = book.titles.find((t) => t.lang === 'en' && t.official);
|
||||||
|
if (englishTitle) return englishTitle.title;
|
||||||
|
if (book.romaji) return book.romaji;
|
||||||
|
return book.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSubtitle(book: RanobeDbBook): string | undefined {
|
||||||
|
const mainTitle = resolveTitle(book);
|
||||||
|
if (book.title_orig && book.title_orig !== mainTitle) {
|
||||||
|
return book.title_orig;
|
||||||
|
}
|
||||||
|
if (book.romaji_orig && book.romaji_orig !== mainTitle) {
|
||||||
|
return book.romaji_orig;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAuthors(book: RanobeDbBook): string[] {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const seenNames = new Set<string>();
|
||||||
|
const authors: string[] = [];
|
||||||
|
for (const edition of book.editions) {
|
||||||
|
for (const staff of edition.staff) {
|
||||||
|
if (staff.role_type !== 'author') continue;
|
||||||
|
if (seen.has(staff.staff_id)) continue;
|
||||||
|
seen.add(staff.staff_id);
|
||||||
|
const name = staff.romaji ?? staff.name;
|
||||||
|
if (!seenNames.has(name)) {
|
||||||
|
seenNames.add(name);
|
||||||
|
authors.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return authors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePublisher(book: RanobeDbBook): string | undefined {
|
||||||
|
const pub = book.publishers.find((p) => p.lang === 'en' && p.publisher_type === 'publisher');
|
||||||
|
if (!pub) return undefined;
|
||||||
|
return pub.romaji ?? pub.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSeriesIndex(book: RanobeDbBook): number | undefined {
|
||||||
|
if (!book.series) return undefined;
|
||||||
|
const pos = book.series.books.findIndex((b) => b.id === book.id);
|
||||||
|
return pos === -1 ? undefined : pos + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGenres(book: RanobeDbBook): string[] {
|
||||||
|
if (!book.series) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const genres: string[] = [];
|
||||||
|
for (const tag of book.series.tags) {
|
||||||
|
if (tag.ttype !== 'genre') continue;
|
||||||
|
const trimmed = tag.name.trim();
|
||||||
|
if (!trimmed || seen.has(trimmed)) continue;
|
||||||
|
seen.add(trimmed);
|
||||||
|
genres.push(trimmed);
|
||||||
|
}
|
||||||
|
return genres;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePublishedYear(book: RanobeDbBook, release: RanobeDbRelease | undefined): number | undefined {
|
||||||
|
if (release?.release_date) {
|
||||||
|
const year = parseDateInt(release.release_date);
|
||||||
|
if (year) return year;
|
||||||
|
}
|
||||||
|
if (book.olang && book.c_release_dates && book.c_release_dates[book.olang]) {
|
||||||
|
const year = parseDateInt(book.c_release_dates[book.olang]);
|
||||||
|
if (year) return year;
|
||||||
|
}
|
||||||
|
return parseDateInt(book.c_release_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapRanobeDbBook(book: RanobeDbBook): MetadataCandidate | null {
|
||||||
|
if (!book?.id) return null;
|
||||||
|
|
||||||
|
const selectedRelease = selectEnglishRelease(book.releases);
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: MetadataProviderKey.RANOBEDB,
|
||||||
|
providerId: String(book.id),
|
||||||
|
title: resolveTitle(book),
|
||||||
|
subtitle: resolveSubtitle(book),
|
||||||
|
authors: resolveAuthors(book),
|
||||||
|
publisher: resolvePublisher(book),
|
||||||
|
publishedYear: resolvePublishedYear(book, selectedRelease),
|
||||||
|
language: book.releases.some((r) => r.lang === 'en') ? 'en' : (book.olang ?? book.lang ?? undefined),
|
||||||
|
pageCount: selectedRelease?.pages ?? undefined,
|
||||||
|
isbn13: selectedRelease?.isbn13 ?? undefined,
|
||||||
|
seriesName: book.series?.title,
|
||||||
|
seriesIndex: resolveSeriesIndex(book),
|
||||||
|
description: book.description?.trim() || book.description_ja?.trim() || undefined,
|
||||||
|
genres: resolveGenres(book),
|
||||||
|
coverUrl: book.image ? `${IMAGE_BASE_URL}/${book.image.filename}` : undefined,
|
||||||
|
sourceUrl: `${RANOBEDB_BASE_URL}/book/${book.id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ProviderConfigurations } from '@bookorbit/types';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
|
||||||
|
import { RanobeDbClient } from './ranobedb.client';
|
||||||
|
import { RanobeDbProvider } from './ranobedb.provider';
|
||||||
|
import { RanobeDbBook } from './ranobedb.types';
|
||||||
|
|
||||||
|
const enabledConfig: ProviderConfigurations = {
|
||||||
|
google: { enabled: false, apiKey: '' },
|
||||||
|
amazon: { enabled: false, domain: 'amazon.com', cookie: '' },
|
||||||
|
goodreads: { enabled: false },
|
||||||
|
hardcover: { enabled: false, apiKey: '' },
|
||||||
|
openLibrary: { enabled: false },
|
||||||
|
itunes: { enabled: false, coverResolution: 'high' },
|
||||||
|
audible: { enabled: false, domain: 'com' },
|
||||||
|
audnexus: { enabled: false },
|
||||||
|
comicvine: { enabled: false, apiKey: '' },
|
||||||
|
ranobedb: { enabled: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const disabledConfig: ProviderConfigurations = {
|
||||||
|
...enabledConfig,
|
||||||
|
ranobedb: { enabled: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockBook: RanobeDbBook = {
|
||||||
|
id: 1287,
|
||||||
|
title: 'ソードアート・オンライン',
|
||||||
|
romaji: 'Sword Art Online',
|
||||||
|
lang: 'ja',
|
||||||
|
c_release_date: 20120710,
|
||||||
|
image: { id: 10, filename: 'covers/sao.jpg', width: 300, height: 450, nsfw: false, spoiler: false },
|
||||||
|
rating: null,
|
||||||
|
titles: [{ book_id: 1287, lang: 'en', official: true, title: 'Sword Art Online Vol. 1', romaji: null }],
|
||||||
|
editions: [
|
||||||
|
{
|
||||||
|
book_id: 1287,
|
||||||
|
lang: 'en',
|
||||||
|
title: 'EN Edition',
|
||||||
|
eid: 1,
|
||||||
|
staff: [{ role_type: 'author', romaji: 'Reki Kawahara', name: '川原礫', staff_id: 1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
releases: [{ lang: 'en', id: 100, title: null, release_date: 20121210, isbn13: '9780316371247', pages: 240, format: 'print' }],
|
||||||
|
publishers: [{ lang: 'en', id: 10, romaji: null, name: 'Yen Press', publisher_type: 'publisher' }],
|
||||||
|
series: { id: 50, title: 'Sword Art Online', romaji: null, books: [{ id: 1287, lang: null, title: null, romaji: null, image: null }], tags: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RanobeDbProvider', () => {
|
||||||
|
let provider: RanobeDbProvider;
|
||||||
|
let client: RanobeDbClient;
|
||||||
|
let providerConfig: ProviderConfigService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
RanobeDbProvider,
|
||||||
|
{
|
||||||
|
provide: RanobeDbClient,
|
||||||
|
useValue: {
|
||||||
|
search: vi.fn().mockResolvedValue([]),
|
||||||
|
fetchBook: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ProviderConfigService,
|
||||||
|
useValue: {
|
||||||
|
getConfig: vi.fn().mockResolvedValue(enabledConfig),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
provider = module.get(RanobeDbProvider);
|
||||||
|
client = module.get(RanobeDbClient);
|
||||||
|
providerConfig = module.get(ProviderConfigService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('provider metadata', () => {
|
||||||
|
it('has key RANOBEDB', () => {
|
||||||
|
expect(provider.key).toBe('ranobedb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has label RanobeDB', () => {
|
||||||
|
expect(provider.label).toBe('RanobeDB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is identifiable', () => {
|
||||||
|
expect(provider.identifiable).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('search()', () => {
|
||||||
|
it('returns empty array when provider is disabled', async () => {
|
||||||
|
vi.mocked(providerConfig.getConfig).mockResolvedValue(disabledConfig);
|
||||||
|
const result = await provider.search({ title: 'SAO' });
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(client.search).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no title and no author', async () => {
|
||||||
|
const result = await provider.search({});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(client.search).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searches by title when only title provided', async () => {
|
||||||
|
vi.mocked(client.search).mockResolvedValue([1287]);
|
||||||
|
vi.mocked(client.fetchBook).mockResolvedValue({ book: mockBook });
|
||||||
|
|
||||||
|
const result = await provider.search({ title: 'Sword Art Online' });
|
||||||
|
|
||||||
|
expect(client.search).toHaveBeenCalledWith('Sword Art Online', undefined);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].providerId).toBe('1287');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines title and author in query', async () => {
|
||||||
|
vi.mocked(client.search).mockResolvedValue([]);
|
||||||
|
|
||||||
|
await provider.search({ title: 'SAO', author: 'Kawahara' });
|
||||||
|
|
||||||
|
expect(client.search).toHaveBeenCalledWith('SAO Kawahara', undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses author alone when no title provided', async () => {
|
||||||
|
vi.mocked(client.search).mockResolvedValue([]);
|
||||||
|
|
||||||
|
await provider.search({ author: 'Kawahara' });
|
||||||
|
|
||||||
|
expect(client.search).toHaveBeenCalledWith('Kawahara', undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when client.search returns no IDs', async () => {
|
||||||
|
vi.mocked(client.search).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await provider.search({ title: 'unknown' });
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips null candidates from fetchBook', async () => {
|
||||||
|
vi.mocked(client.search).mockResolvedValue([1287, 9999]);
|
||||||
|
vi.mocked(client.fetchBook).mockResolvedValueOnce({ book: mockBook }).mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const result = await provider.search({ title: 'SAO' });
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips books where fetchBook returns null book.id (mapper returns null)', async () => {
|
||||||
|
const zeroIdBook: RanobeDbBook = { ...mockBook, id: 0 };
|
||||||
|
vi.mocked(client.search).mockResolvedValue([0]);
|
||||||
|
vi.mocked(client.fetchBook).mockResolvedValue({ book: zeroIdBook });
|
||||||
|
|
||||||
|
const result = await provider.search({ title: 'SAO' });
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns multiple candidates', async () => {
|
||||||
|
const book2: RanobeDbBook = { ...mockBook, id: 1288 };
|
||||||
|
vi.mocked(client.search).mockResolvedValue([1287, 1288]);
|
||||||
|
vi.mocked(client.fetchBook).mockResolvedValueOnce({ book: mockBook }).mockResolvedValueOnce({ book: book2 });
|
||||||
|
|
||||||
|
const result = await provider.search({ title: 'SAO' });
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes signal to client', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
vi.mocked(client.search).mockResolvedValue([1287]);
|
||||||
|
vi.mocked(client.fetchBook).mockResolvedValue({ book: mockBook });
|
||||||
|
|
||||||
|
await provider.search({ title: 'SAO', signal: controller.signal });
|
||||||
|
|
||||||
|
expect(client.search).toHaveBeenCalledWith('SAO', controller.signal);
|
||||||
|
expect(client.fetchBook).toHaveBeenCalledWith(1287, controller.signal);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lookupById()', () => {
|
||||||
|
it('returns null when provider is disabled', async () => {
|
||||||
|
vi.mocked(providerConfig.getConfig).mockResolvedValue(disabledConfig);
|
||||||
|
const result = await provider.lookupById('1287');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-numeric ID', async () => {
|
||||||
|
const result = await provider.lookupById('abc');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(client.fetchBook).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for ID with trailing non-numeric characters', async () => {
|
||||||
|
const result = await provider.lookupById('123abc');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(client.fetchBook).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty string ID', async () => {
|
||||||
|
const result = await provider.lookupById('');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for ID with leading/trailing spaces', async () => {
|
||||||
|
const result = await provider.lookupById(' 123 ');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when fetchBook returns null', async () => {
|
||||||
|
vi.mocked(client.fetchBook).mockResolvedValue(null);
|
||||||
|
const result = await provider.lookupById('1287');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns mapped candidate for valid numeric ID', async () => {
|
||||||
|
vi.mocked(client.fetchBook).mockResolvedValue({ book: mockBook });
|
||||||
|
const result = await provider.lookupById('1287');
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.providerId).toBe('1287');
|
||||||
|
expect(result?.title).toBe('Sword Art Online Vol. 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls fetchBook with correct integer ID', async () => {
|
||||||
|
vi.mocked(client.fetchBook).mockResolvedValue({ book: mockBook });
|
||||||
|
await provider.lookupById('42');
|
||||||
|
expect(client.fetchBook).toHaveBeenCalledWith(42, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes signal to fetchBook', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
vi.mocked(client.fetchBook).mockResolvedValue({ book: mockBook });
|
||||||
|
await provider.lookupById('1287', controller.signal);
|
||||||
|
expect(client.fetchBook).toHaveBeenCalledWith(1287, controller.signal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unsafe integer (overflow)', async () => {
|
||||||
|
const unsafe = String(Number.MAX_SAFE_INTEGER + 1);
|
||||||
|
const result = await provider.lookupById(unsafe);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when mapper returns null (book id=0)', async () => {
|
||||||
|
const zeroIdBook: RanobeDbBook = { ...mockBook, id: 0 };
|
||||||
|
vi.mocked(client.fetchBook).mockResolvedValue({ book: zeroIdBook });
|
||||||
|
const result = await provider.lookupById('1287');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { MetadataCandidate, MetadataProviderKey } from '@bookorbit/types';
|
||||||
|
|
||||||
|
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
|
||||||
|
import { IdentifiableProvider } from '../metadata-provider';
|
||||||
|
import { MetadataSearchParams } from '../metadata-search-params';
|
||||||
|
import { RanobeDbClient } from './ranobedb.client';
|
||||||
|
import { mapRanobeDbBook } from './ranobedb.mapper';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RanobeDbProvider implements IdentifiableProvider {
|
||||||
|
readonly key = MetadataProviderKey.RANOBEDB;
|
||||||
|
readonly label = 'RanobeDB';
|
||||||
|
readonly identifiable = true as const;
|
||||||
|
|
||||||
|
private readonly logger = new Logger(RanobeDbProvider.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly client: RanobeDbClient,
|
||||||
|
private readonly providerConfig: ProviderConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async search(params: MetadataSearchParams): Promise<MetadataCandidate[]> {
|
||||||
|
const { enabled } = await this.providerConfig.getConfig().then((c) => c.ranobedb);
|
||||||
|
if (!enabled) return [];
|
||||||
|
|
||||||
|
if (!params.title && !params.author) return [];
|
||||||
|
|
||||||
|
const query = params.author ? `${params.title ?? ''} ${params.author}`.trim() : (params.title ?? '');
|
||||||
|
|
||||||
|
const ids = await this.client.search(query, params.signal);
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
|
||||||
|
const candidates: MetadataCandidate[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const response = await this.client.fetchBook(id, params.signal);
|
||||||
|
if (!response) continue;
|
||||||
|
const candidate = mapRanobeDbBook(response.book);
|
||||||
|
if (candidate) candidates.push(candidate);
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
async lookupById(providerId: string, signal?: AbortSignal): Promise<MetadataCandidate | null> {
|
||||||
|
const { enabled } = await this.providerConfig.getConfig().then((c) => c.ranobedb);
|
||||||
|
if (!enabled) return null;
|
||||||
|
|
||||||
|
if (!/^\d+$/.test(providerId) || !Number.isSafeInteger(Number(providerId))) {
|
||||||
|
this.logger.warn(`[ranobedb.lookup] invalid providerId="${providerId}"`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.client.fetchBook(Number(providerId), signal);
|
||||||
|
if (!response) return null;
|
||||||
|
|
||||||
|
return mapRanobeDbBook(response.book);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
export type RoleType = 'author' | 'artist' | 'translator' | 'editor' | 'narrator' | 'staff';
|
||||||
|
export type PublisherType = 'publisher' | 'imprint';
|
||||||
|
export type TagType = 'genre' | 'demographic' | 'content' | 'tag';
|
||||||
|
export type ReleaseFormat = 'digital' | 'print' | 'audio';
|
||||||
|
|
||||||
|
export interface RanobeDbSearchResponse {
|
||||||
|
books: { id: number }[];
|
||||||
|
count: number;
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RanobeDbBookResponse {
|
||||||
|
book: RanobeDbBook;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RanobeDbBook {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
title_orig: string | null;
|
||||||
|
romaji: string | null;
|
||||||
|
romaji_orig: string | null;
|
||||||
|
lang: string | null;
|
||||||
|
olang: string | null;
|
||||||
|
description: string | null;
|
||||||
|
description_ja: string | null;
|
||||||
|
c_release_date: number | null;
|
||||||
|
c_release_dates: Record<string, number> | null;
|
||||||
|
image: RanobeDbImage | null;
|
||||||
|
rating: RanobeDbRating | null;
|
||||||
|
titles: RanobeDbTitleEntry[];
|
||||||
|
editions: RanobeDbEdition[];
|
||||||
|
releases: RanobeDbRelease[];
|
||||||
|
publishers: RanobeDbPublisher[];
|
||||||
|
series: RanobeDbSeries | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RanobeDbTitleEntry {
|
||||||
|
book_id: number;
|
||||||
|
lang: string;
|
||||||
|
official: boolean;
|
||||||
|
title: string;
|
||||||
|
romaji: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RanobeDbEdition {
|
||||||
|
book_id: number;
|
||||||
|
lang: string | null;
|
||||||
|
title: string | null;
|
||||||
|
eid: number;
|
||||||
|
staff: RanobeDbStaff[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RanobeDbStaff {
|
||||||
|
role_type: RoleType;
|
||||||
|
romaji: string | null;
|
||||||
|
name: string;
|
||||||
|
staff_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RanobeDbRelease {
|
||||||
|
lang: string;
|
||||||
|
id: number;
|
||||||
|
title: string | null;
|
||||||
|
release_date: number | null;
|
||||||
|
isbn13: string | null;
|
||||||
|
pages: number | null;
|
||||||
|
format: ReleaseFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RanobeDbPublisher {
|
||||||
|
lang: string;
|
||||||
|
id: number;
|
||||||
|
romaji: string | null;
|
||||||
|
name: string;
|
||||||
|
publisher_type: PublisherType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RanobeDbSeries {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
romaji: string | null;
|
||||||
|
books: RanobeDbSeriesBook[];
|
||||||
|
tags: RanobeDbTag[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RanobeDbSeriesBook {
|
||||||
|
id: number;
|
||||||
|
lang: string | null;
|
||||||
|
title: string | null;
|
||||||
|
romaji: string | null;
|
||||||
|
image: RanobeDbImage | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RanobeDbTag {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ttype: TagType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RanobeDbImage {
|
||||||
|
id: number;
|
||||||
|
filename: string;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
nsfw: boolean;
|
||||||
|
spoiler: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RanobeDbRating {
|
||||||
|
score: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
@@ -47,4 +47,5 @@ export class UpdateProviderConfigDto {
|
|||||||
@IsOptional() @ValidateNested() @Type(() => AudibleProviderConfigDto) audible?: AudibleProviderConfigDto;
|
@IsOptional() @ValidateNested() @Type(() => AudibleProviderConfigDto) audible?: AudibleProviderConfigDto;
|
||||||
@IsOptional() @ValidateNested() @Type(() => SimpleProviderConfigDto) audnexus?: SimpleProviderConfigDto;
|
@IsOptional() @ValidateNested() @Type(() => SimpleProviderConfigDto) audnexus?: SimpleProviderConfigDto;
|
||||||
@IsOptional() @ValidateNested() @Type(() => ComicVineProviderConfigDto) comicvine?: ComicVineProviderConfigDto;
|
@IsOptional() @ValidateNested() @Type(() => ComicVineProviderConfigDto) comicvine?: ComicVineProviderConfigDto;
|
||||||
|
@IsOptional() @ValidateNested() @Type(() => SimpleProviderConfigDto) ranobedb?: SimpleProviderConfigDto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ describe('ProviderConfigService', () => {
|
|||||||
audible: { enabled: false, domain: 'com' },
|
audible: { enabled: false, domain: 'com' },
|
||||||
audnexus: { enabled: false },
|
audnexus: { enabled: false },
|
||||||
comicvine: { enabled: false, apiKey: '' },
|
comicvine: { enabled: false, apiKey: '' },
|
||||||
|
ranobedb: { enabled: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -352,6 +353,7 @@ describe('ProviderConfigService', () => {
|
|||||||
audible: { enabled: false, domain: 'com' },
|
audible: { enabled: false, domain: 'com' },
|
||||||
audnexus: { enabled: false },
|
audnexus: { enabled: false },
|
||||||
comicvine: { enabled: false, apiKey: '' },
|
comicvine: { enabled: false, apiKey: '' },
|
||||||
|
ranobedb: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(statuses.map((s) => s.key)).toEqual([
|
expect(statuses.map((s) => s.key)).toEqual([
|
||||||
@@ -364,6 +366,7 @@ describe('ProviderConfigService', () => {
|
|||||||
MetadataProviderKey.AUDIBLE,
|
MetadataProviderKey.AUDIBLE,
|
||||||
MetadataProviderKey.AUDNEXUS,
|
MetadataProviderKey.AUDNEXUS,
|
||||||
MetadataProviderKey.COMICVINE,
|
MetadataProviderKey.COMICVINE,
|
||||||
|
MetadataProviderKey.RANOBEDB,
|
||||||
]);
|
]);
|
||||||
expect(statuses.find((s) => s.key === MetadataProviderKey.GOOGLE)?.configured).toBe(false);
|
expect(statuses.find((s) => s.key === MetadataProviderKey.GOOGLE)?.configured).toBe(false);
|
||||||
expect(statuses.find((s) => s.key === MetadataProviderKey.GOOGLE)?.hint).toContain('API key required');
|
expect(statuses.find((s) => s.key === MetadataProviderKey.GOOGLE)?.hint).toContain('API key required');
|
||||||
@@ -386,6 +389,7 @@ describe('ProviderConfigService', () => {
|
|||||||
audible: { enabled: false, domain: 'com' },
|
audible: { enabled: false, domain: 'com' },
|
||||||
audnexus: { enabled: false },
|
audnexus: { enabled: false },
|
||||||
comicvine: { enabled: false, apiKey: '' },
|
comicvine: { enabled: false, apiKey: '' },
|
||||||
|
ranobedb: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(statuses.find((s) => s.key === MetadataProviderKey.HARDCOVER)?.configured).toBe(true);
|
expect(statuses.find((s) => s.key === MetadataProviderKey.HARDCOVER)?.configured).toBe(true);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const DEFAULT_CONFIG: ProviderConfigurations = {
|
|||||||
audible: { enabled: false, domain: 'com' },
|
audible: { enabled: false, domain: 'com' },
|
||||||
audnexus: { enabled: false },
|
audnexus: { enabled: false },
|
||||||
comicvine: { enabled: false, apiKey: '' },
|
comicvine: { enabled: false, apiKey: '' },
|
||||||
|
ranobedb: { enabled: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
function asObject(value: unknown): Record<string, unknown> {
|
function asObject(value: unknown): Record<string, unknown> {
|
||||||
@@ -124,6 +125,7 @@ const PROVIDER_LABELS: Record<MetadataProviderKey, string> = {
|
|||||||
[MetadataProviderKey.AUDIBLE]: 'Audible',
|
[MetadataProviderKey.AUDIBLE]: 'Audible',
|
||||||
[MetadataProviderKey.AUDNEXUS]: 'AudNexus',
|
[MetadataProviderKey.AUDNEXUS]: 'AudNexus',
|
||||||
[MetadataProviderKey.COMICVINE]: 'ComicVine',
|
[MetadataProviderKey.COMICVINE]: 'ComicVine',
|
||||||
|
[MetadataProviderKey.RANOBEDB]: 'RanobeDB',
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProviderEnableRule = {
|
type ProviderEnableRule = {
|
||||||
@@ -167,6 +169,7 @@ export class ProviderConfigService {
|
|||||||
audible: { ...DEFAULT_CONFIG.audible },
|
audible: { ...DEFAULT_CONFIG.audible },
|
||||||
audnexus: { ...DEFAULT_CONFIG.audnexus },
|
audnexus: { ...DEFAULT_CONFIG.audnexus },
|
||||||
comicvine: { ...DEFAULT_CONFIG.comicvine },
|
comicvine: { ...DEFAULT_CONFIG.comicvine },
|
||||||
|
ranobedb: { ...DEFAULT_CONFIG.ranobedb },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +185,7 @@ export class ProviderConfigService {
|
|||||||
audible: mergeAudibleConfig(base.audible, next.audible),
|
audible: mergeAudibleConfig(base.audible, next.audible),
|
||||||
audnexus: mergeSimpleConfig(base.audnexus, next.audnexus),
|
audnexus: mergeSimpleConfig(base.audnexus, next.audnexus),
|
||||||
comicvine: mergeComicVineConfig(base.comicvine, next.comicvine),
|
comicvine: mergeComicVineConfig(base.comicvine, next.comicvine),
|
||||||
|
ranobedb: mergeSimpleConfig(base.ranobedb, next.ranobedb),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,6 +387,12 @@ export class ProviderConfigService {
|
|||||||
configured: !!this.getEnableRule('comicvine')?.canEnable(cfg),
|
configured: !!this.getEnableRule('comicvine')?.canEnable(cfg),
|
||||||
hint: !this.getEnableRule('comicvine')?.canEnable(cfg) ? this.getEnableRule('comicvine')?.setupHint : undefined,
|
hint: !this.getEnableRule('comicvine')?.canEnable(cfg) ? this.getEnableRule('comicvine')?.setupHint : undefined,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: MetadataProviderKey.RANOBEDB,
|
||||||
|
label: PROVIDER_LABELS[MetadataProviderKey.RANOBEDB],
|
||||||
|
enabled: cfg.ranobedb.enabled,
|
||||||
|
configured: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user