Files
J621/frontend/src/lib/e621.ts
T
JakeBreath 04448bf615 Online browser + post detail: SPA talks to e621 directly
Backend:
- POST /api/files/lookup/ reports which MD5s are already in the library

Frontend:
- e621 client extended with post/tag/favorite types and helpers: post
  search, post detail, batch posts by id, tag autocomplete, toggle
  favorite
- /online: tag search with autocomplete, post grid with rating colors
  and in-library badges, numbered pagination, page tag cloud, blacklist
  panel and filtered counts from /users/me.json, anonymous hint
- /online/view/🆔 media viewer (sample or original, video support),
  taxonomy-colored tags by category, specs sheet, favorite/unfavorite,
  description, sources, pools and parent/children thumbnails
- Shared CollapsibleSidebar extracted from the library page; Online
  added to the nav, command palette and sidebar toggle
2026-09-17 09:00:17 -05:00

350 lines
8.1 KiB
TypeScript

export interface E621Credentials {
username: string;
api_key: string;
base_url: string;
configured: boolean;
}
export interface E621User {
id: number;
name: string;
favorite_count?: number;
blacklisted_tags?: string;
}
export interface E621Tag {
id: number;
name: string;
post_count: number;
category: number;
}
export interface E621Post {
id: number;
created_at: string;
rating: "s" | "q" | "e";
tags: Record<string, string[]>;
file: {
width: number;
height: number;
size: number;
ext: string;
md5: string;
url: string | null;
};
preview: { url: string | null; width: number; height: number };
sample: {
has: boolean;
url: string | null;
width: number;
height: number;
};
score: { up: number; down: number; total: number };
fav_count: number;
comment_count: number;
is_favorited: boolean;
vote: number | null;
sources: string[];
description: string;
pools: number[];
relationships: {
parent_id: number | null;
has_children: boolean;
has_active_children: boolean;
children: number[];
};
uploader_name?: string;
}
export class E621Error extends Error {
status: number;
data: unknown;
constructor(status: number, data: unknown) {
super(`e621 request failed (${status})`);
this.name = "E621Error";
this.status = status;
this.data = data;
}
}
export const ANONYMOUS_CREDENTIALS: E621Credentials = {
username: "",
api_key: "",
base_url: "https://e621.net",
configured: false,
};
export function effectiveCredentials(
credentials: E621Credentials | null,
): E621Credentials {
return credentials ?? ANONYMOUS_CREDENTIALS;
}
const CLIENT_VERSION = "J621/0.1";
// e621 allows 2 requests/second hard, 1/second sustained. Serialize every
// request through a queue with a 1s minimum gap.
let lastRequestAt = 0;
let queue: Promise<unknown> = Promise.resolve();
function schedule<T>(task: () => Promise<T>): Promise<T> {
const run = queue.then(task, task);
queue = run.then(
() => undefined,
() => undefined,
);
return run;
}
async function throttle(): Promise<void> {
const wait = Math.max(0, lastRequestAt + 1000 - Date.now());
if (wait > 0) {
await new Promise((resolve) => setTimeout(resolve, wait));
}
lastRequestAt = Date.now();
}
interface E621RequestOptions {
params?: Record<string, string | number | boolean | undefined>;
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
body?: BodyInit;
signal?: AbortSignal;
}
export function e621Request<T>(
credentials: E621Credentials,
path: string,
options: E621RequestOptions = {},
): Promise<T> {
return schedule(async () => {
await throttle();
const base = credentials.base_url.replace(/\/+$/, "");
const url = new URL(`${base}/${path.replace(/^\/+/, "")}`);
// Browsers cannot set User-Agent; e621 documents `_client` for this case.
url.searchParams.set(
"_client",
`${CLIENT_VERSION} (by ${credentials.username || "anonymous"} on e621)`,
);
for (const [key, value] of Object.entries(options.params ?? {})) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
const headers = new Headers();
if (credentials.username && credentials.api_key) {
headers.set(
"Authorization",
`Basic ${btoa(`${credentials.username}:${credentials.api_key}`)}`,
);
}
const response = await fetch(url, {
method: options.method ?? "GET",
headers,
body: options.body,
signal: options.signal,
});
if (!response.ok) {
let data: unknown = null;
try {
data = await response.json();
} catch {
data = null;
}
throw new E621Error(response.status, data);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
});
}
export function e621ErrorMessage(error: unknown): string {
if (error instanceof E621Error) {
const data = error.data;
if (data && typeof data === "object") {
const record = data as Record<string, unknown>;
for (const key of ["reason", "error", "message", "detail"]) {
if (typeof record[key] === "string") return record[key] as string;
}
}
if (error.status === 401) return "Invalid e621 username or API key.";
if (error.status === 403) {
return "e621 refused the request. Check that API access is enabled on your e621 account.";
}
if (error.status === 429 || error.status === 503) {
return "Hit the e621 rate limit — wait a moment and try again.";
}
return `e621 request failed (${error.status}).`;
}
if (error instanceof TypeError) {
return "Could not reach e621. Check the base URL and your connection.";
}
return error instanceof Error
? error.message
: "Something went wrong talking to e621.";
}
export async function verifyE621Credentials(
credentials: E621Credentials,
): Promise<E621User> {
const data = await e621Request<{ user?: E621User } & Partial<E621User>>(
credentials,
"/users/me.json",
);
return data.user ?? (data as E621User);
}
export async function fetchPosts(
credentials: E621Credentials,
options: {
tags?: string;
page?: number;
limit?: number;
signal?: AbortSignal;
} = {},
): Promise<E621Post[]> {
const data = await e621Request<{ posts: E621Post[] }>(
credentials,
"/posts.json",
{
params: {
tags: options.tags || undefined,
page: options.page,
limit: options.limit ?? 48,
},
signal: options.signal,
},
);
return data.posts;
}
export async function fetchPost(
credentials: E621Credentials,
id: number,
signal?: AbortSignal,
): Promise<E621Post> {
const data = await e621Request<{ post: E621Post }>(
credentials,
`/posts/${id}.json`,
{ signal },
);
return data.post;
}
export async function fetchPostsByIds(
credentials: E621Credentials,
ids: number[],
signal?: AbortSignal,
): Promise<E621Post[]> {
if (ids.length === 0) return [];
const data = await e621Request<{ posts: E621Post[] }>(
credentials,
"/posts.json",
{
params: { tags: `id:${ids.join(",")}`, limit: Math.min(ids.length, 100) },
signal,
},
);
return data.posts;
}
export async function fetchTagSuggestions(
credentials: E621Credentials,
query: string,
signal?: AbortSignal,
): Promise<E621Tag[]> {
const trimmed = query.trim();
if (!trimmed) return [];
return e621Request<E621Tag[]>(credentials, "/tags.json", {
params: {
"search[name_matches]": `*${trimmed}*`,
"search[order]": "count",
limit: 8,
},
signal,
});
}
export async function setFavorite(
credentials: E621Credentials,
postId: number,
favorite: boolean,
): Promise<void> {
if (favorite) {
await e621Request(credentials, "/favorites.json", {
method: "POST",
body: new URLSearchParams({ post_id: String(postId) }),
});
return;
}
await e621Request(credentials, `/favorites/${postId}.json`, {
method: "DELETE",
});
}
export function postIsBlacklisted(
post: E621Post,
blacklist: Set<string>,
): string[] {
if (blacklist.size === 0) return [];
const matches: string[] = [];
for (const tags of Object.values(post.tags)) {
for (const tag of tags) {
if (blacklist.has(tag)) matches.push(tag);
}
}
return matches;
}
export const TAG_CATEGORY_ORDER = [
"general",
"species",
"character",
"copyright",
"artist",
"meta",
"lore",
"invalid",
"contributor",
] as const;
export const TAG_CATEGORY_LABELS: Record<string, string> = {
general: "General",
species: "Species",
character: "Character",
copyright: "Copyright",
artist: "Artist",
meta: "Meta",
lore: "Lore",
invalid: "Invalid",
contributor: "Contributor",
};
const CATEGORY_BY_NUMBER: Record<number, string> = {
0: "general",
1: "artist",
3: "copyright",
4: "character",
5: "species",
6: "invalid",
7: "meta",
8: "lore",
9: "contributor",
};
export function tagCategoryName(category: number): string {
return CATEGORY_BY_NUMBER[category] ?? "unknown";
}
export const RATING_LABELS: Record<string, string> = {
s: "Safe",
q: "Questionable",
e: "Explicit",
};