Skip to content

HTTP API

The HTTP API is the repository’s only contract: any client that can POST JSON can record playback, and the bundled mpv script and Firefox add-on are two clients among possible others. This page documents the API for an integrator building a client or scripting against the server.

The server speaks plain HTTP/1.1 with JSON bodies, and every response carries Content-Type: application/json. Error bodies are always {"error": "<message>"}. The API version is /v1. There are no DELETE or PUT endpoints; corrections are new events. Unknown paths answer 404, and a known path with the wrong method answers 405 with an Allow header.

Each connection serves exactly one request: the server answers Connection: close and does not support keep-alive. Request bodies must be framed with Content-Length; Transfer-Encoding of any kind answers 400, and a POST without a Content-Length answers 411. A connection that stalls mid-read is dropped after 10 seconds.

LimitValueWhen exceeded
Request head (request line plus headers)8 KiB431
Request body1 MiB413
Events per POST /v1/events batch500413
GET /v1/events page size200 default, 500 maxclamped, not an error

The server binds 127.0.0.1:7600 by default; override with --bind ADDR:PORT or PLAYLOG_BIND. Binding is the deployment’s choice — 0.0.0.0 behind a firewall or VPN is a normal choice, and reachability stays the network’s concern, not the server’s.

Bearer authentication is on by default. Every /v1/ path requires Authorization: Bearer <token>; a missing or unknown token answers 401 with WWW-Authenticate: Bearer and body {"error":"missing or invalid token"}. /healthz stays open for liveness probes. The check runs after the request has been fully read and before routing, so an unauthenticated request never reveals which routes exist.

Tokens are issued on the server host and printed exactly once, never shown again:

Terminal window
playlog-api token add --label NAME

A token is plt_ followed by 43 base64url characters, and only its SHA-256 hash is stored. playlog-api token list prints labels and issue times; playlog-api token revoke --label NAME deletes a token, and the next request carrying it answers 401. A fresh server with auth on and an empty token table accepts no one until the first token is issued.

The label names the device. An authenticated request’s label is recorded as its events’ device_id — the stored column only, never the raw event line — and outranks both the client-supplied device_id and the optional peer-address map (--device IP=NAME, repeatable).

--auth off (or PLAYLOG_AUTH=off) disables the gate and restores the zero-trust contract where the network is the trust boundary. The server itself speaks plain HTTP by design; a token over plain HTTP is protected only by the transport in front, so a public authenticated deployment belongs behind a TLS-terminating reverse proxy.

Appends a batch of events to the log. The body is {"events":[...]} with at most 500 elements. Each element is either a JSON string holding one event line verbatim, or a JSON object, which the server serializes deterministically — re-posting the same object produces the same stored line and dedupes. A body that is not JSON, or one without an events array, answers 400.

Each event is classified independently; a batch is never rejected as a whole. An event whose event_id already exists counts as a duplicate and writes nothing, so any batch can be re-sent safely. An event that fails validation is written to the server’s quarantine table with every reason and the exact raw input, and is reported in the response rather than answered with a 4xx. Re-posting the same invalid line classifies it again but appends no second quarantine receipt.

{
"accepted": 3,
"duplicates": 1,
"rejected": [
{ "event_id": "box:i:1:9", "reason": "invalid ts \"yesterday\": not ISO 8601" }
],
"cursor": 2538
}

accepted counts newly appended events, duplicates counts ids already present from an earlier posting, and rejected carries one entry per quarantined event: its event_id when one is readable from the raw text (an empty string otherwise) and every reason joined with ; . cursor is the store-wide head after the batch — the highest assigned server seq. A storage failure answers 500 and rolls the whole batch back.

Pulls events incrementally in server seq order, which is receipt order and never client time. after=<seq> returns events with seq greater than the value, defaulting to 0; it must be an integer, answering 400 otherwise. limit=<n> sets the page size, defaulting to 200 and clamping at 500; it must be a non-negative integer, answering 400 otherwise, and 0 yields an empty page.

{
"events": [
{ "seq": 2531, "event_id": "box:i:1:2", "raw": "{\"v\":1,...}" }
],
"cursor": 2538
}

raw is the stored line as a JSON string: the event’s own raw for imported rows, else the line exactly as received. cursor is the store-wide head, not the last row on this page — resume paging from the last returned row’s seq, not from cursor. Fewer rows than limit means the log is exhausted for now.

Returns the latest playback position for one item across all devices. Exactly one of two query parameters is required: key=<canonical_key> passes the canonical key directly, and url=<percent-encoded URL> is resolved server-side to a canonical key, so clients never hold key rules — url answers exactly as the equivalent key. Neither parameter, both at once, or a url with no resolvable identity answers 400; an unknown key answers 404. Latest means greatest client time, ts_unix with server seq as the tie-break, and events without a position never mask an earlier positional event.

