A local-first media-watch history tool. WatchTrail receives normalized "watch events" from any source (VLC, a browser extension, a script) and stores them in a single SQLite database you own.
What it solves: most media apps track nothing, or track on someone else's server. WatchTrail gives you one append-only log of what you watched, when, and how far you got — on your own disk, in a format that will outlast any app.
Design priorities:
- One static binary + one file — no daemon, no Docker, no cloud account required.
- Push, not poll — collectors send events; the server is idle otherwise.
- Adding a new source requires no changes to core logic.
- Raw events are stored verbatim so derived data (sessions, stats) can be recomputed as the logic improves.
Go 1.26+ to build. No cgo. Runs on macOS, Linux, and Windows.
go build -o watchtrail ./cmd/watchtrailwatchtrail serve
watchtrail serve -config watchtrail.toml-config defaults to watchtrail.toml. A missing config file is fine — built-in defaults apply.
On startup, the server logs the HTTP and TCP addresses it is listening on. It binds loopback by default; running watchtrail enable-tls switches the dashboard to HTTPS on the LAN and advertises it over mDNS as watchtrail.local. Shutdown cleanly with SIGINT or SIGTERM.
All keys are optional. Environment variables override the file.
| Key | Default | Env override | Description |
|---|---|---|---|
http_addr |
127.0.0.1:8765 |
WATCHTRAIL_HTTP_ADDR |
HTTP ingest bind address |
tcp_addr |
127.0.0.1:8766 |
WATCHTRAIL_TCP_ADDR |
TCP line-protocol bind address |
tls_addr |
:8443 |
WATCHTRAIL_TLS_ADDR |
HTTPS bind address (used when TLS is enabled) |
token |
"" |
WATCHTRAIL_TOKEN |
Bearer / TCP-handshake token for collectors |
db_path |
watchtrail.db |
WATCHTRAIL_DB_PATH |
SQLite database file |
data_dir |
. |
WATCHTRAIL_DATA_DIR |
Directory for auth.key, tls/, thumbs, etc. |
mdns_enabled |
true |
WATCHTRAIL_MDNS_ENABLED |
Advertise the dashboard over mDNS on the LAN |
mdns_hostname |
host's name | WATCHTRAIL_MDNS_HOSTNAME |
Hostname under .local to publish |
auth_disabled |
false |
WATCHTRAIL_AUTH_DISABLED |
Skip cookie auth on the dashboard (loopback dev only) |
session_gap_seconds |
1800 |
— | Sessionizer gap threshold |
completion_threshold |
0.9 |
— | Sessionizer completion threshold |
progress_cadence_seconds |
30 |
— | Collector cadence hint |
Dashboard access uses cookie auth bootstrapped from a one-time setup link (see print-link). Collectors continue to authenticate with the bearer / handshake token.
Minimal watchtrail.toml:
db_path = "~/.local/share/watchtrail/history.db"
token = "change-me"| Command | What it does |
|---|---|
serve |
Run the server |
recent / item / stats |
Local read CLI over the same store |
rebuild-sessions |
Replay raw events through the sessionizer |
print-link |
Print a fresh dashboard setup link |
enable-tls / disable-tls |
Mint a local CA + leaf, install / remove user trust, switch the dashboard to HTTPS |
enable-encryption / disable-encryption |
Turn field-level encryption at rest on or off |
status |
Report TLS, encryption, and auth state |
keystore-forget |
Forget a cached keychain passphrase |
All commands accept -config and -h. enable-encryption accepts either -mode=keychain (macOS) or -mode=passphrase.
Dashboard. First start prints a one-shot setup link with an embedded token; opening it sets a cookie and binds the browser. Run print-link later to mint another. enable-tls provisions a local CA, installs it into your user trust store (no sudo on macOS), and serves the dashboard on tls_addr with HTTP redirected. mDNS advertises watchtrail.local on the LAN so other devices can reach it without configuration.
Collectors. Bearer token over HTTP, or token-handshake then JSON-per-line over TCP. Both target loopback by default.
Optional encryption at rest. enable-encryption AEAD-encrypts identity columns (media.external_id, url_or_path, title) and the session-join projections; raw events stay verbatim so the sessionizer remains replayable. The key is wrapped by a keychain entry on macOS or derived from a passphrase via Argon2id elsewhere. A pidfile lock and atomic migration prevent concurrent rekey.
POST http://127.0.0.1:8765/ingest
Authorization: Bearer <token>
Content-Type: application/json
<single event object, or a JSON array for a batch>
Responses:
202 Accepted— single event stored200 OKwith{"accepted": n}— batch stored400— invalid or unsupported event401— bad token
Re-sending the same event_id is a safe no-op (idempotent).
Connect to tcp_addr. Write one JSON event per line, newline-terminated.
If a token is configured, the first line must be the token (handshake). Every subsequent line is a JSON event. Same pipeline as HTTP — validation, dedup, and storage are identical.
One JSON object per event. Required fields:
| Field | Type | Description |
|---|---|---|
v |
int | Protocol version — always 1 |
event_id |
UUID string | Collector-generated; the idempotency key |
type |
string | start / progress / pause / resume / stop / seek |
occurred_at |
ISO-8601 UTC | When the event happened |
source_kind |
string | e.g. vlc, youtube |
media.external_id |
string | Source-scoped media identity |
Optional: source_instance, position_seconds, media.kind, media.title, media.url_or_path, media.duration_seconds, meta (free-form object).
Example:
{
"v": 1,
"event_id": "f3c1e0a2-1111-4abc-8def-000000000001",
"type": "progress",
"occurred_at": "2026-06-14T09:31:02Z",
"source_kind": "vlc",
"source_instance": "laptop-vlc",
"media": {
"external_id": "file:9a1f",
"title": "Spirited Away",
"duration_seconds": 7500
},
"position_seconds": 1342.0,
"meta": { "rate": 1.0 }
}cmd/watchtrail/ # binary entry point — all subcommands
internal/
config/ # config loading: defaults → TOML → env
event/ # canonical event type, parsing, validation
ingest/ # transport-agnostic pipeline + HTTP and TCP transports
store/ # Repository interface, SQLite impl, embedded migrations
api/ web/ # read API and server-rendered htmx dashboard
auth/ # cookie auth, setup-link, CSRF
tlsca/ # local CA + leaf certs, user trust install
discovery/ # mDNS service advertisement
crypto/ keystore/ # field-level encryption + key providers (keychain / passphrase)
lockfile/ # pidfile lock used during rekey
sessionize/ rebuild/ thumb/ lang/ events/
ids/ # dependency-free UUID generator
Data model: three tables — media_item (deduplicated identity), watch_event (append-only facts; the source of truth), watch_session (derived; not yet populated). All rows carry UUID primary keys and updated_at/deleted_at so multi-device sync can be layered in later without a schema rewrite.
Dependencies: modernc.org/sqlite (pure-Go, no cgo) and github.com/BurntSushi/toml.
go test ./...Working today: HTTP and TCP ingest, event validation, media identity deduplication, idempotent append-only storage, SQLite with embedded migrations, sessionizer with replayable rebuild-sessions, a read API and CLI, a server-rendered htmx dashboard with stats and SSE live updates, a VLC Lua collector, and an MV3 browser extension (YouTube + generic <video>) with a full-tab settings page. Cookie auth with one-shot setup link, mDNS service advertisement, optional local TLS (enable-tls), and optional field-level encryption at rest (enable-encryption) all ship today. Recent additions: audio-language capture, VLC thumbnails, hourly stats, and soft-delete with restore-on-rewatch.
Planned next (each independently useful, not yet built):
- Export / import — newline-delimited JSON of events, replayed through the normal pipeline
- Multi-device sync — alternate
Repositoryimplementation, only if actually needed - More sources — mpv, Plex / Jellyfin webhooks, manual entry, platform-history import
- Richer analytics — as the data justifies them
Because raw events are stored verbatim, the sessionizer and any future analytics can be recomputed over your full history whenever the logic improves.
Download the .dmg for your Mac from the latest release:
- Apple Silicon →
WatchTrail-arm64.dmg - Intel →
WatchTrail-amd64.dmg
Open the dmg and drag WatchTrail to Applications. The app lives in the menu bar (no Dock icon) and runs the server in the background. Use the menu to open the dashboard (it signs you in automatically), toggle Start at Login, or quit (which stops the server).
Data lives in ~/Library/Application Support/WatchTrail/.
A passphrase-encrypted database can't be unlocked from the menu bar app — run
watchtrail serve in Terminal instead, or switch the database to keychain
mode. Keychain-encrypted databases work transparently.
The app is not yet code-signed, so macOS Gatekeeper will warn on first launch. Either:
- Right-click (or Control-click) WatchTrail.app → Open → Open, or
- Run once in Terminal:
xattr -dr com.apple.quarantine /Applications/WatchTrail.app
You only need to do this once.
TBD