The server is an Express app (apps/server/src/index.ts). All endpoints are mounted under
/api on a single port (default 3777, configurable via the PORT env var — see
apps/server/src/lib/config.ts). There is no authentication/authorization layer; the API is
meant for local/trusted-network use only.
Request bodies are validated with Zod; invalid input returns 400 with
{ error, issues }. Unhandled errors return 500 with { error }. All routes return JSON
unless noted otherwise (file streams, cover images, SSE streams).
Shared response/request types live in packages/shared/src/index.ts and are referenced below
by name (e.g. Media, Job).
- IDs in path params are validated as non-negative integers (
intParam); a non-integer returns400 { error: "Invalid id" }. - Not found resources return
404 { error: "Not found" }(or a resource-specific message). - Pagination (media list) uses an opaque base64url cursor, not offsets.
- Server-Sent Events endpoints (
/streamsuffix) push JSON-encoded events overtext/event-stream, with a: pingheartbeat every 25s and asnapshotevent on connect.
Liveness check. Response: { ok: true }.
Aggregated home-screen data.
Response (DashboardResponse):
{
libraries: LibraryWithStats[];
continueWatching: Media[]; // view_progress between 0.01 and 0.98, most recent first (max 12)
recentlyViewed: Media[]; // last_viewed_at not null, most recent first (max 12)
recentlyAdded: Media[]; // most recently created (max 12)
likedCount: number;
likedSampleId: number | null;
}List all libraries in display order (sortOrder, then id). Response: LibraryWithStats[].
Get one library; also updates its lastVisitedAt timestamp. Response: LibraryWithStats.
Create a library.
Body: { name: string (1-120 chars), type?: 'image'|'video'|'mixed' (default 'mixed'), autoScanInterval?: number (default 0) }
Response: 201 + LibraryWithStats.
Partially update a library (this is how a rename is done). Body: any subset of the create body plus
thumbnailMediaId?: number | null.
Response: LibraryWithStats.
Set the display order. Body: { ids: number[] } — every library id, in the wanted order.
Response: LibraryWithStats[] in the new order.
Delete a library and all its media, tags, thumbnails, and folders (files on disk are removed for
uploaded media). Response: { ok: true }.
Enqueue a scan job for the library's folders. Response: { job: Job }.
Serve the library's custom cover image file, if one is set. 404 if none/missing.
Upload a custom cover image. multipart/form-data with a file field. Replaces any previous
custom cover. Response: LibraryWithStats.
Remove the custom cover (falls back to auto/media cover). Response: LibraryWithStats.
Attach a filesystem folder to a library (must exist and be a directory; duplicates rejected with
409). Body: { path: string }. Response: 201 + Folder.
Detach a folder and delete all indexed media under it (and their tags/thumbnails). Response:
{ ok: true }.
Paginated, filterable media listing.
Query params:
| param | type | notes |
|---|---|---|
libraryId |
number | filter by library |
type |
'image'|'video' |
filter by type |
tags |
comma-separated string | AND match on tag names |
liked |
number | 1 filters to liked only |
q |
string | matches filename or tag name (substring) |
sort |
'recent'|'name'|'random' |
default recent |
dir |
'asc'|'desc' |
default depends on sort |
seed |
number | seed for stable random ordering |
cursor |
string | opaque pagination cursor from a previous response |
limit |
number | 1-200, default 60 |
Response (MediaListResponse): { items: Media[], nextCursor: string | null, total: number }.
Every media id matching a filter, in the same order GET /api/media would return them. Backs
"select all N matching" in multi-select mode, where the grid has only loaded the first few pages.
Accepts the same filter/sort params as GET /api/media (no cursor/limit).
Response (MediaIdsResponse): { ids: number[], total: number }.
Declared before
/api/media/:id— route order matters, oridsis parsed as an id and400s.
Rows for an explicit id list, in the order requested; unknown ids are dropped. Used by bulk
confirmation dialogs, whose selection can span pages the client never loaded.
Body: { ids: number[] } (1-5000). Response: Media[].
Tag histogram across a selection — drives the bulk "remove tags" picker.
Body: { ids: number[] } (1-5000). Response: TagCount[], most used first.
Groups of media sharing an identical content_hash, sorted by wasted bytes descending.
Response (DuplicatesResponse): { groups: DuplicateGroup[], groupCount, fileCount, wastedBytes }.
Bulk delete. Body: { ids: number[] }. Deletes DB rows, tag links, and files/thumbnails on disk
for existing ids (missing ids are silently skipped). Response: { ok: true, deleted: number }.
Mutations behind the grid's multi-select mode. They report per-item outcomes rather than failing wholesale on one bad row, so a selection containing a stale id still applies to the rest.
Shared response shape (BulkResult): { ok: true, updated: number, failed: { id, error }[] }.
Add and/or remove tags across a selection. Body:
{ ids: number[], add?: string[], remove?: string[], category?: TagCategory }.
Added names are lowercased with whitespace collapsed to _; category (default user) applies
to newly created tags. Removing a tag a file doesn't have is a no-op, not a failure. Usage counts
are refreshed once at the end. 400 if both add and remove are empty.
Body: { ids: number[], liked: boolean }. Sets liked/liked_at across the selection.
Body: { items: { id: number, filename: string }[] }.
Validates everything before touching the disk, then moves files and updates rows for the moves
that succeeded. A row is refused (and reported in failed) when its name is empty/invalid, its
source file is missing, two items in the batch target the same path, or the target already exists
— which includes swaps (a→b while b→a). Names are reduced with path.basename, so a file can
never be renamed out of its own folder. An unchanged name is a skipped no-op, not a failure.
Body: { ids: number[] }. Enqueues one AI tagging job for the whole selection. 409 if the
tagger model isn't downloaded, 404 if no id exists. Response: { job: Job }.
Body: { ids: number[] }. Enqueues one thumbnail regeneration job for the selection.
Response: { job: Job }.
Remove several media from a board at once. Body: { mediaIds: number[] }. Membership-only: the
media rows and files on disk are untouched. A POST rather than a DELETE because DELETE-with-a-body
isn't reliably supported across clients. Response: BoardWithStats.
Media detail including tags. Response: MediaDetail.
Streams the raw media file (supports HTTP Range requests, ETag, conditional GET).
Rename the file on disk. Body: { filename: string }. 409 if a file with that name already
exists in the same directory. Response: MediaDetail.
Delete one media item (DB row, tags, file, thumbnail). Response: { ok: true }.
Open the OS file manager to the file's location (explorer.exe /select on Windows, open -R on
macOS, xdg-open <dir> on Linux). Response: { ok: true }.
Serve (generating on first request) the media's thumbnail image. If the thumbnail cache is disabled and the media is an image, serves the original file instead.
Force-regenerate the thumbnail. Response: { ok: true }.
Add/remove tags and optionally change tag categories.
Body:
{
add?: string[]; // tag names to add (default [])
remove?: string[]; // tag names to remove (default [])
category?: 'rating'|'general'|'character'|'user'; // category for newly created `add` tags, default 'user'
setCategory?: Record<string, 'rating'|'general'|'character'|'user'>; // rename category of existing tags
}Response: MediaDetail.
Body: { liked: boolean }. Response: MediaDetail.
Finds exact duplicates (by content hash) and visually similar images (perceptual-hash Hamming
distance ≤ 10, images only). Response (SimilarResponse): { duplicates: Media[], similar: Media[] }.
Enqueue an AI re-tag job for a single item. 409 if the tagger model isn't downloaded. Response:
{ job: Job }.
Update playback/view progress. Body: { progress: number (0-1) }. Response: { ok: true }.
Tag usage counts, optionally scoped to a library.
Query params: q? (literal substring filter; _ and % match themselves), libraryId? (number), category? (comma-separated list),
limit? (1-100, default 30; ignored when filtering to only the rating category).
Response: TagCount[].
List all jobs (queued/running/done/error). Response: Job[].
SSE stream of job updates. Events: { type: 'snapshot', jobs: Job[] } on connect, then
{ type: 'job', job: Job } per update.
Manually trigger scheduled job types immediately.
Body: { scope: 'global' | { libraryId: number }, jobType?: 'scan'|'tag'|'hash'|'cleanup' }
Response: { ok: true }.
Response (JobSchedulesPayload):
{
globals: Record<string, JobSchedule>; // keyed by job type
perLibrary: Record<number, Record<string, JobSchedule>>; // keyed by library id, then job type
}Upsert a schedule (global if libraryId omitted/null, per-library otherwise).
Body:
{
jobType: 'scan'|'tag'|'hash'|'cleanup';
libraryId?: number | null;
mode?: 'off'|'interval'|'after-scan';
intervalMinutes?: number;
useGlobal?: boolean; // per-library only: fall back to the global schedule
}Response: { ok: true }.
All settings key/value pairs. Response: Settings.
Update one or more settings. Body: Record<string, string> — keys must be one of the editable
keys (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); an unknown key returns 400 and nothing in the request is saved. Toggling
gifs_as_videos enqueues a GIF reclassification job. Response: Settings.
Response (SystemInfo): { version, mediaCount, mediaBytes, dbBytes, thumbBytes }.
Response (StorageInfo): { current, home, local, usingHome, locked } — where the data dir resolved
to this boot, the two candidates, and whether server.dataDir in sakuya.config.json pins it.
Body: { target: 'home' | 'local' }. Closes the DB, copies the whole data dir to the target, deletes
the original, responds { movedTo }, then exits the process — the new location is only picked up on
restart. 400 if server.dataDir is set, the target is already in use, or a database already exists
there.
Delete all cached thumbnails. Response: { removed: number }.
Enqueue a job to reclassify existing GIFs as images/videos per the current gifs_as_videos
setting. Response: Job.
Enqueue a bulk thumbnail regeneration job. Response: { ok: true }.
Run library cleanup synchronously (removes orphaned DB rows/files). Response: cleanup result
object (see services/cleanup.ts).
Response: TaggerStatus — { status: 'absent'|'downloading'|'ready'|'error', model, modelSizeBytes, tagCount, untaggedCount, unhashedCount }.
Available tagger models. Response: TaggerModel[] ({ id, label, repo }).
Select the active model. Body: { modelId: string }. Response: TaggerStatus.
Enqueue perceptual-hashing for all unhashed images. 409 if none need hashing. Response:
{ job: Job }.
Enqueue a model download. 409 if already downloading or already ready. Response: { job: Job }.
Enqueue AI tagging for all untagged media. 409 if the model isn't ready or there's nothing to
tag. Response: { job: Job }.
Upload one or more files directly into a library. multipart/form-data with fields libraryId
(number) and one or more files. Unsupported extensions are rejected (not an error for the
whole request). If AI tagging is enabled and the model is ready, an AI tag job is enqueued for
the newly indexed files.
Response: 201 + { mediaIds: number[], rejected: string[] }.
Detects whether gallery-dl is installed. Response: DownloaderStatus.
Enqueue installation of gallery-dl. 409 if already installed. Response: { job: Job }.
Resolve which library (if any) owns a given filesystem path. Query: path (string). Response:
{ library: LibraryWithStats | null }.
List uploaded cookie files. Response: DownloadCookie[].
Upload one or more cookie files. multipart/form-data, field files. Response: 201 +
DownloadCookie[].
Delete a cookie file (DB row + file on disk). Response: { ok: true }.
Create a download batch (a set of URLs downloaded into a folder attached to a library).
Body:
{
libraryId: number;
folderPath: string;
urls: string[]; // min 1
extraArgs?: string; // extra gallery-dl CLI args
cookieFileId?: number | null;
}404 if the library doesn't exist; 409 if the folder already belongs to a different library;
400 if cookieFileId doesn't resolve. The folder is created on disk and attached to the
library. Response: 201 + DownloadBatchWithItems.
List all batches with their items. Response: DownloadBatchWithItems[].
Log lines for one download item. Query: after? (number, log id cursor). Response:
DownloadLogLine[].
Response: { ok: true }.
Response: { ok: true }.
Response: { ok: true }.
Retry a failed/skipped item. Response: { ok: true }.
Remove an item. Body: { deleteFiles?: boolean (default false) }. Response: { ok: true }.
A single interactive shell session used for running arbitrary downloader commands.
Response: ConsoleSessionStatus.
Body: { command: string }. 409 if a session is already running. Response:
ConsoleSessionStatus.
Write to the running session's stdin. Body: { text: string }. 409 if none running. Response:
{ ok: true }.
Response: { ok: true }.
SSE stream of console output/status. Events: { type: 'snapshot', buffer, status } on connect,
then { type: 'data', chunk } and { type: 'status', status }.
SSE stream of downloader state. Events: { type: 'snapshot', batches: DownloadBatchWithItems[] }
on connect, then { type: 'batch', batch }, { type: 'item', item }, { type: 'log', log },
{ type: 'removed', id, batchId }.