feat: worst-scan fansub web — initial release
Posts engine (SQLite + auto-publisher via poller), public feed with clickable tags, reader, admin panel, submit/search/queue tools. BFF proxy to pipeline API. Clean dark design. Docker-ready.
This commit is contained in:
+143
@@ -0,0 +1,143 @@
|
||||
import type {
|
||||
ApiResponse,
|
||||
GalleryArtifacts,
|
||||
GalleryDetail,
|
||||
GalleryItem,
|
||||
GallerySummary,
|
||||
PaginatedResponse,
|
||||
QueueItem,
|
||||
QueueStats,
|
||||
SearchResult,
|
||||
SystemStatus,
|
||||
} from "./types"
|
||||
|
||||
const API_BASE = process.env.API_BASE_URL || "http://127.0.0.1:8080/api/v1"
|
||||
const API_KEY = process.env.API_KEY || ""
|
||||
|
||||
async function fetchApi<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE}${path}`
|
||||
const headers: Record<string, string> = {
|
||||
...(init?.headers as Record<string, string>),
|
||||
}
|
||||
if (API_KEY) {
|
||||
headers["X-API-Key"] = API_KEY
|
||||
}
|
||||
if (init?.body && typeof init.body === "string" && !(headers["Content-Type"])) {
|
||||
headers["Content-Type"] = "application/json"
|
||||
}
|
||||
|
||||
const res = await fetch(url, { ...init, headers })
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) throw new Error("Not found")
|
||||
if (res.status === 429) throw new Error("Rate limited")
|
||||
if (res.status === 409) throw new Error("Already processing")
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body?.error?.message || `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const api = {
|
||||
health: () =>
|
||||
fetchApi<ApiResponse<{ status: string; version: string; uptime_s: number }>>("/health"),
|
||||
|
||||
status: () =>
|
||||
fetchApi<ApiResponse<SystemStatus>>("/status"),
|
||||
|
||||
galleries: {
|
||||
list: (status?: string, page = 1, perPage = 20) => {
|
||||
const params = new URLSearchParams({ page: String(page), per_page: String(perPage) })
|
||||
if (status) params.set("status", status)
|
||||
return fetchApi<PaginatedResponse<GalleryItem>>(`/galleries?${params}`)
|
||||
},
|
||||
|
||||
get: (gid: string) =>
|
||||
fetchApi<ApiResponse<GalleryDetail>>(`/galleries/${gid}`),
|
||||
|
||||
summary: (gid: string) =>
|
||||
fetchApi<ApiResponse<GallerySummary>>(`/galleries/${gid}/summary`),
|
||||
|
||||
artifacts: (gid: string) =>
|
||||
fetchApi<ApiResponse<GalleryArtifacts>>(`/galleries/${gid}/artifacts`),
|
||||
|
||||
submit: (url: string, opts?: { skipTranslate?: boolean; skipMobi?: boolean; skipEsSearch?: boolean }) =>
|
||||
fetchApi<ApiResponse<{ gid: string; status: string; message: string }>>("/galleries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
skip_translate: opts?.skipTranslate ?? false,
|
||||
skip_mobi: opts?.skipMobi ?? false,
|
||||
skip_es_search: opts?.skipEsSearch ?? false,
|
||||
}),
|
||||
}),
|
||||
|
||||
download: (url: string, opts?: { skipEsSearch?: boolean }) =>
|
||||
fetchApi<ApiResponse<{ gid: string; status: string }>>("/galleries/download", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
skip_es_search: opts?.skipEsSearch ?? false,
|
||||
}),
|
||||
}),
|
||||
|
||||
delete: (gid: string) =>
|
||||
fetchApi<void>(`/galleries/${gid}`, { method: "DELETE" }),
|
||||
},
|
||||
|
||||
search: {
|
||||
query: (query: string, source: "nhentai" | "ehentai" = "nhentai", filter = true, maxPages = 1) =>
|
||||
fetchApi<{ data: SearchResult[]; meta: { total: number; blocked: number; passed: number } }>("/search", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ query, source, filter, max_pages: maxPages }),
|
||||
}),
|
||||
|
||||
process: (
|
||||
query: string,
|
||||
source: "nhentai" | "ehentai" = "nhentai",
|
||||
opts?: { skipTranslate?: boolean; skipMobi?: boolean; maxGalleries?: number }
|
||||
) =>
|
||||
fetchApi<ApiResponse<{
|
||||
search_id: string
|
||||
total_found: number
|
||||
blocked: number
|
||||
duplicate: number
|
||||
queued: number
|
||||
galleries: { gid: string; status: string }[]
|
||||
}>>("/search/process", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
source,
|
||||
skip_translate: opts?.skipTranslate ?? false,
|
||||
skip_mobi: opts?.skipMobi ?? false,
|
||||
max_galleries: opts?.maxGalleries ?? 25,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
|
||||
queue: {
|
||||
list: (status?: string, page = 1, perPage = 20) => {
|
||||
const params = new URLSearchParams({ page: String(page), per_page: String(perPage) })
|
||||
if (status) params.set("status", status)
|
||||
return fetchApi<PaginatedResponse<QueueItem>>(`/queue?${params}`)
|
||||
},
|
||||
|
||||
stats: () =>
|
||||
fetchApi<ApiResponse<QueueStats>>("/queue/stats"),
|
||||
|
||||
retry: (gid: string) =>
|
||||
fetchApi<ApiResponse<{ gid: string; status: string; message: string }>>(`/queue/${gid}/retry`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
wipe: () =>
|
||||
fetchApi<void>("/queue", { method: "DELETE" }),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { SignJWT, jwtVerify } from "jose"
|
||||
|
||||
const SECRET = new TextEncoder().encode(
|
||||
process.env.JWT_SECRET || process.env.WEB_PASSWORD || "worst-scan-web-dev-secret",
|
||||
)
|
||||
|
||||
const COOKIE_NAME = "session"
|
||||
|
||||
export interface SessionPayload {
|
||||
authenticated: boolean
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export async function createSession(): Promise<string> {
|
||||
return new SignJWT({ authenticated: true, timestamp: Date.now() })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("7d")
|
||||
.sign(SECRET)
|
||||
}
|
||||
|
||||
export async function verifySession(token: string): Promise<SessionPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, SECRET)
|
||||
return payload as unknown as SessionPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionCookieOptions(): { name: string; options: { httpOnly: boolean; secure: boolean; sameSite: "lax"; path: string; maxAge: number } } {
|
||||
return {
|
||||
name: COOKIE_NAME,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import JSZip from "jszip"
|
||||
|
||||
const imageExts = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"])
|
||||
|
||||
export async function loadPagesFromCbz(
|
||||
url: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ pages: string[]; pageCount: number }> {
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new Error(`Failed to fetch CBZ: ${response.status}`)
|
||||
|
||||
const blob = await response.blob()
|
||||
const zip = await JSZip.loadAsync(blob)
|
||||
|
||||
const imageEntries = Object.entries(zip.files)
|
||||
.filter(([name, file]) => {
|
||||
const ext = name.toLowerCase().slice(name.lastIndexOf("."))
|
||||
return !file.dir && imageExts.has(ext)
|
||||
})
|
||||
.sort(([a], [b]) => {
|
||||
const numA = parseInt(a.match(/(\d+)/)?.[1] || "0", 10)
|
||||
const numB = parseInt(b.match(/(\d+)/)?.[1] || "0", 10)
|
||||
return numA - numB
|
||||
})
|
||||
|
||||
const pageCount = imageEntries.length
|
||||
const pages: string[] = []
|
||||
|
||||
for (const [, file] of imageEntries) {
|
||||
const blob = await file.async("blob")
|
||||
pages.push(URL.createObjectURL(blob))
|
||||
}
|
||||
|
||||
return { pages, pageCount }
|
||||
}
|
||||
|
||||
export function revokePageUrls(urls: string[]) {
|
||||
for (const url of urls) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
|
||||
const COVERS_DIR = process.env.COVERS_DIR || path.join(process.cwd(), "data", "covers")
|
||||
|
||||
function ensureDir() {
|
||||
fs.mkdirSync(COVERS_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
function extFromContentType(ct: string | null): string {
|
||||
if (!ct) return ".jpg"
|
||||
const m = ct.match(/image\/(\w+)/)
|
||||
if (!m) return ".jpg"
|
||||
const exts: Record<string, string> = {
|
||||
jpeg: ".jpg",
|
||||
png: ".png",
|
||||
webp: ".webp",
|
||||
gif: ".gif",
|
||||
}
|
||||
return exts[m[1]] || ".jpg"
|
||||
}
|
||||
|
||||
export async function cacheCover(gid: string, proxyUrl: string): Promise<string | null> {
|
||||
ensureDir()
|
||||
try {
|
||||
const res = await fetch(proxyUrl, { signal: AbortSignal.timeout(15000) })
|
||||
if (!res.ok) return null
|
||||
|
||||
const buffer = Buffer.from(await res.arrayBuffer())
|
||||
const ext = extFromContentType(res.headers.get("content-type"))
|
||||
const filePath = path.join(COVERS_DIR, `${gid}${ext}`)
|
||||
|
||||
fs.writeFileSync(filePath, buffer)
|
||||
return filePath
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getCoverPath(gid: string): string | null {
|
||||
ensureDir()
|
||||
const files = fs.readdirSync(COVERS_DIR).filter((f) => f.startsWith(gid))
|
||||
if (files.length === 0) return null
|
||||
return path.join(COVERS_DIR, files[0])
|
||||
}
|
||||
|
||||
export function getCoverContentType(gid: string): string {
|
||||
const fp = getCoverPath(gid)
|
||||
if (!fp) return "image/jpeg"
|
||||
const ext = path.extname(fp).toLowerCase()
|
||||
const m: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
}
|
||||
return m[ext] || "image/jpeg"
|
||||
}
|
||||
|
||||
export function deleteCover(gid: string): void {
|
||||
const fp = getCoverPath(gid)
|
||||
if (fp) fs.unlinkSync(fp)
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
import Database from "better-sqlite3"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || path.join(process.cwd(), "data", "worst-scan.db")
|
||||
|
||||
let db: Database.Database | null = null
|
||||
|
||||
function getDb(): Database.Database {
|
||||
if (!db) {
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
|
||||
db = new Database(DB_PATH)
|
||||
db.pragma("journal_mode = WAL")
|
||||
db.pragma("foreign_keys = ON")
|
||||
migrate(db)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
function migrate(db: Database.Database) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
gid TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
title_jpn TEXT,
|
||||
artist TEXT,
|
||||
parody TEXT,
|
||||
tags TEXT,
|
||||
num_pages INTEGER DEFAULT 0,
|
||||
source TEXT,
|
||||
cover_url TEXT,
|
||||
summary TEXT,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
published INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
published_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_gid ON posts(gid);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_created ON posts(created_at DESC);
|
||||
`)
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: number
|
||||
gid: string
|
||||
title: string
|
||||
title_jpn: string | null
|
||||
artist: string | null
|
||||
parody: string | null
|
||||
tags: string[]
|
||||
num_pages: number
|
||||
source: string | null
|
||||
cover_url: string | null
|
||||
summary: string | null
|
||||
slug: string
|
||||
published: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
published_at: string | null
|
||||
}
|
||||
|
||||
export interface PostInput {
|
||||
gid: string
|
||||
title: string
|
||||
title_jpn?: string
|
||||
artist?: string
|
||||
parody?: string
|
||||
tags?: string[]
|
||||
num_pages?: number
|
||||
source?: string
|
||||
cover_url?: string
|
||||
summary?: string
|
||||
slug: string
|
||||
published?: number
|
||||
}
|
||||
|
||||
function rowToPost(row: Record<string, unknown>): Post {
|
||||
return {
|
||||
...row,
|
||||
tags: typeof row.tags === "string" ? JSON.parse(row.tags as string) : [],
|
||||
} as unknown as Post
|
||||
}
|
||||
|
||||
export function getAllPosts(publishedOnly = false): Post[] {
|
||||
const d = getDb()
|
||||
const q = publishedOnly
|
||||
? "SELECT * FROM posts WHERE published = 1 ORDER BY published_at DESC"
|
||||
: "SELECT * FROM posts ORDER BY created_at DESC"
|
||||
return (d.prepare(q).all() as Record<string, unknown>[]).map(rowToPost)
|
||||
}
|
||||
|
||||
export function getPostById(id: number): Post | null {
|
||||
const d = getDb()
|
||||
const row = d.prepare("SELECT * FROM posts WHERE id = ?").get(id) as Record<string, unknown> | undefined
|
||||
return row ? rowToPost(row) : null
|
||||
}
|
||||
|
||||
export function getPostByGid(gid: string): Post | null {
|
||||
const d = getDb()
|
||||
const row = d.prepare("SELECT * FROM posts WHERE gid = ?").get(gid) as Record<string, unknown> | undefined
|
||||
return row ? rowToPost(row) : null
|
||||
}
|
||||
|
||||
export function getPostBySlug(slug: string): Post | null {
|
||||
const d = getDb()
|
||||
const row = d.prepare("SELECT * FROM posts WHERE slug = ?").get(slug) as Record<string, unknown> | undefined
|
||||
return row ? rowToPost(row) : null
|
||||
}
|
||||
|
||||
export function createPost(input: PostInput): Post {
|
||||
const d = getDb()
|
||||
const published = input.published ?? 1
|
||||
const publishedAt = published ? "datetime('now')" : null
|
||||
const stmt = d.prepare(`
|
||||
INSERT INTO posts (gid, title, title_jpn, artist, parody, tags, num_pages, source, cover_url, summary, slug, published, published_at)
|
||||
VALUES (@gid, @title, @title_jpn, @artist, @parody, @tags, @num_pages, @source, @cover_url, @summary, @slug, @published, ${publishedAt})
|
||||
`)
|
||||
const result = stmt.run({
|
||||
gid: input.gid,
|
||||
title: input.title,
|
||||
title_jpn: input.title_jpn || null,
|
||||
artist: input.artist || null,
|
||||
parody: input.parody || null,
|
||||
tags: JSON.stringify(input.tags || []),
|
||||
num_pages: input.num_pages || 0,
|
||||
source: input.source || null,
|
||||
cover_url: input.cover_url || null,
|
||||
summary: input.summary || null,
|
||||
slug: input.slug,
|
||||
published,
|
||||
})
|
||||
return getPostById(result.lastInsertRowid as number)!
|
||||
}
|
||||
|
||||
export function updatePost(id: number, updates: Partial<PostInput & { published: number }>): Post | null {
|
||||
const d = getDb()
|
||||
const fields: string[] = []
|
||||
const values: Record<string, unknown> = { id }
|
||||
|
||||
for (const [k, v] of Object.entries(updates)) {
|
||||
if (v !== undefined) {
|
||||
if (k === "tags") {
|
||||
fields.push("tags = @tags")
|
||||
values.tags = JSON.stringify(v)
|
||||
} else {
|
||||
fields.push(`${k} = @${k}`)
|
||||
values[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) return getPostById(id)
|
||||
|
||||
fields.push("updated_at = datetime('now')")
|
||||
d.prepare(`UPDATE posts SET ${fields.join(", ")} WHERE id = ?`).run(id)
|
||||
return getPostById(id)
|
||||
}
|
||||
|
||||
export function publishPost(id: number, publish: boolean): Post | null {
|
||||
const d = getDb()
|
||||
if (publish) {
|
||||
d.prepare("UPDATE posts SET published = 1, published_at = datetime('now'), updated_at = datetime('now') WHERE id = ?").run(id)
|
||||
} else {
|
||||
d.prepare("UPDATE posts SET published = 0, published_at = NULL, updated_at = datetime('now') WHERE id = ?").run(id)
|
||||
}
|
||||
return getPostById(id)
|
||||
}
|
||||
|
||||
export function deletePost(id: number): boolean {
|
||||
const d = getDb()
|
||||
const result = d.prepare("DELETE FROM posts WHERE id = ?").run(id)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function getPostsByTag(tag: string, publishedOnly = true): Post[] {
|
||||
const d = getDb()
|
||||
const q = publishedOnly
|
||||
? "SELECT * FROM posts WHERE published = 1 AND EXISTS (SELECT 1 FROM json_each(posts.tags) WHERE json_each.value = ?) ORDER BY published_at DESC"
|
||||
: "SELECT * FROM posts WHERE EXISTS (SELECT 1 FROM json_each(posts.tags) WHERE json_each.value = ?) ORDER BY created_at DESC"
|
||||
return (d.prepare(q).all(tag) as Record<string, unknown>[]).map(rowToPost)
|
||||
}
|
||||
|
||||
export function getPostCount(): number {
|
||||
const d = getDb()
|
||||
const row = d.prepare("SELECT COUNT(*) as count FROM posts").get() as { count: number }
|
||||
return row.count
|
||||
}
|
||||
|
||||
export function getGidsNotInPosts(): string[] {
|
||||
return []
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import "server-only"
|
||||
import { api } from "./api"
|
||||
import { slugify } from "./slug"
|
||||
import { createPost, getPostByGid } from "./db"
|
||||
import { cacheCover } from "./cover-cache"
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
const POLL_INTERVAL_MS = 60_000
|
||||
|
||||
export function startPoller() {
|
||||
if (intervalId) return
|
||||
pollOnce()
|
||||
intervalId = setInterval(pollOnce, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
export function stopPoller() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollOnce(): Promise<{ newPosts: number }> {
|
||||
let newPosts = 0
|
||||
|
||||
try {
|
||||
const res = await api.galleries.list("completed", 1, 100)
|
||||
const galleries = res.data || []
|
||||
|
||||
for (const g of galleries) {
|
||||
try {
|
||||
const existing = getPostByGid(g.gid)
|
||||
if (existing) continue
|
||||
|
||||
const sumRes = await api.galleries.summary(g.gid)
|
||||
const summary = sumRes?.data
|
||||
if (!summary) continue
|
||||
|
||||
const slug = slugify(summary.title, g.gid)
|
||||
|
||||
await createPost({
|
||||
gid: g.gid,
|
||||
title: summary.title,
|
||||
title_jpn: summary.title_jpn,
|
||||
artist: summary.artist,
|
||||
parody: summary.parody,
|
||||
tags: summary.tags,
|
||||
num_pages: summary.num_pages,
|
||||
source: summary.source,
|
||||
cover_url: summary.cover?.external_url,
|
||||
summary: generateSummary(summary),
|
||||
slug,
|
||||
published: 1,
|
||||
})
|
||||
|
||||
await cacheCover(g.gid, `/api/proxy/galleries/${g.gid}/cover`)
|
||||
|
||||
newPosts++
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return { newPosts }
|
||||
}
|
||||
|
||||
function generateSummary(summary: {
|
||||
title: string
|
||||
title_jpn?: string
|
||||
artist?: string
|
||||
parody?: string
|
||||
tags?: string[]
|
||||
num_pages?: number
|
||||
source?: string
|
||||
}): string {
|
||||
const parts: string[] = []
|
||||
|
||||
if (summary.title) parts.push(`**${summary.title}**`)
|
||||
if (summary.title_jpn) parts.push(summary.title_jpn)
|
||||
if (summary.artist) parts.push(`Artista: ${summary.artist}`)
|
||||
if (summary.parody) parts.push(`Franquicia: ${summary.parody}`)
|
||||
if (summary.num_pages) parts.push(`${summary.num_pages} páginas`)
|
||||
if (summary.source) parts.push(`Fuente: ${summary.source}`)
|
||||
|
||||
return parts.join("\n\n") || "Sin resumen disponible."
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function slugify(text: string, gid?: string): string {
|
||||
let slug = text
|
||||
.toLowerCase()
|
||||
.replace(/\[.*?\]/g, "")
|
||||
.replace(/\(.*?\)/g, "")
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 80)
|
||||
|
||||
if (!slug) {
|
||||
slug = `gallery`
|
||||
}
|
||||
|
||||
if (gid) {
|
||||
slug = `${slug}-${gid.slice(0, 8)}`
|
||||
}
|
||||
|
||||
return slug
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
export interface GalleryItem {
|
||||
gid: string
|
||||
title: string
|
||||
url: string
|
||||
status: string
|
||||
phase: string
|
||||
done: number
|
||||
total: number
|
||||
is_spanish: boolean
|
||||
pages: number
|
||||
priority: number
|
||||
added_at: string
|
||||
retry_count: number
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface GallerySummary {
|
||||
gid: string
|
||||
title: string
|
||||
title_jpn: string
|
||||
num_pages: number
|
||||
is_spanish: boolean
|
||||
url: string
|
||||
source: string
|
||||
status: string
|
||||
phase: string
|
||||
tags: string[]
|
||||
artist: string
|
||||
parody: string
|
||||
cover: {
|
||||
local_endpoint: string
|
||||
local_available: boolean
|
||||
external_url?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface GalleryDetail {
|
||||
gid: string
|
||||
title: string
|
||||
url: string
|
||||
status: string
|
||||
phase: string
|
||||
done: number
|
||||
total: number
|
||||
is_spanish: boolean
|
||||
pages: number
|
||||
work_dir: string | null
|
||||
has_cbz: boolean
|
||||
cbz_path: string | null
|
||||
queued_at: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface GalleryArtifacts {
|
||||
gid: string
|
||||
artifacts_dir: string
|
||||
files: {
|
||||
source_cbz: string
|
||||
originals: string[]
|
||||
images: string[]
|
||||
masks: string[]
|
||||
regions: string[]
|
||||
inpainted: string[]
|
||||
rendered: string[]
|
||||
}
|
||||
file_count: number
|
||||
total_size_mb: number
|
||||
}
|
||||
|
||||
export interface SlotInfo {
|
||||
max: number
|
||||
used: number
|
||||
free: number
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
active_galleries: {
|
||||
gid: string
|
||||
phase: string
|
||||
done: number
|
||||
total: number
|
||||
title: string
|
||||
}[]
|
||||
active_count: number
|
||||
stats: {
|
||||
completed: number
|
||||
failed: number
|
||||
active: number
|
||||
}
|
||||
slots: {
|
||||
download: SlotInfo
|
||||
translate: SlotInfo
|
||||
}
|
||||
queue: {
|
||||
pending: number
|
||||
processing: number
|
||||
completed: number
|
||||
failed: number
|
||||
}
|
||||
failed_galleries: {
|
||||
url: string
|
||||
gid: string
|
||||
error: string
|
||||
timestamp: string
|
||||
}[]
|
||||
resources: {
|
||||
rss_mb: number
|
||||
cpu_load: number[]
|
||||
cpu_count: number
|
||||
}
|
||||
model: {
|
||||
current: string
|
||||
score: number
|
||||
proxy_health: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface QueueStats {
|
||||
pending: number
|
||||
processing: number
|
||||
completed: number
|
||||
failed: number
|
||||
total: number
|
||||
download_slots: SlotInfo
|
||||
translate_slots: SlotInfo
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
gid: string
|
||||
url: string
|
||||
title: string
|
||||
status: string
|
||||
priority: number
|
||||
added_at: string
|
||||
retry_count: number
|
||||
is_spanish: boolean
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
gid: string
|
||||
title: string
|
||||
url: string
|
||||
pages: number
|
||||
tags: string[]
|
||||
blocked: boolean
|
||||
blocked_reason: string
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
meta: {
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export function cn(...classes: (string | boolean | undefined | null)[]): string {
|
||||
return classes.filter(Boolean).join(" ")
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
if (!iso) return ""
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleDateString("es-AR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
export function formatSize(mb: number): string {
|
||||
if (mb < 1) return `${Math.round(mb * 1024)} KB`
|
||||
return `${mb.toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function extractTag(tags: string[], prefix: string): string {
|
||||
for (const t of tags) {
|
||||
const lower = t.toLowerCase()
|
||||
if (lower.startsWith(prefix)) return t.slice(prefix.length).trim()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user