Skip to content

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 as openapi.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 a ToSchema or IntoParams, and the routes are registered with utoipa_axum’s routes!, so a route can’t be in the server and not in the description.

  • Keeping it current. cargo test fails if openapi.json isn’t what the server makes, and says how to fix it:

    Terminal window
    UPDATE_OPENAPI=1 cargo test -p noct-core --test it openapi

    CI runs the tests, so a change to the API without that is a failed check.

  1. 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.
  2. #[derive(utoipa::ToSchema)] on the types it takes and gives (and IntoParams with #[into_params(parameter_in = Query)] on a query string’s).
  3. Register it in api/mod.rs in the group that says who can call it (public, signed_in or admin) with .routes(routes!(module::handler)). Handlers of the same path with different methods go in one routes!.
  4. Add it to the route lists in tests/it/auth.rs, and regenerate openapi.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.

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/changes lists every album and every Music playlist the profile can see, each as { kind, id, version }, with a token and full: true. A version is 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 as changed, and what has gone as removed (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} or GET /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 answers Range, 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/plays with playedAt. 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.