👀🐶 Video Server
  • Rust 95.2%
  • JavaScript 3%
  • Nix 1.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-27 21:43:25 +02:00
docs a file whose name said what it was, and nothing read it 2026-08-25 03:32:05 +02:00
migrations a query scored against the whole title, and a picture was not quite a media item 2026-08-25 19:21:18 +02:00
src ignore AppleDouble files during scans 2026-08-27 21:43:25 +02:00
.gitignore replace Dockerfile/woodpecker with a nix flake, drop the based dep 2026-08-12 22:37:00 +02:00
Cargo.lock a background transcode could take the whole machine with it 2026-08-18 02:45:44 +02:00
Cargo.toml a background transcode could take the whole machine with it 2026-08-18 02:45:44 +02:00
config.toml docs caught up with the code 2026-08-15 20:13:42 +02:00
devenv.lock refactor media model 2026-02-25 15:28:03 +01:00
devenv.nix refactor media model 2026-02-25 15:28:03 +01:00
docker-compose.yml replace Dockerfile/woodpecker with a nix flake, drop the based dep 2026-08-12 22:37:00 +02:00
flake.lock replace Dockerfile/woodpecker with a nix flake, drop the based dep 2026-08-12 22:37:00 +02:00
flake.nix the image shipped a model, and mounting a config threw it away 2026-08-25 03:39:59 +02:00
README.md a query scored against the whole title, and a picture was not quite a media item 2026-08-25 19:21:18 +02:00
renovate.json Add renovate.json 2025-06-21 21:49:07 +00:00

WatchDogs

A self-hosted media server for organizing and serving local videos, images, and galleries. Exposes a JSON API designed to power custom frontends — a YouTube-style video browser, a TikTok-style image feed, a gallery viewer, and more.

Resource Kinds

WatchDogs manages five kinds of resource, all derived from the directory tree —
nothing is imported or configured per file:

Kind Comes from Notes
Video .mp4, .mkv, .webm (case-insensitive) Metadata, thumbnail, range-based streaming
Image .jpg, .jpeg, .png, .gif, .webp, .avif Sidecar thumbnails are excluded
Gallery A directory holding images and no videos Ordered by filename
Channel A directory holding a creator.json, else the uploader named in a video's .info.json The directory applies to everything beneath it, and outranks the metadata
Playlist Manual curation, or a saved query Create, rename, reorder, delete; a smart playlist fills itself

Two of those directories get a layout of their own rather than a grid of cards:
a directory of numbered files is shown as a series, and vertical
clips under a minute are shorts, which live in /feed.

