diff --git a/.dockerignore b/.dockerignore index 03579ac..7e66e31 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,15 +3,12 @@ apps/web/dist apps/server/data .git .idea -.junie -tray *.log .env # Mounted at run time by docker-compose.yml, never baked into the image. sakuya.config.json run.sh run.bat -run-hoyovault-upload.sh setup.sh setup.bat update.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2edbffa..2a6d7ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: # --isolate is required: each server test file boots its own server and # sqlite singleton, so they cannot share a process. - - run: bun test --isolate + - run: bun run test # The web build is the only check that exercises Vite and the release-notes # glob, so a broken import here would otherwise only surface on deploy. diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 30cf57e..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Ignored default folder with query files -/queries/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.impeccable/config.json b/.impeccable/config.json index e6714e1..c0f2f0b 100644 --- a/.impeccable/config.json +++ b/.impeccable/config.json @@ -3,6 +3,15 @@ "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [ + { + "rule": "gray-on-color", + "value": "text-zinc-500 hover:bg-rose-500/20 hover:text-rose-400", + "files": [ + "apps/web/src/components/MediaViewer.tsx" + ], + "createdAt": "2026-08-28T14:46:01.244Z", + "reason": "false positive: text color also switches to text-rose-400 on the same hover, so gray text never co-occurs with the rose background; pre-existing code, unrelated to mobile-responsive task" + }, { "rule": "gray-on-color", "value": "*", diff --git a/apps/server/package.json b/apps/server/package.json index 379ddb7..dbf663f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -24,7 +24,6 @@ "@types/adm-zip": "^0.5.8", "@types/bun": "^1.2.0", "@types/express": "^4.17.21", - "@types/ffprobe-static": "^2.0.3", - "typescript": "^5.5.3" + "@types/ffprobe-static": "^2.0.3" } } diff --git a/apps/server/src/db/index.ts b/apps/server/src/db/index.ts index 64251d0..4e01ef3 100644 --- a/apps/server/src/db/index.ts +++ b/apps/server/src/db/index.ts @@ -2,7 +2,7 @@ import { Database } from 'bun:sqlite'; import { drizzle } from 'drizzle-orm/bun-sqlite'; import { DB_PATH } from '../lib/config'; import * as schema from './schema'; -import { phashBands } from '../lib/phashBands'; +import { phashBands, PHASH_BAND_COUNT } from '../lib/phashBands'; export const sqlite = new Database(DB_PATH, { create: true }); sqlite.exec('PRAGMA journal_mode = WAL;'); @@ -15,16 +15,6 @@ sqlite.exec('PRAGMA synchronous = NORMAL;'); // rather than failing a request with SQLITE_BUSY. sqlite.exec('PRAGMA busy_timeout = 5000;'); -// A discarded downloader prototype used group_id-based download_groups/download_items tables. -// The current schema is batch_id-based (download_batches/download_items); drop the legacy pair -// so the tables below can be (re)created cleanly. Nothing salvageable was left in them: their -// one row pointed at an already-deleted library and a downloads/ folder that no longer exists on disk. -const legacyItemColumns = sqlite.query(`PRAGMA table_info(download_items)`).all() as { name: string }[]; -if (legacyItemColumns.some((c) => c.name === 'group_id')) { - sqlite.exec('DROP TABLE IF EXISTS download_items'); - sqlite.exec('DROP TABLE IF EXISTS download_groups'); -} - const isFreshDb = !sqlite.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'libraries'`).get(); sqlite.exec(` @@ -229,6 +219,7 @@ END; // Add new columns/indexes here (next version number) instead of a bare try/catch — // a real failure (locked file, disk full) should crash loudly, not get swallowed // alongside "column already exists". +const BANDS = [...Array(PHASH_BAND_COUNT).keys()]; const MIGRATIONS: { version: number; sql: string }[] = [ { version: 1, sql: 'ALTER TABLE libraries ADD COLUMN auto_scan_interval INTEGER NOT NULL DEFAULT 0' }, { version: 2, sql: 'ALTER TABLE libraries ADD COLUMN custom_image_path TEXT' }, @@ -244,14 +235,8 @@ const MIGRATIONS: { version: number; sql: string }[] = [ // Perceptual-hash bands: eight indexed 8-bit slices of media.perceptual_hash, so similarity // search can prefilter candidates rather than scanning every image row. Values are backfilled // from the existing hashes below — no re-hashing of files is needed. - { version: 12, sql: 'ALTER TABLE media ADD COLUMN phash_b0 INTEGER' }, - { version: 13, sql: 'ALTER TABLE media ADD COLUMN phash_b1 INTEGER' }, - { version: 14, sql: 'ALTER TABLE media ADD COLUMN phash_b2 INTEGER' }, - { version: 15, sql: 'ALTER TABLE media ADD COLUMN phash_b3 INTEGER' }, - { version: 16, sql: 'ALTER TABLE media ADD COLUMN phash_b4 INTEGER' }, - { version: 17, sql: 'ALTER TABLE media ADD COLUMN phash_b5 INTEGER' }, - { version: 18, sql: 'ALTER TABLE media ADD COLUMN phash_b6 INTEGER' }, - { version: 19, sql: 'ALTER TABLE media ADD COLUMN phash_b7 INTEGER' }, + // Versions 12-19, one per band. + ...BANDS.map((i) => ({ version: 12 + i, sql: `ALTER TABLE media ADD COLUMN phash_b${i} INTEGER` })), // Both superseded: media_library_created_idx has library_id as its prefix, and the media_tags // primary key (media_id, tag_id) already serves every media_id lookup. { version: 20, sql: 'DROP INDEX IF EXISTS media_library_idx' }, @@ -293,8 +278,7 @@ const unbanded = sqlite .all() as { id: number; hash: string }[]; if (unbanded.length > 0) { const setBands = sqlite.prepare( - `UPDATE media SET phash_b0=?, phash_b1=?, phash_b2=?, phash_b3=?, phash_b4=?, phash_b5=?, phash_b6=?, phash_b7=? - WHERE id = ?`, + `UPDATE media SET ${BANDS.map((i) => `phash_b${i}=?`).join(', ')} WHERE id = ?`, ); sqlite.transaction(() => { for (const row of unbanded) setBands.run(...phashBands(row.hash), row.id); @@ -306,16 +290,7 @@ if (unbanded.length > 0) { // add these columns — on an existing database that block would reference columns that don't exist // yet. Creating them after the backfill is also cheaper than maintaining them during it. // IF NOT EXISTS keeps this idempotent across both fresh and migrated databases. -sqlite.exec(` -CREATE INDEX IF NOT EXISTS media_phash_b0_idx ON media(phash_b0); -CREATE INDEX IF NOT EXISTS media_phash_b1_idx ON media(phash_b1); -CREATE INDEX IF NOT EXISTS media_phash_b2_idx ON media(phash_b2); -CREATE INDEX IF NOT EXISTS media_phash_b3_idx ON media(phash_b3); -CREATE INDEX IF NOT EXISTS media_phash_b4_idx ON media(phash_b4); -CREATE INDEX IF NOT EXISTS media_phash_b5_idx ON media(phash_b5); -CREATE INDEX IF NOT EXISTS media_phash_b6_idx ON media(phash_b6); -CREATE INDEX IF NOT EXISTS media_phash_b7_idx ON media(phash_b7); -`); +sqlite.exec(BANDS.map((i) => `CREATE INDEX IF NOT EXISTS media_phash_b${i}_idx ON media(phash_b${i});`).join('\n')); // Populate the search index for rows that predate it. Keyed on which rows are missing rather // than a migration version, so it also heals a database that lost the index or was written to by diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index ecc648b..a8e2043 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -1,9 +1,12 @@ -import { sqliteTable, text, integer, real, primaryKey, index, uniqueIndex } from 'drizzle-orm/sqlite-core'; +import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'; +import { JOB_TYPES, LIBRARY_TYPES, MEDIA_TYPES, SCHEDULE_JOB_TYPES, SCHEDULE_MODES, TAG_CATEGORIES } from '@sakuya/shared'; + +// Indexes and composite primary keys live in the raw DDL in db/index.ts; nothing here generates SQL. export const libraries = sqliteTable('libraries', { id: integer('id').primaryKey({ autoIncrement: true }), name: text('name').notNull(), - type: text('type', { enum: ['image', 'video', 'mixed'] }).notNull().default('mixed'), + type: text('type', { enum: LIBRARY_TYPES }).notNull().default('mixed'), thumbnailMediaId: integer('thumbnail_media_id'), customImagePath: text('custom_image_path'), createdAt: integer('created_at').notNull(), @@ -20,78 +23,58 @@ export const folders = sqliteTable('folders', { createdAt: integer('created_at').notNull(), }); -export const media = sqliteTable( - 'media', - { - id: integer('id').primaryKey({ autoIncrement: true }), - libraryId: integer('library_id').notNull(), - source: text('source', { enum: ['folder', 'upload'] }).notNull(), - path: text('path').notNull(), - filename: text('filename').notNull(), - type: text('type', { enum: ['image', 'video'] }).notNull(), - width: integer('width'), - height: integer('height'), - sizeBytes: integer('size_bytes').notNull().default(0), - durationSeconds: real('duration_seconds'), - thumbnailPath: text('thumbnail_path'), - contentHash: text('content_hash'), - mtime: integer('mtime'), - createdAt: integer('created_at').notNull(), - indexedAt: integer('indexed_at'), - taggedAt: integer('tagged_at'), - transcodedAt: integer('transcoded_at'), - lastViewedAt: integer('last_viewed_at'), - viewProgress: real('view_progress').notNull().default(0), - viewCount: integer('view_count').notNull().default(0), - watchedSeconds: real('watched_seconds').notNull().default(0), - // How long an image/GIF stayed open in the viewer — the still-image counterpart to watchedSeconds. - dwellSeconds: real('dwell_seconds').notNull().default(0), - liked: integer('liked').notNull().default(0), - likedAt: integer('liked_at'), - perceptualHash: text('perceptual_hash'), - // Eight 8-bit bands of perceptualHash, indexed so similarity search can prefilter - // candidates instead of scanning every image. See lib/phashBands.ts. - phashB0: integer('phash_b0'), - phashB1: integer('phash_b1'), - phashB2: integer('phash_b2'), - phashB3: integer('phash_b3'), - phashB4: integer('phash_b4'), - phashB5: integer('phash_b5'), - phashB6: integer('phash_b6'), - phashB7: integer('phash_b7'), - }, - (t) => [ - uniqueIndex('media_path_idx').on(t.path), - index('media_library_created_idx').on(t.libraryId, t.createdAt), - index('media_created_idx').on(t.createdAt), - index('media_last_viewed_idx').on(t.lastViewedAt), - index('media_type_idx').on(t.type), - index('media_hash_idx').on(t.contentHash), - index('media_liked_idx').on(t.liked), - ], -); - -export const tags = sqliteTable( - 'tags', - { - id: integer('id').primaryKey({ autoIncrement: true }), - name: text('name').notNull(), - category: text('category', { enum: ['rating', 'general', 'character', 'user'] }).notNull().default('general'), - usageCount: integer('usage_count').notNull().default(0), - }, - (t) => [uniqueIndex('tags_name_idx').on(t.name)], -); - -export const mediaTags = sqliteTable( - 'media_tags', - { - mediaId: integer('media_id').notNull(), - tagId: integer('tag_id').notNull(), - confidence: real('confidence'), - source: text('source', { enum: ['ai', 'user'] }).notNull().default('user'), - }, - (t) => [primaryKey({ columns: [t.mediaId, t.tagId] }), index('media_tags_tag_idx').on(t.tagId)], -); +export const media = sqliteTable('media', { + id: integer('id').primaryKey({ autoIncrement: true }), + libraryId: integer('library_id').notNull(), + source: text('source', { enum: ['folder', 'upload'] }).notNull(), + path: text('path').notNull(), + filename: text('filename').notNull(), + type: text('type', { enum: MEDIA_TYPES }).notNull(), + width: integer('width'), + height: integer('height'), + sizeBytes: integer('size_bytes').notNull().default(0), + durationSeconds: real('duration_seconds'), + thumbnailPath: text('thumbnail_path'), + contentHash: text('content_hash'), + mtime: integer('mtime'), + createdAt: integer('created_at').notNull(), + indexedAt: integer('indexed_at'), + taggedAt: integer('tagged_at'), + transcodedAt: integer('transcoded_at'), + lastViewedAt: integer('last_viewed_at'), + viewProgress: real('view_progress').notNull().default(0), + viewCount: integer('view_count').notNull().default(0), + watchedSeconds: real('watched_seconds').notNull().default(0), + // How long an image/GIF stayed open in the viewer — the still-image counterpart to watchedSeconds. + dwellSeconds: real('dwell_seconds').notNull().default(0), + liked: integer('liked').notNull().default(0), + likedAt: integer('liked_at'), + perceptualHash: text('perceptual_hash'), + // Eight 8-bit bands of perceptualHash, indexed so similarity search can prefilter + // candidates instead of scanning every image. See lib/phashBands.ts. + phashB0: integer('phash_b0'), + phashB1: integer('phash_b1'), + phashB2: integer('phash_b2'), + phashB3: integer('phash_b3'), + phashB4: integer('phash_b4'), + phashB5: integer('phash_b5'), + phashB6: integer('phash_b6'), + phashB7: integer('phash_b7'), +}); + +export const tags = sqliteTable('tags', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + category: text('category', { enum: TAG_CATEGORIES }).notNull().default('general'), + usageCount: integer('usage_count').notNull().default(0), +}); + +export const mediaTags = sqliteTable('media_tags', { + mediaId: integer('media_id').notNull(), + tagId: integer('tag_id').notNull(), + confidence: real('confidence'), + source: text('source', { enum: ['ai', 'user'] }).notNull().default('user'), +}); export const boards = sqliteTable('boards', { id: integer('id').primaryKey({ autoIncrement: true }), @@ -101,31 +84,15 @@ export const boards = sqliteTable('boards', { // Membership rows are the "belongs to board N" marker on a media item. Both columns cascade // on delete (see db/index.ts), so deleting media or a board can't leave orphan memberships. -export const boardMedia = sqliteTable( - 'board_media', - { - boardId: integer('board_id').notNull(), - mediaId: integer('media_id').notNull(), - addedAt: integer('added_at').notNull(), - }, - (t) => [primaryKey({ columns: [t.boardId, t.mediaId] }), index('board_media_media_idx').on(t.mediaId)], -); +export const boardMedia = sqliteTable('board_media', { + boardId: integer('board_id').notNull(), + mediaId: integer('media_id').notNull(), + addedAt: integer('added_at').notNull(), +}); export const jobs = sqliteTable('jobs', { id: integer('id').primaryKey({ autoIncrement: true }), - type: text('type', { - enum: [ - 'scan', - 'tag', - 'thumbnail', - 'model-download', - 'hash', - 'cleanup', - 'downloader-install', - 'reclassify-gifs', - 'transcode', - ], - }).notNull(), + type: text('type', { enum: JOB_TYPES }).notNull(), libraryId: integer('library_id'), label: text('label').notNull().default(''), status: text('status', { enum: ['queued', 'running', 'done', 'error'] }).notNull().default('queued'), @@ -150,46 +117,34 @@ export const downloadBatches = sqliteTable('download_batches', { createdAt: integer('created_at').notNull(), }); -export const downloadItems = sqliteTable( - 'download_items', - { - id: integer('id').primaryKey({ autoIncrement: true }), - batchId: integer('batch_id').notNull(), - url: text('url').notNull(), - status: text('status', { - enum: ['queued', 'running', 'paused', 'done', 'error', 'skipped'], - }) - .notNull() - .default('queued'), - filesDownloaded: integer('files_downloaded').notNull().default(0), - pid: integer('pid'), - errorMessage: text('error_message'), - createdAt: integer('created_at').notNull(), - updatedAt: integer('updated_at').notNull(), - }, - (t) => [index('download_items_batch_idx').on(t.batchId)], -); - -export const downloadLogs = sqliteTable( - 'download_logs', - { - id: integer('id').primaryKey({ autoIncrement: true }), - itemId: integer('item_id').notNull(), - line: text('line').notNull(), - createdAt: integer('created_at').notNull(), - }, - (t) => [index('download_logs_item_idx').on(t.itemId)], -); - -export const downloadFiles = sqliteTable( - 'download_files', - { - id: integer('id').primaryKey({ autoIncrement: true }), - itemId: integer('item_id').notNull(), - path: text('path').notNull(), - }, - (t) => [index('download_files_item_idx').on(t.itemId)], -); +export const downloadItems = sqliteTable('download_items', { + id: integer('id').primaryKey({ autoIncrement: true }), + batchId: integer('batch_id').notNull(), + url: text('url').notNull(), + status: text('status', { + enum: ['queued', 'running', 'paused', 'done', 'error', 'skipped'], + }) + .notNull() + .default('queued'), + filesDownloaded: integer('files_downloaded').notNull().default(0), + pid: integer('pid'), + errorMessage: text('error_message'), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), +}); + +export const downloadLogs = sqliteTable('download_logs', { + id: integer('id').primaryKey({ autoIncrement: true }), + itemId: integer('item_id').notNull(), + line: text('line').notNull(), + createdAt: integer('created_at').notNull(), +}); + +export const downloadFiles = sqliteTable('download_files', { + id: integer('id').primaryKey({ autoIncrement: true }), + itemId: integer('item_id').notNull(), + path: text('path').notNull(), +}); export const downloadCookies = sqliteTable('download_cookies', { id: integer('id').primaryKey({ autoIncrement: true }), @@ -198,26 +153,18 @@ export const downloadCookies = sqliteTable('download_cookies', { uploadedAt: integer('uploaded_at').notNull(), }); -export const jobSchedules = sqliteTable( - 'job_schedules', - { - jobType: text('job_type', { enum: ['scan', 'tag', 'hash', 'cleanup'] }).notNull(), - libraryId: integer('library_id'), - mode: text('mode', { enum: ['off', 'interval', 'after-scan'] }).notNull(), - intervalMinutes: integer('interval_minutes').notNull().default(0), - useGlobal: integer('use_global').notNull().default(0), - }, - (t) => [primaryKey({ columns: [t.jobType, t.libraryId] })], -); - -export const savedSearches = sqliteTable( - 'saved_searches', - { - id: integer('id').primaryKey({ autoIncrement: true }), - name: text('name').notNull(), - // Explore URL query string (e.g. "tags=solo,blue_hair&liked=1&sort=name"), replayed as /explore?. - query: text('query').notNull(), - createdAt: integer('created_at').notNull(), - }, - (t) => [uniqueIndex('saved_searches_name_idx').on(t.name)], -); +export const jobSchedules = sqliteTable('job_schedules', { + jobType: text('job_type', { enum: SCHEDULE_JOB_TYPES }).notNull(), + libraryId: integer('library_id'), + mode: text('mode', { enum: SCHEDULE_MODES }).notNull(), + intervalMinutes: integer('interval_minutes').notNull().default(0), + useGlobal: integer('use_global').notNull().default(0), +}); + +export const savedSearches = sqliteTable('saved_searches', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + // Explore URL query string (e.g. "tags=solo,blue_hair&liked=1&sort=name"), replayed as /explore?. + query: text('query').notNull(), + createdAt: integer('created_at').notNull(), +}); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index bbd4131..3459b88 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,6 +1,6 @@ import express from 'express'; import sharp from 'sharp'; -import { PORT, HOST, DATA_DIR, CONFIG_FILE, LIMITS, LIMITS_REPORT } from './lib/config'; +import { CONFIG, DATA_DIR, CONFIG_FILE, LIMITS, LIMITS_REPORT } from './lib/config'; import { describeLimits } from './lib/limits'; import './db'; import { librariesRouter } from './routes/libraries'; @@ -17,7 +17,7 @@ import { savedSearchesRouter } from './routes/savedSearches'; import { boardsRouter } from './routes/boards'; import { authRouter } from './routes/auth'; import { requireAuth } from './lib/auth'; -import { initScheduler } from './services/jobScheduler'; +import { scheduleAll } from './services/jobScheduler'; // Set once here, before any thumbnail or tagger preprocessing can touch it — sharp is a singleton // and this is a runtime setter. @@ -56,12 +56,13 @@ app.use((err: any, _req: express.Request, res: express.Response, _next: express. res.status(status).json({ error: err?.message ?? 'Internal error', issues: err?.issues }); }); -app.listen(PORT, HOST, () => { - console.log(`Sakuya server listening on http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}`); - if (HOST === '127.0.0.1') console.log('Bound to loopback only. Set server.host to 0.0.0.0 to serve other machines.'); +const { port, host } = CONFIG.server; +app.listen(port, host, () => { + console.log(`Sakuya server listening on http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`); + if (host === '127.0.0.1') console.log('Bound to loopback only. Set server.host to 0.0.0.0 to serve other machines.'); console.log(`Config: ${CONFIG_FILE}`); console.log(`Data dir: ${DATA_DIR}`); const limits = describeLimits(LIMITS_REPORT); if (limits) console.log(limits); - initScheduler(); + scheduleAll(); }); diff --git a/apps/server/src/lib/auth.ts b/apps/server/src/lib/auth.ts index 7460372..4c28734 100644 --- a/apps/server/src/lib/auth.ts +++ b/apps/server/src/lib/auth.ts @@ -1,6 +1,6 @@ import crypto from 'node:crypto'; import type { Request, Response, RequestHandler } from 'express'; -import { AUTH_ENABLED, AUTH_SECRET, AUTH_COOKIE_SECURE } from './config'; +import { CONFIG } from './config'; const COOKIE_NAME = 'sakuya_auth'; const SESSION_MS = 1000 * 60 * 60 * 24 * 30; @@ -15,14 +15,7 @@ export function timingSafeEqual(a: string, b: string): boolean { // Cookie, not a header: thumbnails/video/EventSource are plain browser requests // that can't attach custom headers, but do send cookies automatically. function readAuthCookie(req: Request): string | null { - const header = req.headers.cookie; - if (!header) return null; - for (const part of header.split(';')) { - const eq = part.indexOf('='); - if (eq === -1) continue; - if (part.slice(0, eq).trim() === COOKIE_NAME) return decodeURIComponent(part.slice(eq + 1).trim()); - } - return null; + return new Bun.CookieMap(req.headers.cookie ?? '').get(COOKIE_NAME); } /** @@ -37,7 +30,7 @@ function readAuthCookie(req: Request): string | null { * no longer verifies. */ function sign(expiry: number): string { - return crypto.createHmac('sha256', AUTH_SECRET).update(String(expiry)).digest('hex'); + return crypto.createHmac('sha256', CONFIG.auth.secret).update(String(expiry)).digest('hex'); } export function issueToken(now: number = Date.now()): string { @@ -64,7 +57,7 @@ export function setAuthCookie(res: Response): void { sameSite: 'lax', // Off unless SAKUYA_HTTPS=true: a Secure cookie is dropped over plain HTTP, and serving a // LAN over HTTP is a supported setup — defaulting this on would lock those users out. - secure: AUTH_COOKIE_SECURE, + secure: CONFIG.server.https, maxAge: SESSION_MS, }); } @@ -74,7 +67,7 @@ export function clearAuthCookie(res: Response): void { } export function isAuthed(req: Request): boolean { - if (!AUTH_ENABLED) return true; + if (!CONFIG.auth.enabled) return true; return verifyToken(readAuthCookie(req)); } diff --git a/apps/server/src/lib/config.ts b/apps/server/src/lib/config.ts index 1fc13e2..389affa 100644 --- a/apps/server/src/lib/config.ts +++ b/apps/server/src/lib/config.ts @@ -4,6 +4,8 @@ import os from 'node:os'; import { loadConfig } from '@sakuya/shared/config'; import { detectCgroupMemoryLimit, detectCpuQuota, effectiveCpus, resolveLimits, type LimitsReport } from './limits'; import { currentJobMemoryLimit } from './windowsJob'; +import type { TaggerModel } from '@sakuya/shared'; +import pkg from '../../../../package.json' with { type: 'json' }; const serverRoot = path.resolve(import.meta.dir, '..', '..'); @@ -15,7 +17,6 @@ for (const warning of loaded.warnings) console.warn(`[sakuya] ${warning}`); export const CONFIG = loaded.config; export const CONFIG_FILE = loaded.file; -export const IN_DOCKER = loaded.docker; export const HOME_DATA_DIR = path.join(os.homedir(), '.sakuya'); export const LOCAL_DATA_DIR = path.join(serverRoot, 'data'); @@ -29,7 +30,7 @@ export const DATA_DIR_PINNED = CONFIG.server.dataDir !== null; * setup.sh or the Settings > System migrate button creates ~/.sakuya, and from then on every start * finds it. */ -export function resolveDataDir(): string { +function resolveDataDir(): string { if (CONFIG.server.dataDir) return CONFIG.server.dataDir; return fs.existsSync(HOME_DATA_DIR) ? HOME_DATA_DIR : LOCAL_DATA_DIR; } @@ -39,55 +40,24 @@ export const DB_PATH = path.join(DATA_DIR, 'tbge.db'); export const THUMBS_DIR = path.join(DATA_DIR, 'thumbnails'); export const TRANSCODES_DIR = path.join(DATA_DIR, 'transcodes'); export const UPLOADS_DIR = path.join(DATA_DIR, 'uploads'); -export const MODELS_DIR = path.join(DATA_DIR, 'models'); +const MODELS_DIR = path.join(DATA_DIR, 'models'); export const MODEL_PATH = path.join(MODELS_DIR, 'model.onnx'); export const MODEL_TAGS_PATH = path.join(MODELS_DIR, 'selected_tags.csv'); export const DOWNLOADER_DIR = path.join(DATA_DIR, 'downloader'); export const DOWNLOADER_BIN_DIR = path.join(DOWNLOADER_DIR, 'bin'); export const DOWNLOADER_COOKIES_DIR = path.join(DOWNLOADER_DIR, 'cookies'); -export const PORT = CONFIG.server.port; - -/** - * Interface to bind. Defaults to loopback: the server exposes the whole library and - * POST /api/media/:id/reveal, which spawns a file manager on the host, and auth is off by - * default — none of that should be reachable from the LAN because someone started the server. - * - * Containers must bind 0.0.0.0 or published ports never reach the process, so the Docker image - * pins it. Set server.host yourself to serve other machines directly, ideally with auth on. - */ -export const HOST = CONFIG.server.host; - -/** - * Marks the auth cookie Secure. Off by default because serving plain HTTP over a LAN is a - * supported setup, and a Secure cookie is silently dropped there, which would lock users out. - */ -export const AUTH_COOKIE_SECURE = CONFIG.server.https; /** * The root package.json is the one source of truth for the app version: Settings > System reads * this, and the bundled release notes in apps/web/src/releases drive the update toast. They used * to disagree three ways (0.1.0 / 1.0.0 / 1.4.0); version.test.ts now keeps them in step. * - * Read at runtime rather than imported so the value survives outside the workspace layout. The - * Docker image copies the root package.json into /app, one level above apps/server, so this - * resolves there too. + * The Docker image copies the root package.json into /app, one level above apps/server, so this + * import resolves there too. */ -function readAppVersion(): string { - try { - const raw = fs.readFileSync(path.resolve(serverRoot, '..', '..', 'package.json'), 'utf8'); - const version = JSON.parse(raw).version; - if (typeof version === 'string' && version) return version; - } catch { - // A stripped-down install shouldn't fail to boot just because it can't name itself. - } - return 'unknown'; -} - -export const APP_VERSION = readAppVersion(); +export const APP_VERSION: string = pkg.version; -export const AUTH_ENABLED = CONFIG.auth.enabled; -export const AUTH_SECRET = CONFIG.auth.secret; -if (AUTH_ENABLED && !AUTH_SECRET) { +if (CONFIG.auth.enabled && !CONFIG.auth.secret) { throw new Error(`auth.enabled is true but auth.secret is empty in ${CONFIG_FILE}. Set a password there.`); } @@ -105,13 +75,7 @@ export const LIMITS_REPORT: LimitsReport = { // Curated WD v3 taggers — all share 448px input + the same selected_tags.csv format, // so they are drop-in compatible with the existing preprocessing/inference code. -export interface TaggerModelDef { - id: string; - label: string; - repo: string; -} - -export const MODEL_REGISTRY: TaggerModelDef[] = [ +export const MODEL_REGISTRY: TaggerModel[] = [ { id: 'wd-swinv2-tagger-v3', label: 'WD SwinV2 v3 (default)', repo: 'SmilingWolf/wd-swinv2-tagger-v3' }, { id: 'wd-convnext-tagger-v3', label: 'WD ConvNeXT v3', repo: 'SmilingWolf/wd-convnext-tagger-v3' }, { id: 'wd-vit-tagger-v3', label: 'WD ViT v3', repo: 'SmilingWolf/wd-vit-tagger-v3' }, @@ -119,7 +83,7 @@ export const MODEL_REGISTRY: TaggerModelDef[] = [ { id: 'wd-eva02-large-tagger-v3', label: 'WD EVA02 Large v3', repo: 'SmilingWolf/wd-eva02-large-tagger-v3' }, ]; -export const DEFAULT_MODEL_ID = 'wd-swinv2-tagger-v3'; +export const DEFAULT_MODEL_ID = MODEL_REGISTRY[0].id; export function modelRepoBase(id: string): string { const def = MODEL_REGISTRY.find((m) => m.id === id) ?? MODEL_REGISTRY[0]; diff --git a/apps/server/src/lib/download.ts b/apps/server/src/lib/download.ts new file mode 100644 index 0000000..78c0781 --- /dev/null +++ b/apps/server/src/lib/download.ts @@ -0,0 +1,25 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; + +/** Stream `url` to `dest` via a `.part` temp file, so an interrupted download never leaves a truncated `dest`. */ +export async function downloadFile(url: string, dest: string, onProgress: (received: number, total: number) => void) { + const res = await fetch(url, { redirect: 'follow' }); + if (!res.ok || !res.body) throw new Error(`Download failed (${res.status}) for ${url}`); + const total = Number(res.headers.get('content-length') ?? 0); + const tmp = dest + '.part'; + const writer = fs.createWriteStream(tmp); + let received = 0; + try { + for await (const chunk of res.body as any) { + writer.write(chunk); + received += chunk.length; + onProgress(received, total); + } + await new Promise((resolve, reject) => writer.end((err: any) => (err ? reject(err) : resolve()))); + await fsp.rename(tmp, dest); + } catch (err) { + writer.destroy(); + await fsp.unlink(tmp).catch(() => {}); + throw err; + } +} diff --git a/apps/server/src/lib/http.ts b/apps/server/src/lib/http.ts index 6d960b6..d325a94 100644 --- a/apps/server/src/lib/http.ts +++ b/apps/server/src/lib/http.ts @@ -14,6 +14,20 @@ export function intParam(value: string): number { return n; } +/** Opaque pagination cursor: the sort key and tiebreakers of the last row served. */ +export function encodeCursor(parts: unknown[]): string { + return Buffer.from(JSON.stringify(parts)).toString('base64url'); +} + +/** The parts encodeCursor packed; anything that isn't one is a 400. */ +export function decodeCursor(cursor: string): any[] { + try { + return [...JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))]; + } catch { + throw Object.assign(new Error('Invalid cursor'), { status: 400 }); + } +} + /** Buffer the raw body and let Bun's fetch primitives parse the multipart form. */ export async function readFormData(req: ExpressRequest) { const chunks: Buffer[] = []; diff --git a/apps/server/src/lib/mediaByIds.ts b/apps/server/src/lib/mediaByIds.ts index d0d6a4f..9b37c43 100644 --- a/apps/server/src/lib/mediaByIds.ts +++ b/apps/server/src/lib/mediaByIds.ts @@ -1,4 +1,4 @@ -import { inArray } from 'drizzle-orm'; +import { and, eq, inArray, type SQL } from 'drizzle-orm'; import { db, schema } from '../db'; export type MediaRow = typeof schema.media.$inferSelect; @@ -33,3 +33,13 @@ export function mediaRowsByIds(ids: number[]): Map { } return byId; } + +/** Ids of media matching `cond`, optionally narrowed to one library. */ +export function mediaIdsWhere(cond: SQL | undefined, libraryId?: number): number[] { + return db + .select({ id: schema.media.id }) + .from(schema.media) + .where(libraryId === undefined ? cond : and(eq(schema.media.libraryId, libraryId), cond)) + .all() + .map((r) => r.id); +} diff --git a/apps/server/src/lib/rowToMedia.ts b/apps/server/src/lib/rowToMedia.ts index c655277..196661b 100644 --- a/apps/server/src/lib/rowToMedia.ts +++ b/apps/server/src/lib/rowToMedia.ts @@ -1,4 +1,6 @@ +import type { SQLQueryBindings } from 'bun:sqlite'; import type { Media } from '@sakuya/shared'; +import { sqlite } from '../db'; /** * A media row joined with its library name and tag count — the shape rowToMedia expects. Append @@ -9,6 +11,12 @@ export const MEDIA_SELECT = ` (SELECT COUNT(*) FROM media_tags mt WHERE mt.media_id = m.id) AS tag_count FROM media m LEFT JOIN libraries l ON l.id = m.library_id`; +/** How many media rows match `conds` (aliased `m`). Paginated lists compute it for the first page only. */ +export function countMedia(conds: string[], params: SQLQueryBindings[]): number { + const where = conds.length ? `WHERE ${conds.join(' AND ')}` : ''; + return (sqlite.query(`SELECT COUNT(*) AS c FROM media m ${where}`).get(...params) as { c: number }).c; +} + /** A raw sqlite media row: snake_case columns plus the joined ones some queries add. */ export interface MediaSqlRow { id: number; diff --git a/apps/server/src/lib/run.ts b/apps/server/src/lib/run.ts new file mode 100644 index 0000000..8c4ac67 --- /dev/null +++ b/apps/server/src/lib/run.ts @@ -0,0 +1,27 @@ +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import ffmpegStatic from 'ffmpeg-static'; + +export const FFMPEG_PATH: string = (ffmpegStatic as unknown as string) ?? 'ffmpeg'; + +/** Comfortably more than the 300 characters reported on failure, small enough to never matter. */ +const STDERR_TAIL = 4_000; + +/** Run a command to completion and resolve with its stdout; a non-zero exit rejects with the end of stderr. */ +export function run(cmd: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + // Keep only the tail: ffmpeg emits a progress line every few hundred ms and transcoding a + // large video runs for hours, so appending the whole stream grew a string for the life of + // the job when all that is ever read back is the last few hundred characters. + let stderr = ''; + proc.stdout.on('data', (d) => (stdout += d)); + proc.stderr.on('data', (d) => (stderr = (stderr + d).slice(-STDERR_TAIL))); + proc.on('error', reject); + proc.on('close', (code) => { + if (code === 0) resolve(stdout); + else reject(new Error(`${path.basename(cmd)} exited with ${code}: ${stderr.slice(-300)}`)); + }); + }); +} diff --git a/apps/server/src/lib/settings.ts b/apps/server/src/lib/settings.ts index 1a30336..0938171 100644 --- a/apps/server/src/lib/settings.ts +++ b/apps/server/src/lib/settings.ts @@ -1,17 +1,17 @@ import { eq } from 'drizzle-orm'; import { db, schema } from '../db'; +import { DEFAULT_MODEL_ID } from './config'; -const DEFAULTS: Record = { +export const DEFAULTS: Record = { ai_tagging_enabled: '1', confidence_threshold: '35', accent_color: '#8b5cf6', - model_status: 'absent', remember_mute_state: '0', remember_volume_level: '1', continue_where_left: '1', thumbnail_cache_enabled: '1', board_remember_filters: '1', - tagger_model: 'wd-swinv2-tagger-v3', + tagger_model: DEFAULT_MODEL_ID, downloader_concurrency: '2', gifs_as_videos: '0', video_transcode_enabled: '0', diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index 0f73a9f..98578ef 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import rateLimit from 'express-rate-limit'; -import { AUTH_ENABLED, AUTH_SECRET } from '../lib/config'; +import { CONFIG } from '../lib/config'; import { timingSafeEqual, isAuthed, setAuthCookie, clearAuthCookie } from '../lib/auth'; import type { AuthStatus } from '@sakuya/shared'; @@ -15,18 +15,18 @@ const loginLimiter = rateLimit({ }); authRouter.get('/api/auth/status', (req, res) => { - const payload: AuthStatus = { enabled: AUTH_ENABLED, unlocked: isAuthed(req) }; + const payload: AuthStatus = { enabled: CONFIG.auth.enabled, unlocked: isAuthed(req) }; res.json(payload); }); authRouter.post('/api/auth/login', loginLimiter, (req, res) => { - if (!AUTH_ENABLED) { + if (!CONFIG.auth.enabled) { res.json({ ok: true }); return; } const secret = typeof req.body?.secret === 'string' ? req.body.secret : ''; - if (!secret || !timingSafeEqual(secret, AUTH_SECRET)) { + if (!secret || !timingSafeEqual(secret, CONFIG.auth.secret)) { res.status(401).json({ error: 'Invalid password' }); return; } diff --git a/apps/server/src/routes/discover.ts b/apps/server/src/routes/discover.ts index d669f61..b26ea4e 100644 --- a/apps/server/src/routes/discover.ts +++ b/apps/server/src/routes/discover.ts @@ -1,16 +1,16 @@ import { Router } from 'express'; import { z } from 'zod'; import { sqlite } from '../db'; -import { wrap } from '../lib/http'; +import { wrap, encodeCursor, decodeCursor } from '../lib/http'; import type { SQLQueryBindings } from 'bun:sqlite'; -import { rowToMedia, type MediaSqlRow } from '../lib/rowToMedia'; +import { rowToMedia, countMedia, type MediaSqlRow } from '../lib/rowToMedia'; import { tasteVersion } from '../lib/tasteVersion'; -import type { MediaListResponse } from '@sakuya/shared'; +import { MEDIA_TYPES, type MediaListResponse } from '@sakuya/shared'; export const discoverRouter = Router(); const discoverQuerySchema = z.object({ - type: z.enum(['image', 'video']).optional(), + type: z.enum(MEDIA_TYPES).optional(), // 0 = pure taste match, 1 = pure random. "I'm feeling lucky" is 1 with limit=1. surprise: z.coerce.number().min(0).max(1).default(0.25), seed: z.coerce.number().int().default(1), @@ -208,12 +208,8 @@ discoverRouter.get( let cursor: { key: number; id: number; snapshot: number } | null = null; if (query.cursor) { - try { - const [key, id, snapshot] = JSON.parse(Buffer.from(query.cursor, 'base64url').toString('utf8')); - cursor = { key, id, snapshot }; - } catch { - throw Object.assign(new Error('Invalid cursor'), { status: 400 }); - } + const [key, id, snapshot] = decodeCursor(query.cursor); + cursor = { key, id, snapshot }; } // A cursor whose snapshot has aged out falls back to the current one: the rest of that scroll // may repeat or miss a few items, which is the old behaviour, but only after 8 rebuilds. @@ -240,13 +236,7 @@ discoverRouter.get( // Only the first page pays for the count; the client keeps the total from page one, the same // as /api/media. - const total = cursor - ? null - : ( - sqlite - .query(`SELECT COUNT(*) AS c FROM media m ${conds.length ? 'WHERE ' + conds.join(' AND ') : ''}`) - .get(...params) as { c: number } - ).c; + const total = cursor ? null : countMedia(conds, params); const pageConds = [...conds]; const pageParams = [...params]; @@ -283,7 +273,7 @@ discoverRouter.get( let nextCursor: string | null = null; if (items.length === query.limit) { const last = items[items.length - 1]; - nextCursor = Buffer.from(JSON.stringify([last.sort_key, last.id, snap.id])).toString('base64url'); + nextCursor = encodeCursor([last.sort_key, last.id, snap.id]); } const body: MediaListResponse = { items: items.map(rowToMedia), nextCursor, total }; res.json(body); diff --git a/apps/server/src/routes/downloader.ts b/apps/server/src/routes/downloader.ts index 20ca35b..8ce9427 100644 --- a/apps/server/src/routes/downloader.ts +++ b/apps/server/src/routes/downloader.ts @@ -222,11 +222,10 @@ downloaderRouter.post( '/api/downloader/console/start', wrap(async (req, res) => { const body = consoleStartSchema.parse(req.body); - if (getConsoleStatus().running) return res.status(409).json({ error: 'A console session is already running' }); try { await startConsoleSession(body.command.trim()); } catch (err: any) { - return res.status(400).json({ error: err.message }); + return res.status(err.status ?? 400).json({ error: err.message }); } res.json(getConsoleStatus()); }), @@ -238,7 +237,6 @@ downloaderRouter.post( '/api/downloader/console/input', wrap(async (req, res) => { const body = consoleInputSchema.parse(req.body); - if (!getConsoleStatus().running) return res.status(409).json({ error: 'No console session is running' }); writeConsoleInput(body.text); res.json({ ok: true }); }), diff --git a/apps/server/src/routes/jobs.ts b/apps/server/src/routes/jobs.ts index d81248b..2f19c00 100644 --- a/apps/server/src/routes/jobs.ts +++ b/apps/server/src/routes/jobs.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { wrap, openEventStream } from '../lib/http'; import { listJobs, jobEvents } from '../services/jobQueue'; import { runAllNow } from '../services/jobScheduler'; -import type { Job } from '@sakuya/shared'; +import { SCHEDULE_JOB_TYPES, type Job } from '@sakuya/shared'; export const jobsRouter = Router(); @@ -29,7 +29,7 @@ jobsRouter.post( const body = z .object({ scope: z.union([z.literal('global'), z.object({ libraryId: z.number() })]), - jobType: z.enum(['scan', 'tag', 'hash', 'cleanup']).optional(), + jobType: z.enum(SCHEDULE_JOB_TYPES).optional(), }) .parse(req.body); diff --git a/apps/server/src/routes/libraries.ts b/apps/server/src/routes/libraries.ts index 5afcaa2..a3eb6c3 100644 --- a/apps/server/src/routes/libraries.ts +++ b/apps/server/src/routes/libraries.ts @@ -12,11 +12,15 @@ import { scheduleAll } from '../services/jobScheduler'; import { removeMediaRows } from '../services/mediaRemoval'; import { isUnder } from '../lib/paths'; import { UPLOADS_DIR } from '../lib/config'; -import type { LibraryWithStats } from '@sakuya/shared'; +import { LIBRARY_TYPES, type LibraryWithStats } from '@sakuya/shared'; export const librariesRouter = Router(); -export function libraryWithStats(row: typeof schema.libraries.$inferSelect): LibraryWithStats { +// auto_scan_interval is a legacy column, migrated into job_schedules at boot (db/index.ts). +export function libraryWithStats({ + autoScanInterval: _legacy, + ...row +}: typeof schema.libraries.$inferSelect): LibraryWithStats { const count = sqlite.query('SELECT COUNT(*) AS c FROM media WHERE library_id = ?').get(row.id) as { c: number }; const thumb = row.thumbnailMediaId ?? @@ -25,15 +29,7 @@ export function libraryWithStats(row: typeof schema.libraries.$inferSelect): Lib .get(row.id) as { id: number } | null)?.id ?? null); const libFolders = db.select().from(schema.folders).where(eq(schema.folders.libraryId, row.id)).all(); return { - id: row.id, - name: row.name, - type: row.type, - thumbnailMediaId: row.thumbnailMediaId, - customImagePath: row.customImagePath ?? null, - createdAt: row.createdAt, - lastVisitedAt: row.lastVisitedAt, - autoScanInterval: row.autoScanInterval, - sortOrder: row.sortOrder, + ...row, itemCount: count.c, // Cover precedence: custom uploaded image wins; otherwise a media thumbnail. thumbMediaId: row.customImagePath ? null : thumb, @@ -66,8 +62,7 @@ librariesRouter.get( const libraryBodySchema = z.object({ name: z.string().min(1).max(120), - type: z.enum(['image', 'video', 'mixed']).default('mixed'), - autoScanInterval: z.number().int().min(0).default(0), + type: z.enum(LIBRARY_TYPES).default('mixed'), }); /** New libraries land at the bottom of the list. */ @@ -85,7 +80,6 @@ librariesRouter.post( .values({ name: body.name, type: body.type, - autoScanInterval: body.autoScanInterval, createdAt: Date.now(), sortOrder: nextSortOrder(), }) diff --git a/apps/server/src/routes/media.ts b/apps/server/src/routes/media.ts index 46c7c6d..ee4a4c2 100644 --- a/apps/server/src/routes/media.ts +++ b/apps/server/src/routes/media.ts @@ -6,35 +6,35 @@ import { z } from 'zod'; import { eq, and, sql, inArray } from 'drizzle-orm'; import type { SQLQueryBindings } from 'bun:sqlite'; import { db, sqlite, schema } from '../db'; -import { wrap, intParam } from '../lib/http'; +import { wrap, intParam, encodeCursor, decodeCursor } from '../lib/http'; import { thumbPathFor, generateThumbnail, enqueueThumbnailRegenerate } from '../services/thumbnailer'; import { enqueueTagJob, modelReady, upsertTag, refreshUsageCounts } from '../services/tagger'; import { playablePathFor } from '../services/transcoder'; import { removeMediaRows } from '../services/mediaRemoval'; -import { rowToMedia, MEDIA_SELECT, type MediaSqlRow } from '../lib/rowToMedia'; -import { mediaRowsByIds, chunkIds } from '../lib/mediaByIds'; +import { rowToMedia, countMedia, MEDIA_SELECT, type MediaSqlRow } from '../lib/rowToMedia'; +import { mediaRowsByIds, chunkIds, type MediaRow } from '../lib/mediaByIds'; import { bumpTasteVersion } from '../lib/tasteVersion'; import { thumbnailCacheEnabled } from '../lib/settings'; import { hammingDistance } from '../services/perceptualHash'; import { phashBands } from '../lib/phashBands'; -import type { - BulkFailure, - BulkResult, - DuplicatesResponse, - MediaDetail, - MediaIdsResponse, - MediaListResponse, - SimilarResponse, +import { + MEDIA_TYPES, + TAG_CATEGORIES, + type BulkFailure, + type BulkResult, + type DuplicatesResponse, + type MediaDetail, + type MediaIdsResponse, + type MediaListResponse, + type SimilarResponse, } from '@sakuya/shared'; export const mediaRouter = Router(); -const TAG_CATEGORIES = ['rating', 'general', 'character', 'user'] as const; - const filterSchema = z.object({ libraryId: z.coerce.number().int().optional(), boardId: z.coerce.number().int().optional(), - type: z.enum(['image', 'video']).optional(), + type: z.enum(MEDIA_TYPES).optional(), tags: z.string().optional(), liked: z.coerce.number().int().optional(), q: z.union([z.string(), z.array(z.string())]).optional(), @@ -50,6 +50,12 @@ const listQuerySchema = filterSchema.extend({ type MediaFilterQuery = z.infer; +/** The bare filename a client asked to rename to, or null when it names no file. */ +function safeFilename(filename: string): string | null { + const name = path.basename(filename.trim()); + return name && name !== '.' && name !== '..' ? name : null; +} + /** * FTS5 query string for a set of free-text search terms. * @@ -141,24 +147,14 @@ mediaRouter.get( // pays for it. Infinite scroll fetches page after page with identical filters, and a full // filtered COUNT(*) was the most expensive part of each of those fetches; the client keeps // the total it got from page one. - const total = query.cursor - ? null - : ( - sqlite - .query(`SELECT COUNT(*) AS c FROM media m ${conds.length ? 'WHERE ' + conds.join(' AND ') : ''}`) - .get(...params) as { c: number } - ).c; + const total = query.cursor ? null : countMedia(conds, params); const pageConds = [...conds]; const pageParams = [...params]; if (query.cursor) { - try { - const [key, id] = JSON.parse(Buffer.from(query.cursor, 'base64url').toString('utf8')); - pageConds.push(`(${keyExpr}, m.id) ${dir === 'asc' ? '>' : '<'} (?, ?)`); - pageParams.push(key, id); - } catch { - throw Object.assign(new Error('Invalid cursor'), { status: 400 }); - } + const [key, id] = decodeCursor(query.cursor); + pageConds.push(`(${keyExpr}, m.id) ${dir === 'asc' ? '>' : '<'} (?, ?)`); + pageParams.push(key, id); } const sql = ` @@ -174,7 +170,7 @@ mediaRouter.get( let nextCursor: string | null = null; if (rows.length === query.limit) { const last = rows[rows.length - 1]; - nextCursor = Buffer.from(JSON.stringify([last.sort_key, last.id])).toString('base64url'); + nextCursor = encodeCursor([last.sort_key, last.id]); } const body: MediaListResponse = { items: rows.map(rowToMedia), nextCursor, total }; res.json(body); @@ -410,8 +406,8 @@ mediaRouter.post( failed.push({ id: item.id, error: 'Not found' }); continue; } - const safeName = path.basename(item.filename.trim()); - if (!safeName || safeName === '.' || safeName === '..') { + const safeName = safeFilename(item.filename); + if (!safeName) { failed.push({ id: item.id, error: 'Invalid filename' }); continue; } @@ -481,6 +477,13 @@ mediaRouter.post( }), ); +/** The media row for an `:id` route param; a missing row is a 404, like intParam's 400. */ +function mediaOr404(param: string): MediaRow { + const row = db.select().from(schema.media).where(eq(schema.media.id, intParam(param))).get(); + if (!row) throw Object.assign(new Error('Not found'), { status: 404 }); + return row; +} + function getDetail(id: number): MediaDetail | null { const row = sqlite.query(`${MEDIA_SELECT} WHERE m.id = ?`).get(id) as MediaSqlRow | null; if (!row) return null; @@ -514,9 +517,8 @@ mediaRouter.get( mediaRouter.get( '/api/media/:id/file', wrap(async (req, res) => { - const id = intParam(req.params.id); - const row = db.select().from(schema.media).where(eq(schema.media.id, id)).get(); - if (!row || !fs.existsSync(row.path)) return res.status(404).json({ error: 'Not found' }); + const row = mediaOr404(req.params.id); + if (!fs.existsSync(row.path)) return res.status(404).json({ error: 'Not found' }); const servePath = row.type === 'video' ? playablePathFor(row.id, row.path) : row.path; // res.sendFile handles Range requests, ETag and conditional GETs. res.sendFile(servePath, { acceptRanges: true, cacheControl: true, maxAge: '1h' }); @@ -528,14 +530,10 @@ const renameSchema = z.object({ filename: z.string().min(1) }); mediaRouter.patch( '/api/media/:id/rename', wrap(async (req, res) => { - const id = intParam(req.params.id); - const row = db.select().from(schema.media).where(eq(schema.media.id, id)).get(); - if (!row) return res.status(404).json({ error: 'Not found' }); - const { filename } = renameSchema.parse(req.body); - const safeName = path.basename(filename.trim()); - if (!safeName || safeName === '.' || safeName === '..') { - return res.status(400).json({ error: 'Invalid filename' }); - } + const row = mediaOr404(req.params.id); + const id = row.id; + const safeName = safeFilename(renameSchema.parse(req.body).filename); + if (!safeName) return res.status(400).json({ error: 'Invalid filename' }); if (safeName === row.filename) return res.json(getDetail(id)); const newPath = path.join(path.dirname(row.path), safeName); if (fs.existsSync(newPath)) return res.status(409).json({ error: 'A file with that name already exists' }); @@ -549,10 +547,8 @@ mediaRouter.patch( mediaRouter.delete( '/api/media/:id', wrap(async (req, res) => { - const id = intParam(req.params.id); - const row = db.select().from(schema.media).where(eq(schema.media.id, id)).get(); - if (!row) return res.status(404).json({ error: 'Not found' }); - removeMediaRows([id]); + const row = mediaOr404(req.params.id); + removeMediaRows([row.id]); fs.unlink(row.path, () => {}); res.json({ ok: true }); }), @@ -561,9 +557,8 @@ mediaRouter.delete( mediaRouter.post( '/api/media/:id/reveal', wrap(async (req, res) => { - const id = intParam(req.params.id); - const row = db.select().from(schema.media).where(eq(schema.media.id, id)).get(); - if (!row || !fs.existsSync(row.path)) return res.status(404).json({ error: 'Not found' }); + const row = mediaOr404(req.params.id); + if (!fs.existsSync(row.path)) return res.status(404).json({ error: 'Not found' }); const proc = process.platform === 'win32' ? spawn('explorer.exe', [`/select,${row.path}`], { detached: true, stdio: 'ignore' }) @@ -579,9 +574,8 @@ mediaRouter.post( mediaRouter.get( '/api/media/:id/thumbnail', wrap(async (req, res) => { - const id = intParam(req.params.id); - const row = db.select().from(schema.media).where(eq(schema.media.id, id)).get(); - if (!row) return res.status(404).json({ error: 'Not found' }); + const row = mediaOr404(req.params.id); + const id = row.id; // When the thumbnail cache is disabled, serve the original image directly. // Videos always need a generated frame (a raw video is not a usable thumbnail). if (!thumbnailCacheEnabled() && row.type === 'image') { @@ -601,12 +595,10 @@ mediaRouter.get( mediaRouter.post( '/api/media/:id/thumbnail/regenerate', wrap(async (req, res) => { - const id = intParam(req.params.id); - const row = db.select().from(schema.media).where(eq(schema.media.id, id)).get(); - if (!row) return res.status(404).json({ error: 'Not found' }); + const row = mediaOr404(req.params.id); if (!fs.existsSync(row.path)) return res.status(404).json({ error: 'Source missing' }); - const thumb = await generateThumbnail(row.path, id, row.type, row.durationSeconds); - db.update(schema.media).set({ thumbnailPath: thumb }).where(eq(schema.media.id, id)).run(); + const thumb = await generateThumbnail(row.path, row.id, row.type, row.durationSeconds); + db.update(schema.media).set({ thumbnailPath: thumb }).where(eq(schema.media.id, row.id)).run(); res.json({ ok: true }); }), ); @@ -622,9 +614,7 @@ const tagsPatchSchema = z.object({ mediaRouter.patch( '/api/media/:id/tags', wrap(async (req, res) => { - const id = intParam(req.params.id); - const row = db.select().from(schema.media).where(eq(schema.media.id, id)).get(); - if (!row) return res.status(404).json({ error: 'Not found' }); + const id = mediaOr404(req.params.id).id; const body = tagsPatchSchema.parse(req.body); const touched: number[] = []; for (const raw of body.add) { @@ -662,9 +652,7 @@ const likeSchema = z.object({ liked: z.boolean() }); mediaRouter.patch( '/api/media/:id/like', wrap(async (req, res) => { - const id = intParam(req.params.id); - const row = db.select().from(schema.media).where(eq(schema.media.id, id)).get(); - if (!row) return res.status(404).json({ error: 'Not found' }); + const id = mediaOr404(req.params.id).id; const { liked } = likeSchema.parse(req.body); db.update(schema.media) .set({ liked: liked ? 1 : 0, likedAt: liked ? Date.now() : null }) @@ -678,9 +666,8 @@ mediaRouter.patch( mediaRouter.get( '/api/media/:id/similar', wrap(async (req, res) => { - const id = intParam(req.params.id); - const row = db.select().from(schema.media).where(eq(schema.media.id, id)).get(); - if (!row) return res.status(404).json({ error: 'Not found' }); + const row = mediaOr404(req.params.id); + const id = row.id; // Exact duplicates: same cheap content hash (size + first 4 MB). const duplicates = row.contentHash @@ -725,11 +712,9 @@ mediaRouter.get( mediaRouter.post( '/api/media/:id/retag', wrap(async (req, res) => { - const id = intParam(req.params.id); - const row = db.select().from(schema.media).where(eq(schema.media.id, id)).get(); - if (!row) return res.status(404).json({ error: 'Not found' }); + const row = mediaOr404(req.params.id); if (!modelReady()) return res.status(409).json({ error: 'Tagger model not downloaded' }); - const job = enqueueTagJob([id], `AI tag: ${row.filename}`, row.libraryId); + const job = enqueueTagJob([row.id], `AI tag: ${row.filename}`, row.libraryId); res.json({ job }); }), ); diff --git a/apps/server/src/routes/settings.ts b/apps/server/src/routes/settings.ts index a60d1ca..ea0a761 100644 --- a/apps/server/src/routes/settings.ts +++ b/apps/server/src/routes/settings.ts @@ -2,10 +2,10 @@ import { Router } from 'express'; import fs from 'node:fs'; import path from 'node:path'; import { z } from 'zod'; -import { eq, isNotNull, isNull } from 'drizzle-orm'; +import { and, eq, isNotNull, isNull, sql } from 'drizzle-orm'; import { sqlite, db, schema } from '../db'; import { wrap } from '../lib/http'; -import { getAllSettings, getSetting, setSetting, gifsAsVideos } from '../lib/settings'; +import { DEFAULTS, getAllSettings, getSetting, setSetting, gifsAsVideos } from '../lib/settings'; import { THUMBS_DIR, DB_PATH, APP_VERSION, DATA_DIR, DATA_DIR_PINNED, HOME_DATA_DIR, LOCAL_DATA_DIR } from '../lib/config'; import { migrateDataDir } from '../lib/storage'; import { enqueueBulkThumbnailRegenerate } from '../services/thumbnailer'; @@ -13,27 +13,12 @@ import { enqueueBulkTranscodeCheck } from '../services/transcoder'; import { enqueueGifReclassifyJob } from '../services/scanner'; import { scheduleAll } from '../services/jobScheduler'; import { performCleanup } from '../services/cleanup'; -import type { SystemInfo, StorageInfo, JobSchedule, JobSchedulesPayload } from '@sakuya/shared'; +import { SCHEDULE_JOB_TYPES, SCHEDULE_MODES, type SystemInfo, type StorageInfo, type JobSchedule, type JobSchedulesPayload } from '@sakuya/shared'; export const settingsRouter = Router(); -const EDITABLE_KEYS = new Set([ - 'ai_tagging_enabled', - 'confidence_threshold', - 'accent_color', - 'remember_mute_state', - 'remember_volume_level', - 'continue_where_left', - 'thumbnail_cache_enabled', - 'board_remember_filters', - 'downloader_concurrency', - 'gifs_as_videos', - 'video_transcode_enabled', - 'discover_enabled', - 'ui_style', - 'ui_style_chosen', - 'dashboard_hero', -]); +// tagger_model is switched through /api/tagger/select, which also drops the downloaded model. +const EDITABLE_KEYS = new Set(Object.keys(DEFAULTS).filter((key) => key !== 'tagger_model')); const UI_STYLES = new Set(['glass', 'classic']); @@ -70,15 +55,9 @@ settingsRouter.patch( * thousands of them synchronously stalled every other request while the settings page loaded. */ async function dirSize(dir: string): Promise { - const entries = await fs.promises.readdir(dir, { withFileTypes: true }).catch(() => []); - const sizes = await Promise.all( - entries.map((entry) => { - const full = path.join(dir, entry.name); - if (entry.isFile()) return fs.promises.stat(full).then((s) => s.size, () => 0); - if (entry.isDirectory()) return dirSize(full); - return 0; - }), - ); + // Flat: thumbnails are .webp directly in the folder. + const names = await fs.promises.readdir(dir).catch(() => []); + const sizes = await Promise.all(names.map((name) => fs.promises.stat(path.join(dir, name)).then((s) => s.size, () => 0))); return sizes.reduce((a, b) => a + b, 0); } @@ -189,9 +168,9 @@ settingsRouter.get( ); const scheduleBody = z.object({ - jobType: z.enum(['scan', 'tag', 'hash', 'cleanup']), + jobType: z.enum(SCHEDULE_JOB_TYPES), libraryId: z.number().int().positive().nullable().optional(), - mode: z.enum(['off', 'interval', 'after-scan']).optional(), + mode: z.enum(SCHEDULE_MODES).optional(), intervalMinutes: z.number().int().min(0).optional(), useGlobal: z.boolean().optional(), }); @@ -203,43 +182,23 @@ settingsRouter.patch( const libraryId = body.libraryId ?? null; // SQLite treats NULLs as distinct in unique indexes, so composite-PK upsert on (job_type, NULL) - // can't be relied on. Read → decide → update-or-insert explicitly. - const existing = db - .select() - .from(schema.jobSchedules) - .where( - libraryId === null - ? isNull(schema.jobSchedules.libraryId) - : eq(schema.jobSchedules.libraryId, libraryId), - ) - .all() - .find((r) => r.jobType === body.jobType); + // can't be relied on. Read → decide → update-or-insert explicitly; `IS` matches NULL and values alike. + const where = and( + eq(schema.jobSchedules.jobType, body.jobType), + sql`${schema.jobSchedules.libraryId} IS ${libraryId}`, + ); + const existing = db.select().from(schema.jobSchedules).where(where).get(); const merged = { jobType: body.jobType, libraryId, - mode: (body.mode ?? existing?.mode ?? 'off') as 'off' | 'interval' | 'after-scan', + mode: body.mode ?? existing?.mode ?? 'off', intervalMinutes: body.intervalMinutes ?? existing?.intervalMinutes ?? 0, useGlobal: body.useGlobal !== undefined ? (body.useGlobal ? 1 : 0) : (existing?.useGlobal ?? 0), }; - if (existing) { - sqlite - .prepare( - libraryId === null - ? 'UPDATE job_schedules SET mode = ?, interval_minutes = ?, use_global = ? WHERE job_type = ? AND library_id IS NULL' - : 'UPDATE job_schedules SET mode = ?, interval_minutes = ?, use_global = ? WHERE job_type = ? AND library_id = ?', - ) - .run( - merged.mode, - merged.intervalMinutes, - merged.useGlobal, - merged.jobType, - ...(libraryId === null ? [] : [libraryId]), - ); - } else { - db.insert(schema.jobSchedules).values(merged).run(); - } + if (existing) db.update(schema.jobSchedules).set(merged).where(where).run(); + else db.insert(schema.jobSchedules).values(merged).run(); scheduleAll(); res.json({ ok: true }); diff --git a/apps/server/src/routes/tagger.ts b/apps/server/src/routes/tagger.ts index ca37bd8..3e5c4ac 100644 --- a/apps/server/src/routes/tagger.ts +++ b/apps/server/src/routes/tagger.ts @@ -12,7 +12,6 @@ import { unhashedImageIds, } from '../services/tagger'; import { MODEL_REGISTRY } from '../lib/config'; -import type { TaggerModel } from '@sakuya/shared'; export const taggerRouter = Router(); @@ -26,8 +25,7 @@ taggerRouter.get( taggerRouter.get( '/api/tagger/models', wrap(async (_req, res) => { - const models: TaggerModel[] = MODEL_REGISTRY.map((m) => ({ id: m.id, label: m.label, repo: m.repo })); - res.json(models); + res.json(MODEL_REGISTRY); }), ); diff --git a/apps/server/src/routes/tags.ts b/apps/server/src/routes/tags.ts index a87231e..b4a38b5 100644 --- a/apps/server/src/routes/tags.ts +++ b/apps/server/src/routes/tags.ts @@ -1,5 +1,4 @@ import { Router } from 'express'; -import type { SQLQueryBindings } from 'bun:sqlite'; import { z } from 'zod'; import { sqlite } from '../db'; import { wrap } from '../lib/http'; @@ -27,44 +26,33 @@ tagsRouter.get( wrap(async (req, res) => { const { q, libraryId, category, limit } = querySchema.parse(req.query); const categories = category ? category.split(',') : undefined; - const categoryPlaceholders = categories ? categories.map(() => '?').join(',') : ''; // Ratings are a small, fixed set — return all of them rather than truncating to `limit`. const applyLimit = categories?.length !== 1 || categories[0] !== 'rating'; + const filters = `${q ? "AND t.name LIKE ? ESCAPE '\\'" : ''} ${categories ? `AND t.category IN (${categories.map(() => '?').join(',')})` : ''}`; + const params = [...(q ? [likeContains(q)] : []), ...(categories ?? []), ...(applyLimit ? [limit] : [])]; - let rows: TagCount[]; - if (libraryId !== undefined) { - rows = sqlite - .query( - `SELECT t.name, t.category, COUNT(*) AS count - FROM media_tags mt - JOIN tags t ON t.id = mt.tag_id - JOIN media m ON m.id = mt.media_id - WHERE m.library_id = ? ${q ? "AND t.name LIKE ? ESCAPE '\\'" : ''} ${categories ? `AND t.category IN (${categoryPlaceholders})` : ''} - GROUP BY t.id ORDER BY count DESC, t.name ${applyLimit ? 'LIMIT ?' : ''}`, - ) - .all( - ...([ - libraryId, - ...(q ? [likeContains(q)] : []), - ...(categories ?? []), - ...(applyLimit ? [limit] : []), - ] as SQLQueryBindings[]), - ) as TagCount[]; - } else { - rows = sqlite - .query( - `SELECT name, category, usage_count AS count FROM tags - WHERE usage_count > 0 ${q ? "AND name LIKE ? ESCAPE '\\'" : ''} ${categories ? `AND category IN (${categoryPlaceholders})` : ''} - ORDER BY usage_count DESC, name ${applyLimit ? 'LIMIT ?' : ''}`, - ) - .all( - ...([ - ...(q ? [likeContains(q)] : []), - ...(categories ?? []), - ...(applyLimit ? [limit] : []), - ] as SQLQueryBindings[]), - ) as TagCount[]; - } + // Library-scoped counts have to be aggregated; the unscoped ones are the cached usage_count, + // which keeps autocomplete off a full media_tags aggregate. + const rows = ( + libraryId !== undefined + ? sqlite + .query( + `SELECT t.name, t.category, COUNT(*) AS count + FROM media_tags mt + JOIN tags t ON t.id = mt.tag_id + JOIN media m ON m.id = mt.media_id + WHERE m.library_id = ? ${filters} + GROUP BY t.id ORDER BY count DESC, t.name ${applyLimit ? 'LIMIT ?' : ''}`, + ) + .all(libraryId, ...params) + : sqlite + .query( + `SELECT t.name, t.category, t.usage_count AS count FROM tags t + WHERE t.usage_count > 0 ${filters} + ORDER BY t.usage_count DESC, t.name ${applyLimit ? 'LIMIT ?' : ''}`, + ) + .all(...params) + ) as TagCount[]; res.json(rows); }), ); diff --git a/apps/server/src/services/cleanup.ts b/apps/server/src/services/cleanup.ts index ff1ee10..ea0621f 100644 --- a/apps/server/src/services/cleanup.ts +++ b/apps/server/src/services/cleanup.ts @@ -1,8 +1,7 @@ import fs from 'node:fs'; +import path from 'node:path'; import { sqlite, db, schema } from '../db'; import { THUMBS_DIR, TRANSCODES_DIR } from '../lib/config'; -import { thumbPathFor } from './thumbnailer'; -import { transcodePathFor } from './transcoder'; import { enqueueJob, type JobHandle } from './jobQueue'; /** @@ -31,31 +30,22 @@ export function performCleanup(now: number = Date.now()): CleanupResult { // entry per media row, so the per-file query made this scale quadratically with library size. const liveIds = new Set(db.select({ id: schema.media.id }).from(schema.media).all().map((r) => r.id)); - let removedThumbs = 0; - if (fs.existsSync(THUMBS_DIR)) { - for (const entry of fs.readdirSync(THUMBS_DIR)) { - const match = entry.match(/^(\d+)\.webp$/); - if (!match) continue; - const mediaId = Number(match[1]); - if (liveIds.has(mediaId)) continue; - fs.rmSync(thumbPathFor(mediaId), { force: true }); - removedThumbs++; - } - } - - // Transcodes are the same one-file-per-media-id layout, and far larger; an orphan here is often + // Thumbnails and transcodes share the one-file-per-media-id layout; a transcode orphan is often // hundreds of megabytes. - let removedTranscodes = 0; - if (fs.existsSync(TRANSCODES_DIR)) { - for (const entry of fs.readdirSync(TRANSCODES_DIR)) { - const match = entry.match(/^(\d+)\.mp4$/); - if (!match) continue; - const mediaId = Number(match[1]); - if (liveIds.has(mediaId)) continue; - fs.rmSync(transcodePathFor(mediaId), { force: true }); - removedTranscodes++; + const [removedThumbs, removedTranscodes] = [ + [THUMBS_DIR, '.webp'], + [TRANSCODES_DIR, '.mp4'], + ].map(([dir, ext]) => { + if (!fs.existsSync(dir)) return 0; + let removed = 0; + for (const entry of fs.readdirSync(dir)) { + const match = /^(\d+)(\.\w+)$/.exec(entry); + if (!match || match[2] !== ext || liveIds.has(Number(match[1]))) continue; + fs.rmSync(path.join(dir, entry), { force: true }); + removed++; } - } + return removed; + }); // Recompute every tag's usage_count. This used to bind one parameter per tag for an // `WHERE id IN (...)` listing every id in the table — equivalent to no WHERE at all, and on a diff --git a/apps/server/src/services/consoleSession.ts b/apps/server/src/services/consoleSession.ts index f61cabc..b3d154c 100644 --- a/apps/server/src/services/consoleSession.ts +++ b/apps/server/src/services/consoleSession.ts @@ -37,7 +37,7 @@ function quoteShellArg(arg: string): string { } export async function startConsoleSession(commandText: string): Promise { - if (child) throw new Error('A console session is already running'); + if (child) throw Object.assign(new Error('A console session is already running'), { status: 409 }); const galleryDl = await detectGalleryDl(); if (!galleryDl.installed || !galleryDl.path) throw new Error('gallery-dl is not installed'); @@ -51,23 +51,16 @@ export async function startConsoleSession(commandText: string): Promise { // browser=false (dodges the xdg-open crash; the URL is printed either way for the user to open // themselves) plus --no-colors since a pty otherwise makes it emit ANSI codes into the console log. const galleryDlArgs = ['--no-colors', '-o', 'browser=false', ...args]; - let proc: ChildProcessWithoutNullStreams; usingPty = process.platform === 'linux'; - if (usingPty) { - const cmd = [galleryDl.path, ...galleryDlArgs].map(quoteShellArg).join(' '); - proc = spawn('script', ['-qc', cmd, '/dev/null'], { - cwd: DOWNLOADER_DIR, - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8' }, - }); - } else { - proc = spawn(galleryDl.path, galleryDlArgs, { - cwd: DOWNLOADER_DIR, - stdio: ['pipe', 'pipe', 'pipe'], - // Force unbuffered stdout so interactive prompts (e.g. 2FA codes) show up immediately. - env: { ...process.env, PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8' }, - }); - } + const [cmd, cmdArgs]: [string, string[]] = usingPty + ? ['script', ['-qc', [galleryDl.path, ...galleryDlArgs].map(quoteShellArg).join(' '), '/dev/null']] + : [galleryDl.path, galleryDlArgs]; + const proc = spawn(cmd, cmdArgs, { + cwd: DOWNLOADER_DIR, + stdio: ['pipe', 'pipe', 'pipe'], + // Force unbuffered stdout so interactive prompts (e.g. 2FA codes) show up immediately. + env: { ...process.env, PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8' }, + }); child = proc; command = commandText; @@ -89,7 +82,7 @@ export async function startConsoleSession(commandText: string): Promise { } export function writeConsoleInput(text: string): void { - if (!child || !child.stdin.writable) throw new Error('No console session is running'); + if (!child || !child.stdin.writable) throw Object.assign(new Error('No console session is running'), { status: 409 }); if (!usingPty) appendBuffer(`${text}\n`); child.stdin.write(`${text}\n`); } diff --git a/apps/server/src/services/downloader.ts b/apps/server/src/services/downloader.ts index c196bb1..8bacb44 100644 --- a/apps/server/src/services/downloader.ts +++ b/apps/server/src/services/downloader.ts @@ -1,9 +1,9 @@ import fs from 'node:fs'; import path from 'node:path'; +import readline from 'node:readline'; import { EventEmitter } from 'node:events'; import { spawn, type ChildProcess } from 'node:child_process'; import { eq, and, gt, inArray } from 'drizzle-orm'; -import ffmpegStatic from 'ffmpeg-static'; import { db, schema } from '../db'; import { downloaderConcurrency } from '../lib/settings'; import { detectGalleryDl } from './galleryDl'; @@ -11,12 +11,14 @@ import { enqueueScanJob } from './scanner'; import { removeMediaRows } from './mediaRemoval'; import { chunkIds } from '../lib/mediaByIds'; import { parseShellArgs } from '../lib/shellArgs'; -import type { DownloadBatchWithItems, DownloadBatch, DownloadItem, DownloadLogLine } from '@sakuya/shared'; +import { isUnder } from '../lib/paths'; +import { FFMPEG_PATH } from '../lib/run'; +import type { DownloadBatchWithItems, DownloadLogLine } from '@sakuya/shared'; // Directory holding the bundled ffmpeg binary, prepended to the gallery-dl subprocess's // PATH so its "--ugoira gif" postprocessor (which shells out to "ffmpeg") can find it // even when no system ffmpeg is installed. -const ffmpegDir = typeof ffmpegStatic === 'string' ? path.dirname(ffmpegStatic) : null; +const ffmpegDir = path.isAbsolute(FFMPEG_PATH) ? path.dirname(FFMPEG_PATH) : null; export const downloaderEvents = new EventEmitter(); downloaderEvents.setMaxListeners(100); @@ -30,31 +32,6 @@ const queue: number[] = []; const runningProcs = new Map(); let running = 0; -function rowToBatch(row: typeof schema.downloadBatches.$inferSelect): DownloadBatch { - return { - id: row.id, - libraryId: row.libraryId, - folderPath: row.folderPath, - extraArgs: row.extraArgs, - cookieFileId: row.cookieFileId, - createdAt: row.createdAt, - }; -} - -function rowToItem(row: typeof schema.downloadItems.$inferSelect): DownloadItem { - return { - id: row.id, - batchId: row.batchId, - url: row.url, - status: row.status, - filesDownloaded: row.filesDownloaded, - pid: row.pid, - errorMessage: row.errorMessage, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; -} - function getItem(id: number) { return db.select().from(schema.downloadItems).where(eq(schema.downloadItems.id, id)).get(); } @@ -72,7 +49,7 @@ function removeFromQueue(id: number) { function broadcastItem(id: number) { const row = getItem(id); - if (row) downloaderEvents.emit('item', rowToItem(row)); + if (row) downloaderEvents.emit('item', row); } function patchItem(id: number, patch: Partial) { @@ -84,12 +61,11 @@ function patchItem(id: number, patch: Partial 0) break; fs.rmdirSync(dir); @@ -192,36 +167,23 @@ async function runOne(itemId: number): Promise { runningProcs.set(itemId, { child, killIntent: null }); patchItem(itemId, { pid: child.pid ?? null }); - let stdoutBuf = ''; - let stderrBuf = ''; let lastStderrLine = ''; - child.stdout?.on('data', (d) => { - stdoutBuf += d.toString(); - const lines = stdoutBuf.split('\n'); - stdoutBuf = lines.pop() ?? ''; - for (const line of lines) handleStdoutLine(itemId, batch.folderPath, line); - }); - child.stderr?.on('data', (d) => { - stderrBuf += d.toString(); - const lines = stderrBuf.split('\n'); - stderrBuf = lines.pop() ?? ''; - for (const line of lines) { - if (line.trim()) { - lastStderrLine = line.trim(); - appendLog(itemId, line); - } - } - }); + // readline flushes a final unterminated line when the stream ends, which is before 'close'. + if (child.stdout) { + readline.createInterface({ input: child.stdout }).on('line', (line) => handleStdoutLine(itemId, batch.folderPath, line)); + } + if (child.stderr) { + readline.createInterface({ input: child.stderr }).on('line', (line) => { + if (!line.trim()) return; + lastStderrLine = line.trim(); + appendLog(itemId, line); + }); + } child.on('error', (err) => { lastStderrLine = err.message; }); child.on('close', (code) => { - if (stdoutBuf.trim()) handleStdoutLine(itemId, batch.folderPath, stdoutBuf); - if (stderrBuf.trim()) { - lastStderrLine = stderrBuf.trim(); - appendLog(itemId, stderrBuf); - } const entry = runningProcs.get(itemId); runningProcs.delete(itemId); const killIntent = entry?.killIntent ?? null; @@ -275,7 +237,7 @@ export function enqueueBatch(opts: { for (const row of itemRows) queue.push(row.id); queueMicrotask(pump); - const batch: DownloadBatchWithItems = { ...rowToBatch(batchRow), items: itemRows.map(rowToItem) }; + const batch: DownloadBatchWithItems = { ...batchRow, items: itemRows }; downloaderEvents.emit('batch', batch); return batch; } @@ -285,13 +247,8 @@ export function listBatches(): DownloadBatchWithItems[] { return batches .sort((a, b) => b.createdAt - a.createdAt) .map((b) => ({ - ...rowToBatch(b), - items: db - .select() - .from(schema.downloadItems) - .where(eq(schema.downloadItems.batchId, b.id)) - .all() - .map(rowToItem), + ...b, + items: db.select().from(schema.downloadItems).where(eq(schema.downloadItems.batchId, b.id)).all(), })); } @@ -300,8 +257,7 @@ export function listItemLogs(itemId: number, afterId = 0): DownloadLogLine[] { .select() .from(schema.downloadLogs) .where(and(eq(schema.downloadLogs.itemId, itemId), gt(schema.downloadLogs.id, afterId))) - .all() - .map((r) => ({ id: r.id, itemId: r.itemId, line: r.line, createdAt: r.createdAt })); + .all(); } export function pauseItem(id: number): void { @@ -399,14 +355,12 @@ export function removeItem(id: number, opts: { deleteFiles: boolean }): void { /** Walks all registered folders to find the library that owns (or is an ancestor of) `inputPath`. */ export function resolveLibraryForPath(inputPath: string): number | null { const resolved = path.resolve(inputPath); - const allFolders = db.select().from(schema.folders).all(); - for (const f of allFolders) { - const folderResolved = path.resolve(f.path); - if (resolved === folderResolved || resolved.startsWith(folderResolved + path.sep)) { - return f.libraryId; - } - } - return null; + const owner = db + .select() + .from(schema.folders) + .all() + .find((f) => isUnder(resolved, path.resolve(f.path))); + return owner?.libraryId ?? null; } // Requeue items left 'queued' by a server restart (see db/index.ts migration). diff --git a/apps/server/src/services/galleryDl.ts b/apps/server/src/services/galleryDl.ts index 9ef8e53..ae6ee4a 100644 --- a/apps/server/src/services/galleryDl.ts +++ b/apps/server/src/services/galleryDl.ts @@ -1,8 +1,9 @@ import fs from 'node:fs'; import fsp from 'node:fs/promises'; import path from 'node:path'; -import { spawn } from 'node:child_process'; import { DOWNLOADER_BIN_DIR } from '../lib/config'; +import { run } from '../lib/run'; +import { downloadFile } from '../lib/download'; import { getSetting, setSetting } from '../lib/settings'; import { enqueueJob, type JobHandle } from './jobQueue'; import type { DownloaderStatus } from '@sakuya/shared'; @@ -11,19 +12,10 @@ const LOCAL_BIN_NAME = process.platform === 'win32' ? 'gallery-dl.exe' : 'galler const LOCAL_BIN_PATH = path.join(DOWNLOADER_BIN_DIR, LOCAL_BIN_NAME); function runVersionCheck(bin: string): Promise { - return new Promise((resolve) => { - let proc; - try { - proc = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] }); - } catch { - resolve(null); - return; - } - let out = ''; - proc.stdout?.on('data', (d) => (out += d)); - proc.on('error', () => resolve(null)); - proc.on('close', (code) => resolve(code === 0 ? out.trim() : null)); - }); + return run(bin, ['--version']).then( + (out) => out.trim(), + () => null, + ); } /** Locate a working gallery-dl binary: cached setting -> bundled install -> PATH. */ @@ -61,32 +53,15 @@ export function installGalleryDl() { const url = `https://codeberg.org/mikf/gallery-dl/releases/download/latest/${asset}`; job.update({ total: 100, log: `Downloading ${asset}…` }); - const res = await fetch(url, { redirect: 'follow' }); - if (!res.ok || !res.body) throw new Error(`Download failed (${res.status}) for ${url}`); - const total = Number(res.headers.get('content-length') ?? 0); - const tmp = LOCAL_BIN_PATH + '.part'; - const writer = fs.createWriteStream(tmp); - let received = 0; let lastPct = 0; - try { - for await (const chunk of res.body as any) { - writer.write(chunk); - received += chunk.length; - if (total) { - const pct = Math.round((received / total) * 100); - if (pct !== lastPct) { - lastPct = pct; - job.update({ progress: pct, log: `Downloading: ${(received / 1e6).toFixed(1)} / ${(total / 1e6).toFixed(1)} MB` }); - } - } + await downloadFile(url, LOCAL_BIN_PATH, (received, total) => { + if (!total) return; + const pct = Math.round((received / total) * 100); + if (pct !== lastPct) { + lastPct = pct; + job.update({ progress: pct, log: `Downloading: ${(received / 1e6).toFixed(1)} / ${(total / 1e6).toFixed(1)} MB` }); } - await new Promise((resolve, reject) => writer.end((err: any) => (err ? reject(err) : resolve()))); - await fsp.rename(tmp, LOCAL_BIN_PATH); - } catch (err) { - writer.destroy(); - await fsp.unlink(tmp).catch(() => {}); - throw err; - } + }); if (process.platform !== 'win32') { await fsp.chmod(LOCAL_BIN_PATH, 0o755); diff --git a/apps/server/src/services/jobQueue.ts b/apps/server/src/services/jobQueue.ts index b86e819..0910e7d 100644 --- a/apps/server/src/services/jobQueue.ts +++ b/apps/server/src/services/jobQueue.ts @@ -22,24 +22,9 @@ interface QueuedJob { const queue: QueuedJob[] = []; let running = 0; -function rowToJob(row: typeof schema.jobs.$inferSelect): Job { - return { - id: row.id, - type: row.type as JobType, - libraryId: row.libraryId, - label: row.label, - status: row.status, - progress: row.progress, - total: row.total, - log: row.log, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; -} - function broadcast(id: number) { const row = db.select().from(schema.jobs).where(eq(schema.jobs.id, id)).get(); - if (row) jobEvents.emit('job', rowToJob(row)); + if (row) jobEvents.emit('job', row); } function patchJob(id: number, patch: Partial) { @@ -97,11 +82,38 @@ export function enqueueJob( queue.push({ id: inserted.id, fn }); broadcast(inserted.id); queueMicrotask(pump); - return rowToJob(inserted); + return inserted; } export function listJobs(limit = 50): Job[] { // Ordering and slicing belong in SQL: this used to pull the entire jobs table into JS and sort // it there to hand back 50 rows, and jobs is append-only, so the cost grew with every scan. - return db.select().from(schema.jobs).orderBy(desc(schema.jobs.createdAt)).limit(limit).all().map(rowToJob); + return db.select().from(schema.jobs).orderBy(desc(schema.jobs.createdAt)).limit(limit).all(); +} + +/** + * Run `fn` over every item, logging and counting failures rather than aborting on the first one, + * with a progress update every `every` items. Resolves with the number of failures. + */ +export async function eachWithProgress( + job: JobHandle, + items: T[], + progressLog: (done: number, total: number) => string, + failMsg: (item: T) => string, + fn: (item: T) => Promise, + every = 5, +): Promise { + let errors = 0; + for (let i = 0; i < items.length; i++) { + try { + await fn(items[i]); + } catch (err) { + errors++; + console.error(failMsg(items[i]), err); + } + if (i % every === 0 || i === items.length - 1) { + job.update({ progress: i + 1, log: progressLog(i + 1, items.length) }); + } + } + return errors; } diff --git a/apps/server/src/services/jobScheduler.ts b/apps/server/src/services/jobScheduler.ts index d863975..59bf82f 100644 --- a/apps/server/src/services/jobScheduler.ts +++ b/apps/server/src/services/jobScheduler.ts @@ -1,20 +1,15 @@ import { eq, and, inArray, isNull } from 'drizzle-orm'; -import type { ScheduleJobType } from '@sakuya/shared'; +import type { JobSchedule, ScheduleJobType } from '@sakuya/shared'; import { db, schema } from '../db'; import { enqueueScanJob } from './scanner'; -import { enqueueTagJob, enqueueHashJob, modelReady } from './tagger'; +import { enqueueTagJob, enqueueHashJob, modelReady, untaggedMediaIds, unhashedImageIds } from './tagger'; import { enqueueCleanupJob } from './cleanup'; import { enqueueTranscodeJob } from './transcoder'; import { aiTaggingEnabled, videoTranscodeEnabled } from '../lib/settings'; - -type ScheduleMode = 'off' | 'interval' | 'after-scan'; - -interface Schedule { - mode: ScheduleMode; - intervalMinutes: number; -} +import { mediaIdsWhere } from '../lib/mediaByIds'; const timers = new Map>(); +const PER_LIBRARY_TYPES: ScheduleJobType[] = ['scan', 'tag', 'hash']; function activeJobForLibrary(libraryId: number | null, jobType: ScheduleJobType): boolean { const active = db @@ -31,7 +26,7 @@ function activeJobForLibrary(libraryId: number | null, jobType: ScheduleJobType) return active.length > 0; } -export function resolveSchedule(jobType: ScheduleJobType, libraryId: number | null): Schedule { +export function resolveSchedule(jobType: ScheduleJobType, libraryId: number | null): JobSchedule { const row = db .select() .from(schema.jobSchedules) @@ -46,49 +41,10 @@ export function resolveSchedule(jobType: ScheduleJobType, libraryId: number | nu if (libraryId) { // Per-library: if no explicit row or useGlobal is set, fall through to the global default. if (!row || row.useGlobal) return resolveSchedule(jobType, null); - return { mode: row.mode as ScheduleMode, intervalMinutes: row.intervalMinutes }; + return { mode: row.mode, intervalMinutes: row.intervalMinutes }; } if (!row) return { mode: 'off', intervalMinutes: 0 }; - return { mode: row.mode as ScheduleMode, intervalMinutes: row.intervalMinutes }; -} - -function untaggedIdsForLibrary(libraryId: number): number[] { - return db - .select({ id: schema.media.id }) - .from(schema.media) - .where(and(eq(schema.media.libraryId, libraryId), isNull(schema.media.taggedAt))) - .all() - .map((r) => r.id); -} - -function unhashedImageIdsForLibrary(libraryId: number): number[] { - return db - .select({ id: schema.media.id }) - .from(schema.media) - .where( - and( - eq(schema.media.libraryId, libraryId), - eq(schema.media.type, 'image'), - isNull(schema.media.perceptualHash), - ), - ) - .all() - .map((r) => r.id); -} - -function untranscodedVideoIdsForLibrary(libraryId: number): number[] { - return db - .select({ id: schema.media.id }) - .from(schema.media) - .where( - and( - eq(schema.media.libraryId, libraryId), - eq(schema.media.type, 'video'), - isNull(schema.media.transcodedAt), - ), - ) - .all() - .map((r) => r.id); + return { mode: row.mode, intervalMinutes: row.intervalMinutes }; } function libraryName(libraryId: number): string { @@ -98,18 +54,18 @@ function libraryName(libraryId: number): string { function dispatchTagForLibrary(libraryId: number): void { if (!aiTaggingEnabled() || !modelReady()) return; - const ids = untaggedIdsForLibrary(libraryId); + const ids = untaggedMediaIds(libraryId); if (ids.length) enqueueTagJob(ids, `AI tag: ${libraryName(libraryId)}`, libraryId); } function dispatchHashForLibrary(libraryId: number): void { - const ids = unhashedImageIdsForLibrary(libraryId); + const ids = unhashedImageIds(libraryId); if (ids.length) enqueueHashJob(ids, libraryId); } function dispatchTranscodeForLibrary(libraryId: number): void { if (!videoTranscodeEnabled()) return; - const ids = untranscodedVideoIdsForLibrary(libraryId); + const ids = mediaIdsWhere(and(eq(schema.media.type, 'video'), isNull(schema.media.transcodedAt)), libraryId); if (ids.length) enqueueTranscodeJob(ids, `Transcode: ${libraryName(libraryId)}`, libraryId); } @@ -155,22 +111,13 @@ export function runAllNow(scope: 'global' | number, jobType?: ScheduleJobType): : [scope]; for (const libraryId of libraryIds) { - if (!jobType || jobType === 'scan') { - const scanSchedule = resolveSchedule('scan', libraryId); - if (scanSchedule.mode !== 'off' || jobType === 'scan') { - dispatchForLibrary('scan', libraryId); - } - } - if (!jobType || jobType === 'tag') { - const tagSchedule = resolveSchedule('tag', libraryId); - if (jobType === 'tag' || tagSchedule.mode === 'interval') { - dispatchForLibrary('tag', libraryId); - } - } - if (!jobType || jobType === 'hash') { - const hashSchedule = resolveSchedule('hash', libraryId); - if (jobType === 'hash' || hashSchedule.mode === 'interval') { - dispatchForLibrary('hash', libraryId); + for (const type of PER_LIBRARY_TYPES) { + if (jobType && jobType !== type) continue; + const { mode } = resolveSchedule(type, libraryId); + // A scan runs whenever it isn't switched off; tag/hash only on their own interval (on + // 'after-scan' they ride along with the scan instead). + if (jobType === type || (type === 'scan' ? mode !== 'off' : mode === 'interval')) { + dispatchForLibrary(type, libraryId); } } } @@ -183,7 +130,7 @@ export function runAllNow(scope: 'global' | number, jobType?: ScheduleJobType): } } -function setTimer(jobType: ScheduleJobType, libraryId: number | null, schedule: Schedule): void { +function setTimer(jobType: ScheduleJobType, libraryId: number | null, schedule: JobSchedule): void { const key = `${jobType}:${libraryId ?? 0}`; const existing = timers.get(key); if (existing) clearInterval(existing); @@ -194,14 +141,7 @@ function setTimer(jobType: ScheduleJobType, libraryId: number | null, schedule: } const ms = schedule.intervalMinutes * 60 * 1000; - const timer = setInterval(() => { - if (libraryId !== null) { - dispatchForLibrary(jobType, libraryId); - } else { - dispatchForLibrary(jobType, 0); - } - }, ms); - timers.set(key, timer); + timers.set(key, setInterval(() => dispatchForLibrary(jobType, libraryId ?? 0), ms)); } /** @@ -213,9 +153,8 @@ export function scheduleAll(): void { timers.clear(); const libs = db.select({ id: schema.libraries.id }).from(schema.libraries).all(); - const perLibraryTypes: ScheduleJobType[] = ['scan', 'tag', 'hash']; - for (const jobType of perLibraryTypes) { + for (const jobType of PER_LIBRARY_TYPES) { for (const lib of libs) { const schedule = resolveSchedule(jobType, lib.id); setTimer(jobType, lib.id, schedule); @@ -227,7 +166,3 @@ export function scheduleAll(): void { console.log(`[job-scheduler] initialized ${timers.size} interval timers`); } - -export function initScheduler(): void { - scheduleAll(); -} diff --git a/apps/server/src/services/scanner.ts b/apps/server/src/services/scanner.ts index 70fca98..38db0cf 100644 --- a/apps/server/src/services/scanner.ts +++ b/apps/server/src/services/scanner.ts @@ -1,7 +1,6 @@ import path from 'node:path'; import fs from 'node:fs/promises'; import { createHash } from 'node:crypto'; -import { spawn } from 'node:child_process'; import sharp from 'sharp'; import ffprobeStatic from 'ffprobe-static'; import { eq, and } from 'drizzle-orm'; @@ -12,10 +11,12 @@ import { generateThumbnail } from './thumbnailer'; import { transcodePathFor } from './transcoder'; import { removeMediaRows } from './mediaRemoval'; import { isUnder } from '../lib/paths'; -import { enqueueJob, type JobHandle } from './jobQueue'; +import { enqueueJob, eachWithProgress, type JobHandle } from './jobQueue'; import { dispatchAfterScan } from './jobScheduler'; import { tryConvertUgoiraZip } from './ugoira'; +import { computeDHash } from './perceptualHash'; import { phashColumns } from '../lib/phashBands'; +import { run } from '../lib/run'; const ffprobePath: string = ffprobeStatic.path; @@ -28,19 +29,7 @@ interface ProbeResult { } export async function probeVideo(filePath: string): Promise { - const json = await new Promise((resolve, reject) => { - const proc = spawn( - ffprobePath, - ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', filePath], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ); - let out = ''; - let err = ''; - proc.stdout.on('data', (d) => (out += d)); - proc.stderr.on('data', (d) => (err += d)); - proc.on('error', reject); - proc.on('close', (code) => (code === 0 ? resolve(out) : reject(new Error(`ffprobe: ${err.slice(-300)}`)))); - }); + const json = await run(ffprobePath, ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', filePath]); const data = JSON.parse(json); const streams = data.streams ?? []; const video = streams.find((s: any) => s.codec_type === 'video'); @@ -69,14 +58,7 @@ async function probeImage(filePath: string): Promise { async function hashFile(filePath: string, size: number): Promise { const hash = createHash('sha1'); hash.update(String(size)); - const handle = await fs.open(filePath, 'r'); - try { - const buf = Buffer.alloc(Math.min(size, 4 * 1024 * 1024)); - const { bytesRead } = await handle.read(buf, 0, buf.length, 0); - hash.update(buf.subarray(0, bytesRead)); - } finally { - await handle.close(); - } + hash.update(await Bun.file(filePath).slice(0, Math.min(size, 4 * 1024 * 1024)).bytes()); return hash.digest('hex'); } @@ -93,15 +75,11 @@ export function mediaTypeForExt(ext: string): 'image' | 'video' | null { * are full of the latter. A transport stream is 188-byte packets that each start with 0x47. */ export async function isTransportStream(filePath: string): Promise { - const handle = await fs.open(filePath, 'r').catch(() => null); - if (!handle) return false; - try { - const buf = Buffer.alloc(189); - const { bytesRead } = await handle.read(buf, 0, buf.length, 0); - return bytesRead === buf.length && buf[0] === 0x47 && buf[188] === 0x47; - } finally { - await handle.close(); - } + const buf = await Bun.file(filePath) + .slice(0, 189) + .bytes() + .catch(() => null); + return buf?.length === 189 && buf[0] === 0x47 && buf[188] === 0x47; } async function walk(dir: string, out: string[]): Promise { @@ -205,7 +183,6 @@ export async function indexFile( } if (type === 'image') { try { - const { computeDHash } = await import('./perceptualHash'); const phash = await computeDHash(filePath); db.update(schema.media).set(phashColumns(phash)).where(eq(schema.media.id, mediaId)).run(); } catch (err) { @@ -272,12 +249,12 @@ export function enqueueGifReclassifyJob(toVideo: boolean) { .filter((r) => r.path.toLowerCase().endsWith('.gif')); job.update({ total: rows.length, log: `Reclassifying ${rows.length} GIFs…` }); - let done = 0; - let errors = 0; - - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - try { + const errors = await eachWithProgress( + job, + rows, + (done, total) => `Reclassified ${done}/${total}…`, + (row) => `gif reclassify failed for ${row.path}:`, + async (row) => { if (toVideo) { const probe = await probeVideo(row.path); db.update(schema.media) @@ -285,22 +262,15 @@ export function enqueueGifReclassifyJob(toVideo: boolean) { .where(eq(schema.media.id, row.id)) .run(); } else { - const { computeDHash } = await import('./perceptualHash'); const phash = await computeDHash(row.path).catch(() => null); db.update(schema.media) .set({ type: 'image', durationSeconds: null, ...phashColumns(phash) }) .where(eq(schema.media.id, row.id)) .run(); } - done++; - } catch (err) { - errors++; - console.error(`gif reclassify failed for ${row.path}:`, err); - } - if (i % 5 === 0 || i === rows.length - 1) { - job.update({ progress: i + 1, log: `Reclassified ${i + 1}/${rows.length}…` }); - } - } + }, + ); + const done = rows.length - errors; return `Completed. ${done} reclassified${errors ? `, ${errors} errors` : ''}.`; }, @@ -317,7 +287,6 @@ export function enqueueScanJob(libraryId: number) { const libFolders = db.select().from(schema.folders).where(eq(schema.folders.libraryId, libraryId)).all(); let indexed = 0; let skipped = 0; - let errors = 0; let pruned = 0; for (const folder of libFolders) { @@ -333,19 +302,16 @@ export function enqueueScanJob(libraryId: number) { } job.update({ total: files.length, log: `Found ${files.length} files, indexing…` }); - for (let i = 0; i < files.length; i++) { - try { - const id = await indexFile(files[i], libraryId, 'folder'); - if (id !== null) indexed++; + const errors = await eachWithProgress( + job, + files, + (done, total) => `Indexing ${done}/${total} files…`, + (file) => `index failed for ${file}:`, + async (file) => { + if ((await indexFile(file, libraryId, 'folder')) !== null) indexed++; else skipped++; - } catch (err) { - errors++; - console.error(`index failed for ${files[i]}:`, err); - } - if (i % 5 === 0 || i === files.length - 1) { - job.update({ progress: i + 1, log: `Indexing ${i + 1}/${files.length} files…` }); - } - } + }, + ); const unreachable: string[] = []; let withheld = 0; diff --git a/apps/server/src/services/tagger.ts b/apps/server/src/services/tagger.ts index a15f81f..4b47e9f 100644 --- a/apps/server/src/services/tagger.ts +++ b/apps/server/src/services/tagger.ts @@ -1,15 +1,16 @@ import fs from 'node:fs'; -import fsp from 'node:fs/promises'; import sharp from 'sharp'; import { eq, and, isNull } from 'drizzle-orm'; import { db, sqlite, schema } from '../db'; -import { MODEL_PATH, MODEL_TAGS_PATH, modelRepoBase, DEFAULT_MODEL_ID, MODEL_REGISTRY, LIMITS } from '../lib/config'; +import { MODEL_PATH, MODEL_TAGS_PATH, modelRepoBase, MODEL_REGISTRY, LIMITS } from '../lib/config'; import { confidenceThreshold, getSetting, setSetting } from '../lib/settings'; import { thumbPathFor } from './thumbnailer'; -import { enqueueJob, type JobHandle } from './jobQueue'; +import { enqueueJob, eachWithProgress, type JobHandle } from './jobQueue'; +import { computeDHash } from './perceptualHash'; import { bumpTasteVersion } from '../lib/tasteVersion'; import { phashColumns } from '../lib/phashBands'; -import { chunkIds } from '../lib/mediaByIds'; +import { chunkIds, mediaIdsWhere } from '../lib/mediaByIds'; +import { downloadFile } from '../lib/download'; import type { TaggerStatus, TagCategory } from '@sakuya/shared'; const INPUT_SIZE = 448; @@ -70,13 +71,8 @@ export function modelReady(): boolean { return fs.existsSync(MODEL_PATH) && fs.existsSync(MODEL_TAGS_PATH); } -export function untaggedMediaIds(): number[] { - return db - .select({ id: schema.media.id }) - .from(schema.media) - .where(isNull(schema.media.taggedAt)) - .all() - .map((r) => r.id); +export function untaggedMediaIds(libraryId?: number): number[] { + return mediaIdsWhere(isNull(schema.media.taggedAt), libraryId); } /** @@ -90,7 +86,7 @@ export function untaggedCount(): number { } export function selectedModelId(): string { - return getSetting('tagger_model') || DEFAULT_MODEL_ID; + return getSetting('tagger_model'); } function unhashedImageCount(): number { @@ -101,20 +97,14 @@ function unhashedImageCount(): number { } export function taggerStatus(): TaggerStatus { - const untagged = untaggedCount(); - const unhashedCount = unhashedImageCount(); - const model = selectedModelId(); - if (downloading) - return { status: 'downloading', model, modelSizeBytes: null, tagCount: null, untaggedCount: untagged, unhashedCount }; - if (!modelReady()) - return { status: 'absent', model, modelSizeBytes: null, tagCount: null, untaggedCount: untagged, unhashedCount }; + const ready = !downloading && modelReady(); return { - status: 'ready', - model, - modelSizeBytes: fs.statSync(MODEL_PATH).size, - tagCount: loadLabels().length, - untaggedCount: untagged, - unhashedCount, + status: downloading ? 'downloading' : ready ? 'ready' : 'absent', + model: selectedModelId(), + modelSizeBytes: ready ? fs.statSync(MODEL_PATH).size : null, + tagCount: ready ? loadLabels().length : null, + untaggedCount: untaggedCount(), + unhashedCount: unhashedImageCount(), }; } @@ -129,25 +119,21 @@ export function selectModel(modelId: string): void { labels = null; fs.rmSync(MODEL_PATH, { force: true }); fs.rmSync(MODEL_TAGS_PATH, { force: true }); - setSetting('model_status', 'absent'); } function loadLabels(): LabelEntry[] { - if (labels) return labels; - const csv = fs.readFileSync(MODEL_TAGS_PATH, 'utf8'); - const lines = csv.trim().split('\n'); - const out: LabelEntry[] = []; - for (let i = 1; i < lines.length; i++) { - // tag_id,name,category,count — names never contain commas in this dataset - const parts = lines[i].split(','); - const category = Number(parts[2]); - out.push({ - name: parts[1], - category: category === 9 ? 'rating' : category === 4 ? 'character' : 'general', + labels ??= fs + .readFileSync(MODEL_TAGS_PATH, 'utf8') + .trim() + .split('\n') + .slice(1) + .map((line) => { + // tag_id,name,category,count — names never contain commas in this dataset + const [, name, rawCategory] = line.split(','); + const category = Number(rawCategory); + return { name, category: category === 9 ? 'rating' : category === 4 ? 'character' : 'general' }; }); - } - labels = out; - return out; + return labels; } async function getSession(): Promise { @@ -209,14 +195,7 @@ export async function predictTags(imagePath: string): Promise { const allLabels = loadLabels(); // The exported model normally ends in a sigmoid; apply one ourselves if outputs are raw logits. - let needsSigmoid = false; - for (let i = 0; i < probs.length; i++) { - if (probs[i] < -0.0001 || probs[i] > 1.0001) { - needsSigmoid = true; - break; - } - } - if (needsSigmoid) probs = probs.map((v) => 1 / (1 + Math.exp(-v))) as Float32Array; + if (probs.some((v) => v < -0.0001 || v > 1.0001)) probs = probs.map((v) => 1 / (1 + Math.exp(-v))) as Float32Array; const threshold = confidenceThreshold(); const out: PredictedTag[] = []; @@ -314,30 +293,23 @@ export function enqueueTagJob(mediaIds: number[], label: string, libraryId: numb label, async (job: JobHandle) => { job.update({ total: mediaIds.length, log: `Tagging ${mediaIds.length} files…` }); - let tagged = 0; - let errors = 0; - for (let i = 0; i < mediaIds.length; i++) { - try { - await tagOneMedia(mediaIds[i]); - tagged++; - } catch (err) { - errors++; - console.error(`tagging failed for media ${mediaIds[i]}:`, err); - } - job.update({ progress: i + 1, log: `Tagged ${i + 1}/${mediaIds.length} files…` }); - } + const errors = await eachWithProgress( + job, + mediaIds, + (done, total) => `Tagged ${done}/${total} files…`, + (id) => `tagging failed for media ${id}:`, + tagOneMedia, + 1, + ); + const tagged = mediaIds.length - errors; return `Completed. ${tagged} files tagged${errors ? `, ${errors} errors` : ''}.`; }, libraryId, ); } -export function unhashedImageIds(): number[] { - return ( - sqlite - .query(`SELECT id FROM media WHERE type = 'image' AND perceptual_hash IS NULL`) - .all() as { id: number }[] - ).map((r) => r.id); +export function unhashedImageIds(libraryId?: number): number[] { + return mediaIdsWhere(and(eq(schema.media.type, 'image'), isNull(schema.media.perceptualHash)), libraryId); } export function enqueueHashJob(mediaIds: number[], libraryId: number | null = null) { @@ -351,58 +323,30 @@ export function enqueueHashJob(mediaIds: number[], libraryId: number | null = nu 'hash', label, async (job: JobHandle) => { - const { computeDHash } = await import('./perceptualHash'); job.update({ total: mediaIds.length, log: `Hashing ${mediaIds.length} images…` }); let hashed = 0; - let errors = 0; - for (let i = 0; i < mediaIds.length; i++) { - try { - const row = db.select().from(schema.media).where(eq(schema.media.id, mediaIds[i])).get(); - if (row && row.type === 'image' && fs.existsSync(row.path)) { - const hash = await computeDHash(row.path); - db.update(schema.media).set(phashColumns(hash)).where(eq(schema.media.id, mediaIds[i])).run(); - hashed++; - } - } catch (err) { - errors++; - console.error(`hashing failed for media ${mediaIds[i]}:`, err); - } - if (i % 5 === 0 || i === mediaIds.length - 1) { - job.update({ progress: i + 1, log: `Hashed ${i + 1}/${mediaIds.length} images…` }); - } - } + const errors = await eachWithProgress( + job, + mediaIds, + (done, total) => `Hashed ${done}/${total} images…`, + (id) => `hashing failed for media ${id}:`, + async (id) => { + const row = db.select().from(schema.media).where(eq(schema.media.id, id)).get(); + if (!row || row.type !== 'image' || !fs.existsSync(row.path)) return; + const hash = await computeDHash(row.path); + db.update(schema.media).set(phashColumns(hash)).where(eq(schema.media.id, id)).run(); + hashed++; + }, + ); return `Completed. ${hashed} hashed${errors ? `, ${errors} errors` : ''}.`; }, libraryId, ); } -async function downloadFile(url: string, dest: string, onProgress: (received: number, total: number) => void) { - const res = await fetch(url, { redirect: 'follow' }); - if (!res.ok || !res.body) throw new Error(`Download failed (${res.status}) for ${url}`); - const total = Number(res.headers.get('content-length') ?? 0); - const tmp = dest + '.part'; - const writer = fs.createWriteStream(tmp); - let received = 0; - try { - for await (const chunk of res.body as any) { - writer.write(chunk); - received += chunk.length; - onProgress(received, total); - } - await new Promise((resolve, reject) => writer.end((err: any) => (err ? reject(err) : resolve()))); - await fsp.rename(tmp, dest); - } catch (err) { - writer.destroy(); - await fsp.unlink(tmp).catch(() => {}); - throw err; - } -} - export function enqueueModelDownload() { if (downloading) throw new Error('Model download already in progress'); downloading = true; - setSetting('model_status', 'downloading'); const modelId = selectedModelId(); const repoBase = modelRepoBase(modelId); return enqueueJob('model-download', `Download tagger model (${modelId})`, async (job: JobHandle) => { @@ -424,11 +368,7 @@ export function enqueueModelDownload() { }); labels = null; releaseSession(); - setSetting('model_status', 'ready'); return 'Model downloaded and ready.'; - } catch (err) { - setSetting('model_status', 'error'); - throw err; } finally { downloading = false; } diff --git a/apps/server/src/services/thumbnailer.ts b/apps/server/src/services/thumbnailer.ts index 725fb0d..760ae27 100644 --- a/apps/server/src/services/thumbnailer.ts +++ b/apps/server/src/services/thumbnailer.ts @@ -1,15 +1,12 @@ import path from 'node:path'; import fs from 'node:fs'; -import { spawn } from 'node:child_process'; import sharp from 'sharp'; -import ffmpegStatic from 'ffmpeg-static'; import { eq } from 'drizzle-orm'; import { THUMBS_DIR } from '../lib/config'; import { db, schema } from '../db'; -import { enqueueJob, type JobHandle } from './jobQueue'; +import { enqueueJob, eachWithProgress, type JobHandle } from './jobQueue'; import { mediaRowsByIds, type MediaRow } from '../lib/mediaByIds'; - -const ffmpegPath: string = (ffmpegStatic as unknown as string) ?? 'ffmpeg'; +import { run, FFMPEG_PATH } from '../lib/run'; export function thumbPathFor(mediaId: number): string { return path.join(THUMBS_DIR, `${mediaId}.webp`); @@ -17,29 +14,20 @@ export function thumbPathFor(mediaId: number): string { /** Extract a single frame as a webp — used for video thumbnails, and as a fallback for images sharp can't decode. */ async function ffmpegFrameToWebp(sourcePath: string, dest: string, seekSeconds: number): Promise { - await new Promise((resolve, reject) => { - const args = [ - '-y', - // Extracting a single frame doesn't benefit from multi-threaded decode; - // capping this keeps bulk regeneration from eating every core and - // starving request handling / other services on the host. - '-threads', '1', - '-ss', seekSeconds.toFixed(2), - '-i', sourcePath, - '-frames:v', '1', - '-vf', 'scale=512:-2', - '-f', 'webp', - dest, - ]; - const proc = spawn(ffmpegPath, args, { stdio: ['ignore', 'ignore', 'pipe'] }); - let stderr = ''; - proc.stderr.on('data', (d) => (stderr += d)); - proc.on('error', reject); - proc.on('close', (code) => { - if (code === 0 && fs.existsSync(dest)) resolve(); - else reject(new Error(`ffmpeg exited with ${code}: ${stderr.slice(-300)}`)); - }); - }); + await run(FFMPEG_PATH, [ + '-y', + // Extracting a single frame doesn't benefit from multi-threaded decode; + // capping this keeps bulk regeneration from eating every core and + // starving request handling / other services on the host. + '-threads', '1', + '-ss', seekSeconds.toFixed(2), + '-i', sourcePath, + '-frames:v', '1', + '-vf', 'scale=512:-2', + '-f', 'webp', + dest, + ]); + if (!fs.existsSync(dest)) throw new Error('ffmpeg did not produce an output file'); } async function generateImageThumbnail(sourcePath: string, dest: string): Promise { @@ -102,24 +90,18 @@ function regenerateJob(label: string, rows: (typeof schema.media.$inferSelect)[] return enqueueJob('thumbnail', label, async (job: JobHandle) => { job.update({ total: rows.length, log: `Regenerating ${rows.length} thumbnails…` }); let regenerated = 0; - let errors = 0; - - for (let i = 0; i < rows.length; i++) { - try { - if (fs.existsSync(rows[i].path)) { - await generateThumbnail(rows[i].path, rows[i].id, rows[i].type, rows[i].durationSeconds ?? null); - db.update(schema.media).set({ thumbnailPath: thumbPathFor(rows[i].id) }).where(eq(schema.media.id, rows[i].id)).run(); - regenerated++; - } - } catch (err) { - errors++; - console.error(`thumbnail regeneration failed for media ${rows[i].id}:`, err); - } - if (i % 5 === 0 || i === rows.length - 1) { - job.update({ progress: i + 1, log: `Regenerated ${i + 1}/${rows.length} thumbnails…` }); - } - } - + const errors = await eachWithProgress( + job, + rows, + (done, total) => `Regenerated ${done}/${total} thumbnails…`, + (row) => `thumbnail regeneration failed for media ${row.id}:`, + async (row) => { + if (!fs.existsSync(row.path)) return; + await generateThumbnail(row.path, row.id, row.type, row.durationSeconds ?? null); + db.update(schema.media).set({ thumbnailPath: thumbPathFor(row.id) }).where(eq(schema.media.id, row.id)).run(); + regenerated++; + }, + ); return `Completed. ${regenerated} regenerated${errors ? `, ${errors} errors` : ''}.`; }, libraryId); } diff --git a/apps/server/src/services/transcoder.ts b/apps/server/src/services/transcoder.ts index e7c545c..2dc8123 100644 --- a/apps/server/src/services/transcoder.ts +++ b/apps/server/src/services/transcoder.ts @@ -1,17 +1,11 @@ import path from 'node:path'; import fs from 'node:fs'; -import { spawn } from 'node:child_process'; -import ffmpegStatic from 'ffmpeg-static'; import { eq } from 'drizzle-orm'; import { TRANSCODES_DIR, LIMITS } from '../lib/config'; import { db, schema } from '../db'; import { probeVideo } from './scanner'; -import { enqueueJob, type JobHandle } from './jobQueue'; - -const ffmpegPath: string = (ffmpegStatic as unknown as string) ?? 'ffmpeg'; - -/** Comfortably more than the 300 characters reported on failure, small enough to never matter. */ -const STDERR_TAIL = 4_000; +import { enqueueJob, eachWithProgress, type JobHandle } from './jobQueue'; +import { run, FFMPEG_PATH } from '../lib/run'; const SAFE_CONTAINERS = new Set(['.mp4', '.webm', '.m4v']); const SAFE_VIDEO_CODECS = new Set(['h264', 'vp8', 'vp9', 'av1']); @@ -39,46 +33,29 @@ async function needsTranscode(filePath: string): Promise { async function transcodeVideo(sourcePath: string, mediaId: number): Promise { const dest = transcodePathFor(mediaId); const tmp = dest + '.part'; - await new Promise((resolve, reject) => { - const args = [ - '-y', - // Unset, this stays absent and libx264 keeps taking a thread per core, which is what it - // did before these were configurable. Under a CPU budget it is the single biggest thing - // to bound: a transcode is the longest-running job here and the only one that scales - // itself across every core it can see. - ...(LIMITS.ffmpegThreads ? ['-threads', String(LIMITS.ffmpegThreads)] : []), - '-i', sourcePath, - '-c:v', 'libx264', - '-preset', 'veryfast', - '-crf', '23', - '-c:a', 'aac', - '-movflags', '+faststart', - tmp, - ]; - const proc = spawn(ffmpegPath, args, { stdio: ['ignore', 'ignore', 'pipe'] }); - // Keep only the tail: ffmpeg emits a progress line every few hundred ms and transcoding a - // large video runs for hours, so appending the whole stream grew a string for the life of - // the job when all that is ever read back is the last few hundred characters. - let stderr = ''; - proc.stderr.on('data', (d) => (stderr = (stderr + d).slice(-STDERR_TAIL))); - proc.on('error', reject); - proc.on('close', (code) => { - if (code === 0 && fs.existsSync(tmp)) resolve(); - else reject(new Error(`ffmpeg transcode exited with ${code}: ${stderr.slice(-300)}`)); - }); - }); + await run(FFMPEG_PATH, [ + '-y', + // Unset, this stays absent and libx264 keeps taking a thread per core, which is what it + // did before these were configurable. Under a CPU budget it is the single biggest thing + // to bound: a transcode is the longest-running job here and the only one that scales + // itself across every core it can see. + ...(LIMITS.ffmpegThreads ? ['-threads', String(LIMITS.ffmpegThreads)] : []), + '-i', sourcePath, + '-c:v', 'libx264', + '-preset', 'veryfast', + '-crf', '23', + '-c:a', 'aac', + '-movflags', '+faststart', + tmp, + ]); fs.renameSync(tmp, dest); return dest; } async function processOne(mediaId: number): Promise<'transcoded' | 'skipped'> { const row = db.select().from(schema.media).where(eq(schema.media.id, mediaId)).get(); - if (!row || row.type !== 'video' || !fs.existsSync(row.path)) { - db.update(schema.media).set({ transcodedAt: Date.now() }).where(eq(schema.media.id, mediaId)).run(); - return 'skipped'; - } let outcome: 'transcoded' | 'skipped' = 'skipped'; - if (await needsTranscode(row.path)) { + if (row && row.type === 'video' && fs.existsSync(row.path) && (await needsTranscode(row.path))) { await transcodeVideo(row.path, row.id); outcome = 'transcoded'; } @@ -94,17 +71,18 @@ export function enqueueTranscodeJob(mediaIds: number[], label: string, libraryId job.update({ total: mediaIds.length, log: `Checking ${mediaIds.length} videos…` }); let transcoded = 0; let skipped = 0; - let errors = 0; - for (let i = 0; i < mediaIds.length; i++) { - try { - if ((await processOne(mediaIds[i])) === 'transcoded') transcoded++; + const errors = await eachWithProgress( + job, + mediaIds, + (done, total) => `Processed ${done}/${total}…`, + (id) => `transcode failed for media ${id}:`, + async (id) => { + if ((await processOne(id)) === 'transcoded') transcoded++; else skipped++; - } catch (err) { - errors++; - console.error(`transcode failed for media ${mediaIds[i]}:`, err); - } - job.update({ progress: i + 1, log: `Processed ${i + 1}/${mediaIds.length}…` }); - } + }, + // One transcode can run for an hour, so report after every item. + 1, + ); return `Completed. ${transcoded} transcoded, ${skipped} already playable${errors ? `, ${errors} errors` : ''}.`; }, libraryId, @@ -112,28 +90,9 @@ export function enqueueTranscodeJob(mediaIds: number[], label: string, libraryId } export function enqueueBulkTranscodeCheck() { - return enqueueJob('transcode', 'Check all videos for playback compatibility', async (job: JobHandle) => { - const allVideos = db - .select({ id: schema.media.id }) - .from(schema.media) - .where(eq(schema.media.type, 'video')) - .all(); - job.update({ total: allVideos.length, log: `Checking ${allVideos.length} videos…` }); - let transcoded = 0; - let skipped = 0; - let errors = 0; - for (let i = 0; i < allVideos.length; i++) { - try { - if ((await processOne(allVideos[i].id)) === 'transcoded') transcoded++; - else skipped++; - } catch (err) { - errors++; - console.error(`transcode failed for media ${allVideos[i].id}:`, err); - } - if (i % 5 === 0 || i === allVideos.length - 1) { - job.update({ progress: i + 1, log: `Checked ${i + 1}/${allVideos.length}…` }); - } - } - return `Completed. ${transcoded} transcoded, ${skipped} already playable${errors ? `, ${errors} errors` : ''}.`; - }); + const ids = db.select({ id: schema.media.id }).from(schema.media).where(eq(schema.media.type, 'video')).all(); + return enqueueTranscodeJob( + ids.map((r) => r.id), + 'Check all videos for playback compatibility', + ); } diff --git a/apps/server/src/services/ugoira.ts b/apps/server/src/services/ugoira.ts index e86fdd3..577fd07 100644 --- a/apps/server/src/services/ugoira.ts +++ b/apps/server/src/services/ugoira.ts @@ -2,11 +2,8 @@ import fs from 'node:fs'; import fsp from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; -import { spawn } from 'node:child_process'; -import ffmpegStatic from 'ffmpeg-static'; import AdmZip from 'adm-zip'; - -const ffmpegPath: string = (ffmpegStatic as unknown as string) ?? 'ffmpeg'; +import { run, FFMPEG_PATH } from '../lib/run'; // gallery-dl's default pixiv ugoira output: "_p.zip" containing // zero-padded, sequentially numbered frame images and nothing else. @@ -29,16 +26,6 @@ function looksLikeUgoiraZip(zip: AdmZip): boolean { return entries.every((e) => !e.entryName.includes('/') && UGOIRA_FRAME_NAME.test(e.entryName)); } -function runFfmpeg(args: string[]): Promise { - return new Promise((resolve, reject) => { - const proc = spawn(ffmpegPath, args, { stdio: ['ignore', 'ignore', 'pipe'] }); - let stderr = ''; - proc.stderr.on('data', (d) => (stderr += d)); - proc.on('error', reject); - proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`ffmpeg exited with ${code}: ${stderr.slice(-300)}`)))); - }); -} - /** * Detects pixiv ugoira zips (frame dumps gallery-dl leaves behind without --ugoira) and * converts them to an animated gif in place. Returns the new gif path, or null if `zipPath` @@ -73,7 +60,7 @@ export async function tryConvertUgoiraZip(zipPath: string): Promise { if (settings?.accent_color) { diff --git a/apps/web/src/components/BulkConfirmDialog.tsx b/apps/web/src/components/BulkConfirmDialog.tsx index feafa9a..0841067 100644 --- a/apps/web/src/components/BulkConfirmDialog.tsx +++ b/apps/web/src/components/BulkConfirmDialog.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { AlertTriangle } from 'lucide-react'; import { thumbUrl } from '../lib/api'; +import { Modal } from './Modal'; /** One line in the confirmation list: what this file is, and what is about to happen to it. */ export interface BulkChangeRow { @@ -44,78 +45,73 @@ export function BulkConfirmDialog({ const blocking = rows.filter((row) => row.warning).length; return ( -
-
e.stopPropagation()} - > -
{title}
-
{summary}
+
{title}
+
{summary}
- {blocking > 0 && ( -
- - - {blocking} file{blocking === 1 ? '' : 's'} will be skipped — see the highlighted rows below. - -
- )} - -
- {shown.map((row) => ( -
- -
-
- {row.label} -
- {row.warning ? ( -
{row.warning}
- ) : ( - row.detail &&
{row.detail}
- )} -
-
- ))} - {hidden > 0 && ( -
- …and {hidden} more file{hidden === 1 ? '' : 's'} -
- )} + {blocking > 0 && ( +
+ + + {blocking} file{blocking === 1 ? '' : 's'} will be skipped — see the highlighted rows below. +
+ )} -
- - -
+ +
+
+ {row.label} +
+ {row.warning ? ( +
{row.warning}
+ ) : ( + row.detail &&
{row.detail}
+ )} +
+
+ ))} + {hidden > 0 && ( +
+ …and {hidden} more file{hidden === 1 ? '' : 's'} +
+ )} +
+ +
+ +
-
+ ); } diff --git a/apps/web/src/components/BulkRenameDialog.tsx b/apps/web/src/components/BulkRenameDialog.tsx index 113edaa..f29fa9f 100644 --- a/apps/web/src/components/BulkRenameDialog.tsx +++ b/apps/web/src/components/BulkRenameDialog.tsx @@ -4,12 +4,13 @@ import type { Media } from '@sakuya/shared'; import type { BulkRenameItem } from '@sakuya/shared'; import { buildRenamePlan, planStats, DEFAULT_RENAME_OPTIONS, type RenameOptions } from '../lib/bulkRename'; import { BulkConfirmDialog, type BulkChangeRow } from './BulkConfirmDialog'; +import { Modal } from './Modal'; +import { Segmented } from './Segmented'; -function segStyle(active: boolean): string { - return `cursor-pointer rounded-md px-[13px] py-1.5 text-[12.5px] font-semibold ${ - active ? 'bg-accent text-white' : 'text-zinc-400 hover:text-zinc-200' - }`; -} +const MODES = [ + { key: 'pattern', label: 'Pattern' }, + { key: 'replace', label: 'Find & replace' }, +] as const; const inputClass = 'w-full rounded-lg border border-line bg-zinc-900 px-2.5 py-2 text-[12.5px] text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-line-hover'; @@ -33,10 +34,7 @@ export function BulkRenameDialog({ const [opts, setOpts] = useState(DEFAULT_RENAME_OPTIONS); const [confirming, setConfirming] = useState(false); - const plan = useMemo( - () => buildRenamePlan(selected.map((m) => ({ id: m.id, filename: m.filename, path: m.path, createdAt: m.createdAt })), opts), - [selected, opts], - ); + const plan = useMemo(() => buildRenamePlan(selected, opts), [selected, opts]); const stats = planStats(plan); const set = (patch: Partial) => setOpts((prev) => ({ ...prev, ...patch })); @@ -74,128 +72,116 @@ export function BulkRenameDialog({ } return ( -
-
e.stopPropagation()} - > -
Rename {selected.length} files
+
Rename {selected.length} files
-
-
set({ mode: 'pattern' })}> - Pattern -
-
set({ mode: 'replace' })}> - Find & replace -
-
+ set({ mode })} className="mb-3 w-fit" /> - {opts.mode === 'pattern' ? ( - <> -
+ {opts.mode === 'pattern' ? ( + <> +
+ set({ pattern: e.target.value })} + placeholder="{name}{ext}" + className={inputClass} + /> +
+ Start at set({ pattern: e.target.value })} - placeholder="{name}{ext}" - className={inputClass} + type="number" + min={0} + value={opts.startAt} + onChange={(e) => set({ startAt: Number(e.target.value) || 0 })} + className="w-[68px] rounded-lg border border-line bg-zinc-900 px-2 py-2 text-[12.5px] text-zinc-100 outline-none focus:border-line-hover" /> -
- Start at - set({ startAt: Number(e.target.value) || 0 })} - className="w-[68px] rounded-lg border border-line bg-zinc-900 px-2 py-2 text-[12.5px] text-zinc-100 outline-none focus:border-line-hover" - /> -
-
-
- {'{name}'} original name ·{' '} - {'{ext}'} extension ·{' '} - {'{n}'} counter ( - {'{n:3}'} pads to 001) ·{' '} - {'{date}'} date added
- - ) : ( - <> -
- set({ find: e.target.value })} - placeholder="Find…" - className={inputClass} - /> - set({ replace: e.target.value })} - placeholder="Replace with…" - className={inputClass} - /> -
- - - )} - -
-
PREVIEW
-
- {stats.changed} renamed - {stats.errors > 0 && · {stats.errors} blocked}
+
+ {'{name}'} original name ·{' '} + {'{ext}'} extension ·{' '} + {'{n}'} counter ( + {'{n:3}'} pads to 001) ·{' '} + {'{date}'} date added +
+ + ) : ( + <> +
+ set({ find: e.target.value })} + placeholder="Find…" + className={inputClass} + /> + set({ replace: e.target.value })} + placeholder="Replace with…" + className={inputClass} + /> +
+ + + )} + +
+
PREVIEW
+
+ {stats.changed} renamed + {stats.errors > 0 && · {stats.errors} blocked}
-
- {plan.map((row) => ( -
- - {row.from} +
+
+ {plan.map((row) => ( +
+ + {row.from} + + {row.changed && } + {row.changed && ( + + {row.error ?? row.to} - {row.changed && } - {row.changed && ( - - {row.error ?? row.to} - - )} - {!row.changed && unchanged} -
- ))} -
+ )} + {!row.changed && unchanged} +
+ ))} +
-
- - -
+
+ +
-
+ ); } diff --git a/apps/web/src/components/BulkTagDialog.tsx b/apps/web/src/components/BulkTagDialog.tsx index 23b07a3..c1b01a6 100644 --- a/apps/web/src/components/BulkTagDialog.tsx +++ b/apps/web/src/components/BulkTagDialog.tsx @@ -3,15 +3,10 @@ import { useQuery } from '@tanstack/react-query'; import { X } from 'lucide-react'; import type { Media, TagCategory } from '@sakuya/shared'; import { api } from '../lib/api'; +import { CATEGORIES, normalizeTag } from '../lib/format'; import { TagSearchInput } from './TagSearchInput'; import { BulkConfirmDialog, type BulkChangeRow } from './BulkConfirmDialog'; - -const ADD_CATEGORIES: { key: TagCategory; label: string }[] = [ - { key: 'user', label: 'User' }, - { key: 'general', label: 'General' }, - { key: 'character', label: 'Character' }, - { key: 'rating', label: 'Rating' }, -]; +import { Modal } from './Modal'; /** * Compose step for bulk tagging: stage tags to add and to remove, then hand the whole change @@ -85,90 +80,83 @@ export function BulkTagDialog({ } return ( -
-
e.stopPropagation()} - > -
Edit tags on {ids.length} files
-
- Added tags apply to every selected file. Removals only affect files that have the tag. -
- -
ADD
-
- {ADD_CATEGORIES.map((c) => ( -
setCategory(c.key)} - className={`cursor-pointer rounded-md px-2 py-1 text-[11px] font-semibold ${ - category === c.key ? 'bg-accent text-white' : 'bg-zinc-800 text-zinc-400 hover:text-zinc-200' - }`} - > - {c.label} -
- ))} -
- setAdd((prev) => (prev.includes(tag) ? prev : [...prev, tag]))} - onRemoveTag={(tag) => setAdd((prev) => prev.filter((t) => t !== tag))} - onFreeText={(text) => { - const tag = text.trim().toLowerCase().replace(/\s+/g, '_'); - if (tag) setAdd((prev) => (prev.includes(tag) ? prev : [...prev, tag])); - }} - libraryId={libraryId} - placeholder="Tag to add — Tab to complete, Enter for a new tag…" - /> +
Edit tags on {ids.length} files
+
+ Added tags apply to every selected file. Removals only affect files that have the tag. +
-
REMOVE
-
- {(present ?? []).length === 0 && ( -
No tags on the selected files.
- )} -
- {(present ?? []).map((tag) => { - const staged = remove.includes(tag.name); - return ( -
- setRemove((prev) => (staged ? prev.filter((t) => t !== tag.name) : [...prev, tag.name])) - } - className={`flex cursor-pointer items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11.5px] font-semibold ${ - staged - ? 'border-rose-500/50 bg-rose-500/15 text-rose-300 line-through' - : 'border-line-strong bg-zinc-800 text-zinc-300 hover:border-line-hover' - }`} - > - {tag.name} - {tag.count} - {staged && } -
- ); - })} +
ADD
+
+ {CATEGORIES.map((c) => ( +
setCategory(c)} + className={`cursor-pointer rounded-md px-2 py-1 text-[11px] font-semibold capitalize ${ + category === c ? 'bg-accent text-white' : 'bg-zinc-800 text-zinc-400 hover:text-zinc-200' + }`} + > + {c}
-
+ ))} +
+ setAdd((prev) => (prev.includes(tag) ? prev : [...prev, tag]))} + onRemoveTag={(tag) => setAdd((prev) => prev.filter((t) => t !== tag))} + onFreeText={(text) => { + const tag = normalizeTag(text); + if (tag) setAdd((prev) => (prev.includes(tag) ? prev : [...prev, tag])); + }} + libraryId={libraryId} + placeholder="Tag to add — Tab to complete, Enter for a new tag…" + /> -
- - +
REMOVE
+
+ {(present ?? []).length === 0 && ( +
No tags on the selected files.
+ )} +
+ {(present ?? []).map((tag) => { + const staged = remove.includes(tag.name); + return ( +
setRemove((prev) => (staged ? prev.filter((t) => t !== tag.name) : [...prev, tag.name]))} + className={`flex cursor-pointer items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11.5px] font-semibold ${ + staged + ? 'border-rose-500/50 bg-rose-500/15 text-rose-300 line-through' + : 'border-line-strong bg-zinc-800 text-zinc-300 hover:border-line-hover' + }`} + > + {tag.name} + {tag.count} + {staged && } +
+ ); + })}
-
+ +
+ + +
+ ); } diff --git a/apps/web/src/components/Confetti.tsx b/apps/web/src/components/Confetti.tsx deleted file mode 100644 index 4575844..0000000 --- a/apps/web/src/components/Confetti.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useEffect, useRef } from 'react'; - -const COLORS = ['#8b5cf6', '#f43f5e', '#22c55e', '#eab308', '#3b82f6', '#ec4899', '#f97316']; -const DURATION = 4200; -const PARTICLES_PER_BURST = 55; - -interface Particle { - x: number; - y: number; - vx: number; - vy: number; - size: number; - color: string; - rotation: number; - vr: number; - life: number; -} - -/** A cannon-style burst from a screen corner, shooting steeply upward and inward. */ -function burst(x: number, y: number, direction: 1 | -1): Particle[] { - return Array.from({ length: PARTICLES_PER_BURST }, () => { - const angle = (55 + Math.random() * 35) * (Math.PI / 180); - const speed = 14 + Math.random() * 14; - return { - x, - y, - vx: Math.cos(angle) * speed * direction, - vy: -Math.sin(angle) * speed, - size: 5 + Math.random() * 6, - color: COLORS[Math.floor(Math.random() * COLORS.length)], - rotation: Math.random() * 360, - vr: (Math.random() - 0.5) * 28, - life: 1, - }; - }); -} - -/** Full-screen party-popper confetti exploding from both bottom corners. Purely cosmetic — no interaction. */ -export function Confetti() { - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - const ctx = canvas?.getContext('2d'); - if (!canvas || !ctx) return; - - const resize = () => { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; - }; - resize(); - window.addEventListener('resize', resize); - - let particles: Particle[] = []; - let raf = 0; - const start = performance.now(); - - const spawn = () => { - particles = particles.concat( - burst(0, canvas.height, 1), - burst(canvas.width, canvas.height, -1), - ); - }; - spawn(); - const secondBurst = setTimeout(spawn, 350); - const thirdBurst = setTimeout(spawn, 750); - - const tick = (now: number) => { - ctx.clearRect(0, 0, canvas.width, canvas.height); - particles.forEach((p) => { - p.vy += 0.35; - p.vx *= 0.995; - p.x += p.vx; - p.y += p.vy; - p.rotation += p.vr; - p.life -= 0.008; - }); - particles = particles.filter((p) => p.life > 0 && p.y < canvas.height + 40); - for (const p of particles) { - ctx.save(); - ctx.translate(p.x, p.y); - ctx.rotate((p.rotation * Math.PI) / 180); - ctx.globalAlpha = Math.max(p.life, 0); - ctx.fillStyle = p.color; - ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6); - ctx.restore(); - } - if (now - start < DURATION) { - raf = requestAnimationFrame(tick); - } else { - ctx.clearRect(0, 0, canvas.width, canvas.height); - } - }; - raf = requestAnimationFrame(tick); - - return () => { - cancelAnimationFrame(raf); - clearTimeout(secondBurst); - clearTimeout(thirdBurst); - window.removeEventListener('resize', resize); - }; - }, []); - - return ; -} diff --git a/apps/web/src/components/ConfirmDialog.tsx b/apps/web/src/components/ConfirmDialog.tsx index bbce3f6..8b2afea 100644 --- a/apps/web/src/components/ConfirmDialog.tsx +++ b/apps/web/src/components/ConfirmDialog.tsx @@ -1,11 +1,11 @@ import type { ReactNode } from 'react'; +import { Modal } from './Modal'; -/** Styled confirm/warning modal built on the shared overlay pattern. */ +/** Styled confirm/warning modal. */ export function ConfirmDialog({ title, body, confirmLabel = 'Confirm', - cancelLabel = 'Cancel', danger, onConfirm, onCancel, @@ -13,39 +13,30 @@ export function ConfirmDialog({ title: string; body: ReactNode; confirmLabel?: string; - cancelLabel?: string; danger?: boolean; onConfirm: () => void; onCancel: () => void; }) { return ( -
-
e.stopPropagation()} - > -
{title}
-
{body}
-
- - -
+ +
{title}
+
{body}
+
+ +
-
+ ); } diff --git a/apps/web/src/components/FilterToolbar.tsx b/apps/web/src/components/FilterToolbar.tsx index 92a54a3..837dbc5 100644 --- a/apps/web/src/components/FilterToolbar.tsx +++ b/apps/web/src/components/FilterToolbar.tsx @@ -3,18 +3,13 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Shuffle, ArrowUp, ArrowDown, Heart, X, BookmarkPlus, ListChecks, MoreVertical } from 'lucide-react'; import { TagSearchInput } from './TagSearchInput'; import { MenuItem, MenuLabel, MenuPanel } from './Menu'; +import { Segmented, TYPE_OPTIONS } from './Segmented'; import { api } from '../lib/api'; import { exploreQueryString } from '../hooks/useFilters'; import { useOverflowCount } from '../hooks/useOverflowCount'; import type { FilterState, FilterActions } from '../hooks/useFilters'; import type { SelectionApi } from '../hooks/useSelection'; -function segStyle(active: boolean): string { - return `cursor-pointer rounded-md px-[13px] py-1.5 text-[12.5px] font-semibold glass:rounded-full ${ - active ? 'bg-accent text-white' : 'text-zinc-400 hover:text-zinc-200' - }`; -} - function buttonStyle(accent?: boolean): string { return `flex flex-none cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-lg border px-3 py-[7px] text-[13px] font-semibold glass:rounded-full glass:px-3.5 ${ accent ? 'border-accent/40 text-accent' : 'border-line text-zinc-400 hover:text-zinc-200' @@ -86,12 +81,7 @@ export function FilterToolbar({ { id: 'type', inline: ( -
- {(['all', 'image', 'video'] as const).map((t) => ( -
actions.setType(t)}> - {t === 'all' ? 'All' : t === 'image' ? 'Images' : 'Videos'} -
- ))} +
-
+ ), menu: (
Type - {(['all', 'image', 'video'] as const).map((t) => ( + {TYPE_OPTIONS.map((t) => ( actions.setType(t))} + key={t.key} + label={t.label} + active={filters.typeParam === t.key} + onClick={fromMenu(() => actions.setType(t.key))} /> ))} - {SORTS.map((srt) => ( -
actions.setSort(srt.key)}> + ({ + key: srt.key, + label: ( {srt.label} {filters.sort === srt.key && } -
- ))} -
+ ), + }))} + value={filters.sort} + onChange={actions.setSort} + className="flex-none" + /> ), menu: (
diff --git a/apps/web/src/components/HeartButton.tsx b/apps/web/src/components/HeartButton.tsx index 8f88396..c4ff476 100644 --- a/apps/web/src/components/HeartButton.tsx +++ b/apps/web/src/components/HeartButton.tsx @@ -6,12 +6,10 @@ import { isLikedMediaList, patchCachedMedia } from '../lib/mediaCache'; export function HeartButton({ mediaId, liked, - className = '', size = 'md', }: { mediaId: number; liked: boolean; - className?: string; size?: 'sm' | 'md' | 'lg'; }) { const queryClient = useQueryClient(); @@ -37,7 +35,7 @@ export function HeartButton({ }} className={`flex items-center justify-center rounded-full backdrop-blur transition-colors ${ liked ? 'bg-black/40 text-rose-500' : 'bg-black/40 text-white/85 hover:text-rose-400' - } ${className} hover:cursor-pointer`} + } hover:cursor-pointer`} > diff --git a/apps/web/src/components/MediaBrowser.tsx b/apps/web/src/components/MediaBrowser.tsx new file mode 100644 index 0000000..098bc70 --- /dev/null +++ b/apps/web/src/components/MediaBrowser.tsx @@ -0,0 +1,83 @@ +import { useState, type ReactNode } from 'react'; +import type { FilterActions, FilterState } from '../hooks/useFilters'; +import { useMediaInfinite } from '../hooks/useMedia'; +import { useSelection } from '../hooks/useSelection'; +import { FilterToolbar } from './FilterToolbar'; +import { MediaGrid } from './MediaGrid'; +import { SelectionBar } from './SelectionBar'; +import { MediaViewer } from './MediaViewer'; + +/** + * A filterable media page: title with the item count, the sticky filter toolbar, the grid, and + * the selection bar and viewer on top. Explore, a library and a board are all this page. + */ +export function MediaBrowser({ + title, + filters, + actions, + boardId, + sidebar, + headerExtra, +}: { + title: ReactNode; + filters: FilterState; + actions: FilterActions; + /** Restricts the grid to one board and unlocks "remove from board". */ + boardId?: number; + /** Explore's tag sidebar. With one, the page runs full width beside it instead of centered. */ + sidebar?: ReactNode; + headerExtra?: ReactNode; +}) { + const query = { ...filters, boardId }; + const media = useMediaInfinite(query); + const selection = useSelection(media.items, JSON.stringify(query)); + const [viewerIndex, setViewerIndex] = useState(null); + const centered = sidebar ? '' : 'mx-auto max-w-[1400px] '; + + const page = ( + <> +
+
+

{title}

+ + {media.total} item{media.total === 1 ? '' : 's'} + + {headerExtra} +
+
+
+
+ +
+
+
+ +
+ + ); + + return ( +
+ {sidebar} + {sidebar ?
{page}
: page} + {selection.active && } + {viewerIndex !== null && ( + setViewerIndex(null)} + onNearEnd={() => media.hasNextPage && !media.isFetchingNextPage && media.fetchNextPage()} + /> + )} +
+ ); +} diff --git a/apps/web/src/components/MediaCard.tsx b/apps/web/src/components/MediaCard.tsx index 25c5b97..f9f87a2 100644 --- a/apps/web/src/components/MediaCard.tsx +++ b/apps/web/src/components/MediaCard.tsx @@ -5,19 +5,15 @@ import { thumbUrl } from '../lib/api'; import { formatDuration } from '../lib/format'; import { HeartButton } from './HeartButton'; -export function TypeBadge({ type }: { type: 'image' | 'video' }) { +function VideoBadge() { return ( -
- {type === 'video' ? 'VIDEO' : 'IMAGE'} +
+ VIDEO
); } -export function DurationBadge({ seconds }: { seconds: number | null }) { +function DurationBadge({ seconds }: { seconds: number | null }) { const label = formatDuration(seconds); if (!label) return null; return ( @@ -62,7 +58,7 @@ export const MediaCard = memo(function MediaCard({ loading="lazy" className="h-full w-full object-cover" /> - {item.type === 'video' && } + {item.type === 'video' && } {item.reasonTag && (
)} - {item.type === 'video' && } + {item.type === 'video' && }
void; } -const ADD_CATEGORIES: { key: TagCategory; label: string }[] = [ - { key: 'general', label: 'General' }, - { key: 'character', label: 'Character' }, - { key: 'rating', label: 'Rating' }, -]; - // Actual .gif files can't be decoded by an HTML5
- {ADD_CATEGORIES.map((c) => ( + {CATEGORIES.map((c) => (
setAddCategory(c.key)} - className={`cursor-pointer rounded-md px-2 py-1 text-[11px] font-semibold ${ - addCategory === c.key ? 'bg-accent text-white' : 'bg-zinc-800 text-zinc-400 hover:text-zinc-200' + key={c} + onClick={() => setAddCategory(c)} + className={`cursor-pointer rounded-md px-2 py-1 text-[11px] font-semibold capitalize ${ + addCategory === c ? 'bg-accent text-white' : 'bg-zinc-800 text-zinc-400 hover:text-zinc-200' }`} > - {c.label} + {c}
))}
@@ -574,7 +555,6 @@ export function MediaViewer({ items, index, onIndexChange, onClose, onNearEnd }: /** Board membership for the open item: current boards as removable pills, plus an add picker. */ function BoardsSection({ mediaId, boards }: { mediaId: number; boards: Board[] }) { const queryClient = useQueryClient(); - const showToast = useToast(); const [creating, setCreating] = useState(false); const [newName, setNewName] = useState(''); const { data: allBoards } = useQuery({ queryKey: ['boards'], queryFn: api.boards }); @@ -590,15 +570,13 @@ function BoardsSection({ mediaId, boards }: { mediaId: number; boards: Board[] } mutationFn: (boardId: number) => api.addToBoard(boardId, [mediaId]), onSuccess: (board) => { invalidate(); - showToast(`Added to ${board.name}`); + toast(`Added to ${board.name}`); }, - onError: (err: Error) => showToast(`Failed: ${err.message}`), }); const removeMutation = useMutation({ mutationFn: (boardId: number) => api.removeFromBoard(boardId, mediaId), onSuccess: invalidate, - onError: (err: Error) => showToast(`Failed: ${err.message}`), }); const createMutation = useMutation({ @@ -608,7 +586,6 @@ function BoardsSection({ mediaId, boards }: { mediaId: number; boards: Board[] } setCreating(false); addMutation.mutate(board.id); }, - onError: (err: Error) => showToast(`Failed: ${err.message}`), }); const memberIds = new Set(boards.map((b) => b.id)); @@ -717,7 +694,7 @@ function TagPill({ {menuOpen && (
- {(['general', 'character', 'rating', 'user'] as TagCategory[]).map((c) => ( + {CATEGORIES.map((c) => (
void; }) { - const [collapsed, setCollapsed] = useState(true); return ( -
- - {!collapsed && ( -
- {duplicates.length > 0 && ( -
- )} - {similar.length > 0 && ( -
- )} -
- )} -
+ + +
+ {duplicates.length > 0 && ( +
+ )} + {similar.length > 0 && ( +
+ )} +
+ ); } @@ -802,11 +776,11 @@ function Section({ ); } -function MetaRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { +function MetaRow({ label, value }: { label: string; value: string }) { return (
{label} - + {value}
diff --git a/apps/web/src/components/Modal.tsx b/apps/web/src/components/Modal.tsx new file mode 100644 index 0000000..7082adb --- /dev/null +++ b/apps/web/src/components/Modal.tsx @@ -0,0 +1,57 @@ +import { useLayoutEffect, useRef, type ReactNode } from 'react'; + +/** + * Centered modal on a native : Escape and a click on the dimmed backdrop both call + * `onClose`. Leave `onClose` out while something is in flight to make it undismissable. + * Mount it to open it; unmount it to close it. + */ +export function Modal({ + onClose, + className, + children, +}: { + onClose?: () => void; + /** The panel's classes. */ + className: string; + children: ReactNode; +}) { + const ref = useRef(null); + const onCloseRef = useRef(onClose); + useLayoutEffect(() => { + onCloseRef.current = onClose; + }); + useLayoutEffect(() => { + const dialog = ref.current!; + let unmounting = false; + // Chromium closes a dialog on a second Escape in a row even though `cancel` was prevented. + // Treat that like any other dismissal, or reopen when the modal must not be dismissed. + const onNativeClose = () => { + if (unmounting) return; + if (onCloseRef.current) onCloseRef.current(); + else dialog.showModal(); + }; + dialog.addEventListener('close', onNativeClose); + dialog.showModal(); + return () => { + unmounting = true; + dialog.removeEventListener('close', onNativeClose); + dialog.close(); + }; + }, []); + + return ( + e.target === e.currentTarget && onClose?.()} + onCancel={(e) => { + e.preventDefault(); + onClose?.(); + }} + className="fade-in h-full max-h-none w-full max-w-none items-center justify-center bg-zinc-950/80 p-6 text-inherit backdrop-blur backdrop:bg-transparent open:flex" + > +
{children}
+
+ ); +} diff --git a/apps/web/src/components/Navbar.tsx b/apps/web/src/components/Navbar.tsx index fc1e3bd..d2c5dd2 100644 --- a/apps/web/src/components/Navbar.tsx +++ b/apps/web/src/components/Navbar.tsx @@ -5,6 +5,7 @@ import { Activity, Download, Home, Images, LayoutGrid, Lock, Settings, Sparkles import { useJobs } from '../hooks/useJobs'; import { useAuth } from '../hooks/useAuth'; import { useScanAllLibraries } from '../hooks/useScanAllLibraries'; +import { useSettings } from '../hooks/useSettings'; import { TagSearchInput } from './TagSearchInput'; import { api } from '../lib/api'; @@ -174,7 +175,7 @@ export function Navbar() { const barRef = useRef(null); const lastPathname = useRef(location.pathname); const [searchTags, setSearchTags] = useState([]); - const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.settings, staleTime: 60_000 }); + const settings = useSettings(); const discoverEnabled = settings?.discover_enabled === '1'; const items = NAV_ITEMS.filter((item) => item.to !== '/discover' || discoverEnabled); diff --git a/apps/web/src/components/ReleaseNotesDialog.tsx b/apps/web/src/components/ReleaseNotesDialog.tsx index e3076c7..f0a711a 100644 --- a/apps/web/src/components/ReleaseNotesDialog.tsx +++ b/apps/web/src/components/ReleaseNotesDialog.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { PartyPopper, X } from 'lucide-react'; import { releases, compareVersions, type Release } from '../lib/releases'; -import { Confetti } from './Confetti'; +import { Modal } from './Modal'; const STORAGE_KEY = 'sakuya:last-seen-release'; @@ -24,52 +24,44 @@ export function ReleaseNotesDialog() { }; return ( - <> - -
-
e.stopPropagation()} + +
+
+ +
+
+
What's new in Sakuya
+
Here's what's changed since your last visit
+
+ -
-
-
- {unseen.map((r, i) => ( -
0 ? 'mt-6 border-t border-line pt-6' : ''}`} - dangerouslySetInnerHTML={{ __html: r.html }} - /> - ))} -
-
-
- -
+ + +
+
+
+ {unseen.map((r, i) => ( +
0 ? 'mt-6 border-t border-line pt-6' : ''}`} + dangerouslySetInnerHTML={{ __html: r.html }} + /> + ))}
- +
+ +
+ ); } diff --git a/apps/web/src/components/Segmented.tsx b/apps/web/src/components/Segmented.tsx new file mode 100644 index 0000000..abf67c3 --- /dev/null +++ b/apps/web/src/components/Segmented.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react'; + +export const TYPE_OPTIONS = [ + { key: 'all', label: 'All' }, + { key: 'image', label: 'Images' }, + { key: 'video', label: 'Videos' }, +] as const; + +/** Pill-group picker: one option active at a time. `children` render after the options, inside the group. */ +export function Segmented({ + options, + value, + onChange, + className = '', + children, +}: { + options: readonly { key: K; label: ReactNode }[]; + value: K; + onChange: (key: K) => void; + className?: string; + children?: ReactNode; +}) { + return ( +
+ {options.map((option) => ( +
onChange(option.key)} + className={`cursor-pointer rounded-md px-[13px] py-1.5 text-[12.5px] font-semibold glass:rounded-full ${ + value === option.key ? 'bg-accent text-white' : 'text-zinc-400 hover:text-zinc-200' + }`} + > + {option.label} +
+ ))} + {children} +
+ ); +} diff --git a/apps/web/src/components/SelectionBar.tsx b/apps/web/src/components/SelectionBar.tsx index 2ce00e7..c1cd030 100644 --- a/apps/web/src/components/SelectionBar.tsx +++ b/apps/web/src/components/SelectionBar.tsx @@ -13,10 +13,11 @@ import { Trash2, X, } from 'lucide-react'; -import type { BulkResult, BulkRenameItem, Media, TagCategory } from '@sakuya/shared'; +import type { BulkResult, Media } from '@sakuya/shared'; import { api, type MediaFilters } from '../lib/api'; import { formatBytes } from '../lib/format'; -import { useToast } from './Toast'; +import { toast, toastError } from './Toast'; +import { Modal } from './Modal'; import { BulkConfirmDialog, type BulkChangeRow } from './BulkConfirmDialog'; import { BulkTagDialog } from './BulkTagDialog'; import { BulkRenameDialog } from './BulkRenameDialog'; @@ -25,9 +26,6 @@ import type { SelectionApi } from '../hooks/useSelection'; type Action = 'tags' | 'rename' | 'boards' | 'like' | 'unlike' | 'delete' | 'retag' | 'thumbnails' | 'board-remove'; -/** Actions that need the selected rows resolved before their dialog can render. */ -const NEEDS_ROWS: Action[] = ['tags', 'rename', 'like', 'unlike', 'delete', 'retag', 'thumbnails', 'board-remove']; - function actionButton(danger?: boolean): string { return `flex cursor-pointer items-center gap-1.5 rounded-lg border px-3 py-[7px] text-[12.5px] font-semibold ${ danger @@ -58,7 +56,6 @@ export function SelectionBar({ boardId?: number; }) { const qc = useQueryClient(); - const showToast = useToast(); const [action, setAction] = useState(null); const [boardTarget, setBoardTarget] = useState<{ id: number; name: string } | null>(null); const [menuOpen, setMenuOpen] = useState(false); @@ -86,7 +83,7 @@ export function SelectionBar({ } function finish(message: string) { - showToast(message); + toast(message); qc.invalidateQueries({ queryKey: ['media'] }); qc.invalidateQueries({ queryKey: ['media-by-ids'] }); qc.invalidateQueries({ queryKey: ['media-detail'] }); @@ -99,94 +96,43 @@ export function SelectionBar({ } /** Bulk routes report per-item failures instead of failing the whole call. */ - function report(verb: string, result: BulkResult) { + const report = (verb: string) => (result: BulkResult) => { const failed = result.failed.length; - finish(`${verb} ${result.updated} file${result.updated === 1 ? '' : 's'}${failed ? ` · ${failed} skipped` : ''}`); - } - - const fail = (err: Error) => showToast(`Failed: ${err.message}`); + return `${verb} ${result.updated} file${result.updated === 1 ? '' : 's'}${failed ? ` · ${failed} skipped` : ''}`; + }; + const files = `${ids.length} file${ids.length === 1 ? '' : 's'}`; const selectAll = useMutation({ mutationFn: () => api.mediaIds(filters!), onSuccess: (res) => { selection.replace(res.ids); - showToast(`Selected ${res.ids.length} file${res.ids.length === 1 ? '' : 's'}`); + toast(`Selected ${res.ids.length} file${res.ids.length === 1 ? '' : 's'}`); }, - onError: fail, }); - const tagsMutation = useMutation({ - mutationFn: (body: { add: string[]; remove: string[]; category: TagCategory }) => - api.tagsBatch({ ids, ...body }), - onSuccess: (res) => report('Tagged', res), - onError: fail, - }); - - const renameMutation = useMutation({ - mutationFn: (items: BulkRenameItem[]) => api.renameBatch(items), - onSuccess: (res) => report('Renamed', res), - onError: fail, - }); + // Every bulk change goes through this one mutation: the thunk does the work and resolves to the + // toast that reports it. + const run = useMutation({ mutationFn: (change: () => Promise) => change(), onSuccess: finish }); + const busy = run.isPending; - const likeMutation = useMutation({ - mutationFn: (liked: boolean) => api.likeBatch(ids, liked), - onSuccess: (res) => report(action === 'unlike' ? 'Unliked' : 'Liked', res), - onError: fail, - }); - - const deleteMutation = useMutation({ - mutationFn: () => api.deleteMediaBatch(ids), - onSuccess: (res) => finish(`Deleted ${res.deleted} file${res.deleted === 1 ? '' : 's'}`), - onError: fail, - }); - - const addToBoardMutation = useMutation({ - mutationFn: (target: number) => api.addToBoard(target, ids), - onSuccess: (board) => finish(`Added ${ids.length} to ${board.name}`), - onError: fail, - }); - - const removeFromBoardMutation = useMutation({ - mutationFn: () => api.removeFromBoardBatch(boardId!, ids), - onSuccess: (board) => finish(`Removed ${ids.length} from ${board.name}`), - onError: fail, - }); - - const retagMutation = useMutation({ - mutationFn: () => api.retagBatch(ids), - onSuccess: () => finish(`Queued AI tagging for ${ids.length} file${ids.length === 1 ? '' : 's'}`), - onError: fail, - }); - - const thumbnailsMutation = useMutation({ - mutationFn: () => api.thumbnailsBatch(ids), - onSuccess: () => finish(`Queued thumbnail regeneration for ${ids.length} file${ids.length === 1 ? '' : 's'}`), - onError: fail, - }); - - const copyPaths = useMutation({ - mutationFn: async () => { + const copyPaths = async () => { + try { const items = await api.mediaByIds(ids); await navigator.clipboard.writeText(items.map((m) => m.path).join('\n')); - return items.length; - }, - onSuccess: (count) => showToast(`Copied ${count} path${count === 1 ? '' : 's'}`), - onError: fail, - }); - - const busy = - tagsMutation.isPending || - renameMutation.isPending || - likeMutation.isPending || - deleteMutation.isPending || - addToBoardMutation.isPending || - removeFromBoardMutation.isPending || - retagMutation.isPending || - thumbnailsMutation.isPending; - - // The board flow only needs rows once a target board is chosen and the confirm list renders. - const needsRows = - action !== null && (NEEDS_ROWS.includes(action) || (action === 'boards' && boardTarget !== null)); + toast(`Copied ${items.length} path${items.length === 1 ? '' : 's'}`); + } catch (err) { + toastError(err as Error); + } + }; + + const fromMenu = (pick: () => void) => () => { + setMenuOpen(false); + pick(); + }; + + // Every action but the board flow needs the rows up front; that one only once a target board + // is chosen and the confirm list renders. + const needsRows = action !== null && (action !== 'boards' || boardTarget !== null); const plainRows: BulkChangeRow[] = selected.map((item) => ({ id: item.id, label: item.filename })); const selectedBytes = selected.reduce((sum, m) => sum + m.sizeBytes, 0); const allSelected = ids.length >= total && total > 0; @@ -241,48 +187,25 @@ export function SelectionBar({ {menuOpen && ( setMenuOpen(false)}> - } - label="Unlike" - onClick={() => { - setMenuOpen(false); - setAction('unlike'); - }} - /> + } label="Unlike" onClick={fromMenu(() => setAction('unlike'))} /> {boardId !== undefined && ( } label="Remove from this board" - onClick={() => { - setMenuOpen(false); - setAction('board-remove'); - }} + onClick={fromMenu(() => setAction('board-remove'))} /> )} } label="AI re-tag" - onClick={() => { - setMenuOpen(false); - setAction('retag'); - }} + onClick={fromMenu(() => setAction('retag'))} /> } label="Regenerate thumbnails" - onClick={() => { - setMenuOpen(false); - setAction('thumbnails'); - }} - /> - } - label="Copy file paths" - onClick={() => { - setMenuOpen(false); - copyPaths.mutate(); - }} + onClick={fromMenu(() => setAction('thumbnails'))} /> + } label="Copy file paths" onClick={fromMenu(copyPaths)} /> )}
@@ -308,7 +231,7 @@ export function SelectionBar({ selected={selected} libraryId={filters?.libraryId} busy={busy} - onApply={(body) => tagsMutation.mutate(body)} + onApply={(body) => run.mutate(() => api.tagsBatch({ ids, ...body }).then(report('Tagged')))} onCancel={close} /> )} @@ -317,7 +240,7 @@ export function SelectionBar({ renameMutation.mutate(items)} + onApply={(items) => run.mutate(() => api.renameBatch(items).then(report('Renamed')))} onCancel={close} /> )} @@ -325,12 +248,16 @@ export function SelectionBar({ {action === 'boards' && (boardTarget && rows ? ( addToBoardMutation.mutate(boardTarget.id)} + onConfirm={() => + run.mutate(() => + api.addToBoard(boardTarget.id, ids).then((board) => `Added ${ids.length} to ${board.name}`), + ) + } onCancel={() => setBoardTarget(null)} /> ) : ( @@ -343,19 +270,23 @@ export function SelectionBar({ {action === 'board-remove' && rows && ( removeFromBoardMutation.mutate()} + onConfirm={() => + run.mutate(() => + api.removeFromBoardBatch(boardId!, ids).then((board) => `Removed ${ids.length} from ${board.name}`), + ) + } onCancel={close} /> )} {(action === 'like' || action === 'unlike') && rows && ( likeMutation.mutate(action === 'like')} + onConfirm={() => + run.mutate(() => + api.likeBatch(ids, action === 'like').then(report(action === 'like' ? 'Liked' : 'Unliked')), + ) + } onCancel={close} /> )} {action === 'retag' && rows && ( retagMutation.mutate()} + onConfirm={() => run.mutate(() => api.retagBatch(ids).then(() => `Queued AI tagging for ${files}`))} onCancel={close} /> )} @@ -392,14 +327,16 @@ export function SelectionBar({ rows={plainRows} confirmLabel="Queue regeneration" busy={busy} - onConfirm={() => thumbnailsMutation.mutate()} + onConfirm={() => + run.mutate(() => api.thumbnailsBatch(ids).then(() => `Queued thumbnail regeneration for ${files}`)) + } onCancel={close} /> )} {action === 'delete' && rows && ( This cannot be undone. {formatBytes(selectedBytes)} will be @@ -414,7 +351,11 @@ export function SelectionBar({ confirmLabel="Delete permanently" danger busy={busy} - onConfirm={() => deleteMutation.mutate()} + onConfirm={() => + run.mutate(() => + api.deleteMediaBatch(ids).then((res) => `Deleted ${res.deleted} file${res.deleted === 1 ? '' : 's'}`), + ) + } onCancel={close} /> )} @@ -424,14 +365,12 @@ export function SelectionBar({ function LoadingDialog({ onCancel }: { onCancel: () => void }) { return ( -
-
- Loading selection… -
-
+ Loading selection… +
); } @@ -445,7 +384,6 @@ function BoardPicker({ onCancel: () => void; }) { const qc = useQueryClient(); - const showToast = useToast(); const [newName, setNewName] = useState(''); const createMutation = useMutation({ @@ -454,53 +392,47 @@ function BoardPicker({ qc.invalidateQueries({ queryKey: ['boards'] }); onPick({ id: board.id, name: board.name }); }, - onError: (err: Error) => showToast(`Failed: ${err.message}`), }); return ( -
-
e.stopPropagation()} - > -
Add to board
-
- {boards.length === 0 && ( -
No boards yet — create one below.
- )} - {boards.map((board) => ( -
onPick({ id: board.id, name: board.name })} - className="flex cursor-pointer items-center justify-between border-b border-line/60 px-3 py-2 last:border-b-0 hover:bg-white/5" - > - {board.name} - {board.itemCount} -
- ))} -
-
- setNewName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && newName.trim()) createMutation.mutate(newName.trim()); - }} - placeholder="New board name" - className="flex-1 rounded-field border border-line bg-zinc-900 px-3 py-[7px] text-[13px] text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-line-hover" - /> - -
+ {board.name} + {board.itemCount} +
+ ))} +
+
+ setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && newName.trim()) createMutation.mutate(newName.trim()); + }} + placeholder="New board name" + className="flex-1 rounded-field border border-line bg-zinc-900 px-3 py-[7px] text-[13px] text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-line-hover" + /> +
-
+ ); } diff --git a/apps/web/src/components/TagSearchInput.tsx b/apps/web/src/components/TagSearchInput.tsx index c7ac6ff..ac0e7f3 100644 --- a/apps/web/src/components/TagSearchInput.tsx +++ b/apps/web/src/components/TagSearchInput.tsx @@ -13,7 +13,6 @@ interface TagSearchInputProps { /** Called when the user submits text that wasn't completed into a tag. */ onFreeText?: (q: string) => void; placeholder?: string; - autoFocus?: boolean; /** Render selected tags as chips inside the box. Off when the caller lists them itself. */ showChips?: boolean; } @@ -39,7 +38,6 @@ export function TagSearchInput({ libraryId, onFreeText, placeholder = 'Search tags…', - autoFocus, showChips = true, }: TagSearchInputProps) { const [text, setText] = useState(''); @@ -150,7 +148,6 @@ export function TagSearchInput({ ))} { setText(e.target.value); @@ -163,8 +160,8 @@ export function TagSearchInput({ className="min-w-[80px] flex-1 bg-transparent text-[13px] text-zinc-100 outline-none placeholder:text-zinc-500" />
- {/* z-[97]: above dialogs (95) and the confetti canvas (96), since the box can live inside - one, but below toasts (100). */} + {/* Inside a modal the list has to live in the dialog too: a modal sits in the top + layer and leaves the rest of the page inert. */} {showDropdown && anchor && createPortal( @@ -189,7 +186,7 @@ export function TagSearchInput({
))}
, - document.body, + boxRef.current?.closest('dialog') ?? document.body, )}
); diff --git a/apps/web/src/components/TagSidebar.tsx b/apps/web/src/components/TagSidebar.tsx index 9806197..180ff1b 100644 --- a/apps/web/src/components/TagSidebar.tsx +++ b/apps/web/src/components/TagSidebar.tsx @@ -1,3 +1,4 @@ +import { Fragment } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { ChevronLeft, ChevronRight, X } from 'lucide-react'; @@ -53,6 +54,12 @@ export function TagSidebar({ staleTime: 30_000, }); + const sections = [ + { title: 'RATING', tags: ratingTags ?? [] }, + { title: 'CHARACTERS', tags: characterTags ?? [] }, + { title: 'TAGS', tags: generalTags ?? [] }, + ]; + if (collapsed) { return (
); diff --git a/apps/web/src/components/Toast.tsx b/apps/web/src/components/Toast.tsx index 793929d..620e157 100644 --- a/apps/web/src/components/Toast.tsx +++ b/apps/web/src/components/Toast.tsx @@ -1,31 +1,50 @@ -import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Check } from 'lucide-react'; -const ToastContext = createContext<(msg: string) => void>(() => {}); +let show: (msg: string) => void = () => {}; -export function useToast() { - return useContext(ToastContext); +/** Shows a toast from anywhere, React or not. Goes nowhere until has mounted. */ +export function toast(msg: string) { + show(msg); } -export function ToastProvider({ children }: { children: ReactNode }) { +export const toastError = (err: Error) => toast(err.message); + +export function Toaster() { const [message, setMessage] = useState(null); const timer = useRef>(); + const ref = useRef(null); - const showToast = useCallback((msg: string) => { - setMessage(msg); - clearTimeout(timer.current); - timer.current = setTimeout(() => setMessage(null), 2200); + useEffect(() => { + show = (msg) => { + setMessage(msg); + clearTimeout(timer.current); + timer.current = setTimeout(() => setMessage(null), 2200); + }; }, []); + // A manual popover sits in the top layer, so the toast shows above an open modal . The + // top layer stacks in opening order: a modal opened since the toast appeared would cover it, so a + // new message re-opens it on top. + useLayoutEffect(() => { + const el = ref.current; + if (!el?.showPopover) return; + el.popover = 'manual'; + if (el.matches(':popover-open')) { + if (!document.querySelector('dialog[open]')) return; + el.hidePopover(); + } + el.showPopover(); + }, [message]); + + if (!message) return null; return ( - - {children} - {message && ( -
- - {message} -
- )} -
+
+ + {message} +
); } diff --git a/apps/web/src/components/UiStylePicker.tsx b/apps/web/src/components/UiStylePicker.tsx index 7ad5aa1..7261fb7 100644 --- a/apps/web/src/components/UiStylePicker.tsx +++ b/apps/web/src/components/UiStylePicker.tsx @@ -1,10 +1,8 @@ import { useState } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Check, Palette } from 'lucide-react'; import type { UiStyle } from '@sakuya/shared'; -import { api } from '../lib/api'; import { applyUiStyle } from '../hooks/useUiStyle'; -import { useToast } from './Toast'; +import { usePatchSettings } from '../hooks/useSettings'; import { UI_STYLES, UiStylePreview } from './UiStylePreview'; /** @@ -13,20 +11,11 @@ import { UI_STYLES, UiStylePreview } from './UiStylePreview'; * Continue on the preselected style is that, and it keeps the prompt from coming back. */ export function UiStylePicker({ current }: { current: UiStyle }) { - const queryClient = useQueryClient(); - const showToast = useToast(); const [choice, setChoice] = useState(current); - const saveMutation = useMutation({ - mutationFn: () => api.patchSettings({ ui_style: choice, ui_style_chosen: '1' }), - onSuccess: (data) => { - // Applied here as well as in App: when the choice equals the saved style, ui_style doesn't - // change and App's effect wouldn't run. - applyUiStyle(data.ui_style); - queryClient.setQueryData(['settings'], data); - }, - onError: (err: Error) => showToast(err.message), - }); + // Applied here as well as in App: when the choice equals the saved style, ui_style doesn't + // change and App's effect wouldn't run. + const saveMutation = usePatchSettings((data) => applyUiStyle(data.ui_style)); return (
@@ -82,7 +71,7 @@ export function UiStylePicker({ current }: { current: UiStyle }) {
- navigate('/explore?type=video')} /> -
- {(data?.continueWatching ?? []).map((item, i) => ( - setViewer({ items: data!.continueWatching, index: i })} - /> - ))} - {data && data.continueWatching.length === 0 && ( -
Videos you start watching will show up here.
- )} -
- - navigate('/explore')} /> -
- {(data?.recentlyViewed ?? []).map((item, i) => ( - setViewer({ items: data!.recentlyViewed, index: i })} - /> - ))} - {data && data.recentlyViewed.length === 0 && ( -
Images and videos you open will show up here.
- )} -
- - navigate('/explore')} /> -
- {(data?.recentlyAdded ?? []).map((item, i) => ( - setViewer({ items: data!.recentlyAdded, index: i })} /> - ))} - {data && data.recentlyAdded.length === 0 && ( -
Scan a library or upload files to get started.
- )} -
+ {ROWS.map((row, r) => { + const items = data?.[row.key] ?? []; + return ( + + navigate(row.seeAll)} /> +
+ {items.map((item, i) => ( + setViewer({ items, index: i })} + /> + ))} + {data && items.length === 0 &&
{row.empty}
} +
+
+ ); + })} {viewer && ( Math.floor(Math.random() * 2 ** 30) + 1; @@ -31,11 +26,10 @@ function surpriseLabel(value: number): string { } export function Discover() { - const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.settings, staleTime: 60_000 }); + const settings = useSettings(); const [params, setParams] = useSearchParams(); const typeParam = params.get('type') === 'image' ? 'image' : params.get('type') === 'video' ? 'video' : 'all'; const seed = Number(params.get('seed') ?? 1) || 1; - const showToast = useToast(); // The slider thumb has to track the pointer at 60fps; the feed only refetches once it settles. const [surprise, setSurprise] = useState(() => Number(params.get('surprise') ?? 0.25)); @@ -71,9 +65,9 @@ export function Discover() { seed: randomSeed(), }); if (res.items.length) setLucky({ items: res.items, index: 0 }); - else showToast('No media to pick from yet'); + else toast('No media to pick from yet'); } catch (err) { - showToast(`Failed: ${(err as Error).message}`); + toast(`Failed: ${(err as Error).message}`); } finally { setLuckyPending(false); } @@ -115,17 +109,11 @@ export function Discover() {
-
- {(['all', 'image', 'video'] as const).map((t) => ( -
update((p) => (t === 'all' ? p.delete('type') : p.set('type', t)))} - > - {t === 'all' ? 'All' : t === 'image' ? 'Images' : 'Videos'} -
- ))} -
+ update((p) => (t === 'all' ? p.delete('type') : p.set('type', t)))} + />
+
+ + + +
+ ); } diff --git a/apps/web/src/routes/settings/DuplicatesTab.tsx b/apps/web/src/routes/settings/DuplicatesTab.tsx index 453898f..da2f9e7 100644 --- a/apps/web/src/routes/settings/DuplicatesTab.tsx +++ b/apps/web/src/routes/settings/DuplicatesTab.tsx @@ -4,14 +4,12 @@ import { Trash2 } from 'lucide-react'; import type { DuplicateGroup } from '@sakuya/shared'; import { api, thumbUrl } from '../../lib/api'; import { formatBytes } from '../../lib/format'; -import { useToast } from '../../components/Toast'; +import { toast } from '../../components/Toast'; import { ConfirmDialog } from '../../components/ConfirmDialog'; import { TabHeader } from './index'; export function DuplicatesTab() { const queryClient = useQueryClient(); - const showToast = useToast(); - const [scanned, setScanned] = useState(false); const [selected, setSelected] = useState>(new Set()); const [showConfirm, setShowConfirm] = useState(false); @@ -21,26 +19,21 @@ export function DuplicatesTab() { enabled: false, }); - const scanMutation = useMutation({ - mutationFn: () => refetch(), - onSuccess: () => setScanned(true), - onError: (err: Error) => showToast(err.message), - }); - const deleteMutation = useMutation({ mutationFn: (ids: number[]) => api.deleteMediaBatch(ids), onSuccess: async (res) => { - showToast(`Deleted ${res.deleted} file${res.deleted === 1 ? '' : 's'}`); + toast(`Deleted ${res.deleted} file${res.deleted === 1 ? '' : 's'}`); setSelected(new Set()); queryClient.invalidateQueries({ queryKey: ['dashboard'] }); queryClient.invalidateQueries({ queryKey: ['media'] }); queryClient.invalidateQueries({ queryKey: ['system'] }); await refetch(); }, - onError: (err: Error) => showToast(err.message), }); const groups = data?.groups ?? []; + // Nothing runs until the first scan; after that the cached result is what's shown. + const scanned = data !== undefined; function toggle(id: number) { setSelected((prev) => { @@ -77,11 +70,11 @@ export function DuplicatesTab() {
diff --git a/apps/web/src/routes/settings/JobsConfigureTab.tsx b/apps/web/src/routes/settings/JobsConfigureTab.tsx index cc796d6..0210969 100644 --- a/apps/web/src/routes/settings/JobsConfigureTab.tsx +++ b/apps/web/src/routes/settings/JobsConfigureTab.tsx @@ -2,12 +2,12 @@ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { JobSchedule, LibraryWithStats } from '@sakuya/shared'; import { api, libraryCoverUrl, thumbUrl, type ScheduleJobType, type UpdateJobScheduleBody } from '../../lib/api'; -import { useToast } from '../../components/Toast'; -import { Search, Tag, Fingerprint, Trash2, ChevronDown } from 'lucide-react'; +import { toast } from '../../components/Toast'; +import { Search, Tag, Fingerprint, Trash2, ChevronDown, type LucideIcon } from 'lucide-react'; type IntervalOption = { label: string; minutes: number }; -const INTERVAL_OPTIONS: IntervalOption[] = [ +export const INTERVAL_OPTIONS: IntervalOption[] = [ { label: '15 min', minutes: 15 }, { label: '30 min', minutes: 30 }, { label: '1 hour', minutes: 60 }, @@ -17,11 +17,11 @@ const INTERVAL_OPTIONS: IntervalOption[] = [ { label: '24 hours', minutes: 1440 }, ]; -const JOB_TYPES: { key: ScheduleJobType; label: string; recommended?: string }[] = [ - { key: 'scan', label: 'Library scan' }, - { key: 'tag', label: 'AI tagging', recommended: 'Recommended: After every scan' }, - { key: 'hash', label: 'Duplicate detection', recommended: 'Recommended: After every scan' }, - { key: 'cleanup', label: 'Cleanup orphan data' }, +const JOB_TYPES: { key: ScheduleJobType; label: string; icon: LucideIcon; recommended?: string }[] = [ + { key: 'scan', label: 'Library scan', icon: Search }, + { key: 'tag', label: 'AI tagging', icon: Tag, recommended: 'Recommended: After every scan' }, + { key: 'hash', label: 'Duplicate detection', icon: Fingerprint, recommended: 'Recommended: After every scan' }, + { key: 'cleanup', label: 'Cleanup orphan data', icon: Trash2 }, ]; /** All possible { - const file = e.target.files?.[0]; - if (file) uploadCoverMutation.mutate(file); - e.target.value = ''; - }} - /> -
- - {lib.customImagePath && ( - - )} +
+
+ {lib.customImagePath + ? 'A custom cover is set. It stays fixed until removed — pick a media item or remove it to auto-update again.' + : 'Pick a media item as the cover, upload a custom image, or force-regenerate the current thumbnail.'} +
+ { + const file = e.target.files?.[0]; + if (file) uploadCoverMutation.mutate(file); + e.target.value = ''; + }} + /> +
+ + {lib.customImagePath && ( - + +
+
+ {(media?.items ?? []).map((m) => ( +
setThumbMutation.mutate(m.id)} + className={`relative aspect-square cursor-pointer overflow-hidden rounded-lg border-2 ${ + currentThumbId === m.id ? 'border-accent' : 'border-transparent hover:border-line-strong' + }`} > - Use latest media - -
-
- {(media?.items ?? []).map((m) => ( -
setThumbMutation.mutate(m.id)} - className={`relative aspect-square cursor-pointer overflow-hidden rounded-lg border-2 ${ - currentThumbId === m.id ? 'border-accent' : 'border-transparent hover:border-line-strong' - }`} - > - -
- ))} - {media && media.items.length === 0 && ( -
No media in this library yet.
- )} -
+ +
+ ))} + {media && media.items.length === 0 && ( +
No media in this library yet.
+ )}
-
+ ); } diff --git a/apps/web/src/routes/settings/MiscTabs.tsx b/apps/web/src/routes/settings/MiscTabs.tsx index ca2bd3c..aa64136 100644 --- a/apps/web/src/routes/settings/MiscTabs.tsx +++ b/apps/web/src/routes/settings/MiscTabs.tsx @@ -3,31 +3,24 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { Settings } from '@sakuya/shared'; import { api } from '../../lib/api'; import { formatBytes } from '../../lib/format'; -import { useToast } from '../../components/Toast'; +import { toast } from '../../components/Toast'; import { ConfirmDialog } from '../../components/ConfirmDialog'; import { TabHeader } from './index'; -import { applyUiStyle, useUiStyle } from '../../hooks/useUiStyle'; +import { useUiStyle } from '../../hooks/useUiStyle'; +import { usePatchSettings, useSettings } from '../../hooks/useSettings'; import { UI_STYLES, UiStylePreview } from '../../components/UiStylePreview'; const ACCENTS = ['#8b5cf6', '#14b8a6', '#f43f5e']; export function AppearanceTab() { - const queryClient = useQueryClient(); - const showToast = useToast(); - const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.settings }); + const settings = useSettings(); const current = settings?.accent_color ?? '#8b5cf6'; const uiStyle = useUiStyle(); - const patchMutation = useMutation({ - mutationFn: (body: Partial>) => api.patchSettings(body), - onSuccess: (data, body) => { - queryClient.setQueryData(['settings'], data); - document.documentElement.style.setProperty('--accent', data.accent_color); - applyUiStyle(data.ui_style); - showToast(body.accent_color ? 'Accent updated' : 'Appearance updated'); - }, - onError: (err: Error) => showToast(err.message), - }); + // App applies the accent and style whenever the cached settings change. + const patchMutation = usePatchSettings((_, body) => + toast(body.accent_color ? 'Accent updated' : 'Appearance updated'), + ); return (
@@ -93,14 +86,10 @@ export function AppearanceTab() { } export function SystemTab() { - const showToast = useToast(); const queryClient = useQueryClient(); const { data: info } = useQuery({ queryKey: ['system'], queryFn: api.system }); - const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.settings }); - const [showCacheWarning, setShowCacheWarning] = useState(false); - const [showRegenerateWarning, setShowRegenerateWarning] = useState(false); - const [showCleanupWarning, setShowCleanupWarning] = useState(false); - const [migrateTo, setMigrateTo] = useState<'home' | 'local' | null>(null); + const settings = useSettings(); + const [confirm, setConfirm] = useState<'cache' | 'regenerate' | 'cleanup' | 'home' | 'local' | null>(null); const [movedTo, setMovedTo] = useState(null); const cacheEnabled = settings?.thumbnail_cache_enabled !== '0'; @@ -112,63 +101,116 @@ export function SystemTab() { mutationFn: (target: 'home' | 'local') => api.migrateStorage(target), onSuccess: (res) => { setMovedTo(res.movedTo); - showToast(`Data moved to ${res.movedTo} — restart Sakuya`); + toast(`Data moved to ${res.movedTo} — restart Sakuya`); }, - onError: (err: Error) => showToast(err.message), }); const clearMutation = useMutation({ mutationFn: api.clearThumbnails, onSuccess: (res) => { - showToast(`Cleared ${res.removed} thumbnails`); + toast(`Cleared ${res.removed} thumbnails`); queryClient.invalidateQueries({ queryKey: ['system'] }); }, - onError: (err: Error) => showToast(err.message), }); - const cacheMutation = useMutation({ - mutationFn: (value: boolean) => api.patchSettings({ thumbnail_cache_enabled: value ? '1' : '0' }), - onSuccess: (data) => { - queryClient.setQueryData(['settings'], data); - showToast('Thumbnail cache setting updated'); - }, - onError: (err: Error) => showToast(err.message), - }); + const cacheMutation = usePatchSettings(() => toast('Thumbnail cache setting updated')); const regenerateMutation = useMutation({ mutationFn: api.regenerateAllThumbnails, - onSuccess: () => showToast('Thumbnail regeneration started'), - onError: (err: Error) => showToast(err.message), + onSuccess: () => toast('Thumbnail regeneration started'), }); - const transcodeToggleMutation = useMutation({ - mutationFn: (value: boolean) => api.patchSettings({ video_transcode_enabled: value ? '1' : '0' }), - onSuccess: (data) => { - queryClient.setQueryData(['settings'], data); - showToast('Video transcoding setting updated'); - }, - onError: (err: Error) => showToast(err.message), - }); + const transcodeToggleMutation = usePatchSettings(() => toast('Video transcoding setting updated')); const transcodeRunMutation = useMutation({ mutationFn: api.transcodeVideos, - onSuccess: () => showToast('Checking videos for playback compatibility…'), - onError: (err: Error) => showToast(err.message), + onSuccess: () => toast('Checking videos for playback compatibility…'), }); const cleanupMutation = useMutation({ mutationFn: api.cleanupData, onSuccess: (res) => { - showToast( + toast( `Removed ${res.removedThumbs} orphan thumbnails and ${res.removedTranscodes} transcodes · reset ${res.resetTagCounts} tag counts · ` + `pruned ${res.prunedJobs} old jobs and ${res.prunedDownloadLogs} log lines`, ); queryClient.invalidateQueries({ queryKey: ['system'] }); queryClient.invalidateQueries({ queryKey: ['tags'] }); }, - onError: (err: Error) => showToast(err.message), }); + const migrateDialog = (target: 'home' | 'local') => ({ + title: target === 'home' ? 'Move data to ~/.sakuya?' : 'Move data back to the app folder?', + confirmLabel: 'Move and shut down', + body: ( + <> + Everything in {storage?.current} is copied to{' '} + {target === 'home' ? storage?.home : storage?.local} and the old folder + is deleted. Large thumbnail and transcode caches can make this take a while — don't close the browser. The + server shuts down when it's done; start it again to keep using Sakuya. + + ), + run: () => migrateMutation.mutate(target), + }); + const dialogs = { + home: migrateDialog('home'), + local: migrateDialog('local'), + cache: { + title: 'Disable thumbnail cache?', + danger: true, + confirmLabel: 'Disable anyway', + body: 'Without cached thumbnails, the board and dashboard will load full-resolution images directly. This can significantly hurt performance and load times on large libraries.', + run: () => cacheMutation.mutate({ thumbnail_cache_enabled: '0' }), + }, + regenerate: { + title: 'Regenerate all thumbnails?', + confirmLabel: 'Regenerate', + body: 'This may take a long time on large libraries. Existing thumbnails will be overwritten as each file is re-processed.', + run: () => regenerateMutation.mutate(), + }, + cleanup: { + title: 'Clean up orphan data?', + danger: true, + confirmLabel: 'Clean up', + body: 'Removes thumbnail files whose media rows no longer exist and recomputes usage counts for every tag. Safe to run any time — nothing referenced by current media is touched.', + run: () => cleanupMutation.mutate(), + }, + }; + const dialog = confirm && dialogs[confirm]; + + const maintenance = [ + { + title: 'Thumbnail cache', + desc: 'Delete all cached thumbnails to free up disk space', + label: 'Clear cache', + danger: true, + pending: clearMutation.isPending, + onClick: () => clearMutation.mutate(), + }, + { + title: 'Regenerate thumbnails', + desc: 'Re-process all media files and overwrite existing thumbnails', + label: 'Regenerate all', + pending: regenerateMutation.isPending, + onClick: () => setConfirm('regenerate'), + }, + { + title: 'Transcode videos', + desc: "Check every video and re-encode any the browser can't play", + label: 'Transcode all videos now', + pending: transcodeRunMutation.isPending, + onClick: () => transcodeRunMutation.mutate(), + }, + { + title: 'Clean up orphan data', + desc: 'Remove thumbnail files and tag counts with no matching media', + label: 'Clean up', + danger: true, + pending: cleanupMutation.isPending, + onClick: () => setConfirm('cleanup'), + }, + ]; + return (
@@ -199,7 +241,7 @@ export function SystemTab() { pending={migrateMutation.isPending} onChange={(value) => { if (storage?.locked || migrateMutation.isPending) return; - setMigrateTo(value ? 'home' : 'local'); + setConfirm(value ? 'home' : 'local'); }} />
@@ -217,8 +259,8 @@ export function SystemTab() { checked={cacheEnabled} pending={cacheMutation.isPending} onChange={(value) => { - if (!value) setShowCacheWarning(true); - else cacheMutation.mutate(true); + if (!value) setConfirm('cache'); + else cacheMutation.mutate({ thumbnail_cache_enabled: '1' }); }} />
@@ -235,7 +277,7 @@ export function SystemTab() { transcodeToggleMutation.mutate(value)} + onChange={(value) => transcodeToggleMutation.mutate({ video_transcode_enabled: value ? '1' : '0' })} />
@@ -245,118 +287,37 @@ export function SystemTab() {
-
-
-
Thumbnail cache
-
Delete all cached thumbnails to free up disk space
-
- -
- -
-
-
Regenerate thumbnails
-
Re-process all media files and overwrite existing thumbnails
-
- -
- -
-
-
Transcode videos
-
Check every video and re-encode any the browser can't play
-
- -
- -
-
-
Clean up orphan data
-
Remove thumbnail files and tag counts with no matching media
+
+
{row.title}
+
{row.desc}
+
+
- -
+ ))}
- {migrateTo && ( - - Everything in {storage?.current} is copied to{' '} - {migrateTo === 'home' ? storage?.home : storage?.local} and the - old folder is deleted. Large thumbnail and transcode caches can make this take a while — don't close - the browser. The server shuts down when it's done; start it again to keep using Sakuya. - - } - onCancel={() => setMigrateTo(null)} - onConfirm={() => { - const target = migrateTo; - setMigrateTo(null); - migrateMutation.mutate(target); - }} - /> - )} - {showCacheWarning && ( - setShowCacheWarning(false)} - onConfirm={() => { - setShowCacheWarning(false); - cacheMutation.mutate(false); - }} - /> - )} - {showRegenerateWarning && ( + {dialog && ( setShowRegenerateWarning(false)} + {...dialog} + onCancel={() => setConfirm(null)} onConfirm={() => { - setShowRegenerateWarning(false); - regenerateMutation.mutate(); - }} - /> - )} - {showCleanupWarning && ( - setShowCleanupWarning(false)} - onConfirm={() => { - setShowCleanupWarning(false); - cleanupMutation.mutate(); + setConfirm(null); + dialog.run(); }} /> )} @@ -392,18 +353,8 @@ export function ToggleSwitch({ } export function BehaviorTab() { - const queryClient = useQueryClient(); - const showToast = useToast(); - const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.settings }); - - const patchMutation = useMutation({ - mutationFn: (body: Record) => api.patchSettings(body), - onSuccess: (data) => { - queryClient.setQueryData(['settings'], data); - showToast('Behaviour updated'); - }, - onError: (err: Error) => showToast(err.message), - }); + const settings = useSettings(); + const patchMutation = usePatchSettings(() => toast('Behaviour updated')); const rows: { key: keyof Settings; label: string; desc: string; defaultOn?: boolean }[] = [ { @@ -443,8 +394,7 @@ export function BehaviorTab() { const reclassifyMutation = useMutation({ mutationFn: api.reclassifyGifs, - onSuccess: () => showToast('Reclassifying existing GIFs…'), - onError: (err: Error) => showToast(err.message), + onSuccess: () => toast('Reclassifying existing GIFs…'), }); return ( diff --git a/apps/web/src/routes/settings/TaggingTab.tsx b/apps/web/src/routes/settings/TaggingTab.tsx index 4a7dfbc..6bf6cb9 100644 --- a/apps/web/src/routes/settings/TaggingTab.tsx +++ b/apps/web/src/routes/settings/TaggingTab.tsx @@ -1,17 +1,19 @@ import { useEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { Job } from '@sakuya/shared'; import { api } from '../../lib/api'; import { formatBytes } from '../../lib/format'; -import { useToast } from '../../components/Toast'; +import { toast } from '../../components/Toast'; import { useJobs } from '../../hooks/useJobs'; +import { usePatchSettings, useSettings } from '../../hooks/useSettings'; import { TabHeader } from './index'; import { ToggleSwitch } from './MiscTabs'; +import { ProgressBar } from './JobsHistoryTab'; export function TaggingTab() { const queryClient = useQueryClient(); - const showToast = useToast(); const jobs = useJobs(); - const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.settings }); + const settings = useSettings(); const { data: tagger } = useQuery({ queryKey: ['tagger'], queryFn: api.taggerStatus, @@ -28,28 +30,22 @@ export function TaggingTab() { if (settings) setThreshold(Number(settings.confidence_threshold) || 35); }, [settings]); - const patchMutation = useMutation({ - mutationFn: (body: Record) => api.patchSettings(body), - onSuccess: (data) => queryClient.setQueryData(['settings'], data), - onError: (err: Error) => showToast(err.message), - }); + const patchMutation = usePatchSettings(); const downloadMutation = useMutation({ mutationFn: api.taggerDownload, onSuccess: () => { - showToast('Model download started'); + toast('Model download started'); queryClient.invalidateQueries({ queryKey: ['tagger'] }); }, - onError: (err: Error) => showToast(err.message), }); const tagAllMutation = useMutation({ mutationFn: api.taggerTagAll, onSuccess: () => { - showToast('AI tagging started'); + toast('AI tagging started'); queryClient.invalidateQueries({ queryKey: ['tagger'] }); }, - onError: (err: Error) => showToast(err.message), }); const selectModelMutation = useMutation({ @@ -57,18 +53,16 @@ export function TaggingTab() { onSuccess: (data) => { queryClient.setQueryData(['tagger'], data); queryClient.invalidateQueries({ queryKey: ['settings'] }); - showToast('Model switched — download the new model to use it'); + toast('Model switched — download the new model to use it'); }, - onError: (err: Error) => showToast(err.message), }); const hashAllMutation = useMutation({ mutationFn: api.taggerHashAll, onSuccess: () => { - showToast('Hashing started'); + toast('Hashing started'); queryClient.invalidateQueries({ queryKey: ['tagger'] }); }, - onError: (err: Error) => showToast(err.message), }); return ( @@ -115,78 +109,34 @@ export function TaggingTab() { -
-
-
-
- {activeTagJob ? 'Tagging in progress…' : 'Bulk tag'} -
-
- {activeTagJob - ? activeTagJob.log || `${activeTagJob.progress}/${activeTagJob.total}` - : (tagger?.untaggedCount ?? 0) > 0 - ? `${tagger?.untaggedCount} file${tagger?.untaggedCount === 1 ? '' : 's'} without AI tags.` - : 'All files are tagged.'} -
- {activeTagJob && ( -
-
0 ? Math.round((activeTagJob.progress / activeTagJob.total) * 100) : 0}%`, - }} - /> -
- )} -
- -
-
- -
-
-
-
- {activeHashJob ? 'Hashing images…' : 'Duplicate detection'} -
-
- {activeHashJob - ? activeHashJob.log || `${activeHashJob.progress}/${activeHashJob.total}` - : (tagger?.unhashedCount ?? 0) > 0 - ? `${tagger?.unhashedCount} image${tagger?.unhashedCount === 1 ? '' : 's'} without a perceptual hash.` - : 'All images are hashed for similarity search.'} -
- {activeHashJob && ( -
-
0 ? Math.round((activeHashJob.progress / activeHashJob.total) * 100) : 0}%`, - }} - /> -
- )} -
- -
-
+ 0 + ? `${tagger?.untaggedCount} file${tagger?.untaggedCount === 1 ? '' : 's'} without AI tags.` + : 'All files are tagged.' + } + label="Tag all" + activeLabel="Tagging…" + disabled={tagger?.status !== 'ready' || (tagger?.untaggedCount ?? 0) === 0 || tagAllMutation.isPending} + onRun={() => tagAllMutation.mutate()} + /> + 0 + ? `${tagger?.unhashedCount} image${tagger?.unhashedCount === 1 ? '' : 's'} without a perceptual hash.` + : 'All images are hashed for similarity search.' + } + label="Hash images" + activeLabel="Hashing…" + disabled={(tagger?.unhashedCount ?? 0) === 0 || hashAllMutation.isPending} + onRun={() => hashAllMutation.mutate()} + />
@@ -222,3 +172,45 @@ export function TaggingTab() {
); } + +/** A card that starts a background job and shows its progress while it runs. */ +function JobRunCard({ + job, + title, + activeTitle, + idle, + label, + activeLabel, + disabled, + onRun, +}: { + job: Job | undefined; + title: string; + activeTitle: string; + idle: string; + label: string; + activeLabel: string; + disabled: boolean; + onRun: () => void; +}) { + return ( +
+
+
+
{job ? activeTitle : title}
+
+ {job ? job.log || `${job.progress}/${job.total}` : idle} +
+ {job && } +
+ +
+
+ ); +} diff --git a/apps/web/src/routes/settings/UploadsTab.tsx b/apps/web/src/routes/settings/UploadsTab.tsx index 506da0a..bbe574c 100644 --- a/apps/web/src/routes/settings/UploadsTab.tsx +++ b/apps/web/src/routes/settings/UploadsTab.tsx @@ -2,7 +2,7 @@ import { useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Upload, X } from 'lucide-react'; import { api } from '../../lib/api'; -import { useToast } from '../../components/Toast'; +import { toast } from '../../components/Toast'; import { TabHeader } from './index'; interface UploadRow { @@ -20,7 +20,6 @@ const STATUS_COLOR: Record = { export function UploadsTab() { const queryClient = useQueryClient(); - const showToast = useToast(); const { data: libraries } = useQuery({ queryKey: ['libraries'], queryFn: api.libraries }); const [libraryId, setLibraryId] = useState(''); const [rows, setRows] = useState([]); @@ -33,7 +32,7 @@ export function UploadsTab() { const list = Array.from(files); if (!list.length) return; if (!effectiveLibraryId) { - showToast('Create a library first'); + toast('Create a library first'); return; } const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; @@ -58,17 +57,17 @@ export function UploadsTab() { prev.map((r) => (r.id === id ? { ...r, status: ok ? 'done' : 'error', progress: ok ? 100 : r.progress } : r)), ); if (ok) { - showToast(`Uploaded ${label}`); + toast(`Uploaded ${label}`); queryClient.invalidateQueries({ queryKey: ['media'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] }); queryClient.invalidateQueries({ queryKey: ['libraries'] }); } else { - showToast('Upload failed'); + toast('Upload failed'); } }; xhr.onerror = () => { setRows((prev) => prev.map((r) => (r.id === id ? { ...r, status: 'error' } : r))); - showToast('Upload failed'); + toast('Upload failed'); }; xhr.send(form); } diff --git a/apps/web/src/routes/settings/index.tsx b/apps/web/src/routes/settings/index.tsx index 1b4eb4a..d82d6cc 100644 --- a/apps/web/src/routes/settings/index.tsx +++ b/apps/web/src/routes/settings/index.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { LibrariesTab } from './LibrariesTab'; import { JobsTab } from './JobsTab'; @@ -9,27 +8,21 @@ import { AppearanceTab, BehaviorTab, SystemTab } from './MiscTabs'; import { ReleasesTab } from './ReleasesTab'; const TABS = [ - { key: 'libraries', label: 'Libraries' }, - { key: 'jobs', label: 'Jobs' }, - { key: 'tagging', label: 'AI Tagging' }, - { key: 'duplicates', label: 'Duplicates' }, - { key: 'uploads', label: 'Uploads' }, - { key: 'appearance', label: 'Appearance' }, - { key: 'behavior', label: 'Behaviour' }, - { key: 'system', label: 'System' }, - { key: 'releases', label: 'Releases' }, -] as const; - -type TabKey = (typeof TABS)[number]['key']; - -function isTabKey(value: string | null): value is TabKey { - return TABS.some((t) => t.key === value); -} + { key: 'libraries', label: 'Libraries', Tab: LibrariesTab }, + { key: 'jobs', label: 'Jobs', Tab: JobsTab }, + { key: 'tagging', label: 'AI Tagging', Tab: TaggingTab }, + { key: 'duplicates', label: 'Duplicates', Tab: DuplicatesTab }, + { key: 'uploads', label: 'Uploads', Tab: UploadsTab }, + { key: 'appearance', label: 'Appearance', Tab: AppearanceTab }, + { key: 'behavior', label: 'Behaviour', Tab: BehaviorTab }, + { key: 'system', label: 'System', Tab: SystemTab }, + { key: 'releases', label: 'Releases', Tab: ReleasesTab }, +]; export function Settings() { - const [searchParams] = useSearchParams(); - const initialTab = searchParams.get('tab'); - const [tab, setTab] = useState(isTabKey(initialTab) ? initialTab : 'libraries'); + // The tab lives in ?tab= so links like /settings?tab=jobs work even while Settings is open. + const [searchParams, setSearchParams] = useSearchParams(); + const current = TABS.find((t) => t.key === searchParams.get('tab')) ?? TABS[0]; return (
@@ -37,9 +30,9 @@ export function Settings() { {TABS.map((t) => (
setTab(t.key)} + onClick={() => setSearchParams({ tab: t.key }, { replace: true })} className={`flex-none cursor-pointer whitespace-nowrap rounded-lg border px-3 py-[9px] text-[13.5px] font-semibold glass:rounded-full glass:px-4 glass:transition-colors ${ - tab === t.key + current === t ? 'border-line bg-zinc-900 text-zinc-100 glass:border-white/10 glass:bg-white/10 glass:text-white glass:shadow-[inset_0_1px_0_rgba(255,255,255,0.1)]' : 'border-transparent text-zinc-400 hover:text-zinc-200 glass:font-medium glass:hover:bg-white/5' }`} @@ -49,15 +42,7 @@ export function Settings() { ))}
- {tab === 'libraries' && } - {tab === 'jobs' && } - {tab === 'tagging' && } - {tab === 'duplicates' && } - {tab === 'uploads' && } - {tab === 'appearance' && } - {tab === 'behavior' && } - {tab === 'system' && } - {tab === 'releases' && } +
); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 67a88ef..b713c3a 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,4 +1,5 @@ -import { defineConfig } from 'vite'; +import { defineConfig, type Plugin } from 'vite'; +import { marked } from 'marked'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; // Relative, not @sakuya/shared/config: Vite bundles relative imports of its config, while a bare @@ -20,8 +21,18 @@ const { config } = loadConfig(); const wildcard = config.server.host === '0.0.0.0' || config.server.host === '::'; const apiHost = wildcard ? '127.0.0.1' : config.server.host; +// Release notes ship as HTML: each src/releases/*.md import becomes its rendered markup, so the +// markdown parser stays out of the bundle. +const markdown: Plugin = { + name: 'markdown-html', + transform(src, id) { + if (id.split('?')[0].endsWith('.md')) + return `export default ${JSON.stringify(marked.parse(src, { async: false }))};`; + }, +}; + export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [react(), tailwindcss(), markdown], server: { // The web UI is useless on the network without the API behind it, and the API is exposed on // purpose when server.host is — so the two follow one setting. diff --git a/bun.lock b/bun.lock index 4efb3f9..c9f154e 100644 --- a/bun.lock +++ b/bun.lock @@ -4,10 +4,6 @@ "workspaces": { "": { "name": "sakuya", - "dependencies": { - "better-sqlite3": "^13.0.3", - "form-data": "^4.0.6", - }, "devDependencies": { "@eslint/js": "^10.0.1", "eslint": "^10.11.0", @@ -38,7 +34,6 @@ "@types/bun": "^1.2.0", "@types/express": "^4.17.21", "@types/ffprobe-static": "^2.0.3", - "typescript": "^5.5.3", }, }, "apps/web": { @@ -49,7 +44,6 @@ "@tanstack/react-query": "^5.62.0", "@tanstack/react-virtual": "^3.11.0", "lucide-react": "^1.28.0", - "marked": "^18.0.10", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.30.0", @@ -59,8 +53,8 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "marked": "^18.0.10", "tailwindcss": "^4.1.0", - "typescript": "^5.5.3", "vite": "^6.0.0", }, }, @@ -440,8 +434,6 @@ "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.11.9", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-cp447VUsGS07+n1Dqf7YSQ8maeJrjEhaDxTm1ZefbqDtypHBC5GzGMQbklR6IPR13Y8OAJRHZWEMtZipJLCttg=="], @@ -470,8 +462,6 @@ "caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="], - "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], @@ -496,8 +486,6 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], @@ -524,8 +512,6 @@ "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], - "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -580,8 +566,6 @@ "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], - "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], @@ -612,8 +596,6 @@ "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - "hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="], "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], diff --git a/docker/nginx.conf b/docker/nginx.conf index b12741d..94b72e3 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -18,24 +18,8 @@ server { try_files "" =404; } - # Original media (videos/images). Kept proxied since DB paths are - # environment-specific, but with Range support + long timeouts so big - # videos stream reliably through nginx. - location ~ ^/api/media/(\d+)/file$ { - proxy_pass http://server:3777; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Range $http_range; - proxy_set_header X-Range $http_range; - proxy_read_timeout 1h; - proxy_send_timeout 1h; - proxy_buffering off; - proxy_cache off; - } - + # Unbuffered with long timeouts so big videos stream and server-sent events + # arrive as they are written. Range is forwarded by default. location /api/ { proxy_pass http://server:3777; proxy_http_version 1.1; @@ -43,8 +27,10 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 1d; proxy_set_header Connection ''; + proxy_read_timeout 1d; + proxy_send_timeout 1h; + proxy_buffering off; } location / { diff --git a/docs/SETUP.md b/docs/SETUP.md index 841249f..f503625 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -40,8 +40,8 @@ docker/ nginx config for the container build | `node_modules/` (root and per-workspace) | Installed deps | Managed by `bun install`. Never hand-edit; delete and reinstall if something's wrong. | | `apps/web/dist/` | Vite build output | Regenerated by `bun build`. Not source. | | `bun.lock` | Dependency lockfile | Only changes via `bun install`/`bun add`; hand-editing desyncs it from `node_modules`. | -| `.idea/`, `.junie/`, `.impeccable/` | Editor/tool-specific state (JetBrains, Junie, Impeccable hook cache) | Local tool metadata, not project source. Gitignored (except `.idea` is partially tracked — check before deleting). | -| `tray/` | Currently empty scaffold dir | Not wired to anything yet; nothing to run there. | +| `.idea/` | JetBrains editor state | Local editor metadata, not project source. Gitignored. | +| `.impeccable/` | Impeccable design-detector config (ignored false positives) | Tracked. Only matters if you use Impeccable; let the tool edit it. | Anything under `apps/server/data/` is the one place that holds actual user state (your media library metadata, tags, downloaded files). Back it up before any destructive experiment; everything else here is regeneratable from source. diff --git a/features.txt b/features.txt deleted file mode 100644 index 33b868f..0000000 --- a/features.txt +++ /dev/null @@ -1,61 +0,0 @@ -fix the ai tagging n progress so the tagged and progrss bar occupy full width until the button right before - -in the dashboard add a new section of recently viewed and it should be videos and images - -in the continue watching it should update in real time, on clos ethe video it should update, also on press on a started video from that view it should continue the video where it was left at - -add a way to change the tagger model and download newones (defaulkt model is SmilingWolf/wd-swinv2-tagger-v3) - -when viewing a image make it so on click on a tag it closes the image and applies that filter - -when searching on the topbar, if a tag add a way to search by tag instead of by name, make the dropdwon appear - -when searching on both searchbars (all) make it so you can use arrow keys to select options below and if typing something and press enter, autosearch by the first tag (make this a setting, default on) - -when adding manual tags add a way to update the tag to be the rating, character or general like right now - -add option in settings or libraries idk, to disable thumnbnail cache and it will use the image instead, default on (to create thumbnail) on attempt to disable add a popup warning the user that this will affect performance and load times - -in the dashbaord on the libraries add a way to regenerate the image when there is already one and a way to upload a cusotm image to the library (upon upload custom it will not autoupdate unless user has removed it) - -if the user watches the full video and it autoloops (expected behavour) dont add that to continue watching, user must leave and reenter the video to trigger that - -add a setting in the behaviour to "continue where left of (video)" and default on - - -add a new library "likes", there will appear media that the user has liked, user can like the media by pressing the heart that appears on hover on a thumbnail default heart is only outline on press it turns red filled inside, and appears red on thumbnail if hearted, also on view media on press / detail user can press the ehart somewhere there, in the likes it should act as a library normalluy, on the boeard in the images and videos next to it add a new section that its a heart and it will act as a filter to filter only liked media - -on the searchbar instead of putting tags outside of the searchbar, make it so the tags appear inside the searchbar and text converts to a tag and user can keep typing and use the delete to delete tags (user can keep pressing the x button) - -in the board view remember the filters / tags or whatever that the user left of (add a setting in the behaviour to autoreset on leave and dont remmeber) - - -duplicate iamge detection / similar image detection, on duplicate or similar detection, make it so the images appear when viewing the media on a corner the similar images and duplicates appear (one section for duplicate and the orther for similar) - -=========== -ALL UP DONE - -========== - -remember audio volume level (settings to enable feature default on) - -change like library thumbnail - -remove image tag from images - -updates icons to use lucide icons - -in the side where the tags appear, make 3 sections, rating, characters and tags - -make the similar and duplicates section appear closed by default, make it less tall when closed - -==== TO CHECK PRETTY MUCH DONE - ----- - -gallery-dl section, a way to import stuff tbd - ----- - - -do cleanups of data / cache thumnails and stuff diff --git a/package.json b/package.json index 319c5b7..545aadd 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "bun run --cwd apps/server typecheck && bun run --cwd apps/web typecheck", - "_comment_test": "--isolate is required: each server test file boots its own server and sqlite singleton", "test": "bun test --isolate" }, "devDependencies": { @@ -27,9 +26,5 @@ "prettier": "^3.9.8", "typescript": "^5.9.3", "typescript-eslint": "^8.70.0" - }, - "dependencies": { - "better-sqlite3": "^13.0.3", - "form-data": "^4.0.6" } } diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index 9a53f05..d7b212f 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -19,7 +19,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -export interface SakuyaConfig { +interface SakuyaConfig { server: { port: number; /** Interface to bind. Loopback by default: the API serves the whole library and can open a file manager on the host. */ @@ -48,7 +48,7 @@ export interface SakuyaConfig { type Env = Record; -export const CONFIG_FILE_NAME = 'sakuya.config.json'; +const CONFIG_FILE_NAME = 'sakuya.config.json'; /** packages/shared/src -> repo root. Also right inside the Docker image, which mirrors the layout under /app. */ export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); @@ -67,29 +67,16 @@ export class ConfigError extends Error { } } -const MEMORY_UNITS: Record = { - b: 1, - k: 1024, - kb: 1024, - m: 1024 ** 2, - mb: 1024 ** 2, - g: 1024 ** 3, - gb: 1024 ** 3, - t: 1024 ** 4, - tb: 1024 ** 4, -}; - /** Accepts what Docker's mem_limit accepts — "2g", "512m", "1.5G" — plus a bare byte count. */ export function parseMemory(raw: string | number | null | undefined): number | null { if (typeof raw === 'number') return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null; if (!raw) return null; - const match = /^\s*(\d+(?:\.\d+)?)\s*([a-z]*)\s*$/i.exec(raw); + const match = /^\s*(\d+(?:\.\d+)?)\s*([kmgt]?)b?\s*$/i.exec(raw); if (!match) return null; const value = Number(match[1]); const unit = match[2].toLowerCase(); - const multiplier = unit === '' ? 1 : MEMORY_UNITS[unit]; - if (!multiplier || !Number.isFinite(value) || value <= 0) return null; - return Math.floor(value * multiplier); + if (!Number.isFinite(value) || value <= 0) return null; + return Math.floor(value * 1024 ** (unit ? 'kmgt'.indexOf(unit) + 1 : 0)); } export function formatBytes(bytes: number): string { @@ -106,18 +93,15 @@ function isObject(value: unknown): value is Record { } const KNOWN_KEYS: Record = { - '': ['$schema', 'server', 'web', 'auth', 'limits'], - server: ['port', 'host', 'dataDir', 'https'], - web: ['port'], - auth: ['enabled', 'secret'], - limits: ['cpus', 'memory'], + '': ['$schema', ...Object.keys(DEFAULT_CONFIG)], + ...Object.fromEntries(Object.entries(DEFAULT_CONFIG).map(([section, values]) => [section, Object.keys(values)])), }; /** * Pure: validate parsed JSON into a full config, filling defaults for anything absent. Returns * warnings for unknown keys rather than failing on them — most likely a typo, worth saying out loud. */ -export function parseConfig(raw: unknown, file: string, baseDir: string): { config: SakuyaConfig; warnings: string[] } { +function parseConfig(raw: unknown, file: string, baseDir: string): { config: SakuyaConfig; warnings: string[] } { if (!isObject(raw)) throw new ConfigError(file, 'must contain a JSON object'); const warnings: string[] = []; const section = (name: keyof SakuyaConfig): Record => { @@ -214,7 +198,7 @@ export function parseConfig(raw: unknown, file: string, baseDir: string): { conf * The image pins host, port and dataDir itself: the container must bind 0.0.0.0, nginx proxies to * a fixed port, and the host's dataDir path means nothing inside the container. */ -export const DOCKER_OVERRIDES = { +const DOCKER_OVERRIDES = { SAKUYA_PORT: 'server.port', SAKUYA_HOST: 'server.host', SAKUYA_DATA_DIR: 'server.dataDir', @@ -327,13 +311,13 @@ export interface LoadedConfig { warnings: string[]; } -export interface LoadOptions { +interface LoadOptions { env?: Env; /** Write a default file (migrating any old .env) when none exists. The server side does; Vite only reads. */ create?: boolean; } -export function configFilePath(env: Env = process.env): string { +function configFilePath(env: Env = process.env): string { // SAKUYA_CONFIG picks a different *file*; it overrides no values. The test suite points it at // throwaway files so tests never read or create the real one. return env.SAKUYA_CONFIG ? path.resolve(env.SAKUYA_CONFIG) : path.join(REPO_ROOT, CONFIG_FILE_NAME); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 390284d..eabc260 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,23 +1,32 @@ -type LibraryType = 'image' | 'video' | 'mixed'; -type MediaType = 'image' | 'video'; +// Runtime arrays, so the server's drizzle columns and zod schemas validate against the same lists. +export const LIBRARY_TYPES = ['image', 'video', 'mixed'] as const; +export const MEDIA_TYPES = ['image', 'video'] as const; +export const JOB_TYPES = [ + 'scan', + 'tag', + 'thumbnail', + 'model-download', + 'hash', + 'cleanup', + 'downloader-install', + 'reclassify-gifs', + 'transcode', +] as const; +export const TAG_CATEGORIES = ['rating', 'general', 'character', 'user'] as const; +export const SCHEDULE_MODES = ['off', 'interval', 'after-scan'] as const; +export const SCHEDULE_JOB_TYPES = ['scan', 'tag', 'hash', 'cleanup'] as const; + +type LibraryType = (typeof LIBRARY_TYPES)[number]; +type MediaType = (typeof MEDIA_TYPES)[number]; type MediaSource = 'folder' | 'upload'; type FolderStatus = 'pending' | 'scanning' | 'indexed' | 'error'; -export type JobType = - | 'scan' - | 'tag' - | 'thumbnail' - | 'model-download' - | 'hash' - | 'cleanup' - | 'downloader-install' - | 'reclassify-gifs' - | 'transcode'; +export type JobType = (typeof JOB_TYPES)[number]; type JobStatus = 'queued' | 'running' | 'done' | 'error'; -export type TagCategory = 'rating' | 'general' | 'character' | 'user'; +export type TagCategory = (typeof TAG_CATEGORIES)[number]; type TagSource = 'ai' | 'user'; type ModelStatus = 'absent' | 'downloading' | 'ready' | 'error'; -export type ScheduleMode = 'off' | 'interval' | 'after-scan'; -export type ScheduleJobType = 'scan' | 'tag' | 'hash' | 'cleanup'; +export type ScheduleMode = (typeof SCHEDULE_MODES)[number]; +export type ScheduleJobType = (typeof SCHEDULE_JOB_TYPES)[number]; export interface Library { id: number; @@ -27,7 +36,6 @@ export interface Library { customImagePath: string | null; createdAt: number; lastVisitedAt: number | null; - autoScanInterval: number; sortOrder: number; } @@ -156,7 +164,6 @@ export interface Settings { ai_tagging_enabled: string; confidence_threshold: string; accent_color: string; - model_status: ModelStatus; remember_mute_state: string; remember_volume_level: string; continue_where_left: string; diff --git a/run.bat b/run.bat index a0b06f2..b8cfe2a 100644 --- a/run.bat +++ b/run.bat @@ -1,10 +1,4 @@ @echo off -setlocal enabledelayedexpansion - -echo ======================================== -echo Sakuya - Dev Server -echo ======================================== -echo. where bun >nul 2>&1 if %errorlevel% neq 0 ( @@ -13,20 +7,14 @@ if %errorlevel% neq 0 ( exit /b 1 ) -if not exist "node_modules\" ( - echo Dependencies not found. Running bun install... - bun install - if %errorlevel% neq 0 ( - echo Failed to install dependencies. - pause - exit /b 1 - ) +bun install +if %errorlevel% neq 0 ( + echo Failed to install dependencies. + pause + exit /b 1 ) rem Ports, bind address, login and resource limits all come from sakuya.config.json (created with rem the defaults on first run). The server and web URLs are printed below once each is up. -echo Starting development server... -echo. - bun dev pause diff --git a/run.sh b/run.sh index 005d48d..0140cf7 100755 --- a/run.sh +++ b/run.sh @@ -1,24 +1,13 @@ #!/usr/bin/env bash set -e -echo "========================================" -echo " Sakuya - Dev Server" -echo "========================================" -echo "" - if ! command -v bun &>/dev/null; then echo "Bun is not installed. Run ./setup.sh first." exit 1 fi -if [ ! -d "node_modules" ]; then - echo "Dependencies not found. Running bun install..." - bun install -fi +bun install # Ports, bind address, login and resource limits all come from sakuya.config.json (created with the # defaults on first run). The server and web URLs are printed below once each is up. -echo "Starting development server..." -echo "" - bun dev diff --git a/setup.bat b/setup.bat index 9496468..587ca36 100644 --- a/setup.bat +++ b/setup.bat @@ -1,11 +1,6 @@ @echo off setlocal enabledelayedexpansion -echo ======================================== -echo Sakuya - Setup -echo ======================================== -echo. - where bun >nul 2>&1 if %errorlevel% neq 0 ( echo Bun is not installed. Installing Bun... diff --git a/setup.sh b/setup.sh index 50e36aa..16e0bad 100755 --- a/setup.sh +++ b/setup.sh @@ -1,19 +1,12 @@ #!/usr/bin/env bash set -e -echo "========================================" -echo " Sakuya - Setup" -echo "========================================" -echo "" - if ! command -v bun &>/dev/null; then echo "Bun is not installed. Installing Bun..." - curl -fsSL https://bun.sh/install | bash - - if [ $? -ne 0 ]; then + curl -fsSL https://bun.sh/install | bash || { echo "Failed to install Bun. Please install it manually from https://bun.sh" exit 1 - fi + } export BUN_INSTALL="$HOME/.bun" export PATH="$BUN_INSTALL/bin:$PATH" diff --git a/update.bat b/update.bat index 429b5ff..d7fef41 100644 --- a/update.bat +++ b/update.bat @@ -1,10 +1,4 @@ @echo off -setlocal enabledelayedexpansion - -echo ======================================== -echo Sakuya - Update -echo ======================================== -echo. where bun >nul 2>&1 if %errorlevel% neq 0 ( diff --git a/update.sh b/update.sh index b13d2f3..be5996d 100755 --- a/update.sh +++ b/update.sh @@ -1,11 +1,6 @@ #!/usr/bin/env bash set -e -echo "========================================" -echo " Sakuya - Update" -echo "========================================" -echo "" - if ! command -v bun &>/dev/null; then echo "Bun is not installed. Run ./setup.sh first." exit 1