Merge pull request #269 from ltctceplrm/openlibrary-genres
Partial rewrite of OpenLibraryAPI
This commit is contained in:
commit
fc5ef663ca
3 changed files with 222 additions and 82 deletions
2
.github/workflows/checkPR.yml
vendored
2
.github/workflows/checkPR.yml
vendored
|
|
@ -22,4 +22,4 @@ jobs:
|
||||||
|
|
||||||
- name: Run Checks
|
- name: Run Checks
|
||||||
run: |
|
run: |
|
||||||
bun run check
|
bun run check
|
||||||
|
|
|
||||||
|
|
@ -12,35 +12,42 @@ import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
|
||||||
import { obsidianFetch } from 'packages/obsidian/src/utils/Utils';
|
import { obsidianFetch } from 'packages/obsidian/src/utils/Utils';
|
||||||
import type { paths } from 'packages/schemas/src/OpenLibrary';
|
import type { paths } from 'packages/schemas/src/OpenLibrary';
|
||||||
|
|
||||||
interface SearchResponse {
|
type OpenLibraryIdKind = 'book' | 'search';
|
||||||
editions: {
|
|
||||||
docs: {
|
|
||||||
key?: string;
|
|
||||||
title?: string;
|
|
||||||
cover_i?: number;
|
|
||||||
isbn?: string[];
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
cover_i?: number;
|
|
||||||
has_fulltext?: boolean;
|
|
||||||
edition_count?: number;
|
|
||||||
title?: string;
|
|
||||||
author_name?: string[];
|
|
||||||
first_publish_year?: number;
|
|
||||||
key: string;
|
|
||||||
description?: string;
|
|
||||||
|
|
||||||
|
interface SearchResponse {
|
||||||
|
key?: string;
|
||||||
|
title?: string;
|
||||||
|
cover_i?: number;
|
||||||
|
author_name?: string[];
|
||||||
|
author_key?: string[];
|
||||||
|
first_publish_year?: number;
|
||||||
|
description?: string | { value?: string };
|
||||||
|
subject?: string[];
|
||||||
number_of_pages_median?: number;
|
number_of_pages_median?: number;
|
||||||
isbn?: string[];
|
number_of_pages?: number;
|
||||||
ratings_average?: number;
|
ratings_average?: number;
|
||||||
|
isbn?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookResponse {
|
||||||
|
key?: string;
|
||||||
|
title?: string;
|
||||||
|
covers?: number[];
|
||||||
|
isbn_10?: string[];
|
||||||
|
isbn_13?: string[];
|
||||||
|
authors?: { key?: string }[];
|
||||||
|
works?: { key?: string }[];
|
||||||
|
pagination?: string;
|
||||||
|
publish_date?: string;
|
||||||
|
number_of_pages?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OpenLibraryAPI extends APIModel {
|
export class OpenLibraryAPI extends APIModel {
|
||||||
plugin: MediaDbPlugin;
|
plugin: MediaDbPlugin;
|
||||||
|
private client = createClient<paths>({ baseUrl: 'https://openlibrary.org/' });
|
||||||
|
|
||||||
constructor(plugin: MediaDbPlugin) {
|
constructor(plugin: MediaDbPlugin) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
this.apiName = 'OpenLibraryAPI';
|
this.apiName = 'OpenLibraryAPI';
|
||||||
this.apiDescription = 'A free API for books';
|
this.apiDescription = 'A free API for books';
|
||||||
|
|
@ -48,16 +55,90 @@ export class OpenLibraryAPI extends APIModel {
|
||||||
this.types = [MediaType.Book];
|
this.types = [MediaType.Book];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private detectIdKind(id: string): OpenLibraryIdKind {
|
||||||
|
if (/\/books\/OL\d+M/i.test(id)) return 'book';
|
||||||
|
if (/\/isbn\/\d+/i.test(id)) return 'book';
|
||||||
|
return 'search';
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeId(id: string): string {
|
||||||
|
return id.startsWith('http') ? new URL(id).pathname : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickDescription(desc?: string | { value?: string }): string | undefined {
|
||||||
|
if (!desc) return undefined;
|
||||||
|
return typeof desc === 'string' ? desc : desc.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchOpenLibraryJson<T>(url: string, context: Record<string, unknown>): Promise<Result<T, MDBError>> {
|
||||||
|
try {
|
||||||
|
const response = await obsidianFetch(new Request(`https://openlibrary.org${url}`));
|
||||||
|
if (!response.ok) {
|
||||||
|
return err({
|
||||||
|
kind: MDBErrorKind.Api,
|
||||||
|
message: `MDB | Received status code ${response.status} from ${this.apiName}.`,
|
||||||
|
userMessage: `Received status code ${response.status} from ${this.apiName}.`,
|
||||||
|
context: { ...context, apiName: this.apiName, status: response.status },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ok((await response.json()) as T);
|
||||||
|
} catch (cause) {
|
||||||
|
return err(
|
||||||
|
toMdbError(cause, {
|
||||||
|
kind: MDBErrorKind.Network,
|
||||||
|
message: `MDB | Network error querying ${this.apiName}`,
|
||||||
|
userMessage: `Network error querying ${this.apiName}`,
|
||||||
|
context,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async searchByOlid(olid: string): Promise<Result<SearchResponse | undefined, MDBError>> {
|
||||||
|
const responseResult = await fromPromise(
|
||||||
|
this.client.GET('/search.json', {
|
||||||
|
params: {
|
||||||
|
query: {
|
||||||
|
q: olid,
|
||||||
|
fields: 'key,title,author_name,author_key,first_publish_year,cover_i,subject,number_of_pages,number_of_pages_median,description,ratings_average,isbn',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fetch: obsidianFetch,
|
||||||
|
}),
|
||||||
|
cause =>
|
||||||
|
toMdbError(cause, {
|
||||||
|
kind: MDBErrorKind.Network,
|
||||||
|
message: `MDB | Network error querying ${this.apiName}`,
|
||||||
|
userMessage: `Network error querying ${this.apiName}`,
|
||||||
|
context: { apiName: this.apiName, olid },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!responseResult.ok) return err(responseResult.error);
|
||||||
|
|
||||||
|
const response = responseResult.value;
|
||||||
|
if (response.error !== undefined) {
|
||||||
|
return err({
|
||||||
|
kind: MDBErrorKind.Api,
|
||||||
|
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
|
||||||
|
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
|
||||||
|
context: { apiName: this.apiName, status: response.response.status, olid },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = response.data as { docs?: SearchResponse[] };
|
||||||
|
return ok(data.docs?.[0]);
|
||||||
|
}
|
||||||
|
|
||||||
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], MDBError>> {
|
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], MDBError>> {
|
||||||
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
|
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
|
||||||
|
|
||||||
const client = createClient<paths>({ baseUrl: 'https://openlibrary.org/' });
|
|
||||||
|
|
||||||
const responseResult = await fromPromise(
|
const responseResult = await fromPromise(
|
||||||
client.GET('/search.json', {
|
this.client.GET('/search.json', {
|
||||||
params: {
|
params: {
|
||||||
query: {
|
query: {
|
||||||
q: title,
|
q: title,
|
||||||
|
fields: 'key,title,author_name,first_publish_year,cover_i,subject,number_of_pages,number_of_pages_median,description,ratings_average,isbn',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fetch: obsidianFetch,
|
fetch: obsidianFetch,
|
||||||
|
|
@ -71,11 +152,9 @@ export class OpenLibraryAPI extends APIModel {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!responseResult.ok) {
|
if (!responseResult.ok) return err(responseResult.error);
|
||||||
return err(responseResult.error);
|
|
||||||
}
|
|
||||||
const response = responseResult.value;
|
|
||||||
|
|
||||||
|
const response = responseResult.value;
|
||||||
if (response.error !== undefined) {
|
if (response.error !== undefined) {
|
||||||
return err({
|
return err({
|
||||||
kind: MDBErrorKind.Api,
|
kind: MDBErrorKind.Api,
|
||||||
|
|
@ -85,23 +164,35 @@ export class OpenLibraryAPI extends APIModel {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = response.data as {
|
const data = response.data as { docs: SearchResponse[] };
|
||||||
docs: SearchResponse[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// console.debug(data);
|
|
||||||
|
|
||||||
const ret: MediaTypeModel[] = [];
|
const ret: MediaTypeModel[] = [];
|
||||||
|
|
||||||
for (const result of data.docs) {
|
for (const result of data.docs) {
|
||||||
|
const isbn10 = result.isbn?.find(el => el.length <= 10);
|
||||||
|
const isbn13 = result.isbn?.find(el => el.length === 13);
|
||||||
|
|
||||||
ret.push(
|
ret.push(
|
||||||
new BookModel({
|
new BookModel({
|
||||||
title: result.title,
|
title: result.title,
|
||||||
englishTitle: result.title,
|
englishTitle: result.title,
|
||||||
year: result.first_publish_year?.toString() ?? 'unknown',
|
year: result.first_publish_year?.toString() ?? 'unknown',
|
||||||
dataSource: this.apiName,
|
dataSource: this.apiName,
|
||||||
id: result.key,
|
id: result.key ?? title,
|
||||||
|
url: result.key ? `https://openlibrary.org${result.key}` : undefined,
|
||||||
author: result.author_name?.join(', '),
|
author: result.author_name?.join(', '),
|
||||||
|
plot: this.pickDescription(result.description),
|
||||||
|
genres: result.subject,
|
||||||
|
pages: result.number_of_pages_median ?? result.number_of_pages,
|
||||||
|
onlineRating: result.ratings_average,
|
||||||
|
isbn: isbn10 ? Number(isbn10) : undefined,
|
||||||
|
isbn13: isbn13 ? Number(isbn13) : undefined,
|
||||||
|
image: result.cover_i ? `https://covers.openlibrary.org/b/id/${result.cover_i}-L.jpg` : undefined,
|
||||||
|
released: true,
|
||||||
|
userData: {
|
||||||
|
read: false,
|
||||||
|
lastRead: '',
|
||||||
|
personalRating: 0,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -112,14 +203,83 @@ export class OpenLibraryAPI extends APIModel {
|
||||||
async getById(id: string): Promise<Result<MediaTypeModel, MDBError>> {
|
async getById(id: string): Promise<Result<MediaTypeModel, MDBError>> {
|
||||||
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
|
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
|
||||||
|
|
||||||
const client = createClient<paths>({ baseUrl: 'https://openlibrary.org/' });
|
const normalizedId = this.normalizeId(id);
|
||||||
|
const kind = this.detectIdKind(normalizedId);
|
||||||
|
|
||||||
|
if (kind === 'book') {
|
||||||
|
return this.getByBookId(normalizedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getBySearchQuery(normalizedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getByBookId(bookKey: string): Promise<Result<MediaTypeModel, MDBError>> {
|
||||||
|
const bookResult = await this.fetchOpenLibraryJson<BookResponse>(`${bookKey}.json`, {
|
||||||
|
apiName: this.apiName,
|
||||||
|
bookKey,
|
||||||
|
});
|
||||||
|
if (!bookResult.ok) return err(bookResult.error);
|
||||||
|
|
||||||
|
const book = bookResult.value;
|
||||||
|
const olid = bookKey.replace(/^\/books\//i, '').replace(/^\/isbn\//i, '');
|
||||||
|
const searchResult = await this.searchByOlid(olid);
|
||||||
|
const search = searchResult.ok ? searchResult.value : undefined;
|
||||||
|
|
||||||
|
const title = book.title ?? search?.title ?? 'unknown';
|
||||||
|
const coverId = book.covers?.[0] ?? search?.cover_i;
|
||||||
|
|
||||||
|
const yearFromBook = book.publish_date;
|
||||||
|
const yearFromSearch = search?.first_publish_year?.toString();
|
||||||
|
const year = yearFromBook ?? yearFromSearch ?? 'unknown';
|
||||||
|
|
||||||
|
const bookPagesRaw = book.number_of_pages ?? (book.pagination ? Number(book.pagination) : undefined);
|
||||||
|
const searchPagesRaw = search?.number_of_pages ?? search?.number_of_pages_median;
|
||||||
|
|
||||||
|
const pages =
|
||||||
|
Number.isFinite(bookPagesRaw!) && Number(bookPagesRaw) > 0
|
||||||
|
? Number(bookPagesRaw)
|
||||||
|
: Number.isFinite(searchPagesRaw!) && Number(searchPagesRaw) > 0
|
||||||
|
? Number(searchPagesRaw)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const bookIsbn10 = book.isbn_10?.find(el => el.length <= 10);
|
||||||
|
const bookIsbn13 = book.isbn_13?.find(el => el.length === 13);
|
||||||
|
const searchIsbn10 = search?.isbn?.find(el => el.length <= 10);
|
||||||
|
const searchIsbn13 = search?.isbn?.find(el => el.length === 13);
|
||||||
|
|
||||||
|
return ok(
|
||||||
|
new BookModel({
|
||||||
|
title,
|
||||||
|
englishTitle: title,
|
||||||
|
year,
|
||||||
|
dataSource: this.apiName,
|
||||||
|
url: `https://openlibrary.org${bookKey}`,
|
||||||
|
id: bookKey,
|
||||||
|
isbn: bookIsbn10 ? Number(bookIsbn10) : searchIsbn10 ? Number(searchIsbn10) : undefined,
|
||||||
|
isbn13: bookIsbn13 ? Number(bookIsbn13) : searchIsbn13 ? Number(searchIsbn13) : undefined,
|
||||||
|
author: search?.author_name?.join(', '),
|
||||||
|
plot: this.pickDescription(search?.description),
|
||||||
|
genres: search?.subject,
|
||||||
|
pages,
|
||||||
|
onlineRating: search?.ratings_average,
|
||||||
|
image: coverId ? `https://covers.openlibrary.org/b/id/${coverId}-L.jpg` : undefined,
|
||||||
|
released: true,
|
||||||
|
userData: {
|
||||||
|
read: false,
|
||||||
|
lastRead: '',
|
||||||
|
personalRating: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getBySearchQuery(query: string): Promise<Result<MediaTypeModel, MDBError>> {
|
||||||
const responseResult = await fromPromise(
|
const responseResult = await fromPromise(
|
||||||
client.GET('/search.json', {
|
this.client.GET('/search.json', {
|
||||||
params: {
|
params: {
|
||||||
query: {
|
query: {
|
||||||
q: `${id}`,
|
q: query,
|
||||||
fields: 'key,title,author_name,number_of_pages_median,first_publish_year,isbn,ratings_score,first_sentence,title_suggest,rating*,cover*,editions,description',
|
fields: 'key,title,author_name,first_publish_year,cover_i,subject,number_of_pages,number_of_pages_median,description,ratings_average,isbn',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fetch: obsidianFetch,
|
fetch: obsidianFetch,
|
||||||
|
|
@ -129,77 +289,54 @@ export class OpenLibraryAPI extends APIModel {
|
||||||
kind: MDBErrorKind.Network,
|
kind: MDBErrorKind.Network,
|
||||||
message: `MDB | Network error querying ${this.apiName}`,
|
message: `MDB | Network error querying ${this.apiName}`,
|
||||||
userMessage: `Network error querying ${this.apiName}`,
|
userMessage: `Network error querying ${this.apiName}`,
|
||||||
context: { apiName: this.apiName, id },
|
context: { apiName: this.apiName, query },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!responseResult.ok) {
|
if (!responseResult.ok) return err(responseResult.error);
|
||||||
return err(responseResult.error);
|
|
||||||
}
|
|
||||||
const response = responseResult.value;
|
|
||||||
|
|
||||||
|
const response = responseResult.value;
|
||||||
if (response.error !== undefined) {
|
if (response.error !== undefined) {
|
||||||
return err({
|
return err({
|
||||||
kind: MDBErrorKind.Api,
|
kind: MDBErrorKind.Api,
|
||||||
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
|
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
|
||||||
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
|
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
|
||||||
context: { apiName: this.apiName, status: response.response.status, id },
|
context: { apiName: this.apiName, status: response.response.status, query },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = response.data as {
|
const data = response.data as { docs: SearchResponse[] };
|
||||||
docs: SearchResponse[];
|
|
||||||
q?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = data.docs?.[0];
|
const result = data.docs?.[0];
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return err({
|
return err({
|
||||||
kind: MDBErrorKind.Api,
|
kind: MDBErrorKind.Api,
|
||||||
message: `MDB | No data found for ID ${id} in ${this.apiName}.`,
|
message: `MDB | No data found for query ${query} in ${this.apiName}.`,
|
||||||
userMessage: `No data found for ID ${id}.`,
|
userMessage: `No data found for query ${query}.`,
|
||||||
context: { apiName: this.apiName, id },
|
context: { apiName: this.apiName, query },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = result.key;
|
const isbn10 = result.isbn?.find(el => el.length <= 10);
|
||||||
let title = result.title;
|
const isbn13 = result.isbn?.find(el => el.length === 13);
|
||||||
let cover_i = result.cover_i;
|
|
||||||
let isbnArr = result.isbn;
|
|
||||||
|
|
||||||
// Check if the query is for /isbn/ or /books/ and extract from editions.docs if present
|
|
||||||
const q = data.q ?? '';
|
|
||||||
if ((q.includes('/isbn/') || q.includes('/books/')) && result.editions && Array.isArray(result.editions.docs) && result.editions.docs.length > 0) {
|
|
||||||
const edition = result.editions.docs[0];
|
|
||||||
key = edition.key ?? key;
|
|
||||||
title = edition.title ?? title;
|
|
||||||
cover_i = edition.cover_i ?? cover_i;
|
|
||||||
isbnArr = edition.isbn ?? isbnArr;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pages = Number(result.number_of_pages_median);
|
|
||||||
const isbn = Number((isbnArr ?? []).find((el: string) => el.length <= 10));
|
|
||||||
const isbn13 = Number((isbnArr ?? []).find((el: string) => el.length == 13));
|
|
||||||
|
|
||||||
return ok(
|
return ok(
|
||||||
new BookModel({
|
new BookModel({
|
||||||
title: title,
|
title: result.title,
|
||||||
|
englishTitle: result.title,
|
||||||
year: result.first_publish_year?.toString() ?? 'unknown',
|
year: result.first_publish_year?.toString() ?? 'unknown',
|
||||||
dataSource: this.apiName,
|
dataSource: this.apiName,
|
||||||
url: `https://openlibrary.org` + key,
|
id: result.key ?? query,
|
||||||
id: key,
|
url: result.key ? `https://openlibrary.org${result.key}` : undefined,
|
||||||
isbn: Number.isNaN(isbn) ? undefined : isbn,
|
isbn: isbn10 ? Number(isbn10) : undefined,
|
||||||
isbn13: Number.isNaN(isbn13) ? undefined : isbn13,
|
isbn13: isbn13 ? Number(isbn13) : undefined,
|
||||||
englishTitle: title,
|
|
||||||
|
|
||||||
author: result.author_name?.join(', '),
|
author: result.author_name?.join(', '),
|
||||||
plot: result.description ?? undefined,
|
plot: this.pickDescription(result.description),
|
||||||
pages: Number.isNaN(pages) ? undefined : pages,
|
genres: result.subject,
|
||||||
|
pages: result.number_of_pages_median ?? result.number_of_pages,
|
||||||
onlineRating: result.ratings_average,
|
onlineRating: result.ratings_average,
|
||||||
image: cover_i ? `https://covers.openlibrary.org/b/id/` + cover_i + `-L.jpg` : undefined,
|
image: result.cover_i ? `https://covers.openlibrary.org/b/id/${result.cover_i}-L.jpg` : undefined,
|
||||||
|
|
||||||
released: true,
|
released: true,
|
||||||
|
|
||||||
userData: {
|
userData: {
|
||||||
read: false,
|
read: false,
|
||||||
lastRead: '',
|
lastRead: '',
|
||||||
|
|
@ -208,6 +345,7 @@ export class OpenLibraryAPI extends APIModel {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getDisabledMediaTypes(): MediaType[] {
|
getDisabledMediaTypes(): MediaType[] {
|
||||||
return this.plugin.settings.OpenLibraryAPI_disabledMediaTypes;
|
return this.plugin.settings.OpenLibraryAPI_disabledMediaTypes;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export type BookData = ModelToData<BookModel>;
|
||||||
export class BookModel extends MediaTypeModel {
|
export class BookModel extends MediaTypeModel {
|
||||||
author: string;
|
author: string;
|
||||||
plot: string;
|
plot: string;
|
||||||
|
genres: string[];
|
||||||
pages: number;
|
pages: number;
|
||||||
image: string;
|
image: string;
|
||||||
onlineRating: number;
|
onlineRating: number;
|
||||||
|
|
@ -27,6 +28,7 @@ export class BookModel extends MediaTypeModel {
|
||||||
|
|
||||||
this.author = '';
|
this.author = '';
|
||||||
this.plot = '';
|
this.plot = '';
|
||||||
|
this.genres = [];
|
||||||
this.pages = 0;
|
this.pages = 0;
|
||||||
this.image = '';
|
this.image = '';
|
||||||
this.onlineRating = 0;
|
this.onlineRating = 0;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue