The API
Everything the apps and the web app do, they do through this: JSON over HTTP,
mounted at /api. It is described in OpenAPI, so a client can be generated
rather than written by hand.
-
The description is served at
/api/openapi.json(to anyone, signed in or not) and kept in this repository asopenapi.json, so a change to the API shows up in a diff and a client can be made without a server running. -
It is made from the code. Each handler has a
#[utoipa::path(...)], each type aToSchemaorIntoParams, and the routes are registered withutoipa_axum’sroutes!, so a route can’t be in the server and not in the description. -
Keeping it current.
cargo testfails ifopenapi.jsonisn’t what the server makes, and says how to fix it:Terminal window UPDATE_OPENAPI=1 cargo test -p noct-core --test it openapiCI runs the tests, so a change to the API without that is a failed check.
Adding a route
Section titled “Adding a route”- Write the handler in
crates/noct-core/src/api/<name>.rs, and put#[utoipa::path(...)]above it: its method, path (relative to/api), tag, parameters, request body and the responses it can give. The doc comment above is its summary and description. #[derive(utoipa::ToSchema)]on the types it takes and gives (andIntoParamswith#[into_params(parameter_in = Query)]on a query string’s).- Register it in
api/mod.rsin the group that says who can call it (public,signed_inoradmin) with.routes(routes!(module::handler)). Handlers of the same path with different methods go in oneroutes!. - Add it to the route lists in
tests/it/auth.rs, and regenerateopenapi.json.
The tests say what’s missing: a route not in the lists, or in them and not the
description; an operation sharing an operationId; a field named with an
underscore.
Conventions
Section titled “Conventions”Names. Fields and query parameters are camelCase. Paths are lower-case with
hyphens (/next-up, /retry-failed). Each operation has an operationId of
the form <area>_<action>, which is what a generated client calls it.
IDs. Everything Noct makes has a short string ID (twelve lower-case letters and digits), and nothing else about an ID
should be relied on. TMDB’s own IDs are integers, and only appear where they say
so (tmdbId, /tmdb/...). A route that takes a file, movie, series, season or
episode takes that ID in its path.
The exception is a person. They have no row of Noct’s own, so a person’s id
(on cast and crew, and in /people/{id}) is TMDB’s, written as a string like
every other ID. Nothing else about it should be relied on either.
Links for other screens. A cast device fetches the video itself and holds no
token, so POST /files/{id}/cast (a file as it is) and POST /hls/{session}/cast
(a conversion session) give a link for it: an address another device on the
network can reach, with a ticket in the path (/api/t/{ticket}/...). A ticket
is good for that one file or session and for twelve hours, and opens only the
stream, the playlist and segments, and the file’s subtitles. It is in the path
rather than the query so a playlist’s relative links to its segments keep it.
Those routes answer cross-origin requests; the rest of the API doesn’t.
What is playing, and remote control. A player says what it is playing every few
seconds with PUT /now-playing (the movie or episode, where it is, whether it is paused), and
DELETE /now-playing when it stops; a device not heard from for 30 seconds is taken off. It works
for direct play too, which the server otherwise never sees. GET /now-playing lists what is playing
on the devices the profile may control, its own and, for the admin, everyone’s, and
POST /now-playing/{id}/commands (id is the device’s, as in /devices) tells one to play,
pause, seek (with positionSeconds) or stop. The device picks its commands up with
GET /now-playing/commands, which waits up to wait seconds (25 by default, 60 at most) for one and
answers with an empty list when there is none, to be asked again. It reports before it asks. The list
is kept in memory, so it is empty after the server restarts. Where the viewer is, and whether it was
watched, is still POST /progress/{id}/report, from the device the person is using, as their profile.
Times and sizes. Timestamps are RFC 3339 in UTC (2026-10-08T10:02:24Z).
Positions and durations are seconds, as numbers. Sizes are bytes.
Errors. Every error is { "error": "<message>" } with a status that
means what it says: 400 for something the request got wrong, 401 for no
valid token (or a wrong username, password, passcode or profile), 403 for a profile that isn’t
the admin, 404 for something that isn’t there, 409 where it conflicts with
what is (a name taken, a server already set up), 413 too large, 415 the wrong
kind of thing, 429 too many wrong guesses (with Retry-After), and 5xx for
the server or what it depends on (502 TMDB, 503 ffmpeg). The message is a
sentence for a person; a client decides by the status.
What a client can’t make sense of (a body that isn’t JSON, a missing field) is also this shape, with the server’s message of what was wrong.
Expected outcomes aren’t errors. “Already watching that folder”, “that path
doesn’t exist” are answers (a 409, a 400 with a reason), not crashes: they
have a status and a message, and a client shows the message.
Finding the server. A server announces itself on the local network as
_noct._tcp (mDNS/Bonjour), with its name, its port and, as text, id,
version, setup (whether it still needed an admin when it began) and
path (/api). An app asks GET /api/system, which anyone can, for what it is
now: name, id, version and setupNeeded. The id is the same for the
server at any address, so an app that has met it knows it again. The name is a
setting (serverName, at most 63 characters) and is the machine’s own until
it’s set; a new one is announced the next time the server starts.
NOCT_MDNS=off turns the announcement off (see docker).
Signing in. There are two steps, as with Netflix. A device first gives the
server’s login, a username and password, once (POST /auth/server-login, or
/setup the first time), and is let in: it holds a device token (the
noct_device cookie for a browser) that is good for listing the profiles and
signing in as one (GET /profiles, POST /auth/login), and for nothing else.
A profile is who is watching, and has a passcode of 4 to 6 digits or none.
Signing in as one gives a token. Every route except /health, /setup,
/auth/server-login, /auth/server-logout, /pairing (and its /claim),
/system and /openapi.json needs a device or a token, and everything else
needs a token, and the admin’s routes need an admin’s profile. A token goes as
Authorization: Bearer <token> (the apps), or in the noct_token cookie (the
browser: cookie: true when signing in, and the token isn’t in the body).
Signing out of a profile (POST /auth/logout) keeps the device let in, so it
goes back to the profiles; POST /auth/server-logout makes it give the login
again, and so does a new server password (PUT /server-login). A device that
can’t be typed on pairs instead: it asks
POST /pairing for a code to show, someone signed in approves the code at
POST /pairing/approve, and the device asks POST /pairing/{id}/claim with its
secret until it’s given its token (see security). For a URL that a player opens itself (the stream, a
playlist, a subtitle, an image) it can go in the address as ?token=, for
reading those and nothing else. See security.
/metrics is the exception: it takes the server’s metrics token and no
profile’s (see metrics).
Apps. Noct is a few apps (so far, cinema: movies and TV, music and books: audiobooks and ebooks), each with a
first-run the admin goes through once. GET /apps says which are set up, to any
profile; PUT /apps/{app} (admin) says one is, once it’s been through the
first-run or skipped it. A client shows an app that is set up, the first-run
(to the admin) for one that isn’t, and (to anyone else) a note to ask the admin.
A server that already has folders or a TMDB key counts as set up.
A watched folder belongs to the app its kind says (movies, shows and mixed
are Cinema’s, music is Music’s, audiobooks and ebooks are Books’), and a folder is never moved to another app by
changing its kind. GET /folders, /files, /files/review, /pipeline and
/dashboard take ?app=cinema, ?app=music or ?app=books for one app’s alone; without it
they show every app’s.
Music’s own routes are under /music (browsing, search, plays, lyrics, scrobbling, playback and
sync); what’s shared with Cinema (images, favourites, the stream) stays where it is, and
playlists take ?app=music.
Books’ own routes are under /books (see docs/design/audiobooks.md). A book is
one recording made of parts (files) heard on one timeline: GET /books/books/{id} gives each part
with where it starts and how long it is, and the chapters on that same timeline, so a client plays
the part the timeline says a position is in. GET /books/books, /authors, /narrators, /series
(and /series/{id}, a series’ books in position order, a book in two series being in both) and
/genres list and page as /movies does. Nothing in Books is rated, so a profile’s content level
isn’t about it. What’s shared (images, favourites, the stream) stays where it is.
A part is played as it is through GET /stream/{id} (it answers Range). For a client that can’t play
a part’s codec, POST /files/{id}/playback-decision says so for its profile, and
GET /files/{id}/audio sends the part converted to AAC (or format=opus) as it’s made, from start
seconds in; Music’s GET /music/files/{id}/audio is the same conversion. Both take ?token=.
Listening progress follows a person from device to device. A client reports where it is
(PUT /books/books/{id}/progress, { position, speed, at, device }, position in seconds on the book’s
timeline) every thirty seconds while playing, on pause, on a chapter change and when the app goes to the
background. at is when the audio was heard, not when the report arrives, so one made offline and sent
on reconnecting is judged by when the listening happened: the latest heard wins, and an older report is
not applied (applied: false) and is answered with the progress as it is, for the client to offer to jump
to (a seek back is a later report, so it wins). An at more than a minute ahead is taken to be a wrong
clock and dated when it arrived. Less than half a minute in isn’t a start. A book is finished in its last
half minute or by hand (POST/DELETE /books/books/{id}/finish); listening again clears that half a
minute in. Speed is kept per book per profile. A book whose parts have changed is resumed in the same
part if that part is the same length, and from the same time if not. GET /books/progress is the books
being listened to, the latest first.
GET /books/search finds books, authors, narrators and series by name (case and accents don’t matter; a
book is also found by its authors’ and narrators’ names), and GET /books/home has the rows Books opens
on: what is being listened to, the next book of a series (the first not finished, by position, of one the
profile has finished a book of and isn’t part way through another), what was added lately and more by an
author. Books, authors and series can be favourites (PUT /favourites/{id}) and are in ?favourite=true
lists of Books alone.
What a book is linked to. After a book is read, its ASIN (from its tags or a sidecar) is looked up on
Audnexus, an ISBN on Open Library, and a book with neither is searched for on Open Library by its first
author and title. A work is linked only when it fits well and a different book doesn’t fit nearly as well;
several that fit are kept as candidates (providerState: ambiguous), and none is not_found. Linking only
adds to a book (its IDs, a description, genres, a series or narrators it had none of, a year, a cover or an
author’s picture): never what the files gave or what a person set (lockedFields). A provider that is down
leaves the book pending, to be asked again, and its file isn’t a failure.
Corrections (admin). PATCH /books/books/{id}, /books/authors/{id} and /books/series/{id} set fields
(a book’s title, subtitle, description, year, language, publisher, authors, narrators in order and series with
positions), each locked until unlock names it; a new name for an author or a series is for showing, and the
files that named them before are still them. GET /books/openlibrary/works searches Open Library and
POST /books/books/{id}/match ({ workId }) links a book to the work chosen and locks it. A book’s parts are
put in an order with PUT /books/books/{id}/parts (every part’s file ID once), files are moved to a book with
POST /books/books/{id}/parts, POST /books/books/{id}/split takes parts off onto a book of their own and
POST /books/books/{id}/merge ({ into }) puts one book after another, keeping what listeners had of it. The
files a correction moves are locked, so identifying them again leaves them where they were put; the timeline and
chapters are made again from the parts. A cover or a person’s picture is uploaded, or taken away, with the
/items/{id}/images/{cover|picture} routes and given back with unlock: ["cover"] or ["picture"].
POST /identify/rerun takes a bookId, for a book’s parts.
Ebooks. A book may also have a text edition, one file that is an EPUB or a PDF, from an ebooks
folder (which reads those two and nothing else), so a book is audio, text, or both (formats: audio,
text or both, and ?format= on the lists), and a book that is both is one book, paired with its audiobook
by ISBN or by title and author when there is exactly one of each. GET /books/books/{id} has a text
object (the file’s id, format, size, pageCount for a PDF, whether it reflowable, its toc and any
otherFormats merged in), which is served as it is by GET /stream/{id} (it answers Range, as an audio part
does) as an attachment with X-Content-Type-Options: nosniff and Content-Security-Policy: sandbox, to be
read from its bytes and never shown by the browser itself.
Reading progress is kept apart from listening’s, since a book read and a book heard are two places in it,
and follows a person the same way: PUT /books/books/{id}/reading ({ locator, progress, at, device }, a
Readium locator kept as sent, and progress from 0 to 1
over the whole book) is judged by when it was read, the latest wins, and an older report comes back with
applied: false and where the book is. A PDF’s progress is the pages before the one it is on over all its
pages, so the first page is not a start and the last finishes it; an EPUB is finished in its last hundredth;
POST/DELETE /books/books/{id}/reading/finish do it by hand, and GET /books/reading is Continue reading,
which the home page has beside Continue listening.
Reader settings (GET/PUT /books/reader-settings) are how the profile likes to read (text size, font,
theme, fit and zoom), kept as the reader sent them (at most 4 KiB) and judged by updatedAt as a reading
position is, so a device that was offline can say when it changed them.
Bookmarks in an audiobook are GET, POST, PATCH and DELETE /books/books/{id}/bookmarks, each profile’s
own: a place on the book’s timeline (positionSeconds) and a name, which is the time (12:05) unless one is given.
A profile can keep 200 in a book.
Bookmarks, highlights and notes are GET, POST, PATCH and DELETE /books/books/{id}/annotations,
each profile’s own: a kind, a locator, the text a highlight covers, a note and a color. A device
chooses the ID, so sending it again is the same annotation, edits are last-write-wins by updatedAt, and a
deletion is kept 90 days as a tombstone (deleted: true, with nothing else in it) so GET ?since= (a revision
from an earlier answer) can tell a device that has been away. A profile can have 2,000 in a book.
Pairing by hand. POST /books/books/{id}/merge and /split take a text file as they take a part, so
putting an ebook with an audiobook, or taking them apart, is a merge or a split; POST /books/books/{id}/pairing/decline ({ other }) says two are not a pair, which the library then never
remakes. A book the library couldn’t pair has pairing among its reasons in Review, with the books it might
be paired with.
Review. GET /books/review/books lists the books that are ambiguous (with the works that fit, best first)
and those whose tags don’t settle who is the author and who the narrator (with the evidence behind how their
files were read), which a person settles by saying who it’s by or who reads it. Files in conflict or
unmatched are in GET /files/review?app=books, and Books’ review count (/dashboard?app=books) is both.
Keeping a copy of Music on a device. A device that keeps the library, or the albums and playlists a person chooses, stays in step without listing everything each time:
GET /music/sync/changeslists every album and every Music playlist the profile can see, each as{ kind, id, version }, with atokenandfull: true. Aversionis a number that changes whenever anything about that album or playlist does (its tracks and their files, its metadata, its artists, its cover, a track’s lyrics; for a playlist its name, what’s on it and who it’s shared with), so the same version is the same copy. The device keeps the token.GET /music/sync/changes?since=<token>lists what is new or different since that token aschanged, and what has gone asremoved(an album that was deleted, a playlist that was deleted or is no longer shared with the profile), with a new token. Asking with the token just had gives nothing until something changes. A device then asks for the detail of each album (GET /music/albums/{id}) and playlist (GET /playlists/{id}?app=music) that changed.- A token is only for the server that gave it. One this server doesn’t know is a 400, and one from
the future (the server was restored from a backup) is answered as if there were none, with
full: true: whatever the device has that isn’t listed is gone. - A track is downloaded as its original file (
GET /stream/{id}orGET /music/files/{id}/download) or as AAC at a chosen bitrate (GET /music/files/{id}/download?format=aac&bitrate=128), made once and kept on the server. A download has a length and answersRange, so it can be resumed. The file IDs are in each track of an album. - Plays made offline are reported when the device is back, with the time they were made:
POST /music/playswithplayedAt. The same play sent twice is kept once.
Lists and paging. Lists of what a library has (/movies, /series, /files)
are pages:
{ "items": [ ... ], "total": 1234, "limit": 50, "offset": 0 }Ask for one with limit (default 50, at most 500) and offset. total is how
many there are in all, so a client knows when to stop. Short lists that a screen
shows all of (folders, profiles, scans, backups, a row on the home page) are
plain arrays, and a row takes a limit of its own.
Sorting and filtering. /movies and /series take sort (title, year,
added, rating) and order (asc, desc; each sort has the order people
expect without asking). They filter by genre, year, search (part of the
title), collection, favourite=true and watched (true for only what the
profile has watched, false for only what it hasn’t; a series is watched when every
episode of it is). /files filters by folderId,
stage, status and search (part of the path), in path order. Filters
combine: each one narrows.
Jumping about a list. /movies/index and /series/index take the same filters as the
lists and say where each letter of the title sort starts (letter, count, and the offset
to list from to land on it) and which genres there are, with how many have each. The genres
leave out the genre filter, so one can be swapped for another.
The home page. GET /homepage is the whole home page for the profile asking, in one
request: the hero to feature at the top (a title with a backdrop, from the server’s heroSource setting: added for the most recently added, the default, trending for the highest popularity score on TMDB, released for the most recently released that is out, or random, picked by heroSeed, or by seed without one, so a client can keep a hero for a whole session while the rows change), then
the rows to show in order, each with its id, title, where seeAll leads and its items
(movies, series, and episodes for Continue watching and Next up). Clients render what they’re
given and need no idea how a row is found. The rows are the same for everyone, in this order:
continue watching, next up, the watchlist, the favourites, recently added (without the hero), recently
released (TV shows and movies together, newest first), what may have been missed (unwatched, and not already
on the page) and up to three genres that have at least six titles. What may have been missed and
the genres are picked at random from what’s there, by a seed: the same seed is the same page,
so a client keeps one for a visit and the rows don’t change as someone goes into a title and
back, and without one every request is a new pick. A row with nothing in it is left out. A movie has its runtimeMinutes, and a series its
episodeCount.
Trailers. GET /items/{id}/trailer is a movie’s or series’ trailer from TMDB: the
key is its YouTube video ID, or trailer is null if there is none. It’s looked up
when asked for, not with the rest of the metadata, and cached. Playing it is up to the
client: the web app embeds YouTube’s player, which is the one place a person’s browser
(not the server) contacts YouTube, and only once they press the button.
More like this. /movies/{id}/similar and /series/{id}/similar give a plain
array of up to limit (12) items from the library: what TMDB recommends that the
library has, in TMDB’s order, and then what shares a genre (the most in common
first, then the better rated), never the item itself and never anything the library
doesn’t have. Where TMDB can’t be asked (no key, or it’s down) it goes by genre.
Images. An item’s images are at /items/{id}/images/{kind} (poster,
backdrop, logo, still), which takes width and height to be
resized and format. Each image’s URL in a response carries a tag, which
changes when the image does, so an image at a URL with its current tag never
changes and can be kept for good.
Playing. /movies/{id}/playback and /episodes/{id}/playback (and /episodes/{id}, an
episode on its own with its season and series) list the
files that play it and what is in each. Given what a client can decode,
/files/{id}/playback-decision says whether to fetch /stream/{id} (as it
is, with Range), or to ask /files/{id}/hls for a session whose playlist
/hls/{session}/index.m3u8 is served as it is made. A session is ended with
DELETE /hls/{session}, or the server ends it when nobody asks for it.
Versioning. There’s no version in the paths. From version 1 a change that
could break a client that follows these rules is not made under the same paths:
a field is never removed or changed to mean something else, a new field, route
or optional parameter can be added at any time, and so a client ignores fields
it doesn’t know, and an enum value it doesn’t know. Whatever must change
otherwise gets a new path. The server’s version is in /health.