Features

  • yt-dlp metadata — Reads .info.json sidecars (title, description, tags, categories, upload date, language, age limit, source URL), falling back to metadata embedded in MKV attachments
  • Tags in the filenameTalk [rust, web dev].mp4 files itself under both tags, and the group is kept out of the title. Rename the file to retag it; yt-dlp's own [video-id] suffix is left alone
  • Channels without ceremony — A creator.json names a channel explicitly, but where there is none the uploader recorded in each .info.json is used instead, so a plain yt-dlp archive browses by creator with nothing to set up. Keyed on the platform-side channel id, so a rename does not fork one creator into two
  • Full-text search — PostgreSQL-backed search using websearch syntax across titles, categories, descriptions, tags, channel names and transcripts, stemmed in the language the text is actually in. Fuzzy matching is by trigram word similarity, so a partial word finds the whole one (postg → Postgres), a typo still lands, and a one-word query matches a long title
  • Transcripts & subtitles.srt / .vtt sidecars are indexed, served to the player as WebVTT, shown as a clickable transcript, and searched — so a hit deep-links to the moment the words were said
  • Thumbnails — Generated with FFmpeg and cached on disk; sidecar images next to a video (.jpg, .jpeg, .png, .avif) take precedence
  • Range streamingRange requests are served as 206 Partial Content, so seeking works without buffering the whole file
  • It just plays — every file's codecs are probed at index time; anything a browser cannot decode is repackaged or re-encoded on the job queue and cached, and served from the same URL
  • Move detection — Files that move within the library are re-linked by content fingerprint instead of being re-indexed
  • Recommendations — Related items of the same kind, scored by shared channel, tags and directory
  • Watch history & progress — Resume where you left off, a progress strip on every card, and a "Continue watching" row
  • Playlists — Create, rename, delete, reorder (drag or arrow buttons), add, remove, and "play all" with autoplay through the queue; smart playlists save a query instead of a list and fill themselves
  • Series — A directory of numbered files — a tutorial run, a let's play, an episodic show — is shown as an ordered episode list with per-episode progress and a Continue button, instead of a grid; a library.json beside them overrides the guess either way
  • Image metadata — Dimensions and an average colour per image, so galleries lay out as masonry at each picture's own shape with a colour placeholder while it loads, instead of cropping everything square
  • One page for every item — A picture opens on the same page as a video, with the same favourite, playlist, share link, tags, description, history and related items. Click to zoom, drag to pan, 0 to reset; arrow keys step through the gallery it belongs to, which is worked out from the picture rather than from the link that reached it
  • Scrubbing previews — Hover the seek bar for a thumbnail of that moment, from one cached sprite sheet built in a single ffmpeg pass
  • Favorites — Per-user, one button; hearts show on every card and /favorites lists them
  • Stats — Library counts, disk usage, total runtime, transcript coverage as a share of videos, additions per month, top channels and tags, and what you personally have watched
  • Duplicate detection — Groups files sharing a content fingerprint, with reclaimable space (admin-only; a report, never a delete)
  • Speech-to-text — whisper.cpp on the job queue, writing transcripts indistinguishable from .srt/.vtt sidecars; the container ships a multilingual model and detects language per video
  • Podcast feeds — The library, a channel, a playlist or a directory as RSS with proper enclosures, authenticated by a feed token in the URL; &audio=true serves an audio-only copy, built on first request and cached
  • Share links — Hand one item to someone with no account: a /s/<token> link that can expire, cap its views, be revoked, and carry start/end offsets so a share is a clip
  • Multi-user — Session-based authentication with Admin and Regular roles, bcrypt password hashing, CSRF protection, expiring sessions and logout; an admin UI for creating accounts, changing roles and resetting passwords, plus per-user API tokens
  • Private mode — Optionally require login to access all content
  • Open Graph Protocol — OGP metadata for video and thumbnail sharing
  • JSON API — Machine-readable endpoints for building any kind of frontend on top

Not yet wired up

A smart playlist's query cannot be edited after it is created (it can be
renamed and deleted), and its channel filter exists in the query layer with no
UI to set it. Whisper transcription only runs when an admin asks for it on a
specific video — there is no automatic pass over the library.

Configuration

Edit config.toml before starting:

[general]
private       = true                # Require login to access content
allow_ogp     = true                # Serve thumbnails publicly even if private (for OG embeds)
root_url      = "http://127.0.0.1:8080"
video_path    = "./videos"          # Path to your media directory
thumbnail_dir = "./thumbnails"      # Cached thumbnails
transcode_dir = "./transcodes"      # Cached browser-playable copies

# Optional when running from source: without this section there is no
# speech-to-text, because whisper needs a binary and a model that a plain
# `cargo build` does not provide. The container image ships both and turns the
# feature on through the environment, so there it works without this section —
# see "Speech-to-text" below.
[whisper]
enabled = true
binary  = "whisper-cli"             # older whisper.cpp builds call it "main"
model   = "/models/ggml-base.bin"
language = "de"                     # omit to detect per video (recommended)
threads  = 0                        # 0 lets whisper choose

Both cache directories hold derived, disposable files: delete either one and it
rebuilds on demand.

Environment variables (Docker):

