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:
renato97
2026-07-23 15:58:28 -03:00
commit 25449aa5df
71 changed files with 11296 additions and 0 deletions
+143
View File
@@ -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" }),
},
}
+41
View File
@@ -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,
},
}
}
+41
View File
@@ -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)
}
}
+64
View File
@@ -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
View File
@@ -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 []
}
+88
View File
@@ -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."
}
+22
View File
@@ -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
}
+161
View File
@@ -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
}
+28
View File
@@ -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 ""
}