- Rust 98.7%
- Nix 1.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
Some checks are pending
moira/ci moira/ci — succeeded
moira/flake/packages.containerImage@x86_64-linux packages.containerImage — queued
moira/flake/packages.default@x86_64-linux packages.default — queued
moira/flake/packages.watchdogs@x86_64-linux packages.watchdogs — queued
moira/flake/checks.watchdogs@x86_64-linux checks.watchdogs — queued
moira/container moira/container — succeeded
The README's schema table had prose spliced into it, so half the rows rendered as literal text; the Transcripts section still claimed whisper was unimplemented while Speech-to-text documented the working job. Route tables regenerated against the handlers: /api/videos has six filter params, POST /api/rescan was undocumented entirely, and ten POST routes were missing. docs/library.md gains the library.json and subtitle sidecars, and drops its copy of the config — it had drifted three fields and a whole section behind, which is what two copies buys you. It links to the README now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
| 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 |
Applies to everything beneath it |
| Playlist | Manual curation, or a saved query | Create, rename, reorder, delete; a smart playlist fills itself |
Orthogonal to all of that, every item also has a type — video, short, music,
course, meme or art — which decides how it is browsed and played. See
Media types.
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 - Full-text search — PostgreSQL-backed search using websearch syntax and trigram similarity for fuzzy matching, across titles, descriptions and transcripts
- 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 videos 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
- Media types — Every item is classified (video / short / music / course / meme / art) from a
library.jsonsidecar or from metadata already indexed, and each type gets the presentation it wants: courses become an ordered lesson list with a Continue button, music browses by artist as square art, shorts stay in the feed - 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
- Image viewer — Click to zoom, drag to pan,
0to reset, with dimensions and a download that keeps the file's name - 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, 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 — Optional whisper.cpp integration on the job queue, writing transcripts indistinguishable from
.srt/.vttsidecars - 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 is implemented but off by default and
unverified against a real model — see below.
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. Without this section there is no speech-to-text, which is the
# default: whisper needs a binary and a model this project does not ship.
[whisper]
enabled = true
binary = "whisper-cli" # older whisper.cpp builds call it "main"
model = "/models/ggml-base.en.bin"
language = "en" # omit to let whisper detect
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) |
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 and mkvtoolnix and ships a default
/config.toml pointing at the /videos and /thumbnails volumes. 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 |
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&media_type=T&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>&gallery=<id> |
Single image view with prev/next nav |
GET /watch?v=<id>&playlist=<id>&t=<seconds> |
Video player; with playlist, the sidebar becomes the queue and autoplays through it. An explicit t outranks stored resume progress |
GET /channel/<id>?offset=N |
Channel page with follow button |
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 /artists · GET /artist/<name>?offset=N |
Music, browsed by artist |
POST /watch/<id>/type |
Set an item's media type and lock it (admin only) |
GET /video/sprite?v=<id> |
Scrubbing sprite sheet, built on first request |
GET /latest?media_type=<type>&… |
The browse filter bar also filters by media type |
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 /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. 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.
Media types
media_kind (video vs image) is a storage distinction. media_type is what an
item is, and therefore how it should be browsed, played and resumed — a course
is not a meme with a longer runtime.
Precedence, highest first:
- Manual override. An admin sets the type on the watch page, which also
setstype_lockedso the classifier cannot undo it on the next scan. - A
library.jsonsidecar, mirroringcreator.json—{"type": "course"}
next to the files, applying to everything beneath. The filesystem stays the
source of truth and there is still no import step. - Heuristics over metadata that is already indexed:
artist/track/album
or aMusiccategory make it music; vertical and under a minute makes it a
short; an image is art unless told otherwise.
The classifier is conservative — anything it is unsure of stays video, which
is the experience that already existed. A wrong guess is worse than no guess,
because it changes how the item is presented.
| Type | What changes |
|---|---|
| Video | The default. 16:9 cards, ordinary player. |
| Short | Vertical thumbnails, kept out of browse listings and recommendations — it lives in /feed. |
| Music | Square cover art, artist/album shown instead of channel/date, browsable at /artists. |
| Course | The directory page becomes an ordered lesson list with per-lesson progress and a Continue button; lessons sort by numeric filename prefix, so 10 follows 9. |
| Meme | Square, dense. |
| Art | Never cropped — masonry at its native aspect, with a colour placeholder. |
Recommendations filter on type rather than merely weighting it: mixing them put
a three-hour course next to a twelve-second meme, and no amount of weighting
fixes that.
The migration backfills existing libraries using the same heuristics, so types
appear without waiting for a rescan.
Scrubbing previews
Hovering the strip under the player shows the frame at that position, 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 itself costs no requests: the preview is a window onto the one
JPEG, moved with background-position.
The strip sits beneath the player rather than on the seek bar because the
native <video> controls are a closed shadow tree; it accepts clicks and seeks,
so it behaves like the bar it sits under. A video whose sheet has not been built
yet simply has no preview.
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.
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.
Caveat: this has been exercised end to end against a stub binary that
emits canned VTT — covering the WAV extraction, the CLI contract, the parse, the
stored row and the player track — but not against a real whisper model,
because whisper-cpp does not currently build on the development machine. Treat
the model-facing half as untested.
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 |
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 |
media also carries media_type / type_locked and the artist / track /
album fields read from .info.json.
Full-text search runs off a generated search_tsv column on media, with
pg_trgm indexes on title and description for fuzzy matching. Fuzzy matching
uses the % operator rather than similarity(x, q) > 0.3: only the operator
form can use a GIN trigram index, and the function form turned every search
into a sequential scan.
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.