Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,18 @@ Now you select the result you want, and the plugin will cast its magic, creating
| [VNDB](https://vndb.org/) | The VNDB API offers metadata for visual novels | games | No | 200 requests per 5 minutes | Yes |
| [Boardgame Geek](https://boardgamegeek.com) | The Boardgame Geek API offers metadata for boardgames | boardgames | Yes, by making an account [here](https://boardgamegeek.com/join/) and then [requesting an application token](https://boardgamegeek.com/applications) | Exact usage limits are still undetermined | No |

#### Hardcover setup

Hardcover is an optional book provider, disabled by default. It uses OAuth authorization-code flow with S256 PKCE, requesting only `read:catalog` (public catalog metadata and search). No personal access token or client secret is required.

For development, register a **Mobile, desktop, or CLI** public application at [Hardcover Developer Apps](https://hardcover.app/account/developer-apps). Register `obsidian://media-db-hardcover` as its redirect URI, allow only `read:catalog`, and disable Device Authorization Grant. Enter its public client ID in **Hardcover OAuth client ID** in Media DB settings. A production client registration should be owned by the project maintainer; the plugin does not include a default client ID.

Click **Connect**, authorize catalog access in your browser, and return to the vault that started the connection. Enable **HardcoverAPI** under **Book settings**, then search for books normally or select HardcoverAPI in advanced search. OAuth access and rotating refresh credentials are stored only in Obsidian SecretStorage; settings contain a secret identifier. **Disconnect** revokes the refresh credentials before clearing the local connection. If revocation fails, reconnect to the network and retry Disconnect.

For ID searches, enter the numeric Hardcover **book ID**, not an edition ID or ISBN. Imports include title, authors, original publication year, description, genres, cover, average Hardcover rating, pages, and available ISBNs. ISBNs and page count use the default physical edition, falling back to the default ebook edition. This imports catalog metadata; it does not sync reading history or modify your Hardcover library.

Search returns up to 20 results. Requests are spaced at least 1.1 seconds apart. Authorization failures, insufficient permissions, and rate limits are reported without creating an entry. See [Hardcover's OAuth documentation](https://docs.hardcover.app/api/oauth/) for registration and authorization details.

#### Notes

- [Tenrai](https://tenrai.org/)
Expand Down
220 changes: 220 additions & 0 deletions packages/obsidian/src/api/HardcoverOAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { requestUrl } from 'obsidian';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import type { MDBError } from 'packages/obsidian/src/utils/MDBError';
import { MDBErrorKind } from 'packages/obsidian/src/utils/MDBError';
import type { Result } from 'packages/obsidian/src/utils/result';
import { err, ok } from 'packages/obsidian/src/utils/result';

export const HARDCOVER_REDIRECT_URI = 'obsidian://media-db-hardcover';
export const HARDCOVER_SCOPE = 'read:catalog';
const ISSUER = 'https://api.hardcover.app';

interface Tokens {
accessToken: string;
refreshToken: string;
expiresAt: number;
clientId: string;
}

interface PendingAuthorization {
state: string;
verifier: string;
clientId: string;
expiresAt: number;
}

function base64url(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}

function randomString(): string {
return base64url(crypto.getRandomValues(new Uint8Array(32)));
}

export class HardcoverOAuth {
private pending?: PendingAuthorization;
private refreshing?: Promise<Result<string, MDBError>>;
private generation = 0;
private authGeneration = 0;
private disconnecting = false;

constructor(private plugin: MediaDbPlugin) {}

private failure(message: string): Result<never, MDBError> {
return err({ kind: MDBErrorKind.Validation, message, userMessage: message });
}

private readTokens(requireMatchingClient = true): Tokens | undefined {
const id = this.plugin.settings.HardcoverOAuthSecretId;
if (!id) return undefined;
try {
const tokens = JSON.parse(this.plugin.app.secretStorage.getSecret(id) ?? 'null') as Tokens | null;
return tokens &&
typeof tokens.accessToken === 'string' &&
typeof tokens.refreshToken === 'string' &&
Number.isFinite(tokens.expiresAt) &&
typeof tokens.clientId === 'string' &&
(!requireMatchingClient || tokens.clientId === this.plugin.settings.HardcoverClientId.trim())
? tokens
: undefined;
} catch {
return undefined;
}
}

isConnected(): boolean {
return !!this.readTokens();
}

async beginAuthorization(): Promise<Result<string, MDBError>> {
if (this.disconnecting) return this.failure('Hardcover is disconnecting. Please try again.');
const clientId = this.plugin.settings.HardcoverClientId.trim();
if (!clientId) return this.failure('Configure the public Hardcover OAuth client ID first.');
const generation = ++this.authGeneration;
const verifier = randomString();
const state = randomString();
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
if (generation !== this.authGeneration) return this.failure('Hardcover connection was cancelled.');
this.pending = { state, verifier, clientId, expiresAt: Date.now() + 10 * 60 * 1000 };
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: HARDCOVER_REDIRECT_URI,
state,
code_challenge: base64url(new Uint8Array(digest)),
code_challenge_method: 'S256',
scope: HARDCOVER_SCOPE,
});
return ok(`https://hardcover.app/oauth2/authorize?${params}`);
}

async completeAuthorization(params: Record<string, string>): Promise<Result<void, MDBError>> {
if (this.disconnecting) return this.failure('Hardcover is disconnecting. Please try again.');
const pending = this.pending;
if (
!pending ||
pending.expiresAt <= Date.now() ||
params.state !== pending.state ||
params.iss !== ISSUER ||
pending.clientId !== this.plugin.settings.HardcoverClientId.trim()
) {
return this.failure('Hardcover sign-in could not be verified. Start Connect again in the originating vault.');
}
this.pending = undefined;
if (params.error || !params.code) return this.failure('Hardcover sign-in was cancelled or denied.');
const generation = this.authGeneration;
const response = await this.exchange({
grant_type: 'authorization_code',
code: params.code,
code_verifier: pending.verifier,
redirect_uri: HARDCOVER_REDIRECT_URI,
client_id: pending.clientId,
});
if (!response.ok) return response;
if (generation !== this.authGeneration) return this.failure('Hardcover connection was cancelled.');
this.generation++;
await this.storeTokens(response.value);
return ok(undefined);
}

private async exchange(form: Record<string, string>): Promise<Result<Tokens, MDBError>> {
try {
const response = await requestUrl({
url: `${ISSUER}/oauth2/token`,
method: 'POST',
contentType: 'application/x-www-form-urlencoded',
body: new URLSearchParams(form).toString(),
throw: false,
});
if (response.status !== 200) return this.failure('Hardcover authorization expired or was rejected. Connect again.');
const data = response.json as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown; token_type?: unknown; scope?: unknown };
if (
typeof data.access_token !== 'string' ||
!data.access_token ||
typeof data.refresh_token !== 'string' ||
!data.refresh_token ||
typeof data.expires_in !== 'number' ||
data.expires_in <= 0 ||
!Number.isFinite(data.expires_in) ||
typeof data.token_type !== 'string' ||
data.token_type.toLowerCase() !== 'bearer'
) {
return this.failure('Hardcover returned an invalid OAuth response. Connect again.');
}
return ok({ accessToken: data.access_token, refreshToken: data.refresh_token, expiresAt: Date.now() + data.expires_in * 1000, clientId: form.client_id });
} catch {
return this.failure('Could not contact Hardcover for authorization. Please try again.');
}
}

private async storeTokens(tokens: Tokens): Promise<void> {
if (!this.plugin.settings.HardcoverOAuthSecretId) this.plugin.settings.HardcoverOAuthSecretId = `media-db-hardcover-${crypto.randomUUID()}`;
this.plugin.app.secretStorage.setSecret(this.plugin.settings.HardcoverOAuthSecretId, JSON.stringify(tokens));
await this.plugin.saveSettings();
}

async getAccessToken(): Promise<Result<string, MDBError>> {
if (this.disconnecting) return this.failure('Hardcover is disconnecting. Please try again.');
const tokens = this.readTokens();
if (!tokens) return this.failure('Connect to Hardcover in Media DB settings first.');
if (tokens.expiresAt > Date.now() + 60_000) return ok(tokens.accessToken);
if (!this.refreshing) {
const generation = this.generation;
this.refreshing = this.refreshTokens(tokens, generation).finally(() => {
this.refreshing = undefined;
});
}
return this.refreshing;
}

private async refreshTokens(tokens: Tokens, generation: number): Promise<Result<string, MDBError>> {
const result = await this.exchange({ grant_type: 'refresh_token', refresh_token: tokens.refreshToken, client_id: tokens.clientId });
if (!result.ok) return result;
if (generation !== this.generation || this.readTokens(false)?.refreshToken !== tokens.refreshToken) return this.failure('Hardcover connection changed. Connect again.');
// Preserve rotated credentials even if settings currently select another client.
await this.storeTokens(result.value);
if (tokens.clientId !== this.plugin.settings.HardcoverClientId.trim()) return this.failure('Hardcover connection changed.');
return ok(result.value.accessToken);
}

async disconnect(): Promise<Result<void, MDBError>> {
if (this.disconnecting) return this.failure('Hardcover is already disconnecting.');
this.disconnecting = true;
try {
this.cancelPendingAuthorization();
// Finish rotation before revoking, so Disconnect uses the current refresh token.
await this.refreshing;
this.generation++;
const tokens = this.readTokens(false);
if (tokens) {
try {
const response = await requestUrl({
url: `${ISSUER}/oauth2/revoke`,
method: 'POST',
contentType: 'application/x-www-form-urlencoded',
body: new URLSearchParams({ token: tokens.refreshToken, token_type_hint: 'refresh_token', client_id: tokens.clientId }).toString(),
throw: false,
});
if (response.status !== 200) return this.failure('Hardcover could not revoke the connection. Please try Disconnect again.');
} catch {
return this.failure('Hardcover could not revoke the connection. Please try Disconnect again.');
}
}
const id = this.plugin.settings.HardcoverOAuthSecretId;
if (id) this.plugin.app.secretStorage.setSecret(id, '');
this.plugin.settings.HardcoverOAuthSecretId = '';
await this.plugin.saveSettings();
return ok(undefined);
} finally {
this.disconnecting = false;
}
}

cancelPendingAuthorization(): void {
this.pending = undefined;
this.authGeneration++;
}
}
159 changes: 159 additions & 0 deletions packages/obsidian/src/api/apis/HardcoverAPI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { requestUrl } from 'obsidian';
import { APIModel } from 'packages/obsidian/src/api/APIModel';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import { BookModel } from 'packages/obsidian/src/models/BookModel';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import type { MDBError } from 'packages/obsidian/src/utils/MDBError';
import { MDBErrorKind } from 'packages/obsidian/src/utils/MDBError';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { Result } from 'packages/obsidian/src/utils/result';
import { err, ok } from 'packages/obsidian/src/utils/result';
import { contactEmail, mediaDbVersion, pluginName } from 'packages/obsidian/src/utils/Utils';

interface HardcoverEdition {
isbn_10?: string | null;
isbn_13?: string | null;
pages?: number | null;
}

interface HardcoverBook {
id: number;
title: string;
slug?: string | null;
release_year?: number | null;
release_date?: string | null;
description?: string | null;
pages?: number | null;
rating?: number | null;
image?: { url?: string | null } | null;
contributions?: { contribution?: string | null; author?: { name: string } | null }[];
cached_tags?: { Genre?: { tag: string }[] } | null;
default_physical_edition?: HardcoverEdition | null;
default_ebook_edition?: HardcoverEdition | null;
}

const BOOK_FIELDS = `
id title slug release_year release_date description pages rating
image { url }
contributions { contribution author { name } }
cached_tags
default_physical_edition { isbn_10 isbn_13 pages }
default_ebook_edition { isbn_10 isbn_13 pages }
`;

export class HardcoverAPI extends APIModel {
private nextRequestAt = 0;

constructor(plugin: MediaDbPlugin) {
super();
this.plugin = plugin;
this.apiName = 'HardcoverAPI';
this.apiDescription = 'Book metadata from Hardcover (connect your free account using OAuth).';
this.apiUrl = 'https://api.hardcover.app/v1/graphql';
this.types = [MediaType.Book];
}

private failure(message: string, kind = MDBErrorKind.Api): Result<never, MDBError> {
return err({ kind, message: `MDB | ${message}`, userMessage: message, context: { apiName: this.apiName } });
}

private async query<T>(query: string, variables: Record<string, unknown>): Promise<Result<T, MDBError>> {
const authorization = await this.plugin.hardcoverOAuth.getAccessToken();
if (!authorization.ok) return authorization;
const token = authorization.value;

// Reserve a slot before waiting so concurrent imports also respect the per-minute limit.
const now = Date.now();
const requestAt = Math.max(now, this.nextRequestAt);
this.nextRequestAt = requestAt + 1100;
if (requestAt > now) await new Promise(resolve => window.setTimeout(resolve, requestAt - now));

try {
const response = await requestUrl({
url: this.apiUrl,
method: 'POST',
contentType: 'application/json',
headers: {
Authorization: `Bearer ${token}`,
'User-Agent': `${pluginName}/${mediaDbVersion} (${contactEmail})`,
},
body: JSON.stringify({ query, variables }),
throw: false,
});
if (response.status === 401) return this.failure('Hardcover authorization is invalid or expired. Connect again in Media DB settings.');
if (response.status === 403) return this.failure('Hardcover authorization does not have permission for this query.');
if (response.status === 429) return this.failure('Hardcover rate limit reached. Please try again later.');
if (response.status !== 200) return this.failure(`Received status code ${response.status} from Hardcover.`);

const payload = response.json as { data?: T | null; errors?: unknown[] } | null;
if (!payload || payload.errors?.length || !payload.data) {
return this.failure('Hardcover could not complete the query. Check your connection permissions and try again.');
}
return ok(payload.data);
} catch {
// Do not retain transport errors: they may contain the Authorization header.
return this.failure('Network error querying Hardcover.', MDBErrorKind.Network);
}
}

async searchByTitle(title: string): Promise<Result<MediaTypeModel[], MDBError>> {
const search = await this.query<{ search?: { ids?: unknown } | null }>(
`query SearchBooks($query: String!) {
search(query: $query, query_type: "Book", per_page: 20, page: 1) { ids }
}`,
{ query: title },
);
if (!search.ok) return search;
const rawIds = search.value.search?.ids;
if (!Array.isArray(rawIds)) return this.failure('Invalid search response from Hardcover.');
const ids = [...new Set(rawIds.map(Number).filter(id => Number.isInteger(id) && id > 0 && id <= 2147483647))].slice(0, 20);
if (ids.length === 0) return ok([]);

const books = await this.query<{ books?: HardcoverBook[] }>(`query SearchBookDetails($ids: [Int!]!) { books(where: {id: {_in: $ids}}) { ${BOOK_FIELDS} } }`, { ids });
if (!books.ok) return books;
if (!Array.isArray(books.value.books)) return this.failure('Invalid book response from Hardcover.');
// The books query has no search ranking; restore the search endpoint's order.
const byId = new Map(books.value.books.map(book => [book.id, book]));
return ok(ids.flatMap(id => (byId.has(id) ? [this.toBook(byId.get(id)!)] : [])));
}

async getById(id: string): Promise<Result<MediaTypeModel, MDBError>> {
if (!/^\d+$/.test(id) || !Number.isInteger(Number(id)) || Number(id) <= 0 || Number(id) > 2147483647) {
return this.failure('Enter a numeric Hardcover book ID.', MDBErrorKind.Validation);
}
const response = await this.query<{ books_by_pk?: HardcoverBook | null }>(`query BookDetails($id: Int!) { books_by_pk(id: $id) { ${BOOK_FIELDS} } }`, { id: Number(id) });
if (!response.ok) return response;
if (!response.value.books_by_pk) return this.failure(`No book found for Hardcover ID ${id}.`);
return ok(this.toBook(response.value.books_by_pk));
}

private toBook(book: HardcoverBook): BookModel {
const edition = book.default_physical_edition ?? book.default_ebook_edition;
const today = new Date().toISOString().slice(0, 10);
const authors = book.contributions
?.filter(contributor => !contributor.contribution || contributor.contribution.toLowerCase() === 'author')
.map(contributor => contributor.author?.name)
.filter((name): name is string => !!name);
return new BookModel({
title: book.title,
englishTitle: book.title,
year: book.release_year?.toString() ?? book.release_date?.slice(0, 4) ?? 'unknown',
dataSource: this.apiName,
id: book.id.toString(),
url: book.slug ? `https://hardcover.app/books/${book.slug}` : undefined,
author: [...new Set(authors)].join(', '),
plot: book.description ?? undefined,
genres: book.cached_tags?.Genre?.map(genre => genre.tag),
pages: edition?.pages ?? book.pages ?? undefined,
onlineRating: book.rating ?? undefined,
isbn: edition?.isbn_10 ?? undefined,
isbn13: edition?.isbn_13 ?? undefined,
image: book.image?.url ?? undefined,
released: book.release_date ? book.release_date <= today : book.release_year != null && book.release_year <= Number(today.slice(0, 4)),
});
}

getDisabledMediaTypes(): MediaType[] {
return this.plugin.settings.HardcoverAPI_disabledMediaTypes;
}
}
Loading