Variable Description
DATABASE_URL PostgreSQL connection string
RUST_LOG Log level (default: info)
ROCKET_ADDRESS Bind address (default: 0.0.0.0)
WHISPER_ENABLED Default for [whisper] enabled1, true, yes or on. Set by the image
WHISPER_MODEL Default for [whisper] model. Set by the image to the model it ships

The two WHISPER_* variables are defaults, not overrides: a [whisper] block
in config.toml beats either one. That is backwards from the usual precedence
on purpose — see Speech-to-text.

Building

The build is a Nix flake — there is no Dockerfile.

nix build                    # the watchdogs binary
nix build .#containerImage   # an OCI image as a docker-archive
nix develop                  # dev shell with cargo, ffmpeg, postgres

The container image bundles ffmpeg, mkvtoolnix and whisper-cpp plus a
speech-to-text model under /models, and ships a default /config.toml
pointing at the /videos and /thumbnails volumes, with speech-to-text
switched on from the environment so that mounting your own config does not
turn it off. nix develop deliberately
leaves whisper out — it does not build on darwin. Load a local build with:

docker load < $(nix build --no-link --print-out-paths .#containerImage)

CI is defined in the same flake as moira
threads under moiraPipelines: ci (fmt, clippy, test), container (push
:latest on main) and container-release (push :<tag> on v* tags).
Pushing requires the REGISTRY_USER / REGISTRY_PASS org secrets.

API Routes

All /api/* routes require a session token in a token header (the APIUser
guard in src/auth/). Issue one from the account page; API tokens do not
expire and are revoked from the same place.

Videos

Route Description
GET /api/videos?limit=N&offset=N&sort=S&min_duration=N&max_duration=N&channel=N&tag=T&directory=D Paginated list of videos. sort is newest (default), oldest, random or title; durations are in seconds. Shorts are excluded — ask /feed for those
GET /api/videos/<id> Single video with full metadata

limit is clamped to a server maximum, and offset floors at zero, so a
hostile page size cannot ask for the whole library in one request.

Images

Route Description
GET /api/images?limit=N&offset=N Paginated list of images, newest first
GET /api/images/<id> Single image metadata

Galleries

Route Description
GET /api/galleries?limit=N&offset=N Paginated list of galleries
GET /api/galleries/<id>?limit=N&offset=N Gallery with a page of its images and a total count

Tags & Channels (yt-dlp)

Route Description
GET /api/tags All distinct tags
GET /api/tags/<tag>?limit=N&offset=N Media with a specific tag
GET /api/channels All distinct channels with metadata
GET /api/channels/<id>?limit=N&offset=N Media from a channel

Playlists

Route Description
GET /api/playlists All playlists
GET /api/playlists/<id>?limit=N&offset=N Playlist with a page of its items

Search & Generic

Route Description
GET /api/search?query=X&limit=N&offset=N Full-text + trigram search across all media
GET /api/media/<id> Any media by ID (video or image)

Jobs

Route Description
POST /api/rescan Queue a library rescan; returns the job's id and state. Admin only — a non-admin token gets 403
POST /api/reindex Same, but re-reads the metadata of files a rescan would skip as unchanged. Admin only
POST /api/transcribe-all Queue a transcription for every video that has none; returns queued. Skips videos that already have a transcript or one in flight. Admin only

Web UI

Route Description
GET / Home — random videos, latest, galleries, directories
GET /latest?sort=newest&min_duration=N&max_duration=N&min_minutes=N&max_minutes=N&offset=N Filtered, sortable video browser (infinite scroll). Minutes in the form, seconds in the query — both are accepted
GET /latest.json JSON: 24 most recent videos
GET /search?query=X&offset=N Search results
GET /d/<directory>?sort=newest&min_duration=N&max_duration=N&min_minutes=N&max_minutes=N&offset=N Filtered, sortable videos in a directory
GET /d/<directory>.json JSON: directory videos
GET /galleries?offset=N Gallery grid (infinite scroll)
GET /gallery/<id>?offset=N Gallery image grid (infinite scroll)
GET /image?v=<id> Redirects (308) to /watch?v=<id> — images have the same page as everything else
GET /watch?v=<id>&playlist=<id>&t=<seconds> Any media item: a player for a video, a viewer for an image. With playlist, the sidebar becomes the queue and autoplays through it; without one, an episode finds its series and a picture its gallery. An explicit t outranks stored resume progress
GET /channels Every channel in the library
GET /series Every directory that reads as a series, least-finished first
GET /channel/<id>?offset=N Channel page with follow button
GET /tags Every tag in use, most-applied first
GET /tag/<tag>?offset=N Media carrying a tag
GET /category/<category>?offset=N Media in a yt-dlp category
GET /feed?kind=video|image Full-screen vertical shorts feed
GET /playlists · GET /playlist/<id> · GET /playlist/<id>/play Playlist management and continuous playback
POST /playlists · POST /playlists/smart Create a playlist, or one backed by a saved query
POST /playlist/<id>/edit · POST /playlist/<id>/delete Rename / re-describe, or delete
POST /playlist/<id>/reorder · POST /playlist/<id>/items/<media_id> Whole-list reorder (drag), or add / remove / move one item
GET /s/<token> Public — a shared item, no account needed
GET /s/<token>/media · GET /s/<token>/thumbnail Public — the shared bytes and its preview image
POST /watch/<id>/share · POST /shares/<token>/revoke Create and revoke share links
GET /feed.xml?token=<t>&audio=true RSS for the whole library
GET /channel/<id>/feed.xml · GET /playlist/<id>/feed.xml · GET /d/<dir>/feed.xml RSS scoped to one collection (same token / audio parameters)
GET /feed/media/<id>?token=<t> · GET /feed/audio/<id>.m4a?token=<t> Feed enclosures — the file, or its audio-only copy
POST /account/feed-token Issue, regenerate or turn off the feed token
GET /subscribe?to=<feed path>&audio=true Resolves your feed token and redirects to the tokenized URL
GET /favorites?offset=N Your favorites
POST /watch/<id>/favorite Add or remove a favorite
GET /stats Library and viewing statistics
GET /admin/duplicates Files sharing a content fingerprint (admin only)
POST /watch/<id>/transcribe Queue whisper for one video (admin only)
GET /video/sprite?v=<id> Scrubbing sprite sheet, built on first request
GET /video/raw?v=<id> Stream video file
GET /video/thumbnail?v=<id> Thumbnail for any media (video or image)
GET /image/raw?v=<id> Full-size raw image file
GET /history User watch history
GET /account Account page
GET /login Login form
POST /login Submit login
GET /passwd Change password form
POST /passwd Submit password change
POST /logout End the session (CSRF-protected)
GET /admin/jobs Background job queue (admin only)
POST /admin/jobs/rescan Queue a library rescan (admin only)
POST /admin/jobs/reindex Queue a rescan that re-reads metadata for unchanged files too (admin only)
POST /admin/jobs/transcribe-all Queue a transcription for every video without one (admin only)
POST /watch/<id>/convert Queue a browser-playable copy of a video
GET /video/subtitles?v=<id>&lang=<lang> Transcript as WebVTT, for the player's <track>
GET /admin/users User management (admin only)
POST /admin/users Create an account (admin only)
POST /admin/users/<username>/role · POST /admin/users/<username>/password · POST /admin/users/<username>/delete Change a role, reset a password, delete an account (admin only)
POST /admin/jobs/<id>/<action> retry or cancel one job (admin only)
POST /account/tokens Issue an API token, shown once
POST /account/sessions/<id>/revoke Revoke a session or token
POST /watch/<id>/progress · POST /watch/<id>/watched Store playback position, or mark watched
POST /watch/<id>/tags Edit an item's tags
POST /watch/<id>/playlist Add or remove the item from a playlist
POST /channel/<id>/follow Follow or unfollow a channel

Every POST above is CSRF-protected: the form carries a token that the handler
verifies before doing anything, and a failed check redirects rather than acts.

Every content route is gated by the ContentAccess request guard rather than a
check inside the handler, so a new route cannot forget it. On a private
instance an anonymous request is rejected with 401, which renders a login
page (or a JSON error under /api). Two routes are deliberately different:
/watch still serves Open Graph tags to anonymous callers so link previews
work, and /video/thumbnail serves a downscaled thumbnail — both only when
allow_ogp is on.

The /s/<token> routes are the third exception, and the only ones that serve
real content without an account. They carry no ContentAccess guard because
the token is the credential: each handler resolves it first and serves
nothing it did not authorize. A link is scoped to one media item, so holding
one grants nothing else; it can carry an expiry, a view cap, or both; its
thumbnail is always the generated derivative rather than the original file; and
its creator can revoke it from the account page. Prefer these to allow_ogp,
which is instance-wide and cannot be taken back.

Share links are counted at page load, not per byte-range request, and the check
and the increment happen in one statement — two people opening a one-view link
simultaneously cannot both be served.

Feed routes are the fourth exception, and take ?token= in the URL because that
is the only credential a podcast client can carry. That token is deliberately
not the API token: a feed URL ends up in podcast apps, sync services and
sometimes a group chat, and leaking one must not hand over /api/*. It is
accepted on feed routes only. There is one per user, so regenerating it
invalidates every feed URL that user has handed out — the only revocation that
means anything once the URLs are scattered across clients. Item guids are
media ids rather than URLs, so regenerating does not make every client
re-download the back catalogue.

Browser sessions expire after 30 days; API sessions do not expire and are
revoked by deleting the row.

Transcripts

Any .srt or .vtt sitting next to a video is indexed at scan time — matched
by filename stem, with the language read out of the name (talk.en.srt,
talk.de.vtt, or plain talk.srt for an untagged one). A subtitle whose stem
belongs to a different video is not adopted, so talk 2.srt stays with
talk 2.mp4.

That single table gets you four things:

  • Subtitles. Served as WebVTT at /video/subtitles?v=<id>&lang=<lang> and
    attached to the player as <track> elements, whatever format the sidecar
    was. The default track is the media's own language when .info.json recorded
    one, then any tagged language, then an untagged sidecar last.
  • A clickable transcript under the video, with a filter box. Every line is
    a seek target.
  • Search inside videos. transcripts.search_tsv is unioned into the search
    query, so a phrase spoken forty minutes into a talk finds the talk.
  • Deep links. A transcript hit carries the timestamp of the matching cue,
    so the result links to /watch?v=<id>&t=<seconds> and the player starts
    there, quoting that same cue. An explicit ?t= outranks stored resume
    progress.

Sidecars are not the only producer. A whisper job writes the same rows with
source = 'whisper' — same table, same endpoint, same player track — so
generated transcripts are a drop-in producer rather than a separate path. See
Speech-to-text.

Series

A directory of numbered files is shown as an ordered episode list with
per-episode progress, time remaining and a Continue button pointing at the
first unfinished episode, because the only question it has to answer is where
was I
. /series lists every one of them, least-finished first.

The name is deliberately broader than it first was. This began as "courses",
but the rule that finds them keys on numbered files — which is the shape of a
let's play, a podcast run and an episodic show just as much as a tutorial
series. Naming it after the narrowest case it catches would have been a label
that argued with what was on the screen.

An episode keeps its series around it while it plays: the sidebar becomes the
episode list with the current one marked, a bar above the title says which
series and how far in, Previous and Next move between episodes, and finishing
one rolls into the next. None of that needs a parameter in the URL — the series
is derived from the video — so a search hit or a bookmark lands inside it too.

What counts as a number

Episodes order by the number in the title, and a title carries its number in
whatever position the series happens to use:

Title Number
01 - Intro, 10) Advanced, 7 Traits leading
Rust Tutorial #26 - Traits after a #
Part 3, Episode 12, Ep. 5, Folge 4, Teil 2 after a word that says so
S02E01 season first, so it follows S01E12

Deliberately narrow: a bare number loose in a title is not an index. Top 10 Mistakes is not the tenth of anything and Rust 2024 Recap is not the
two-thousand-and-twenty-fourth, so neither is read as one.

One rule, in one function (pages::series::episode_number), used both to sort
episodes and to decide what a series is — those two have to agree. When they
did not, a run numbering itself #1, #2, #10 was recognised as a series but
scored no number on any title, so the sort fell through to alphabetical and
#10 through #19 came out between #1 and #2.

What makes a directory a series

Nothing in the database. /d/<dir> decides when it renders:

  1. A library.json beside the files, mirroring creator.json
    {"series": true}, or {"series": false} for a folder of numbered clips
    that is not a series at all. The filesystem stays the source of truth, and
    because the file is read when the page renders rather than when the library
    is scanned, editing it takes effect on the next page load. course is still
    accepted as an alias for the field.
  2. Otherwise, the titles. Three files or more, most of them numbered. Two
    files are not a series however they are named, and one Part 1 of my move
    among ordinary videos does not qualify a directory.

Shorts

A short is a video that is vertical and under a minute. Vertical alone is
phone footage of a birthday party; brief alone is every trailer in the library.

Both facts live in video_meta already, so this is derived at query time
(library::IS_SHORT) rather than stored — there is no column to keep in step
and no rescan needed after a file is re-probed. Shorts are kept out of browse
listings, the API and recommendations, and are browsed by swiping through
/feed.

The player

The controls are the application's own, not the browser's. <video controls>
renders a closed shadow tree, so nothing can be attached to the real seek bar —
which is why scrub previews and chapter markers used to sit in separate strips
underneath the player. One video, four stacked control surfaces, none of them
aware of each other.

Now there is one bar over the picture. The seek rail carries the buffered range,
the chapter divisions and the hover preview; play, volume, time, subtitles,
speed, PiP, theater and fullscreen sit under it. The chrome fades out after a
few idle seconds of playback and comes back on pointer movement or focus —
never while paused, because a paused video that hides its controls reads as
broken. Fullscreen is requested on the container rather than the <video>, so
the custom chrome comes with it.

Keyboard: space/K play, / 5s, J/L 10s, ,/. frame step, /
volume, F fullscreen, M mute, C subtitles, T theater, 09 jump.

Scrubbing previews come from a sprite sheet of 100 thumbnails (10×10, 160px
wide) built on first request and cached beside the thumbnails. One ffmpeg pass
with fps + tile filters rather than 100 seeks — the difference between one
read and minutes of work on a long file. Scrubbing costs no requests at all: the
preview is a window onto that one JPEG, moved with background-position, and it
names the chapter under the pointer alongside the timestamp. A video whose sheet
has not been built yet simply has no preview.

The behaviour lives in src/pages/player.js, include_str!d into the page. It
is a file rather than a string literal so that checks.playerScript can run
node --check over it: as a literal it was invisible to every tool in the repo,
and a SyntaxError in it disables every custom behaviour while the video keeps
playing — a failure with no symptom except things quietly not working.

Speech-to-text

Off unless [whisper] is configured. When it is, an admin gets a Transcribe
button on any video without subtitles; the job extracts 16 kHz mono WAV with
ffmpeg, runs the whisper CLI asking for VTT output, and stores the result
through the same parser the .srt/.vtt sidecars use. Downstream nothing can
tell the two apart — the player track, the clickable panel and the search index
all treat a whisper transcript exactly like a sidecar. Only source = 'whisper'
and the model name differ.

Nothing queues these automatically. A transcription is minutes of CPU on a
button any account can see, so it is admin-only and deduped against a job
already queued or running for the same video.

Shelling out to a binary rather than linking a library is deliberate: the model
is far larger than this application, operators have opinions about which one to
run, and any CLI with a compatible interface works without a rebuild.

What the container image ships

The image carries whisper-cpp (whose CLI is whisper-cli, matching the
config default) and the multilingual ggml-base model at
/models/ggml-base.bin, and turns the feature on out of the box by setting
WHISPER_ENABLED=1 and WHISPER_MODEL=/models/ggml-base.bin in its
environment. base.en is the same 141 MB but English-only, which would cap the
feature for no saving, so the multilingual build is the one shipped.

Those live in the image's environment rather than in its /config.toml
because a deployment routinely mounts its own config over that file, which
replaces it rather than merging with it — so anything the image stated only
there went with it, and an image shipping a CLI and a model ran with
speech-to-text off and nothing to say why. The environment survives the mount.
Both variables are only defaults: an explicit [whisper] block still wins, so
enabled = false turns the feature off and model points it at your own
file.

Language is chosen per video, not per instance: a media item's own language
wins, then the queued job's, then [whisper] language, and if none of those
exist the job asks whisper to detect one and stores what it reports. Leaving
language unset is usually right for a mixed library — setting it forces every
video through one language.

Two whisper quirks the job works around, both of which fail silently rather than
loudly. Its -l default is English, not detection, so the job always passes
an explicit -l (auto when it has nothing better) — omitting it would give a
fluent English transcript of a German video. And an -l it does not recognise
makes it print usage and exit zero, so tags are reduced to the primary
subtag first: metadata carries en-US, whisper knows only en.

To run a different model, either flip whisperModelName in flake.nix (a
small source is already pinned there — better outside English, ~3× the size
and CPU) or mount a volume over /models and point WHISPER_MODEL — or a
[whisper] model in your own config — at what you put there. Whisper is deliberately absent from the devShell: it does not build on
darwin, and runtimeDeps is shared with nix develop.

Caveat: the plumbing has been exercised end to end against a stub binary
emitting canned VTT — the WAV extraction, the CLI contract, the parse, the
stored row and the player track. The image now ships a real binary and model,
but transcription quality itself is whisper's, and has not been benchmarked
here.

Background jobs

Everything slow runs off the jobs table: library scans, transcodes and
transcriptions. The queue is durable — a job survives a restart, and anything
left running when the process died is re-queued at boot.

Workers are split into lanes, one per kind of work, each with its own pool:

Lane Kinds Slots
scan rescan, reindex 1
transcode transcode 2
transcribe transcribe 2

A lane's workers claim only that lane's kinds, so one kind of work cannot starve
another. This is not a tuning detail: with a single shared pool, "Transcribe
all" took every slot for hours, and a rescan queued behind it — seconds of work
— never started at all. Scans hold a single slot on purpose, since two of them
walking the same directory would index every file twice.

Adding a job kind means adding it to LANES in src/jobs.rs. A kind no lane
lists is refused at enqueue rather than inserted, because nothing would ever
claim it: the row would sit queued forever, looking on /admin/jobs like work
about to start, and never record an error to say otherwise. Rows left behind by
an older build under a since-removed kind get a warning naming the kind and the
count at every boot.

Jobs that queue from a page anyone can open — transcode, transcribe — are
deduped against one already queued or running for the same media, so five
viewers opening the same unplayable film start one re-encode, not five.

Playability

media_kind says whether a row is a video or an image. Whether that video will
actually play is a separate question, and one a browser answers differently
per codec, per pixel format and per container.

At index time ffprobe records video_codec, audio_codec and pix_fmt.
From those plus the file extension, each video falls into one of four states:

State Meaning Cost
Native Container and codecs are all fine none
Remux Codecs fine, container is not — H.264 in Matroska seconds; -c copy, no frame is re-encoded
Transcode A codec or pixel format the browser cannot decode — HEVC, AC-3 audio, 10-bit H.264 minutes of CPU
Unknown Never probed; indexed before codec detection existed none — tried natively

A remux is queued automatically, since it is nearly free. A transcode waits for
someone to press the button on the watch page, which says plainly why it is
there. Either way the result is cached as {id}.mp4 and served from the
existing /video/raw?v=<id> — so the player, the shorts feed and any podcast
client keep working unchanged, and a file that needed converting simply starts
playing once the job finishes.

Note that Playability is deliberately conservative. HEVC is treated as
unplayable even though Safari handles it: guessing generously is what leaves one
video in six as a black rectangle for everyone else.

Database Schema

Table Purpose
media Videos and images in one table, keyed by kind
video_meta Duration, dimensions, chapters and probed codecs for videos
channels One row per creator.json directory, plus one per uploader derived from .info.json — the latter keyed on (source_platform, source_id) with no directory
galleries One row per image-only directory
gallery_items Gallery membership, ordered by sort_order
playlists Curated collections; a non-NULL query makes one smart — filled by a saved query instead of by hand
playlist_items Playlist membership, ordered by sort_order (empty for smart playlists)
share_links Public per-item links: token, optional expiry and view cap, optional clip bounds
feed_tokens One per user; authenticates podcast feed URLs and nothing else
favorites Per-user favorites
image_meta Image dimensions and average colour (the video_meta counterpart)
users User accounts
user_session Active sessions (browser and API)
user_profile_pic Optional profile images
watch_history Per-user watch history
watch_progress Per-user playback position and completion
channel_follows Per-user channel subscriptions
tags / tag_aliases / media_tags Normalized, case-folded tag model
jobs Durable background job queue (library rescans, media conversion)
transcripts Subtitles and transcripts, one row per media item per language
media_thumbnail Unused — thumbnails moved to the disk cache

Full-text search runs off a generated search_tsv column on media — title
at weight A, categories at B, description at C — unioned with three sources a
generated column cannot see: media_tags, channels.name and
transcripts.search_tsv. Tags are matched through the join rather than through
the media.tags JSONB, which is only the raw .info.json staging array and
holds neither the tags written into a filename nor the ones added by hand.

Fuzzy matching uses the <% (word similarity) operator, not %.
similarity(title, query) scores the query against the whole title, so it
falls away as the title lengthens — similarity('rust', 'Rustacean Station Episode 12') is 0.14 against a 0.3 threshold, which meant a one-word query
never matched a longer title and the fuzzy path was effectively dead.
word_similarity scores against the best matching extent inside the text, and
the same pair scores 0.8. It degrades gently enough to cover prefixes too — two
characters of "Postgres" already clear the 0.6 threshold — so partial words and
typos both land without a prefix tsquery. It is the same GIN index either way:
<% is %>'s commutator, so idx_media_trgm and idx_media_desc_trgm serve
it unchanged. The function form of either operator cannot use the index at all
and turns every search into a sequential scan.

Each vector is built with the text search configuration for its own language —
transcripts.lang and media.language, via text_search_config(), currently
German or English with English as the fallback. A search box has no language, so
the query is stemmed both ways and the two are OR'd; both tsqueries are
constants, so this stays two index scans and a bitmap OR. Without this, German
text was tokenised by the English stemmer, which leaves it unstemmed and keeps
German stopwords as searchable tokens — Haus did not find Häuser.

List endpoints load their side data — channel, duration, tags, watch progress —
in one query per dimension for the whole page, not per row.

Migrations are applied automatically on startup.

Use Cases

  • Personal video archive with search and browsing
  • Archiving yt-dlp downloads with preserved metadata
  • Image gallery server for a local photo or art collection
  • Internal media hosting for a small team
  • Building a custom frontend on top of the JSON API — a YouTube-style video browser, TikTok-style image feed, gallery viewer, etc.