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; 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 = Promise.resolve(); function schedule(task: () => Promise): Promise { const run = queue.then(task, task); queue = run.then( () => undefined, () => undefined, ); return run; } async function throttle(): Promise { 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; method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; body?: BodyInit; signal?: AbortSignal; } export function e621Request( credentials: E621Credentials, path: string, options: E621RequestOptions = {}, ): Promise { 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; 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 { const data = await e621Request<{ user?: E621User } & Partial>( 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 { 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 { 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 { 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 { const trimmed = query.trim(); if (!trimmed) return []; return e621Request(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 { 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[] { 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 = { general: "General", species: "Species", character: "Character", copyright: "Copyright", artist: "Artist", meta: "Meta", lore: "Lore", invalid: "Invalid", contributor: "Contributor", }; const CATEGORY_BY_NUMBER: Record = { 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 = { s: "Safe", q: "Questionable", e: "Explicit", };