{
"canonical_key": "youtube:jNQXAC9IVRw",
"position": 10665.706,
"ts": "2026-10-27T11:55:10+01:00",
"ts_unix": 1793098510,
"device_id": "phone",
"event_id": "phone:1a2b3c4d:2:5",
"session_id": "phone:1a2b3c4d:2"
}

session_id is null when the position’s event carried no session. Encode the url value with encodeURIComponent; + is not decoded as a space.

Liveness plus one database round-trip. It is open without a token and answers 200 with {"status":"ok"}, or 500 when the database cannot answer.

One event is one JSON object, per the table below. A ? in the type column marks an optional field; fields without one are required on complete events, and the sparse create path below fills most of them.

FieldTypeNotes
vintSchema version, currently 1. Unknown major versions quarantine; the server never guesses.
event_idstringdevice:instance:session#:seq, client-generated and globally unique. The dedupe key.
session_idstringOne per playback session per player process; absent on manual or imported rows.
seqintPer-session counter starting at 1.
typestringstart, progress, end, or tag.
reasonstringWhat fired the event; the valid values depend on type (below).
tagstring?Only on type: "tag": the tag payload.
tsstringISO 8601 with offset. Authoritative for display.
ts_unixint?Whole seconds since the Unix epoch. Authoritative for ordering and arithmetic; derived from ts when absent.
device_idstringIdentifies the device; an authenticated token’s label overrides it on the stored column only.
pathstringAbsolute local path or redacted URL. Absent with no title is a valid imported shape.
positionnumber?Seconds, 3 decimals. May be absent when unknown.
durationnumberSeconds. Varies per event — partial downloads, live streams — so never treat it as a property of the media.
speednumberBase playback speed.
skipsilenceobject?On every event when the source player runs the skipsilence script: enabled (bool at event time) and config (explicit overrides as verbatim strings, absent meaning defaults). An absent field means the script was not running.
is_url, title, metadataOnly on identity-bearing events: start, end, and manual progress. metadata may be {} or []; both shapes are valid.
errorstring?On end when the player reported a playback error.

Valid reason values by type: start carries file-loaded; progress carries heartbeat, pause, or manual; end carries the player’s end reason (eof, stop, quit, error, and peers) or shutdown/replaced; tag carries attach or detach.

Tag events qualify further. The tag string is trimmed at validation to between 1 and 64 characters with no control characters, and stored case-preserved; a detach pairs with its attach case-sensitively, so no silent normalization can split a pair. attach or detach on any other type quarantines, as does a tag event with any other reason. Tag events omit position, title, metadata and is_url, and carry duration and speed read at tag time.

Manually created or imported rows use the same shape with a free-form source (for example "manual", "import", "firefox"), plus optional raw (the original text) and raw_hash.

v and ts alone are valid input. Before validation the server completes absent fields deterministically: event_id becomes import:<sha256 of the trimmed line>, type becomes progress, reason and device_id become manual, seq becomes 0, and ts_unix is derived from ts. Identical lines therefore synthesize the same event_id and dedupe naturally, and an explicit seq of 0 is accepted as the no-session marker. Present values are never overridden, and no position, duration, or path is invented. An absent or unusable ts, or an unknown major v, still quarantines.

Deserialization is maximally tolerant and strictness lives in validation. Unknown fields are ignored; absent optional fields, rows without a session_id, minute-resolution timestamps, and rows with neither path nor title are all ingested. Negative position, duration, or speed quarantines. Warnings — a duration of 24 hours or more, ts and ts_unix disagreeing, neither path nor title present, a tag payload riding a non-tag event — are stored on the row and never quarantine.

Every event’s path is resolved at ingest to a canonical key: one identity per item, shared by every URL variant or filename that points at it. The resolver lives entirely server-side — a client only ever needs the page URL — and keys look like this:

  • youtube:ID from watch, embed, shorts, and live paths on youtube.com, m., and music. hosts (with or without www.), from youtu.be links, and from youtube-nocookie.com/embed; tracking parameters drop out because only the id is kept.
  • twitch:vod:ID from twitch.tv/videos/ID on the bare, www., and m. hosts, numeric ids only; live channel pages are deliberately not keyed as VODs.
  • jellyfin:ID from the item id in a /Videos/<itemId>/ stream path, on any host.
  • file:/abs/path from lexically normalized local paths.
  • hash:<sha256> as the fallback for any other URL.

An unrecognized input is still ingested under the hash fallback, never rejected.

Terminal window
TOKEN='plt_...'
URL='http://127.0.0.1:7600'

Post one minimal event and let the sparse create path fill the rest:

Terminal window
curl -X POST "$URL/v1/events" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"events":[{"v":1,"ts":"2026-10-27T19:30:00+01:00","path":"/srv/videos/movie.mkv","position":900.5,"duration":5400,"title":"A movie"}]}'

Pull the newest events:

Terminal window
curl -H "Authorization: Bearer $TOKEN" "$URL/v1/events?after=2530&limit=10"

Ask where you left off, by URL:

Terminal window
curl -H "Authorization: Bearer $TOKEN" \
"$URL/v1/resume?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DjNQXAC9IVRw"