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
350 lines
8.1 KiB
TypeScript
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",
|
|
};
|