👀🐶 Video Server
  • Rust 98.7%
  • Nix 1.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
JMARyA 0fcddcfdec
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
docs caught up with the code
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>
2026-08-15 20:13:42 +02:00
docs docs caught up with the code 2026-08-15 20:13:42 +02:00
migrations art at its own shape, clip authoring, and a browse type filter 2026-08-15 19:44:42 +02:00
src art at its own shape, clip authoring, and a browse type filter 2026-08-15 19:44:42 +02:00
.gitignore replace Dockerfile/woodpecker with a nix flake, drop the based dep 2026-08-12 22:37:00 +02:00
Cargo.lock replace Dockerfile/woodpecker with a nix flake, drop the based dep 2026-08-12 22:37:00 +02:00
Cargo.toml replace Dockerfile/woodpecker with a nix flake, drop the based dep 2026-08-12 22:37:00 +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 flake: expose the binary at a stable /bin/watchdogs 2026-08-12 23:50:50 +02:00
README.md docs caught up with the code 2026-08-15 20:13:42 +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 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.json sidecars (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 / .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 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.json sidecar 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, 0 to 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 /favorites lists 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/.vtt sidecars
  • 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 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.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. 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:

  1. Manual override. An admin sets the type on the watch page, which also
    sets type_locked so the classifier cannot undo it on the next scan.
  2. A library.json sidecar, mirroring creator.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.
  3. Heuristics over metadata that is already indexed: artist/track/album
    or a Music category 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.