- Rust 95.2%
- JavaScript 3%
- Nix 1.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| docs | ||
| migrations | ||
| src | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| config.toml | ||
| devenv.lock | ||
| devenv.nix | ||
| docker-compose.yml | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| renovate.json | ||
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.jsonsidecars (title, description, tags, categories, upload date, language, age limit, source URL), falling back to metadata embedded in MKV attachments - Tags in the filename —
Talk [rust, web dev].mp4files 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.jsonnames a channel explicitly, but where there is none the uploader recorded in each.info.jsonis 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/.vttsidecars 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 streaming —
Rangerequests are served as206 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.jsonbeside 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,
0to 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
/favoriteslists 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/.vttsidecars; 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=trueserves 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] enabled — 1, 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.jsonrecorded
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_tsvis 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:
- A
library.jsonbeside the files, mirroringcreator.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.courseis still
accepted as an alias for the field. - Otherwise, the titles. Three files or more, most of them numbered. Two
files are not a series however they are named, and onePart 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, 0–9 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.