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:
Chris Mansell
2026-05-31 22:56:40 -06:00
committed by GitHub
parent 7fca7af9d4
commit 53a08788bc
44 changed files with 11556 additions and 5 deletions
@@ -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')
})
})
+2
View File
@@ -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',
} }
+1
View File
@@ -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 {
+2 -1
View File
@@ -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
} }
] ]
} }
+1
View File
@@ -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');
+6 -1
View File
@@ -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,
},
]; ];
} }