In this page
Look up the Engine’s HTTP routes, authentication rules, and endpoint responsibilities.
Where this lives in the code
src/MediaEngine.Api/Endpointssrc/MediaEngine.Websrc/MediaEngine.Contracts
Public client base URL: the Dashboard origin plus /api/v1 (for local development, http://localhost:5016/api/v1).
The Engine origin (http://localhost:61495 in development) is an internal service boundary. Native clients must discover and call the Dashboard origin and never receive the Dashboard service credential or connect to the Engine port.
Interactive documentation: http://localhost:61495/swagger
Administrative Engine endpoints require live effective administrator authority (with any configured surface unlock), or the exact registered Application permission where supported. Public v1 client endpoints use scoped bearer tokens issued to a paired device. Every route also enforces its resource/profile scope; the Auth column below is a summary, not a substitute for the mapped endpoint policies. See the Access implementation status for final cutover gates.
Public client API v1
API v1 is the frozen native-client contract. Breaking request or response changes require a new major path. Additive fields may be introduced in v1; clients must ignore fields they do not understand.
Discovery and pairing
GET /.well-known/tuvimaon the Dashboard origin returns the public API base, supported versions, device-authorization and token endpoints, verification URL, and server capabilities. It is anonymous and always answers (there is no off switch for apps). It also carries an optionaloutside_address, an origin such ashttps://tuvima.example.comor a Tailscale address, when the owner has set up a secure outside address in Settings → Network → Remote Access; it isnullor absent otherwise. Apps use it away from home. The Dashboard fetches it from the Engine actionGET /network/outside-address(Dashboard service credential only; returns{ "outside_address" }). The old Anywhere readiness action and its409refusal no longer exist.- A television or other input-constrained client posts form data or JSON to
POST /api/v1/oauth/device_authorizationwith a stable publicclient_id, display names, version, device class, requested space-delimited scopes, and capabilities. - The user opens the returned
verification_urion an authenticated browser, reviews the exact client/device/scopes, and approves or denies it. Device codes expire after 10 minutes and polling starts at the returned interval. - The device polls
POST /api/v1/oauth/tokenusing grant typeurn:ietf:params:oauth:grant-type:device_code.authorization_pending,slow_down,access_denied, andexpired_tokenare normal OAuth error responses. - Access tokens last 10 minutes. Refresh tokens rotate on every successful
refresh_tokengrant and expire after 30 days. Reuse of a consumed refresh token revokes its token family.
Tokens are opaque bearer credentials and must be stored in the platform secure credential store. Device and token identifiers in request bodies or query strings are never authority; the Engine uses the profile, device class, client, and scopes bound to the bearer token. GET /api/v1/devices lists the devices of the active profile for a signed-in person (the Dashboard), but a paired app only ever sees its own device in that list, GET /api/v1/devices/current describes the caller, PUT /api/v1/devices/current/capabilities replaces negotiated capabilities, PUT /api/v1/devices/current/backup-profile (body profile_id, optional pin) chooses whose photos this phone backs up (428 when that person has a PIN and it is missing or wrong, 429 while that person is locked, 403 when the caller is not the paired app of that device or the person is not in the phone’s household), and GET /api/v1/devices/current/profiles lists the people this paired app may switch to as {"profiles":[{profile_id, display_name, avatar_url, is_kids, requires_pin, is_active}]} (the account’s enabled profile grants; avatar_url is always null today), POST /api/v1/devices/current/switch-profile (body profile_id, optional pin) switches the app to that person without re-pairing and returns the ordinary token response (profile_id is the new person, device_id is unchanged; the previous access and refresh tokens are revoked at once, and the PIN is checked at every switch). Switch failures are OAuthErrorResponse bodies: 428 pin_required (a PIN is needed or wrong; 428 rather than 401 so a retrying client does not replay a wrong PIN), 403 profile_not_allowed (the account has no grant for that person; an id that does not exist gets the same answer, so ids cannot be probed) or forbidden (the caller is not the paired app of a live device of its own account), 409 already_active_profile (the requested person is the one the device is already using; nothing is checked or changed), 429 pin_locked (too many wrong PINs) or rate_limited (with Retry-After), and 401 invalid_grant when the device, account or Application changed or another switch from the same device got there first. Both actions are limited to 30 a minute per device, counted across token rotation (a new token does not start a new allowance), and are open through the Dashboard’s app door (POST only for devices/current/switch-profile). DELETE /api/v1/devices/{deviceId} immediately revokes that device and all of its tokens. A paired app can only revoke itself (the apps call it with their own device id to sign out): any other id answers 404, exactly like an unknown device, so a token (for example a TV switched to someone’s profile) cannot probe or sign out another device of that profile. A signed-in person keeps the profile-wide behaviour, and managing other devices stays in /access/devices. The Dashboard’s app door forwards only the anchored routes the apps call: GET devices, DELETE devices/{id}, PUT devices/current/capabilities, GET devices/current/profiles, POST devices/current/switch-profile, GET devices/current/playback-preferences, GET/POST devices/current/downloads, DELETE devices/current/downloads/{variantId} and DELETE devices/current/downloads/photos/{itemId}. GET devices/current and PUT devices/current/backup-profile are not forwarded.
Scopes and resources
| Resource | Primary routes | Required scope |
|---|---|---|
| Discovery | GET /.well-known/tuvima | None |
| Browse/home | GET /api/v1/display/home, /browse, /shelves/{key}, /groups/{id}. /groups/{id} is a Series’ (or show’s) see-all list: its run in the shared Series order as the items shelf, and its supplementary, collected and broader-context members as a second also-in-this-world shelf titled “Also in this world”, split exactly like also_in_this_world on GET /api/v1/universe/{qid}, so the run lists the same items as the Universe shelf (for a show, the Universe row has one show card while see-all lists its episodes); a Series with only such members has no items shelf, and the page subtitle counts the run (or, without one, the Also members); TV episodes keep their episode order. A shelf that has a View all grid (see the next row) answers /shelves/{key} from the grid’s full list in its default order, so a long shelf is paged in full rather than cut off at the Home window; the cursor is still the offset as a string. Other shelves keep their previous behaviour | library.read |
| Item lookup | POST /api/v1/display/items with { "ids": [guid, ...] } (at most 200 ids; 400 when longer, so the caller splits the list). Returns a DisplayPageDto whose catalog holds the same cards the lane browse pages return, in the order asked: a Work id gives that Work’s card, a show or album root id gives the show or album card. It reads the same authorized projection as /browse, so the profile’s content limit and the account’s lane (area) and hidden libraries apply exactly as they do on a lane page. An id the viewer cannot see, an id that is not a catalogue item (a Collection, a View document) and an id that does not exist all get the same answer: no card. Documents never appear, whatever the profile’s Documents setting. Not limited to a lane page’s size | library.read |
| Browse tabs | GET /api/v1/display/browse/libraries (GetDisplayBrowseLibraries) returns an array of BrowseLibraryDto (id, name, kind_key, area, media_type, has_folders) for the libraries the signed-in profile may browse, one browse tab each: an administrator sees every library that is not in the Vault, and everyone else sees every one in the areas they have except the ones an administrator hid from them. The catalogue read’s own rule decides, so a library is also left out when the profile lacks that lane’s feature (Watch, Listen or Read). The Vault and the View area are never listed. has_folders is true when the library has at least one folder that is not merged away. Order: area (Watch, Listen, Read), then the kind’s own order, then name. /browse takes two more optional query parameters. libraryId keeps only that library’s rows; it is applied after the profile’s grants, content limit and Vault rules, so a library the profile cannot see answers exactly like an empty one. unmatched=true keeps only items whose file the Engine could not match and has not been kept as it is (unmatched_assets with kept_as_is = 0); it narrows the rows the profile may already see (grants, lane feature, content limit and Vault apply first), so it never widens access. Both filters look at all of a work’s files the profile may see: a work is in a library’s tab when any such file is in that library, and counts as Unmatched when any such file is Unmatched and not kept (in the same library when both are given); the answer stays one row per work. Without either parameter /browse answers exactly as before. The group reads behind the grouped views, GET /collections/content-groups and GET /collections/system-views, take the same two optional parameters and narrow the works they group the same way, after authorization (one shared rule, BrowseNarrowing), so a library tab’s groups come from the Engine instead of being cut down from a page of cards | library.read |
| View all grid | GET /api/v1/display/grids/{lane}/{shelfKey}?sort=&type=&genre=&format=&status=&cursor=&offset=&limit= (GetDisplayGrid) returns one page of a shelf’s full item list as a DisplayGridPageDto: lane, shelfKey, title, subtitle, items (the same cards the shelf shows), totalCount, offset, limit, hasMore, nextCursor, sort (the order applied), defaultSort, rememberKey, sortOptions and filters. Grids: home/continue, home/watch-next, home/read-next, home/listen-next, read/continue-reading, read/recently-added, read/author-{key}, watch/continue-watching, watch/tv-shows, watch/movies, watch/genre-{key}, listen/continue-listening, listen/recently-played, listen/recently-updated-albums, listen/new-tracks-added, listen/albums, listen/recently-added-audiobooks, personal/my-list and personal/liked (the signed-in profile’s own lists only). Sorts: added (default for most), title, year, played (default for the continue and recently-played grids). Each filter takes one value: type (movie, tv, book, comic, audiobook, music, collection), genre (any genre name), format (epub, pdf, comic, 4k, hd, sd; an item matches when any owned file has it) and status (not-started, in-progress, finished). filters only holds groups worth showing: at least two options, or one chosen; each group’s counts follow the other chosen filters; there is no status group on a recently-played grid or while music is in view, no type group when everything is one type and no genre group on a genre grid. cursor is the offset as a string; limit defaults to 48 and is clamped to 1-100. 404 for an unknown grid, 400 for a sort or filter value the grid does not offer. It reads the same authorized projection as /browse, so the content limit, the profile’s lanes and its hidden libraries apply, and Documents never appear | library.read |
| Continue | GET /api/v1/display/continue?lane=&mediaType=&limit= returns the in-progress cards. The finished list that My Library’s old History row read is gone; the Activity record (/api/v1/activity) is the one list of what a person did | progress.read |
| Artwork workspace (Dashboard only) | GET /api/v1/display/artwork, /artwork/assets, /artwork/assets/{id}/content, /artwork/entities/{type}/{id}, /artwork/universes/{id}/entities, /artwork/works/{id}/effective | An administrator or a service Application holding artwork.read. A paired Kids or adult (non-administrator) app token answers 403 even though it holds artwork.read; a paired administrator’s phone would pass the Engine policy, so the app door (which never forwards these routes) is what keeps it out. Art linked to a person, Work or Universe is read as /stream/artwork/{id}, a catalogue-checked route open to paired apps |
| Search | GET /api/v1/display/search?q= | library.read. For a paired app a Collection or Series result carries the cover of a member the profile may see (or none); it never carries the collection’s own cover, so a restricted item’s poster cannot show through a mixed collection |
| Grid sort | GET /api/v1/display/grid-preferences returns {"sorts": {"watch": "title", ...}}, the sort the active profile last chose on each View all grid (empty map when none; a grid with no entry uses its default order). PUT /api/v1/display/grid-preferences/{gridKey} with {"sort": "added" | "title" | "year" | "played"} remembers it and returns 204; gridKey is watch, read, listen, my-list or liked, and an unknown key or sort is 400. The profile always comes from the caller’s own claim (no profile id is accepted); no active profile is 401. Every signed-in profile, Kids included, may use both. | library.read (GET), progress.write (PUT) |
| Details | GET /api/v1/details/{entityType}/{id} | library.read |
| Next episodes | GET /api/v1/details/show/{id}/next-episodes?after={assetId}&count=3 | library.read |
| Collections and Universes | GET /api/v1/collections, /collections/{id}, /collections/{id}/items, GET /api/v1/universe/{qid} | library.read |
| Owned-file cover | GET /api/v1/display/artwork/media-assets/{assetId}/effective-cover (checked against the profile’s access to that file) | artwork.read |
| Artwork/media bytes | URLs returned by display/detail/manifest models; /api/v1/stream/{assetId} supports ranges and signed /api/v1/stream/hls/... paths serve adaptive packages | artwork.read or playback.read as applicable |
| Progress | GET/PUT /api/v1/progress/{assetId}, GET /recent, GET /journey | progress.read / progress.write |
| Queues | GET /api/v1/player/state, mutations under /api/v1/player/queue | queue.read / queue.write |
| Playback session | /api/v1/player/command, /heartbeat, /session/takeover | playback.write or progress.write |
| Companion | GET /api/v1/companion/devices, GET /session, PUT /session/follow, PUT /session/manual, DELETE /session, GET /context (see Companion) | queue.read / progress.write |
| Playback decision | GET /api/v1/playback/{assetId}/manifest | playback.read |
| Cast session | POST /api/v1/playback/{assetId}/cast with {"receiver":"google-cast-default"}. A paired app only (a signed-in Dashboard session or an Application is refused 403); answers status ready (with mediaPath, contentType, posterPath, textTracks, expiresAt), preparing (the title is being converted; ask again, no link is issued yet) or unavailable (with a plain message). The answer carries a secret link and is never cached (Cache-Control: private, no-store). Rate-limited to 20 per minute per device | playback.read, plus the catalogue’s own limits for that title (a Kids limit applies) |
| Cast receiver routes | GET/HEAD/OPTIONS /cast/v1/{grant}/media, /hls/{packageId}/{**path}, /text/{trackId}, /poster. Anonymous on purpose: a TV cannot send a token, so the signed {grant} in the path is the credential and the Engine re-checks all of it on every request. One title only, read only, byte ranges supported; 403 for any grant that no longer holds. The Engine answers these only on this computer; the Dashboard relays them for the home network | the cast grant (see Security) |
| Downloads | encode/offline routes under /api/v1/playback | downloads.read / downloads.write |
| Device playback preferences | GET /api/v1/devices/current/playback-preferences | playback.read |
| Device download list | GET/POST /api/v1/devices/current/downloads, DELETE /api/v1/devices/current/downloads/{variantId}, DELETE /api/v1/devices/current/downloads/photos/{itemId} | downloads.read (GET) / downloads.write (POST, DELETE); keeping a photo (kind photo) also needs view.personal.read |
| Photos | GET /api/v1/view/timeline, /places, /people, /people/{key}/items, /items/{id}, /items/{id}/thumbnail, /items/{id}/preview | view.personal.read |
| Photo Galleries | GET /api/v1/view/galleries, /galleries/{id}/items | view.galleries.read |
| Photo originals and video | GET /api/v1/view/items/{id}/original, /items/{id}/playback | view.originals.read |
Photos (View) for paired apps
The /api/v1/view routes give a paired app the active person’s own photos and videos, read-only. Every route takes who is asking from the device token’s active profile and asks the View authorization service for the Mine scope; there is no scope, scopeProfileId or profile parameter, and sending one changes nothing. Only a paired app may call them (a Dashboard session or service application is refused with 403, no token with 401), and the token and the Application must both hold the scope named above (403 with insufficient_scope otherwise). The Dashboard’s own /view routes are unchanged.
| Route | Answer |
|---|---|
GET /timeline?cursor&limit | ViewClientTimelinePageDto: months (each year, month in UTC, and items), next_cursor, has_more. Newest first; limit defaults to 100 (maximum 200). A month can continue on the next page, so merge buckets with the same year and month. An invalid cursor is 400. |
GET /galleries | ViewGalleryListResponse (owned, shared_with_you). Counts leave documents out. |
GET /galleries/{id}/items?cursor&limit | ViewClientItemPageDto (items, next_cursor, has_more), newest first. |
GET /people?cursor&limit, GET /places?cursor&limit | The existing ViewPeoplePageDto and ViewPlacesPageDto (places carry latitude and longitude). |
GET /people/{key}/items?cursor&limit | ViewClientItemPageDto for one person’s key. |
GET /items/{id} | The existing LocalAssetDto: media_kind (image or video), size (width, height, duration_seconds, file byte_size), captured_at, camera, location. thumbnail_url and content_url point at the routes below. |
GET /items/{id}/thumbnail?size= | JPEG; size is the longest edge: 160, 320 or 640 (default). Any other value is 400. |
GET /items/{id}/preview?size= | JPEG; size is 1280 or 2048 (default). |
GET /items/{id}/original | The original file with HTTP range support, only for a token that holds view.originals.read and may read the item. No address works without the bearer token. |
GET /items/{id}/playback | For a video: a PlaybackManifestDto with one direct-stream delivery of /api/v1/view/items/{id}/original. Personal videos have no catalogue asset, probe or adaptive package, so the catalogue playback is not involved. |
These routes show photos and videos only. A document, someone else’s item and an id nobody has all answer with the same 404, on every route and for every profile, so a profile without Documents cannot learn that a document exists from the timeline, Galleries, counts, people, places, items or files, and nobody can tell a foreign id from an unknown one. Hidden, archived and trashed items stay out of the timeline. A photo or Gallery that reached the caller through a share carries nothing from its owner’s space: no owner or space ids, no Smart Gallery rule, no embedded location or date, and no people or tags. Thumbnail, preview and original responses are Cache-Control: private, no-cache.
Consent. view.personal.read, view.originals.read and view.galleries.read are scopes a paired app may request in scope at POST /api/v1/oauth/device_authorization, but they are never part of the default scopes. The pairing screen shows them as one separate, unticked item (Allow access to my photos and videos); they reach the token only when the person ticks it (PairingDecisionRequest.scopes then lists them) and the app asked for them. An approval must name its scopes: it grants exactly what the list names within what the app requested (so these three only when listed), and an approval that leaves nothing to grant (omitted, an empty list, or only scopes the app did not request) is refused with 400 and stores nothing, as is a photo scope for an Application that does not hold the three photo permissions. GET /api/v1/pairing/review/{userCode} returns offers_photo_scopes, true only when the Application holds those permissions; the pairing screen shows the photo item only then. A scope the app did not request cannot be added by the decision. The token’s scope shows what was granted, and refreshing or switching profile never adds or removes any. The built-in native-client Application also has to hold them (it does, on a new data store and once on an existing one, and an administrator can remove them in Settings, which makes a pairing that asks for them fail with access_denied and stops existing devices). Without them every route above answers 403 insufficient_scope; a revoked or unknown device token answers 401. The Dashboard’s app door forwards exactly these eleven GET routes, with their query string, and nothing else under view/.
Offline media for paired apps
Syncing progress recorded offline. PUT /api/v1/progress/{assetId} and POST /api/v1/player/heartbeat accept two optional, additive fields: offline (boolean, default false) and client_timestamp (ISO 8601, UTC, when the device recorded the progress). A request without them behaves exactly as before. With offline: true and a client_timestamp, the later of the server’s last update for that asset and the client timestamp wins:
PUT /progress: whenexpected_revisionis stale, the Engine does not answer409. A later client timestamp replaces the server record (written against the current revision,last_accessedset to the client timestamp). An equal or earlier timestamp leaves the server record untouched and the200response carries the server’s state, so the app comparesrevision,progress_pctandlast_accessedto see which one stands. A non-offline request, or an offline one with no timestamp, still gets409on a stale revision.POST /player/heartbeathas no revision to go stale, so the rule decides whether the heartbeat is applied at all: an offline heartbeat whose timestamp is not later than the server’s last update for that asset is ignored (the answer is the current player state), and a later one is applied with the client timestamp as the update time.offlineandclient_timestampare read on this route under those exact snake_case names (the rest of the heartbeat body keeps its camelCase names).- A
client_timestamplater than the Engine’s clock is clamped to the Engine’s clock, so a device with a fast clock cannot make its record win over every later write. - An applied offline replay also adds one entry to the profile’s History, stamped with the client time (UTC). Syncing the same play again (same device, same second) does not add another, and an ignored (older) replay adds none. No extra fields are involved.
Device playback preferences. GET /api/v1/devices/current/playback-preferences (scope playback.read) lets a paired app read, never change, the watching and subtitle preferences of the profile its device is using now. Only a paired app’s own bearer token may call it: a signed-in Dashboard session and a service Application are refused with 403 before any data is read, as is a token without a device or active profile, and a token without playback.read gets 403 insufficient_scope. The profile comes only from the token’s active-profile claim (no profile id in the path, query or body), so an app can never read another person’s settings, and a switch of person on the device changes the answer. 404 when the profile no longer exists. There is no write route: PUT, POST, PATCH and DELETE answer 405. The answer is a NativePlaybackPreferencesDto (camelCase, unlike the snake_case device routes): {"audioLanguage", "subtitleLanguage", "subtitleMode", "subtitleSize", "subtitleBackground", "skipBackSeconds", "skipForwardSeconds", "autoplayNextEpisode", "resumePlayback", "defaultPlaybackSpeed"}. These are the values saved in Settings > Playback; with nothing saved they are the defaults, and nothing is written. audioLanguage and subtitleLanguage are language names such as English, not codes. subtitleMode is Auto, Always or Off (the “Show subtitles” setting, stored as Auto, Always or Never; Never is sent as Off). subtitleSize is always one of Small, Medium, Large or ExtraLarge (matched in any case, anything else is Medium). resumePlayback is the general Resume setting and defaultPlaybackSpeed is the watching speed (1.0 is normal). Listening, reading and sync settings are not part of it.
Device download list. The paired app reports what it has stored for offline use. Only the paired app’s own bearer token may call these routes: a Dashboard session, another account’s token or a service credential is refused with 403, the device always comes from the token (never from the body), and a device sees only its own rows, for the profile it is using now.
| Route | Body / answer |
|---|---|
GET /api/v1/devices/current/downloads | 200 {"downloads":[{asset_id, variant_id, kind, photo_item_id, stored_bytes, stored_at, expires_at}]}, oldest stored first, at most 500 entries of any kind. kind is variant (the default; asset_id and variant_id name the offline variant, photo_item_id is null) or photo (photo_item_id is the View item id, asset_id and variant_id are the empty id, expires_at is always null). expires_at of a variant is when the Engine will remove it (null when it never does). A kept photo the active person can no longer read (deleted from the index, a share withdrawn, a document rule) is left off the list, so an app purges its local copy when an id it holds is missing; a photo whose file is only missing from disk for now (a library volume that is briefly unmounted) stays listed. All kept photos are decided in one bulk pass (the capability and scope once, the items by list), never one query set per photo. |
POST /api/v1/devices/current/downloads | Body {asset_id, variant_id, stored_bytes, stored_at} plus the optional kind (variant when omitted, or photo) and photo_item_id. A variant report adds or updates the row for that variant: 204. 404 when the variant is unknown, belongs to another profile or device, is not ready, has expired, or the profile may not download the asset (the same catalogue check as GET /api/v1/playback/{assetId}/offline/{variantId}, so these all look identical). stored_bytes is clamped to 0 through the variant’s size, and a stored_at later than the Engine’s clock becomes the Engine’s clock. With kind photo the body names photo_item_id instead (asset_id and variant_id are ignored), the token must also hold view.personal.read (403 otherwise), and the item must be a photo or video with a file that the active person can read through the photo routes (GET /api/v1/view/items/{id}/preview: the View Mine scope for the token’s profile, never someone else’s item, never a document, whatever the person’s Documents setting): an unknown id, someone else’s item and a document all answer the same 404. stored_bytes is clamped to 0 through 256 MB. A photo has no expiry. A photo whose file is not on disk right now cannot be kept (the preview route answers 404 too). 400 for kind book (reserved for later), any other unknown kind, or a photo without photo_item_id. 409 once the device already lists 500 entries of any kind (updating a listed one still works). Only entries the list would show count: before answering 409 the Engine clears this person’s kept photos that the list no longer shows them (access withdrawn) and tries once more. |
DELETE /api/v1/devices/current/downloads/{variantId} | Forgets the variant row: 204, or 404 when it is not listed. |
DELETE /api/v1/devices/current/downloads/photos/{itemId} | Forgets the kept-photo row for that View item and the token’s active person: 204, or 404 when it is not listed. Both DELETE routes need downloads.write. An app sends this when it purges a kept photo that has gone from the list, so a row the list hides does not keep counting. |
Removal of an expired variant also removes its list row, so a device that still believes it holds the file finds the row gone on its next GET. The Dashboard’s app door forwards exactly these three route shapes.
Unmatched state on a single-item page. For an administrator, GET /api/v1/details/{entityType}/{id} on a single-item page (movie, episode, book, audiobook, comic issue, work) carries unmatchedAssets ([{assetId, keptAsIs}], the page’s own files that have an unmatched_assets row) and adds unmatched-retry (Retry now) and unmatched-keep (Keep as is) to overflowActions while a file is Unmatched and not kept, or only unmatched-look-up-again when every Unmatched file is kept. The Dashboard calls the existing Unmatched item routes with those asset ids. Only a signed-in effective administrator gets them (a service application or paired app does not, because the item routes refuse it). For anyone else, and for a Vault file, unmatchedAssets is empty and none of the actions appear. This is separate from isUnmatched, which is about placement (“Add anyway”).
Next episodes. GET /api/v1/details/show/{id}/next-episodes?after={assetId}&count=3 returns {"show_id", "after_asset_id", "episodes":[{work_id, asset_id, title, season_number, episode_number, still_url}]}: the owned episodes that follow the episode file after in the show’s watch order (seasons ascending with specials last, then episodes ascending). A combined file that covers several episodes counts once and is skipped when it is the file in play. count defaults to 3 and is clamped to 1-10. Libraries hidden from the account and the profile’s content limit (Kids) apply exactly as on the other details routes, and an unknown show, an after file that is not one of the show’s episodes, and one the profile may not see all answer 404; the last episode answers 200 with an empty list. after is required (400 when missing). The first path segment after details is the literal show on this route.
Long TV episode lists
GET /api/v1/details/{entityType}/{id} carries a TV show’s seasons in sequencePlacement.groups. Each group reports itemCount (every episode in the season, owned and missing) and itemOffset (position of items[0]). Windowing is opt-in: a request with no sequence* parameter receives the whole list exactly as before (orderedItems and every group’s items complete), so older clients keep working. A client opts in by sending sequenceLimit (the Dashboard sends sequenceLimit=50). Up to 100 episodes in total a windowed request still lists every episode. A longer show then lists every season but fills items for one season only (the season being watched, starting at the window that holds the current episode); orderedItems is empty because the episodes are carried once, inside their season.
A season by its own address. GET /api/v1/details/tvseason/{id} answers with the page of the season’s show, opened on that season: the payload’s entityType is the show and its sequencePlacement selects the season. The optional containerId is the show’s id; when it is omitted the Engine resolves the show from the season. A season id that does not exist, or that the profile may not see (hidden libraries and the content limit apply as on the other details routes), answers 404; so does a season whose show cannot be resolved.
Optional query parameters on the same route:
| Parameter | Meaning |
|---|---|
sequenceGroup | Season key (for example season-2) to read; returns exactly that window. An unknown key falls back to the season being watched. |
sequenceOffset / sequenceLimit | First episode and window size (default 50 when a season or offset is named, at most 200). An offset past the end returns the last full window. sequenceLimit=0, or no sequence* parameter at all, returns every episode, for playback queues and the player’s episode picker. |
sequenceCurrent | Episode id to mark isCurrent in the answer. |
sequenceOnly=true | Return just sequencePlacement, without credits and the rest of the page. |
The routes need no extra scope or permission: they are the same detail request.
Collections and Universes (read only)
Paired apps read the same Collections catalogue the Dashboard shows, limited to the profile the device token acts as. The profile comes from the token, never from a query value. Its content limit (Kids and maturity), libraries and features are applied before anything is listed or counted, so an item over the limit appears in no page, no item_count, no total_count and no artwork preview. There are no write, lore or graph routes on this surface; collection edits, personal-media sources and the relationship graph stay out of the app door.
| Method and route | Answer |
|---|---|
GET /api/v1/collections?cursor=&limit= | PagedResponse of catalogue entries (CollectionManagementCatalogDto), default 24, at most 250, with total_count and next_cursor. Collections with nothing the profile may see are left out. |
GET /api/v1/collections/{id} | One catalogue entry. |
GET /api/v1/collections/{id}/items?cursor=&limit= | PagedResponse of CollectionItemDto (title, creator, media type, cover, detail route, membership_scope, position_label), default 24, with total_count (at most the first 5,000 items). A Series is listed in Series order: the numbered run first (position_label is the source’s own text, so a decimal such as 1.5 stays 1.5), then members with no position, then “Also in this world” members (membership_scope Supplementary, CollectedContent or BroaderContext), which are never numbered with the run. sort_order is the item’s place in that list, counted from zero. |
GET /api/v1/universe/{qid} | UniverseClientDetailDto: the Universe’s name, hero totals (series_count, item_count, and media_types, the distinct formats of the visible items), its member shelves, a read-only cast and a read-only adaptations list. Each shelf is a DisplayShelfDto (the same card shape as /api/v1/display, first 24 cards, seeAllRoute pointing at /api/v1/display/groups/{collection_id}) with the member’s own collection_id, group_type and item_count. shelf and item_count hold only the Series run; the Series’ supplementary, collected and broader-context members (the same “Also in this world” rule as the item page) are in also_in_this_world (a DisplayShelfDto, null when there are none; never present for a show) with also_in_this_world_count, in the same Series order. The Universe’s item_count counts every run and Also item once. cast has one row per person (person_id, name, headshot_url, characters[] of {qid, label} distinct by QID and ordered by label, work_count), most works first then by name, at most 50, and only credits from works the profile may see. adaptations has one row per real adaptation relationship (based_on, derivative_work, inspired_by) between things the profile may see (qid, label, media_type, relationship, source_qid, source_label), at most 50; it is an empty list when the Library records no such relationship (as is /universe/{qid}/adaptations). A Universe the profile cannot see is the same 404 as an unknown one. |
cursor is the next_cursor of the previous page; pass it back unchanged. A malformed cursor is 400. Catalogue entries carry group_type, the user-facing kind of group: show, series, collection (a rollup across shelves or a rule-built group) or list (a hand-picked or system list); the Universe detail uses universe. A catalogue entry that is a Universe has group_type universe and universe_qid, the Wikidata QID (Q and digits) that opens GET /api/v1/universe/{qid}; universe_qid is null for every other entry, and it is only set when that route would serve the Universe to the same profile. Authoring fields of an entry (rule_json, rule_hash, selected_profile_ids, profile_id, refresh_schedule, the can_* flags and the collection’s own uploaded artwork URLs) are always blank for apps; member artwork is in artwork_items.
An unknown id or QID, a collection that belongs to another profile, and one with nothing this profile may see all give the same empty 404, so an app cannot tell them apart. Collections hold owned media only: a profile that has Documents turned off never sees a document here, because Documents are View files and are not part of any collection’s items or counts.
Capability negotiation declares containers, video/audio codecs, subtitle formats, protocols, maximum dimensions/bitrate/channels, HDR, speed, and offline-download support. The playback manifest compares those registered capabilities with inspected source media and returns directPlaySupported, recommendedDelivery, source/alternate URLs, tracks, chapters, resume state, warnings, and a conversion reason. For HLS it also returns hlsStatus, hlsUrl, and hlsExpiresAt. preparing means the client should retry the manifest; ready supplies the playable path. Clients must follow the manifest rather than independently guessing whether a file can direct-play.
The HLS URL is a bearer capability scoped to one asset, package, and expiration time. It is intentionally usable by native HLS media stacks that cannot attach application authorization headers to every segment request. Clients must not log, persist, or share it. Expired or modified paths return 403; missing and traversal-rejected package resources return 404. Relative URIs in the master and media playlists preserve the same signed path for variant, alternate-audio, caption, and segment requests.
Offline encode jobs created by paired clients are owned by the authenticated profile and device. Job listing and cancellation use those trusted token claims; one client cannot enumerate, cancel, discover, or download another device’s work. Public JSON never includes the Engine’s local output path. Clients download only the API v1 URL exposed by their ready offline-variant model.
Most paginated GET endpoints accept offset/limit query parameters. View
timeline and discovery endpoints use opaque/keyset cursor plus limit, and
Manual Gallery membership uses position/item keysets. Limits are clamped
server-side so a caller cannot force an unbounded read.
JSON errors use RFC 7807 Problem Details through the shared ApiErrors
factories. JSON success bodies use named MediaEngine.Contracts types, and
every production route declares its 2xx response metadata for Swagger. Untyped
success metadata is reserved for reviewed bodyless, redirect, file, stream, and
direct-response routes.
MediaEngine.Contracts is the sole owner of non-frozen HTTP and SignalR wire
types. Engine endpoints map internal projections explicitly into those
contracts, and the Dashboard may add presentation wrappers without redefining
the JSON payload. The only frozen exception is the exact Universe/Chronicle
SignalR payload UniverseEnrichmentProgressEvent; Universe HTTP responses are
not exempt.
The canonical contracts intentionally include fields that existed on only one
side of the former API/Dashboard mirror boundary. Notable examples are
ingestion count_unit; person and person-backed collection roles; complete AI
settings plus resource cpu_pressure; complete managed-collection, placement,
rule, permission, and artwork fields; playback manifest, player, settings,
chapter, bookmark, text-track, and offline-delivery fields; and plugin
manifest, capability, permission, tool, AI-permission, health, job, and approved
catalog fields. These are contract completeness corrections, not separate
compatibility versions.
System
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /system/status | Service health, version, uptime | None |
| GET | /system/activity-status | Sanitized leased/running durable operations used to seed the Dashboard navbar activity indicator; excludes media titles and filesystem paths | Required |
| GET | /system/readiness | Launch readiness report: status (ready, degraded or not_ready), checked_at and checks[] (name, category, status, required, description, data) for the data store, configuration, storage, AI models, providers, media runtimes and background workers. ?category=storage runs only the library and watch folder checks, without the data store integrity scan and write probe; the Library summary in Settings uses it. The other categories are database, configuration, models, providers, media and workers; any other value is refused with 400. | Administrator, or application with system.status.read |
| GET | /system/watcher-status | File watcher diagnostic - shows monitored folders and last event | Required |
| GET | /system/resources | Local reading for the Settings System summary: Engine version, start time and uptime (engine_version, started_at, uptime_seconds), Engine processor use as a percent of the machine and memory in use and available in MB (processor_percent, memory_used_mb, memory_total_mb), and drives (root, label, total and free bytes, and whether each holds the library and/or the data store; a drive holding both appears once) plus data_store_size_bytes. Nothing leaves the machine. | Administrator |
| GET | /system/development-tools | Which development-only Settings pages this Engine offers: { "enrichment_tester": bool, "development_data": bool }. Both are false in a release build, where the actions behind those pages are not mapped. The Dashboard reads it to decide whether to show the Enrichment Tester and Development data pages. | Administrator |
| GET | /system/data-store-version | Latest upgrade step applied to the data store (step_number, step_name, applied_at); step 0 with no date means none is recorded yet. Shown as a read-only line in Settings. | Administrator |
| GET | /system/backups/state | Counts for the Backup and recovery screen, never a title: file_only_titles (titles that have your progress but no external id, so a restore can match them by exact file only), waiting_period_days (missing_file_retention_days), still_waiting (restored items still waiting for their files, the Vault’s included) and latest_restore (restored_at, backup_file_name, applied, attached, yielded, purged; null when nothing was ever restored). | Administrator or application with backup.read |
| PUT | /system/backups/waiting-period | Body { "days": 90 }: how many days restored progress waits for its files before it is cleared, saved to config/maintenance.json (missing_file_retention_days, the same limits as the configuration check: 7 to 3650). Returns the new BackupStateDto. 400 with one sentence (“Choose between 7 and 3650 days.”) when out of range; nothing is saved. | Administrator |
| POST | /system/backups/validate | Body { "file_name": "..." }: the Test restore. Checks the backup file as a real restore would and changes nothing. Returns valid, file_name, validated_at, message and contents, a first look from the backup’s manifest (profiles, configuration_rows, personal_items = progress, bookmarks, favourites, choices and corrections, titles, file_only_titles); totals only, it names no title, profile or kind of title. 400 with one plain sentence for an older layout or a damaged file. | Administrator or application with backup.read |
| GET | /system/scheduled-tasks | Every recurring background task with its schedule, next run and last run: tasks[] of key, name, owner (Library, Local AI or Plugin), plugin_id, schedule (a cron expression read from settings at request time, or plain text such as every 5 seconds for continuous work or every 30 minutes for a plugin job on an interval), is_continuous, settings_place, next_run_at (UTC, null for continuous work and for a paused plugin job) and last_run (started_at, finished_at, outcome of running, succeeded, failed, skipped or interrupted, items_processed, message; null when the task has never run). Sorted by name. Recurring jobs declared by switched-on plugins are listed too, as plugin:{plugin_id}:{job_key} with owner Plugin, plugin_id set and the name “Plugin name: Job name”. Reads the durable scheduled_task_runs records; webhook delivery reports its newest delivery attempt instead. | Administrator, or application with system.status.read |
| POST | /maintenance/sweep-orphan-assets | Scan .data/assets/ for managed files with no database reference and remove them. | Administrator |
| POST | /maintenance/storage/run | Run storage maintenance on demand. Supports dryRun, cache retention, image retention, and claim compaction batch query parameters. | Administrator |
Library
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /library/works | All works with their canonical values | Required |
Media Management
Library configuration uses schema 8.0, including explicit server-visible folder-picker roots. Older flat-folder and Photos/General
schemas are rejected during the pre-beta cutover rather than migrated or served
through compatibility routes.
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /settings/libraries | Return the complete library configuration, including stable sources and universal incoming sources. Each library carries a read-only unmatched_count (files the Engine could not match, kept-as-is files excluded; 0 for anyone who is not an effective administrator), and each source a read-only first_check ({ state, tried, unmatched, total_files } while its first check is checking or paused; left out otherwise, and for anyone who is not an effective administrator) | Administrator |
| GET | /settings/library-kinds | Every library kind (type of library) in order (Watch, Listen, Read, View, Vault, then each kind’s order): the kinds in config/library_kinds.json followed by the kinds of switched-on declarative plugins. Each entry has key, area, label, icon, format, lookup_providers (empty means no lookup), naming_template, misfit, default_handling, tooltip, order, built_in, plugin_id (null for a built-in kind), available (false for a kind that is defined but not offered yet; screens show only available kinds), allows_multiple (true when a person may keep several named libraries of the kind) and max_libraries (how many; 1 unless allows_multiple). Same access as GET /settings/libraries | Administrator |
| PUT | /settings/libraries | Validate and replace the complete schema-7 library configuration; the library responses (GET, PUT, mutation) include import_folder_status (whether the optional import folder is set and which types still need a destination) | Administrator |
| GET | /settings/tools | The last tools check: each tool and AI model with whether it is required, its state (ready, missing, problem, downloading, installer_ready), what it affects, how to fix it for this install, and how many files are waiting on it, whether the Library can install it (can_install, can_download_again). Runs a first check if none has run | Administrator |
| POST | /settings/tools/check | Look for the tools again, let work that was waiting on them continue, and return the fresh result. Setup’s server check (POST /setup/v1/preflight) carries the same result as tools and, while FFmpeg is missing and auto_install_required is on, starts its download so the row comes back as downloading; a missing tool never makes the check fail. GET /setup/v1/tools (setup session) returns the same tools list on its own so setup can follow a download’s progress | Administrator |
| PUT | /settings/tools/{id}/location | Save the folder a person chose as the home of FFmpeg (ffmpeg) or Tesseract (tesseract). Body: storage_location_id and relative_path as the folder picker returns them, or manual_path. The folder must hold the tool (FFmpeg needs both ffmpeg and ffprobe), otherwise 400 with a plain message such as “That folder doesn’t contain FFmpeg.”; an unknown id is 404. On success the explicit path is saved to config/transcoding.json or config/ocr.json, the tools are checked again, and the fresh result is returned | Administrator |
| POST | /settings/tools/{id}/install | Start downloading FFmpeg (ffmpeg) or Tesseract (tesseract) from the fixed address and with the checksums in config/tool_downloads.json (“Install for me” when missing, “Download again” when broken). Offered only where a row says can_install or can_download_again: Windows x64, never in a container; a Windows-installer install is offered FFmpeg only. Returns 202 with the tools result, where the row is downloading with download_percent; FFmpeg is unpacked, checked and put in {DataRoot}/tools/ffmpeg, then the tools are checked again so waiting files continue. Tesseract ends as installer_ready, with its language data already in place. 409 when that tool is already downloading, 400 with a plain message when it is not offered here, 404 for an unknown id. Every attempt is written to the system activity log | Administrator |
| DELETE | /settings/tools/{id}/install | Cancel a download in progress and remove its temporary files; does nothing if none is running. Returns the tools result; 404 for an unknown id | Administrator |
| POST | /settings/tools/{id}/run-installer | Start Tesseract’s downloaded and re-checked setup program on the machine running the Engine (Windows asks for approval); when it closes the Engine looks for Tesseract again and removes the installer. Only when the Engine runs in a signed-in desktop session, otherwise 400 says to reinstall with the Windows installer. 202 with the tools result, 409 while the installer is open, 404 for an unknown id | Administrator |
| GET | /settings/notices | The Settings attention lines the active profile dismissed with the X, each as key (needs-review or missing-tools) and the fingerprint that was true at the time (the Needs Review count, or the sorted comma-joined ids of the missing tools). The profile comes from the caller’s own authority, never from the request | Administrator |
| POST | /settings/notices/{key}/dismiss | Remember that the active profile dismissed that line. Body: fingerprint. 204; 400 for an unknown key or a blank or over-long fingerprint, saving nothing. A line returns only when something new needs attention (more review items than the stored count, or a missing tool id not in the stored set) | Administrator |
| GET | /settings/ocr | Text recognition settings, whether the helper and language data are installed, the language checklist, and the reading queue (length and current file name only) | Administrator |
| PUT | /settings/ocr | Save text recognition on or off, effort (normal or low) and the languages to read with | Administrator |
| POST | /settings/ocr/languages/{code} | Download the language data for one listed language; the only network use of text recognition | Administrator |
| POST | /settings/ocr/reread | Queue every PDF and photo without text to be read again, in batches (202; 409 when already running or not ready) | Administrator |
| GET | /settings/incoming-sources | Return universal unassigned intake folders | Administrator |
| PUT | /settings/incoming-sources | Validate and replace universal incoming folders | Administrator |
| POST | /settings/libraries/{libraryId}/reorganization/plan | Produce a read-only, expiring move/rename plan with stable source IDs, conflicts, blocked work, and an exact confirmation fingerprint | Administrator |
| POST | /settings/libraries/{libraryId}/reorganization/execute | Execute one confirmed plan once, revalidating source policy, files, collisions, and free space immediately before each operation | Administrator |
| POST | /settings/libraries/folders/conversion/preview | {source_id, quick, file_limit}. quick: true counts the folder’s media files from folder listings only (total_files, video_files, time estimate, duplicate_policy). Otherwise a read-only dry run over the first file_limit files (0 = all): to_rename, already_correct, duplicates_to_remove, needs_review, name_collisions, skipped with skip_reasons, skipped_links (shortcuts inside the folder that the job never follows or touches), five samples of old to new path, and estimated_seconds. Nothing is changed | Administrator |
| POST | /settings/libraries/folders/convert | {source_id}. Makes the folder Managed (conversion_state: converting) and queues one organise_folder job on the folder_jobs queue; a folder that already has an open job gets that job back. Returns the job status | Administrator |
| POST | /settings/libraries/folders/merge/preview | {source_id, target_source_id, mode, quick, file_limit} (mode is copy or move). quick: true counts the source folder’s media files from folder listings and file sizes only: total_files, video_files, source_bytes, target_free_bytes, a time estimate, duplicate_policy and blocked_reason when the target’s drive lacks room. Otherwise a read-only dry run over the first file_limit files (0 = all): to_transfer, already_in_target (exact copies already held, in the target or elsewhere in the library), needs_review (files that go to the From merge folder), name_collisions, skipped with skip_reasons, five samples. Nothing is changed. 400 when the merge cannot go ahead (target not managed, another library, nested folders, Move out of a Read-only folder, the New media folder as source, a merge already done) | Administrator |
| POST | /settings/libraries/folders/merge | {source_id, target_source_id, mode}. Records merge_state: merging on the source folder and queues one merge_folder job on the folder_jobs queue; a folder that already has a merge in progress gets that job back (409 when a conversion is open on it). Refuses when the target is not a managed, writable folder of the same library or lacks room. Returns the job status (job_type: merge_folder) | Administrator |
| POST | /settings/libraries/folders/{sourceId}/change-kind/preview | {kind_key, library_id?}. What changing what the folder is would do, nothing changed (with no library_id the preview is about a new library of the kind, for a kind with none or with several named libraries: to_library_id is empty and visibility_loss is empty and checked, since a new library is hidden from nobody): file_count, files_to_refile (files queued for the next batch’s re-filing; 0 for a Read-only folder; one already at its planned path is not moved), drops_details (the new kind is plain and the old one looked things up), starts_first_check, other_apps_warning (Managed), visibility_loss (people who see the files now and would not in the new library, Simpler access D13) with visibility_loss_checked (false, with visibility_message, when it could not be worked out), and the labels and library chosen. 400 for a Vault target, an unknown or same kind, a kind from another area or a library that does not match the kind; 404 for a Vault or unknown folder; 409 when a folder job is open on it | Administrator |
| POST | /settings/libraries/folders/{sourceId}/change-kind | {kind_key, library_id?}, refused as the preview is. The preview and the start mean the same by a blank library_id: a kind with exactly one library means that library; a kind that holds several named libraries (Other videos, Other audio, Other reading) means none in particular, so the start refuses it with “Choose which {kind} library the folder goes into.” and never quietly picks the one that exists (the preview calls it a new library); a kind with no library yet is refused with “There is no {kind} library yet. Create it first, then choose it.” (a new library is created first through the library action). Writes the plan and queues one change_folder_kind job on the folder_jobs queue. Returns the job status (job_type: change_folder_kind) | Administrator |
| GET | /settings/libraries/folders/jobs | The open folder jobs and each folder’s most recent finished job from the last week (state, items_total/completed/failed, progress_percent, estimated_seconds_remaining, message, queued_behind, has_log) | Administrator |
| GET | /settings/libraries/folders/jobs/{id} | One folder job | Administrator |
| POST | /settings/libraries/folders/jobs/{id}/control | {action} of pause, resume or cancel; 409 when the job has already finished (or is not paused) | Administrator |
| GET | /settings/libraries/folders/jobs/{id}/log | A page (offset, limit) of the job’s log: every file, old path to new path, with its outcome | Administrator |
| GET | /settings/libraries/folders/jobs/{id}/log/export | The whole log as a CSV download (the Dashboard serves it at /engine-folder-log/{id}) | Administrator |
| POST | /settings/libraries/mutations | {Library, ExpectedLibrary} (or {ViewStorage, ExpectedViewStorage} for the View root). Saves one library’s change (add, label, remove or switch a folder, move the New media marker) without replacing the rest of the configuration; 409 when the library changed since the screen read it. The folder pickers and the Libraries screen use this instead of the whole-configuration PUT | Administrator |
| GET | /settings/server-folders/roots | The places the folder picker can start from: the server’s drives and root, plus any administrator-approved folders (id, label, path, allow_write, available_bytes, file_system). Tuvima’s own library, import and View folders are never listed. An unreachable location is left out and logged | Administrator |
| POST | /settings/server-folders/browse | {storage_location_id, relative_path, search, include_files, kind_key}. With include_files it also lists the files of that folder (files with name, relative_path, path, size_bytes, modified_at, supported; supported is false for a file the kind_key library kind does not take; hidden files and links leaving the place are left out). The sub-folders of one listed place (directories with name, relative_path, modified_at; parent_relative_path; display_path). Cannot leave the place or enter one of Tuvima’s own folders; 400 when it tries | Administrator |
| POST | /settings/server-folders/validate | {storage_location_id, relative_path} or {manual_path}, plus selection_mode (managed_library, existing_library, personal_space_managed, personal_space_existing, and tool_location for choosing a tool’s folder, which is read-only and skips the library-overlap and reserved-name rules but still refuses the Library’s own folders) and optional current_source_id and kind_key (the library kind the folder is for, such as movies). Checks, from the server’s side, that the folder exists, can be read (and written for the managed modes) and does not overlap another library, the import folder or a Tuvima folder. With a kind_key it also counts the files that kind takes (the extensions of the kind’s format from the media type catalogue, subfolders included, links and dot-folders skipped), stopping at 10,000 and giving up after a few seconds or when the request is cancelled, so a slow network drive never freezes the folder picker. Returns can_select, exists, has_read, has_write, contains_files, available_bytes, supported_file_count (null when no kind was asked for or the count is unavailable), supported_file_count_capped (true reads “10,000+”), supported_file_count_unavailable, supported_file_kind_label (video, audio, book or comic, so the screen can say “126 video files found”) and issues (code, message, library_name, severity); a refused overlap (already_configured, inside_configured_source, contains_configured_source) carries a plain sentence that names the other library, and no count is made for a folder that cannot be used. 400 for a path outside every listed place | Administrator |
| POST | /library/libraries/{libraryId}/scan | Scans this library’s own folders only: those with scan_enabled that exist on disk. 202 with {message, directories}; 400 when no folder can be scanned; 404 for an unknown or Vault library | Administrator |
| GET | /library/libraries/{libraryId}/folder-titles | {counts}: titles in the catalogue per folder, keyed by the folder’s source id (orphaned files and catalogue-only titles are not counted) | Administrator |
| POST | /library/libraries/{libraryId}/add-files | {paths}: copies server files into this library’s import destination (the originals are only read). Returns {copied, skipped_duplicates, unreadable, unsupported, bytes_copied}, then scans the destination. Refused before anything is copied with a problem body carrying code: nothing_selected (400), path_not_approved (400, any path outside an approved storage location, a link leaving it, one of Tuvima’s own folders or a missing file), no_import_destination (409), destination_unavailable (409) or not_enough_space (507, “Not enough space: N needed, M free”). Duplicates follow the folder, then library, then server duplicate rule | Administrator |
| GET | /setup/v1/library-kinds | Setup-session protected. The same list as GET /settings/library-kinds, so the first-run Libraries step and Add folder draw their types from it | Setup session |
| GET | /setup/v1/libraries | Setup-session protected. The library configuration, so the first-run Libraries step can show the folders added so far (the same shape as GET /settings/libraries) | Setup session |
| PUT | /setup/v1/libraries | Setup-session protected. Replaces the library configuration during first-run setup, with the same checks as PUT /settings/libraries. Setup only prepares the folders; watching starts when setup is complete | Setup session |
| GET | /setup/v1/server-folders/roots | Setup-session protected. The same list as GET /settings/server-folders/roots, for the folder picker inside setup | Setup session |
| POST | /setup/v1/server-folders/browse | Setup-session protected. The same action and body as POST /settings/server-folders/browse | Setup session |
| POST | /setup/v1/server-folders/validate | Setup-session protected. The same action and body as POST /settings/server-folders/validate | Setup session |
Reorganization execution requires both the returned plan_id and its exact
fingerprint. A plan expires after 15 minutes and cannot be reused. Partial
outcomes are reported per item; an existing-library source is always blocked
from mutation.
View
Personal View routes require the trusted profile assertion established by the
Dashboard. The server-to-server X-Tuvima-View-Profile,
X-Tuvima-View-Timestamp, and X-Tuvima-View-Signature headers bind the active
profile to the exact method/path/query through HMAC-SHA256 using the already
validated API key. They are accepted only for /view and /collections, must
be within the configured short skew window, and are not browser credentials.
Browser-supplied profile, library, Gallery, scope-profile, or asset IDs are
never treated as authority. Friendly Shared, Mine, and permitted Profile scopes
(plus scope=othershared&scopeProfileId=… for a server administrator reading another household’s Shared Library, read-only and
never saved as the last scope; any look at another household is recorded as view.other_household_opened, at most once an hour
per viewer and space) are resolved by the Engine before any asset, Gallery, People, Places,
thumbnail, original-file, or personal-media Collection query runs. Direct
unauthorized identifiers return the same not-found shape as missing resources.
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /view/scopes | Resolve the selected/default scope and return only authorized labeled scope options, plus can_view_documents (false when the profile’s Documents permission is off, so the Dashboard hides the Documents tab), can_view_photos and can_view_personal_videos (false when that kind’s permission is off) and can_file_shared_inbox (true for a household or server administrator with Documents, who can file Shared Library Inbox documents) | Required + trusted profile |
| GET | /view/other-people | List other households and their people for read-only browsing (“Other people”); never includes the caller’s own household | Server administrator signed in as a person (household administrators, members and apps get 403) |
| GET | /view/photo-views | Page the times a server administrator opened your own photos, newest first. Read-only and answered only for a signed-in person (no profile or household parameter; a paired app’s device token is refused with 403): another person’s personal-space views are never returned, not even to a household administrator, who additionally sees views of the household’s Shared Library (household_wide: true) | Required |
| GET, PUT | /view/preferences | Read or persist the active profile’s last scope, timeline density and viewer-info state. A person always reads and saves their own (the Mine scope); no one reaches another person’s. The unscoped /view/assets and /view/assets/timeline-index follow the saved scope through this same permission | Required + trusted profile |
| GET | /view/assets | Cursor-page authorized mixed local assets by scope, search, kind, state, or Gallery. With q, each item carries a snippet (words around the match, matches wrapped in control characters U+0001 and U+0002 so text is never markup); every document carries document_info (text_source, text_truncated, encrypted, has_text). kind=document is not narrowed to Photos-timeline sources. A profile without the Documents permission never gets documents back, and asking only for documents returns 403. A profile without Photos or Personal videos never gets items of that kind back (nor in counts or search), by each item’s View kind (content_kind), so a picture of paper kept in a Documents folder is hidden from a profile without Documents. Every item carries content_kind, added_by_profile_id and added_by_name (the adding profile’s current name; null when it was removed), and added_by (repeat) narrows the list to items added by those profiles. Document filters: tag (repeat; every tag must be present), correspondent, docType, docFrom and docTo (the document’s date, or the day it was added when it has none), filing (inbox or filed), field (repeat, <field id>:<value>) and docSort (added, document_date, title or correspondent; a sorted list pages with an opaque position cursor, never a date boundary; any other value is 400). A saved Documents view is listed with galleryId, and the filters narrow it further; cards carry the correspondent and type in document_info | Required + scope read |
| GET | /view/assets/added-by | The profiles that added items in a scope, most items first: profile_id, name, item_count (the filter chips on shared galleries). Takes scope, scopeProfileId and optional kind (repeat), and reads the same rows as GET /view/assets (same visibility and permission rules), so a profile whose only items the caller cannot see is left out. A profile without the Documents permission asking only for documents gets 403 | Required + scope read |
| GET | /view/assets/timeline-index | Month buckets (year, month, asset_count, earliest_at, latest_at) for the same filters as GET /view/assets (scope, q, kind, favorite, hidden, galleryId, lifecycle, added_by). A profile without the Documents permission is narrowed to the other kinds exactly like the list, so neither the counts nor a text search ever reach document words, and asking only for documents returns 403 | Required + scope read |
| POST | /view/uploads | Upload to the caller’s server-resolved Personal Space. A file View indexes as a document (by extension, e.g. PDF, Word, text) is refused with 403 before anything is stored or compared when the caller, or the person whose space it would land in, may not use Documents, so no “already added” answer can mention an existing document. A photo or video is refused with 403 the same way when the caller, or the person whose space it would land in, may not use Photos or Personal videos. The upload/backup source takes pictures, videos and documents; audio and other files are refused with 400. Each item is recorded as added by the profile making the request (not the person a paired phone backs up for), and takes the View kind of its own type | Required + Personal Space owner |
| GET | /view/items/{id} | Read authorized personal-asset metadata (documents also need the Documents permission, as do content, thumbnail and preview) | Required + asset read |
| PUT | /view/items/{id}/description, /captured-at, /people, /location | Change one item’s description, captured date, identified people or place; each answers with the whole item. On a document they return 403 for a profile without the Documents permission, because the answer carries the document’s notes, field values and suggestions | Required + asset owner |
| GET | /view/items/{id}/content | Stream an authorized original with range support | Required + asset read |
| GET | /view/items/{id}/thumbnail | Return a bounded local image preview when supported; a PDF returns a small picture of its first page (drawn once in the background and cached), other documents get the generic placeholder | Required + asset read |
| GET | /view/items/{id}/text | Return the readable text of one document (at most 200,000 characters, with shown_truncated) and its document_info for the Documents viewer text panel | Required + asset read + Documents permission |
| GET | /view/items/{id}/document-details | One document’s organising details as document_info: document_date, correspondent, document_type, archive_number, notes, filing_state (inbox or filed), tags and field_values (the only read that fills field_values) | Required + asset read + Documents permission |
| PUT | /view/items/{id}/document-details | Replace those details in one request (title, document_date, correspondent and document_type by name, archive_number, notes, filing_state, tags by name, tag_ids, field_values). A name that exists is reused; a new name joins the global list. Anything not sent is cleared, except field_values: leave it out to keep the custom field values as they are, send an empty list to clear them | Required + asset owner + Documents permission |
| PUT | /view/items/{id}/tags | Replace an item’s tags (tags by name, tag_ids for tags the person can already see). A tag id the person cannot see is answered like an unknown one (404). Documents also need the Documents permission | Required + asset owner |
| GET | /view/tag-suggestions | Type-ahead tag names for the person: tags they can see, most used first, plus their saved Library tags. A profile without the Documents permission is never offered (or weighted by) a tag that only documents carry | Required |
| GET | /view/catalogue | The tags, correspondents, document types and custom fields the person can see, with their own usage_count (narrow the counts with kind; with scope the counts describe that scope, without it the lists cover every scope they can open) and can_manage | Required + Documents permission |
| POST/PUT/DELETE | /view/tags, /view/correspondents, /view/document-types (PUT/DELETE take /{id}) | Add, rename (and recolour) or delete a name. A duplicate in any letter case is refused with 409 and a plain message. Rename and delete are allowed for the creator, a server administrator, and a household administrator for names made in their own household or by nobody (for example names carried over from the earlier tags); anyone else gets 403, and a name the person cannot see is 404. Delete removes links, never items | Required + Documents permission |
| POST/PUT/DELETE | /view/document-fields (PUT/DELETE take /{id}) | Define, rename (choices may be replaced; the kind cannot change) or delete a custom field of kind text, number, date, money, bool or choice. Same permission rules as above | Required + Documents permission |
| POST | /view/documents/bulk | Apply one bulk edit to up to 500 documents. Body: item_ids, operation (add_tags, remove_tags, set_correspondent, set_document_type, set_document_date, mark_filed, move_to_trash, restore) and what the operation needs: tags (names) and/or tag_ids, correspondent, document_type, document_date; for the three set_ operations a blank value is refused unless clear is true. More than 500 ids, no ids, an unknown operation or a missing value is 400. The answer has requested, done, skipped, failed and one items entry per distinct id in the order sent, each with a status (done, skipped, failed), a plain reason and, for the few reasons a client reacts to, a reason_code (read_only_source: a move_to_trash document with a file in a linked read-only folder is skipped, the others are trashed). Each document is checked on its own: an id outside the person’s libraries, another person’s document or a missing one is skipped as “not available” (the same answer for all three), a non-document is skipped, a Shared Library document is skipped (“only filing rules can change it”), except mark_filed, which a household or server administrator can use on Shared Library Inbox documents (anyone else gets “Filed by a household administrator.”), and one that fails is reported without stopping the rest. Person-set values are recorded as the person’s so rules never replace them. Trash and restore only mark the document; no file is deleted | Required + Documents permission + asset owner per document |
| GET | /view/documents/export | Download the filtered list as text/csv (UTF-8 with a byte-order mark, CRLF line ends, formula-looking cells prefixed with a single quote). Takes the same query as GET /view/assets for documents (scope, scopeProfileId, q, galleryId, lifecycle, the document filters and docSort). Columns: Title, File name, Document date, Correspondent, Type, Tags, Archive number, one per custom field the person can see, Added. Only documents the person can see; never document words or notes. At most 10,000 rows: a longer list is cut and the response carries X-Tuvima-Export-Truncated: true. A bad filter is 400 | Required + Documents permission |
| GET/PUT | /view/preferences/documents-sort | Read or save how the signed-in profile likes the Documents list ordered: {"sort": "added" | "document_date" | "title" | "correspondent" | null}. Saving any other word is 400; null returns to the default order. Saving the scope or density never forgets it. A profile without the Documents permission gets 403 | Required + Documents permission |
| GET | /view/documents/inbox-summary | How many documents wait in the Inbox of the scope (scope, scopeProfileId): count. Counts what GET /view/assets?filing=inbox lists | Required + Documents permission |
| POST | /view/items/{id}/inbox/file | File one Inbox document. accept_suggestions fills blank fields from the suggestions in suggestions (what the person was shown) or, when that is not sent, the stored ones (a value already set by a rule or a person is kept); document_date, correspondent, document_type sent here are the person’s own choice and win, and sent tags are added. With nothing sent and accept_suggestions false it only marks the document filed. Returns the new document_info. Filing an already filed document changes nothing. A Shared Library document is also filed here by a household administrator or the server administrator; any other member, including a child profile, gets 403 “Filed by a household administrator.” (GET /view/scopes returns can_file_shared_inbox so the Dashboard knows whether to offer File on a Shared Inbox card) | Required + asset owner (or household/server administrator for Shared Library documents) + Documents permission |
| GET/POST | /view/document-rules | List the person’s own auto-filing rules in running order (with sources to choose from and can_manage_shared), or add one. A rule has a name, enabled, match_mode, files_automatically, scope (mine, or shared for household administrators only), conditions (text_contains, filename_contains, folder_contains, source, file_kind) and actions (add_tag, set_correspondent, set_document_type, set_field). Everything a rule points at must be something the person can see; refused with 400 (bad rule), 403 (Shared Library without being a household administrator) or 404 (a name the person cannot see) | Required + Documents permission |
| PUT/DELETE | /view/document-rules/{id} | Replace or delete one of the person’s own rules (404 for anyone else’s). The scope cannot change | Required + Documents permission |
| PUT | /view/document-rules/order | Give the person’s rules the running order they are listed in; the list must name every rule they have, once each (otherwise 404); 204 | Required + Documents permission |
| POST | /view/document-rules/test | Try a rule (saved or not): up to ten matching document titles from documents the person can open, documents_checked and has_more | Required + Documents permission |
| POST | /view/document-rules/apply | Queue the person’s rules to run again on their existing documents (their Personal Space, and the Shared Library when they have household rules): queued is how many documents will be queued; the queueing itself continues in the background | Required + Documents permission |
| PUT | /view/items/{id}/favorite | Set favorite state on an owned asset. On a document this and the four actions below return 403 for a profile without the Documents permission (after the usual 404 for anyone who does not own the item) | Required + asset owner |
| PUT | /view/items/{id}/hidden | Set hidden state on an owned asset | Required + asset owner |
| POST | /view/items/{id}/archive | Archive an owned asset without changing its file | Required + asset owner |
| POST | /view/items/{id}/trash | Soft-delete an owned asset without deleting its original. An item with any file in a linked (read-only) folder is refused with 409 and the code read_only_source (“This item is in a read-only folder, so it can’t be moved to Trash.”), and nothing changes; Archive, Favorite, Hide and Restore are not affected. Item responses carry can_trash, false for items in a read-only folder, Shared Library items and items that are not the caller’s own. While an item is in Trash it is out of every Manual Gallery (list, count, cover, shared access); Restore puts it back where it was | Required + asset owner |
| POST | /view/items/{id}/restore | Restore an archived or trashed owned asset | Required + asset owner |
| GET | /view/trash/summary | How much is in the signed-in person’s own Trash: item_count, photo_count, video_count, document_count, other_count and bytes (the size of the files those items use). Only the caller’s own Personal Space (Mine) is ever counted; a profile without the Documents permission is not told about documents. Nothing is deleted. A paired app token or a service application gets 403 | Required + signed-in person (Dashboard session) + Mine scope |
| POST | /view/trash/empty-preview | What Empty Trash would do right now for the caller’s own Trash, with no body. Returns items (each item_id, media_kind, outcome and reason), galleries (Manual Galleries that would lose items: gallery_id, name, item_count, A to Z), gallery_item_count (how many of the items going were in a Gallery, each counted once), delete_count, photo_count, video_count, document_count, other_count, skipped (reason_code, reason, count), bytes_freed, has_compound (a Live Photo, RAW set or original with an edited version is present: one item, all its files) and fingerprint (the summary the person confirms). An item’s outcome is delete, skip_read_only (a file is in a linked folder), skip_contribution_pending (waiting to be added to the Shared Library, or being copied there) or skip_refused (the shared source-file safety gate or the folder check refused a file, for example one outside the owner’s own profile folder or reached through a link). A file another item still uses on disk is kept and its size is not counted. No disk paths are returned. Shared Library items, other people’s items and items the caller cannot manage never appear. A paired app token or a service application gets 403. This action deletes nothing | Required + signed-in person (Dashboard session) + Mine scope + asset owner per item |
| POST | /view/trash/empty | Permanently deletes everything in the signed-in person’s own Trash that the latest empty-preview said would go. Body: { "fingerprint": "..." }, the fingerprint of the summary the person confirmed. If the Trash changed since (or the fingerprint is missing or wrong) the answer is 409 with message (“Something changed since you opened this. Here’s the updated list.”) and a fresh preview, and nothing is changed. Otherwise each item goes all-or-nothing: every file is re-checked (path, size, safety gate) and parked in a hidden folder inside the owner’s profile folder; the item’s records are removed in one transaction; then the parked files and the item’s thumbnails are deleted. If any file is in use or refused, every file is put back and the item stays in Trash. Items waiting to be added to the Shared Library, in read-only folders or refused by the safety check are skipped. The answer has deleted_count, bytes_freed, skipped_count (items the summary said would stay) and failed (item_id and a plain reason for each item that was meant to go but is still in Trash). One activity entry records the item count, space freed and skipped count, with no file names. Only one Empty Trash runs at a time. A paired app token or a service application gets 403; another person’s Trash is never reachable | Required + signed-in person (Dashboard session) + Mine scope + asset owner per item |
| POST | /view/shared/contributions/preview | Preview a selected batch, compound-file totals, destination, and eventual move/copy behavior. A document is refused with 403 for its owner when their Documents permission is off, and answered like a missing item (404) for anyone else in the household | Shared submit grant + asset owner |
| POST | /view/shared/contributions | Create an idempotent, revision-bound pending contribution without changing files | Shared submit grant + asset owner |
| GET | /view/shared/contributions | Page the caller’s submissions or their household’s review queue. For a profile without the Documents permission a contribution that holds only documents is left out, and a mixed one lists only its non-document items, activity lines that count items or carry a transfer error are rewritten, and includes_hidden_items is true (never a number; always false with Documents) so the Dashboard shows only “Includes items you cannot view” and no Accept, Decline or Retry. The item list, the “N item(s) submitted” activity line and the activity log (transfer lines are dropped, since they can name files and count steps per item) all describe only the visible items | Required; household administrator for review mode |
| GET | /view/shared/contributions/{id} | Read an authorized contribution, item states, decision, and activity timeline. Filtered like the list for a profile without the Documents permission; a documents-only contribution is 404 for it | Contributor or household administrator (own household) / server administrator |
| POST | /view/shared/contributions/{id}/cancel | Cancel a caller-owned pending contribution with optimistic revision checking | Contributor |
| POST | /view/shared/contributions/{id}/decision | Accept or decline a pending contribution and queue accepted transfers. 403 for a reviewer without the Documents permission when the contribution holds documents (404 when it holds only documents, since it does not exist for them) | Household administrator (own household) or server administrator |
| POST | /view/shared/contributions/{id}/retry | Requeue recoverable accepted item transfers. Same Documents refusal as decision | Household administrator (own household) or server administrator |
| POST | /view/shared/items/direct | Create an auto-accepted contribution for household items using the same transfer worker | Household administrator or server administrator |
| GET, POST | /view/galleries | List owned/shared Galleries or create an owned Gallery. For a profile without the Documents permission item_count leaves documents out (manual and Smart Galleries, shared ones too) and a cover_item_id that is a document is returned as null; naming one of their own documents as a cover is 403. A saved Documents view is a Smart Gallery whose rule is one “all of” group that includes media_type is document (ViewSmartGalleryRules.IsDocumentView; rule fields tags (all of), correspondent, document_type, document_date (including within a relative range), filing_state, text_contains and field:<field id>). A profile without the Documents permission never gets one back in the lists, and creating one is 403 | Required + trusted profile |
| GET, PUT, DELETE | /view/galleries/{id} | Read or manage an authorized Gallery. Reading or changing a saved Documents view without the Documents permission is 403 | Required + Gallery permission |
| GET, POST, DELETE | /view/galleries/{id}/items | Page or mutate Manual Gallery membership. For a profile without the Documents permission the page leaves documents out, adding a document is 403 (nothing in the request is added), and remove and reorder never touch or count a document | Required + Gallery permission |
| GET, PUT | /view/galleries/{id}/shares | Read or replace selected-profile Gallery shares. A saved Documents view is always private: replacing its shares is refused with 403 (“A saved Documents view is private and cannot be shared.”) | Owner + Gallery sharing enabled |
| GET | /view/share-targets | Return only enabled profiles the caller may select as Gallery share recipients | Required + trusted profile |
| GET | /view/people | Cursor-page named/reviewed, provenance-aware people annotations within an authorized scope; no face-recognition claim | Required + scope read |
| GET | /view/places | Cursor-page real GPS/place aggregates within an authorized scope; no map/tile request | Required + scope read |
| GET | /view/admin/profiles/{profileId}/sources | Return an administrator-safe source/device summary without paths, secrets, client identifiers, or invented capacity. Each source carries content_kind (photos, personal_videos or documents), skipped_file_count (files the last full scan skipped because they do not fit the kind), added_by_profile_id and accepts_photos_and_videos (true for the built-in upload source, which is shown as “accepts photos and videos”) | Administrator |
| POST | /view/admin/profiles/{profileId}/sources | Add a folder to a Personal Space. content_kind is required (400 without a valid one); the folder records the acting administrator as added_by_profile_id, and files found in it are tagged with that profile | Administrator |
| PUT | /view/admin/profiles/{profileId}/sources/{sourceId} | Rename, enable or disable a folder, set whether it feeds the timeline, or change content_kind (omitted keeps it; the built-in upload source keeps its kind). A changed kind is applied at once to what is already indexed (items the new kind accepts take it; the rest leave the index and are counted in skipped_file_count), and nothing on disk is moved or deleted; the next scan picks up files the new kind now accepts | Administrator |
| GET, POST | /view/admin/shared/sources | List or add folders of the server’s Shared Library. Adding takes content_kind (required) and records the acting administrator, as above | Administrator |
| PUT | /view/admin/shared/sources/{sourceId} | Update a Shared Library folder, including content_kind | Administrator |
| POST | /view/admin/profiles/{profileId}/reconcile | Recovery/admin reconciliation for a profile’s Personal Space | Administrator |
Works
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /works/{id} | Work detail with canonical values, editions, and owned assets | Required |
| GET | /works/{id}/editions | Editions and owned assets for a work | Required |
| GET | /works/{id}/cast | Actor and character credits for a single work. The actor always stays; a character the requesting profile has not reached (the Universe spoiler rule, at the profile’s spoiler level) is left off the credit. The same holds for every “as Character” credit: title cast lists, person-page credits and the people search (a hidden character neither matches the search nor is named in “Plays …”). The admin Universe editor reads are a correction tool and stay unfiltered | Required |
Collections
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /collections | Browse-oriented collection list with works and canonical metadata | Required |
| GET | /collections/catalog | Collections catalog: system/user/managed collections plus broader rollups where trusted relationships connect multiple shelves | Required |
| GET | /collections/{id}/summary | One Collections summary for a detail page without loading the full catalog | Required |
| GET | /collections/{id}/items | Items for a collection detail page, including generated rollup aggregation | Required |
| POST | /collections | Create a collection (collection_type, membership_mode, rule_definition, work_ids, ownership and audience). A manually curated collection (Custom, manual membership) must arrive with at least one work_ids entry, so an empty one never exists: otherwise 400 with “A collection needs at least one item.” Playlists and rule-driven collections may be created without items. | Effective administrator or precise Application permission |
| PUT | /collections/{id} | Update a collection’s settings, rules or membership mode. 409 “A collection needs at least one item. Delete the collection instead.” when the change would leave a curated collection with no items (for example switching a rule-driven collection to manual membership before anything is in it). | Effective administrator or precise Application permission |
| POST | /collections/{id}/items | Add a work to a saved/manual collection | Effective administrator or precise Application permission |
| DELETE | /collections/{id}/items/{itemId} | Remove an item from a saved/manual collection. 409 “A collection needs at least one item. Delete the collection instead.” when it is the last item of a curated collection; playlists may be emptied. A curated collection whose last work leaves the library is deleted automatically by the Engine (an AutoPurge activity entry records it). | Effective administrator or precise Application permission |
| POST | /collections/reconcile | Dry-run or run collection shelf repair for already-ingested media. Body: dry_run, batch_size, max_items. Returns candidate, processed, assigned, skipped, failed, and elapsed counts. | Effective administrator or precise Application permission |
| GET | /collections/{collectionId}/series-manifest | Ordered Wikidata series checklist with total, owned, missing, provisional, ambiguous counts and named entries | Required |
| GET | /collections/search?q= | SQL-backed search across visible library works, canonical values, and collection names. Returns up to 20 work results. | Required |
| GET | /collections/personal-media/galleries | List Galleries eligible for the administrator’s Collection editor without exposing individual assets | Administrator + trusted profile |
| GET | /collections/{id}/personal-media | Return count-free Gallery/rule sources after reapplying the viewer’s View authorization | Required + trusted profile |
| POST, PUT, DELETE | /collections/{id}/personal-media[/{sourceId}] | Manage whole-Gallery references or a versioned View rule; individual asset identifiers are rejected. DELETE answers 409 “A collection needs at least one item. Delete the collection instead.” when the source is the last member (no items, no other source) of a curated collection | Administrator + trusted profile |
| GET | /persons?catalog=true&q=&role=&lane=&sort=&offset=&limit= | Paged canonical primary contributors on owned works for Collections / People | Required |
| GET | /persons/role-counts?catalog=true | Canonical owned-library contributor counts grouped by role | Required |
Library Item
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /library/items/{entityId}/detail | Full item detail including claims, canonical values, pipeline projection fields, artwork truth, and linked persons The item also carries lookup_state: matched, unmatched (the Engine could not match it; it stays in place and is looked up again later), kept_as_is (unmatched and kept: no more lookups) or not_looked_up (its library never looks anything up, such as a plain kind). | Server administrator or Application with review.read, plus catalogue access |
| GET | /library/items/{entityId}/history | Processing history timeline for an item (identity, metadata, artwork and ingestion events) | Server administrator or Application with review.read, plus catalogue access |
| GET | /library/items/{entityId}/editor-preferences/{profileId} | Profile-owned notes, local tags, hidden/recommendation flags, and optimistic editor revision | Required |
| PUT | /library/items/{entityId}/editor-preferences/{profileId} | Atomically save supported display overrides and profile-owned editor preferences; returns 409 for a stale revision | Effective administrator or precise Application permission |
| POST | /library/items/delete | Delete 1 to 200 titles (Works) from the library, confirming the plan by the fingerprint the preview returned. Files go only through the source-file safety gate, asked again just before each delete: managed files and their sidecars are removed from disk, read-only folders, merge originals and anything the gate refuses stay and are remembered as excluded files, and a file that cannot be removed (in use, no permission) stays too while the title is still deleted. Removes every record tied to the titles (chosen values, claims, jobs, Needs Review rows, artwork only they used, search entries, every profile’s progress), deletes hand-picked collections left empty, tidies empty groups and folders, keeps one activity entry (who, what, files, space freed) and publishes the LibraryItemsRemoved live event. Returns what was deleted, bytes freed, files left on disk and any file that failed. A changed plan answers 409 with { message, preview } and nothing is touched; an unknown id is 404; a group, a catalog-only entry, none or more than 200 is 400. The old /library/items/batch/delete is gone | Effective administrator signed in at the Dashboard (administrator PIN unlocked where set); no paired app, native client or service application, even one acting for an administrator |
| POST | /library/items/deletion-preview | Preview deleting 1 to 200 titles (Works) from the library without changing anything: every media file and sidecar with its folder label, size and outcome (delete, keep_as_is, keep_protected, keep_missing) as decided by the source-file safety gate, the hand-picked collections affected (and which would be deleted because they would be empty), how many profiles have progress, whether processing is running, the space freed, and a fingerprint of the plan. Refuses groups (400), catalog-only entries (400), and unknown ids or View personal items (404). Full disk paths are never returned | Effective administrator signed in at the Dashboard (administrator PIN unlocked where set); no paired app, native client or service application, even one acting for an administrator |
| GET | /library/items/{id}/move-destinations | The places one item can be moved into, optionally narrowed by query (matches the place’s name or what it sits under), at most 25. Each is { id, kind, label, parent_label, has_list }; kind is album, season (Season 0 is the Specials), series, audiobook or collection. Only places that accept the item’s kind are offered: a song gets albums and hand-picked collections, an episode seasons, a book, comic or movie the series of its own media type (for adding it there without a number only, see add_anyway_only under move-preview), an audiobook part other audiobooks; never the place it is already in (that place comes back separately as current_destination), and never another format of the same title (formats are joined by Match, not moved). has_list is true when moving there goes through the Match step. The answer also carries current_destination (the same shape, or null): the album, season or audiobook the item already sits in (never a series), when that place has its own list and the caller may edit it, so a screen can open the Match step on it directly to match a “Not matched” item or to change a number. A hand-picked collection is offered only when the library owns it. 403 when the caller is neither an administrator nor an application holding metadata.write; 404 for an item that is gone or that the caller’s access to that item does not allow editing (a place the caller may not edit is simply left out of the list) | Server administrator or Application with metadata.write, plus resource access to the item and each place |
| POST | /library/items/move-preview | Preview moving 1 to 200 items into one destination ({ item_ids, destination_id, source_collection_id?, choices? }) without changing anything. choices is optional and has the commit’s row shape ([{ item_id, mode, entry_key? }]): the Match step sends the person’s choices just before the confirm dialog so each row’s file lines are worked out for the entry they picked, not for the Engine’s guess (see file_change). A choice is only used to describe the move: one that cannot be done is not refused here, it just gets the general file line, and the commit refuses it. Item ids are titles (Works) or, for an audiobook, one of its parts (an Edition id). Returns the destination, list_kind (tracks, episodes, parts or none), add_anyway_only and note, the destination’s own entries (owned and known-missing; each { entry_key, number, title, length_seconds, taken_by_item_id, taken_by_title, can_add_version, joins_existing_title }; can_add_version is true only for a taken entry that a file can be attached to as another version, so an entry that cannot take another version is shown as taken but not offered; joins_existing_title is true when matching it merges the item into a title already in the list, false for a slot with no title yet), one row per item (current_placement_label, the Engine’s best guess suggested_entry_key with suggestion_reason of “Same title” or “Closest length”, blocked_reason such as “Still being processed”, can_match, merge_blocked_reason, and the two file fields file_change and file_note: in a Managed folder a move always renames and moves the file to fit its new place, so file_change is { from, to } (the full path before and after) when exactly one file will be renamed and moved, and file_note is one plain line otherwise, either a summary (“2 files will be renamed and moved to OK Computer (1997).”, “The file will be renamed and moved to fit its new place.” for an item with no suggested entry, whose name depends on the number the person picks) or the reason the file is not touched (“Read-only folder: the file stays where it is.”, “Read-only folder: the file stays where it is.”, “Protected original: the file is not touched.”, “A file with that name is already there, so this file stays where it is.” with a matching entry in warnings, “The file can’t be renamed right now, so it stays where it is.”); both are null when the file already fits, for a hand-picked collection, and for a title with no file. file_change names the file for the entry in the row’s choices entry when the request sent one (and, for add_anyway, for the item placed with no number, for example Hidden Jam.flac inside the album folder); without a choice it names the file for the entry in suggested_entry_key. When a preview was made with choices, the commit must send the same ones: a different choice answers 409 with a fresh preview for what is being committed, so the dialog never names a file the move will not make. The file lines are part of the preview’s fingerprint, so if a target changes before the move is confirmed the answer is 409 with a fresh preview. The files themselves are renamed and moved afterwards by a durable background job (the file-write queue, trigger library_move), which asks the shared mutation policy again, never overwrites, and records in the activity history why a file was left in place; POST /library/items/move returns once the Library change is saved; can_match is false when the caller may write the item but lacks metadata.match, so only add_anyway (or a place with no list) will work for it; merge_blocked_reason is set when the person set details on the title, which cannot be merged into an entry that already has a title (joins_existing_title), while add_anyway and a slot with no title still work, and it does not block Apply), plain warnings (“Leaves The Best Of”, “Season 2 will be empty and removed”, “Road trip will be empty and removed”), and a preview_token that lasts 10 minutes and works only for the person who asked. source_collection_id names a hand-picked collection the items leave as they move; it must be a hand-picked collection the library owns (never a person’s private one) that holds the items, and the caller needs the same access to it as to the items. A Series has no list (list_kind is none) and takes only add_anyway (spec D20: a Series is built from each title’s match, never by hand; re-matching the title in Match and Identity places it properly): add_anyway_only is true and note carries the plain line “Series are built from each title’s match. Use Match and Identity to re-match it, or add it here without a number.”; match and add_as_version into a Series are 400 with that line. A hand-picked collection has no list either but keeps free order (add_anyway_only is false). A whole show, season, album, series or audiobook is never moved, only what is in it; moving anything into itself or into something inside it is refused. The destination may be the place an item already sits in (an album, season or audiobook), for match and add_as_version: that is how an item added anyway is matched later and how a numbered item is renumbered (nothing leaves, so there is no “leaves” warning, no clean-up and no Series change, and a match there clears the “Not matched” mark and sets the number); the taken-entry rules still apply, an item is never offered its own entry, and add_anyway into its own place is 400 because it would change nothing. These, a wrong kind and a collection that does not hold the items are 400; a missing item or place is 404; an item, place or source collection the caller may not edit is 404. The last file of an audiobook whose details the person set themselves cannot be moved out (the row is blocked), and the preview never suggests an entry a row cannot be merged into | Server administrator or Application with metadata.write, plus resource access to every item and the destination |
| POST | /library/items/move | Move the items as the person chose ({ preview_token, rows: [{ item_id, mode, entry_key? }] }). mode is match (take the number and place of one free entry; with no list the item simply goes at the end and takes no entry), add_anyway (join with no number, in an “Unnumbered” group at the end, flagged “Not matched”; no details are borrowed from any entry and no Needs Review entry is made) or add_as_version (the entry already has a file: this item’s files become another Edition of that entry’s Work, and the original file stays). A taken entry is never matched or replaced silently: match on one is 400, and so is add_as_version on an entry whose can_add_version is false. Every item must be placed once and no two items may take the same entry. match and add_as_version change what an item is, so each such row also needs the caller to hold metadata.match on the item (as the music track move does); add_anyway and a move into a place with no list need only metadata.write; a caller without the right gets 404. 400 against 409: only a real change since the preview is 409. A request the Engine never allows, however fresh the preview (a title whose details the person set merged into an entry’s own title, its own entry, add_anyway into its own place, a blocked row, a missing or doubled choice), is 400 with the reason in plain words, no fresh preview and the review still valid, so the person picks something that works instead of seeing “Something changed” again. The Engine works the preview out again first; anything that changed since (the destination’s list, a taken entry, an item moved, deleted or running a job, its identity revision) answers 409 with { message, preview } carrying a fresh preview, and nothing is changed; an unknown or expired token, or one that belongs to another person, is 409 with a problem body (“This review expired. Review the move again.”): the review is gone, so there is no fresh preview to return, and the Match step asks again. The writes are one transaction that checks identity revision, active jobs, taken entries and details the person set again, asks the files’ tags to catch up (write-back pending), removes places left empty (albums, seasons, audiobooks, and hand-picked collections the items left) and writes one activity entry. When an item is merged into an entry or an audiobook is emptied, what people did with the old title (hearts and saves, plays, listening history, bookmarks, queue places, hand-picked collections, personal tags) is carried over to the surviving one first: a heart or save is kept if either had it and the most recent reaction wins, play counts are added with the later last-played kept, history and bookmarks are all kept, queue places are re-pointed and the entry’s own tags win; nothing is lost. A title relocated into a place with no Series link no longer keeps the Series link it left. Pictures and text tracks only the merged-away title used are removed from Tuvima’s own data folder after the commit; source media are never touched. The Library changes first; the files are renamed and moved afterwards by the file-write job described under move-preview, so a move never waits for the file work. An item added anyway loses only its own list’s numbers: a title’s mark covers the title-level numbers (track, disc, episode, season, series position) and an audiobook part’s mark covers only the part number, so an audiobook added anyway into a series keeps the part numbers of its files. Returns { moved_count, rows: [{ item_id, outcome, result_entity_id }], removed_containers }; result_entity_id is the Work the item now lives in or as: its own Work when it stayed a title of its own, the entry’s Work when it was merged into one (its own Work is gone), and the audiobook for a part, so a screen showing the item re-targets to it | Server administrator or Application with metadata.write, plus resource access to every item and the destination (the same policy as the other structural edits) |
| GET | /library/excluded-files | The files that stayed on disk when their title was deleted from the library (Settings > Libraries > Excluded files), newest first; optional library_id query narrows it to one library. Each row is { id, library_id, file_name, folder_label, title, excluded_at, excluded_by, reason }. Only the file name and a readable folder label (library name, then folders) are returned, never a disk path | Effective administrator (administrator PIN unlocked where set); no application permission |
| POST | /library/excluded-files/{id}/add-back | Add one excluded file back: removes the exclusion, makes scans forget it and queues a scan of that one file through the ingestion engine’s direct-intake path. Returns { queued, message }. If the file is no longer on disk, or its folder is no longer set up, the exclusion is still removed and queued is false with the reason in message; an unknown id is 404 | Effective administrator (administrator PIN unlocked where set); no application permission |
| POST | /library/batch-edit/preview | How 1 to 500 selected items (work or file ids) stand on each batch field, so the page can say “No change” or “Mixed”. Body { entity_ids, field_keys? } (field_keys empty or omitted means every batch field; an unknown key or more than 500 ids is 400). Returns { affected_count, fields, unavailable_items }. Each field is { key, label, kind ("scalar" or "list"), operations, editable, locked_items, is_mixed, value, values, distinct_values, distinct_count, empty_count }: when every item agrees is_mixed is false and value (scalar) or values (list) holds it; when they differ is_mixed is true and distinct_values lists up to 5 { value, count } (count of items carrying it) with distinct_count the full number. The shown value is what the detail page shows: the item’s own override, else its own provider data, else its parent’s. editable is false when any item cannot take a change; locked_items names each with the reason (Genres, Language and Content rating an item only inherits from its show, season or album are changed there; credits such as directors and cast are provider facts and are listed with no operations). Items the caller may not edit, that are gone, that are not titles a batch can change (editions, collections, people) or that are still being ingested are left out and named in unavailable_items with the reason. Batch fields: genres and tags (lists), year, rating, content rating, language and tagline (single values). Metadata only: names, files and parents are never touched | Effective administrator (administrator PIN unlocked where set), plus the same per-item metadata-write check a single edit gets |
| POST | /library/batch-edit | Apply metadata changes to 1 to 500 selected items. One Apply; each item saves on its own; failures go to Needs Review. Body { entity_ids, field_changes: [{ key, op, value?, values? }] }. op is set (needs value) or clear for single-value fields, and add (the default choice in the Dashboard), replace or remove for genres and tags; a field not sent is left alone. An operation that does not fit the field’s kind, a credit or other provider fact, an unknown field, a missing or invalid value, two changes to one field, or more than 500 ids is 400. It writes the same presentation overrides the pencil on a detail page writes, never provider facts. A cleared single value reverts to the provider’s value. A tag list can be emptied (replace with no values; it stays empty). A genre list can never be emptied here: an empty genre override means “use the provider’s genres”, so an item whose genre list would end up empty (remove the last genre, or replace with no genres) is refused with “Genres can’t be emptied here; the provider’s genres would come back.” and nothing is written for it. Each item is saved by its own write, so one item that cannot take the change never undoes the others; there is no transaction around the whole batch. An item whose change cannot apply (a value it only inherits from its show, season or album, a result outside the field’s limits such as too many genres or tags, or a write error) is left unchanged and goes to Needs Review as a BatchEditFailed row (category Metadata Conflicts, its own trigger so it never merges into a pipeline conflict row) on the work that was being edited, naming the item, the fields and the reason. An item’s changes are saved together or not at all. An item that already shows the requested result (adding a genre it has, removing one it lacks) is not written and not logged. Each changed item gets one activity entry (a batch edit changes only overrides, which the single save also does not write back to files). Once writing starts the Engine finishes the whole batch even if the caller disconnects, and logs one summary line. Returns { updated_count, unchanged_count, sent_to_review_count, refused_count, items: [{ entity_id, outcome ("updated", "unchanged", "sent_to_review" or "refused"), reason, fields_not_applied, review_id }] }. refused rows get no review entry: the caller may not edit the item, it is still being ingested, it is gone, it is not a title a batch can change (editions, collections and people get “Not a library title that can be batch edited.”; a photo or a deleted title cannot be told apart, so its reason covers both), or its genre list would be emptied | Effective administrator (administrator PIN unlocked where set), plus the same per-item metadata-write check a single edit gets |
Library Overview
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /library/overview | Aggregated library health and readiness view, including hidden_by_quality_gate, art_pending, retail_needs_review, qid_no_match, and completed_with_art. Every count comes from one counts-only pass over the item projection. | Required |
| GET | /library/unmatched/counts | Each library’s Unmatched count (kept-as-is files excluded) as { counts: { library_id: n } }, for the libraries the caller may see; a library with none is absent | Effective administrator |
| GET | /library/unmatched/libraries/{libraryId} | One library’s Unmatched files, newest first: { items, total, skip, take }. Query include_kept (default false), skip, take (1 to 200, default 50). Each item is { asset_id, library_id, title, file_name, reason ("no_match" or "no_youtube_id"), first_unmatched_at, last_tried_at, next_retry_at, kept_as_is }; only the file’s name is returned, never its folder. 404 for an unknown library or a Vault library | Effective administrator |
| GET | /library/libraries/{libraryId}/activity | What has happened in one library, newest first, in plain sentences, for the library page’s Activity tab: { library_id, entries: [{ id, occurred_at, kind ("added", "review", "failed", "scan", "conversion", "merge" or "move"), text, needs_review, route }] }. Query take (1 to 200, default 50). Built from events that already exist (added, review and failed rows tied to the library by its files and folders, first checks, organising jobs, kind changes); nothing is stored for it. route is the Needs Review address on a line that needs review, otherwise null. 404 for an unknown library, a Vault library, or a library the caller may not see | Administrator |
| POST | /library/unmatched/libraries/{libraryId}/retry | Retry now for a whole library: looks up again every Unmatched file in it that is not kept. Returns { affected } | Effective administrator |
| POST | /library/unmatched/libraries/{libraryId}/keep | Keep all Unmatched as they are, for one library: stops their retries and clears them from the count and filter. Returns { affected } | Effective administrator |
| POST | /library/unmatched/items/{assetId}/retry | Retry now for one Unmatched file that is not kept (affected is 0 for a kept file). 404 when the file is not Unmatched | Effective administrator |
| POST | /library/unmatched/items/{assetId}/keep | Keep as is: stops the file’s retries and clears it from the count and filter | Effective administrator |
| POST | /library/unmatched/items/{assetId}/look-up-again | Look up again: clears the kept mark and looks the file up now | Effective administrator |
| GET | /library/first-checks | The folders whose first check is running or paused (Library setup D35, D61): { items: [{ library_id, source_id, path, state ("checking" or "paused"), tried, unmatched, total_files }] }. A folder added to a library whose kind looks details up is checked before anything in it is renamed, moved or tagged; Vault folders never are | Effective administrator |
| POST | /library/first-checks/{sourceId}/keep-going | Keep going on a paused folder, or as an override on one still being checked: identification resumes, Unmatched files stay Unmatched and are looked up again later, and the matched files’ held moves and tags are released to the next run. 204, or 409 when the folder is neither paused nor being checked. Removing the folder is the existing folder removal, which drops its check | Effective administrator |
Metadata
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /metadata/claims/{entityId} | All claims for an entity, grouped by field, with source and confidence | Required |
| GET | /metadata/conflicts | All unresolved metadata conflicts across the library | Effective administrator or precise Application permission |
| GET | /metadata/{entityId}/series-placement | The editor’s Matching Series block for the Work behind any Work, Edition or file id: { work_id, media_type, supports_series, current, is_person_choice, listed, candidates }. current and each listed and candidates entry are { collection_id, label, series_qid, media_type, scope, position_label }; listed are the Series whose manifests name the Work, candidates are other Series of the same media type matching the q name filter (at most 20; none are returned unless q has at least 2 characters). current is always returned to anyone who can see the Work; listed and candidates include only Series with at least one member the caller can see (the same resource scope the collection reads use), so an empty Series is not offered. supports_series is false for TV episodes and music tracks, which keep their parent placement | Effective administrator or precise Application permission (metadata.read), plus catalogue access |
| PUT | /metadata/{entityId}/series-placement | Body { "collection_id": guid or null, "locked": bool }. locked: true saves the person’s choice as a lock the Engine always respects (a Series shelf of the Work’s media type, or “Not in a Series” when collection_id is null), keeps collection_items consistent and closes the Work’s pending AmbiguousSeriesMembership item; a shelf that is not a Series of the Work’s media type is 400, and a Series the caller may not see is 404. The Universe step runs for the Series the Work joined and the one it left. locked: false releases the lock and runs placement for that Work again so the Library decides (through collection assignment when the Work has a file, from the manifests that name it when it has none). Returns the same shape as the GET. Refused with 409 while the item is still being ingested. No History event is written (Matching moves are not recorded there) | Effective administrator or precise Application permission (metadata.write), plus catalogue access |
| GET | /metadata/{entityId}/work-versions | Bounded Work, Edition and file (asset) tree for the owned Work behind any Work, Edition or asset id. Each file also carries the facts the Details tab shows, read only from stored rows and never probed: file_size_bytes, added_at, bitrate_bps, container, audio_tracks (language, codec, is_default) and subtitle_languages; each is null or empty when nothing is stored | Effective administrator or precise Application permission |
| GET | /metadata/assets/{assetId}/file-write | What is waiting to be written into this media file (for the File section of Editing Mode): { waiting, next_run_at, last_error, last_written_at } | Effective administrator or precise Application permission |
| GET | /metadata/{entityId}/canon-discrepancies | Field-level mismatches between the canonical value and file-embedded metadata | Required |
| GET | /metadata/{entityId}/file-coverage?assetId= | Episodes a TV file covers plus the other episodes of its season it could cover (404 when the file is not a TV episode) | Effective administrator or precise Application permission |
| PUT | /metadata/{entityId}/file-coverage | Replace the full list of episodes one TV file covers. Must include the file’s own episode, stay within its season, name at most 6 episodes and skip episodes that already have another file. Idempotent: saving the same list changes nothing. Resolves the file’s pending review and records a History entry | Effective administrator or precise Application permission |
| GET | /metadata/{entityId}/artwork | Artwork context for the media editor, including variants by artwork type and preferred selections | Effective administrator or precise Application permission |
| GET | /metadata/{entityId}/artwork/{scopeId} | Artwork variants for a specific editor scope | Effective administrator or precise Application permission |
| POST | /metadata/{entityId}/artwork/{scopeId}/{assetType} | Upload a user-owned artwork variant for the selected type | Effective administrator or precise Application permission |
| POST | /metadata/{entityId}/artwork/{scopeId}/{assetType}/from-url | Add an artwork variant from a provider or user-supplied image URL | Effective administrator or precise Application permission |
| POST | /metadata/{entityId}/artwork/{assetType} | Compatibility upload route for an artwork type | Effective administrator or precise Application permission |
| PUT | /metadata/artwork/{variantId}/preferred | Make an artwork variant the preferred image for its artwork type | Effective administrator or precise Application permission |
| DELETE | /metadata/artwork/{variantId} | Remove an artwork variant from the item. Shared provider/image cache files are retained when still referenced elsewhere. | Effective administrator or precise Application permission |
Streaming
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /stream/{assetId} | Stream media file. Supports HTTP 206 byte-range requests for seeking. | Required |
| GET | /stream/{assetId}/cover?size= | The item’s preferred cover. size is t (96 px long side), s (320), m (960) or l (2160); no size sends the original. | Required |
| GET | /stream/{assetId}/cover-thumb | The item’s cover at the small copy (320 px long side). | Required |
| GET | /stream/{assetId}/background?size= | The item’s preferred background, at a copy when size is given. | Required |
| GET | /stream/{assetId}/logo | The item’s preferred logo, as stored (logos have no copies). | Required |
| GET | /stream/artwork/{variantId}?size=&v= | One stored artwork variant. size is t (96 px long side), s (320), m (960) or l (2160); any other value is 400. v is the picture’s change marker. | Required |
| GET | /stream/entity/{entityType}/{entityId}/cover?size= | The cover a detail page shows, at a copy when size is given. | Required |
Picture sizes and browser keep (right-sized pictures). A picture route given a size answers with that copy. When the copy is missing it sends the next bigger copy and makes the missing one in the background; when only the original exists it makes the copies first. It never sends the original for a sized request when a copy can be made. Responses are Cache-Control: private (the viewer’s own browser only). A response is kept for a year (max-age=31536000, immutable) only when the address carries v= matching the picture’s current change marker and the exact size was sent; only /stream/artwork/{variantId} and /library/portraits/{portraitId} can earn this. Faces (/persons/{id}/headshot, whose address is keyed by the person) and the per-item routes are always checked on each use; everything else is no-cache with an ETag and Last-Modified, so an unchanged picture costs a 304 with no body. The “no picture” placeholder is no-store. The Dashboard’s /engine-image/… relay forwards If-None-Match and If-Modified-Since and passes these headers back unchanged.
Ingestion
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /ingestion/scan | Dry-run scan of configured library folders. Reports what would be ingested without making changes. | Administrator |
| POST | /ingestion/rescan | Scans the watched folders now (optional {root_path, include_subdirectories} for one folder). Also looks at the Import folder, even one set after startup, and files anything waiting in it. | Administrator |
| POST | /ingestion/library-scan | Scan library folders and update known file paths. Triggers ingestion for new files. | Administrator |
| POST | /ingestion/upload | Upload a file to the library’s explicit destination (a folder Tuvima manages, checked through the source-mutation gate as a destination, with no fallback folder) identified by multipart destinationLibraryId; catalogued files retain stable intake IDs, while personal files are indexed directly into View without catalogue/provider work | Administrator |
| GET | /ingestion/import-folder/waiting | What is being left untouched in the optional Import folder because a media type has no managed folder yet (reason no_managed_folder) or its New media folder is offline, full or not writable (folder_unavailable). One entry per type with media_type, folder, file_count and a plain-language message; empty when nothing is waiting. A situation, not a state of the Import folder. | Effective administrator or precise Application permission |
| GET | /ingestion/queue-progress | The live ingestion numbers, worked out once by the Engine from the per-file intake rows: state (working, waiting or idle), files_found, files_done, files_waiting and waiting_for (tools holding files), files_more_found, outcomes (added, needs_review, failed, skipped, unmatched), tally (files per media_type), files_unsorted, steps_total, steps_completed, progress (0 to 1, never decreasing within an active period, 1 only when idle), started_at, last_activity_at, estimated_seconds_remaining (null unless reliable) and updated_at. The same snapshot is pushed on the Intercom IngestionQueueProgress event whenever it changes. | Effective administrator or precise Application permission |
| GET | /ingestion/presentation | The live ingestion page in one response: current_media (the groups in flight, most recently active first), recent_days (what became Ready on each recent day, with per-type counts), attention, the group, Ready and Needs Review counts, and personal_media_in_progress. No run or batch record is involved; a group is its structural identity alone. | Effective administrator or precise Application permission |
| GET | /ingestion/media-groups | Paged groups in flight: not Ready and not failed, or with an open per-file intake row. | Effective administrator or precise Application permission |
| GET | /ingestion/media-groups/{groupId} | One group as the live page drawer shows it, with its per-file progress gates and facets. | Effective administrator or precise Application permission |
| GET | /ingestion/media-groups/{groupId}/children | Lazy, paged tracks, episodes, issues or parts of one group. | Effective administrator or precise Application permission |
| GET | /ingestion/recent-additions | Paged groups that became Ready, one row per group, by the time each first became Ready (search, lane, start, end, sort, status=added). | Effective administrator or precise Application permission |
| GET | /ingestion/watch-folder | Returns the derived current watch folder view from configured library source folders. | Required |
Resumable uploads into the Import folder
Added for dropping big files on a library page (Library setup spec D44). The browser talks to the Dashboard relay (/import-uploads..., same shape, signed-in session plus the page’s antiforgery token in the RequestVerificationToken header for everything that changes something), and the relay calls these Engine routes as the signed-in person. Only an effective administrator signed in as a person may use them: no application and no service credential alone. A file name is refused (invalid_file_name) when it is longer than 240 UTF-8 bytes or holds control or invisible format characters (such as the right-to-left override). An upload belongs to the administrator who started it; anyone else gets 404. A refusal is a problem body (application/problem+json) with a stable code (no_import_folder, no_managed_destination, destination_unavailable, not_enough_space, large_upload_needs_confirm, drag_and_drop_off, vault_not_available (dropping files onto the Vault waits for the Vault sections spec, D57), unknown_kind, unknown_library, invalid_file_name, invalid_size, invalid_piece, upload_not_found, upload_closed, pieces_missing, size_mismatch). The settings are upload_piece_bytes (64 MB), large_upload_warning_bytes (50 GB) and upload_abandon_hours (24) in config/maintenance.json.
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /imports/uploads | Start an upload: {kind_key, library_id?, file_name, total_bytes, confirm_large?}. Everything about the destination is checked first (an Import folder is set and writable, the kind has a Managed folder, drag and drop is on, both drives have room, and a file above large_upload_warning_bytes needs confirm_large: true, otherwise it is refused with large_upload_needs_confirm and an import_folder_path property holding the kind’s Import sub-folder, so the person can copy the file there instead) and nothing is created on a refusal (409, 400 or 507 with a code). Answers {upload_id, piece_bytes, piece_count, total_bytes}. | Effective administrator |
| GET | /imports/uploads/{uploadId} | The pieces received so far (received_pieces, received_bytes, state), so a dropped upload resumes without resending them. | Effective administrator |
| PUT | /imports/uploads/{uploadId}/pieces/{index} | One piece (application/octet-stream, exactly piece_bytes long, the last one shorter), written at its place in a hidden working file under the Import folder’s .data/uploads folder. Sending a received piece again is accepted and changes nothing. The request-size limit and the general rate limit are relaxed for this route only. | Effective administrator |
| POST | /imports/uploads/{uploadId}/complete | Checks every piece arrived and the file is the size promised, moves it into the kind’s Import sub-folder under a safe unique name (never overwriting) and queues it for filing by the normal rules. Answers {file_name, renamed, destination_path}; asking again answers the same. A move that fails for a passing reason (destination_unavailable) leaves the upload open to be completed again; a file of the wrong size or an Import folder that changed cancels the upload with its reason. | Effective administrator |
| DELETE | /imports/uploads/{uploadId} | Cancels the upload and deletes its working file (any piece in flight is stopped first). An upload with no new piece for upload_abandon_hours is cancelled the same way by the hourly sweep. | Effective administrator |
Operations And Capabilities
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /operations | Durable work queue ordered by queue priority and position. Supports queueName and limit (default 200, capped at 250). | Effective administrator or precise Application permission |
| GET | /operations/{id} | One operation plus its event timeline. | Effective administrator or precise Application permission |
| GET | /operations/summary | Counts by durable operation status. | Effective administrator or precise Application permission |
| POST | /operations/{id}/retry | Requeue a durable operation for another attempt. | Effective administrator or precise Application permission |
| POST | /operations/{id}/cancel | Cancel a durable operation. | Effective administrator or precise Application permission |
| GET | /assets/{id}/capabilities | Explicit capability/readiness states for one media asset. | Effective administrator or precise Application permission |
| GET | /capabilities/summary | Counts by capability and status. | Effective administrator or precise Application permission |
Search
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /search/universe | Search Wikidata for entity candidates by title, author, and media type; returns candidates[] (qid, label, description, instance_of, year, author, director, cover_url, wikipedia_extract, resolution_tier, confidence, bridge_ids, media_type, media_type_metadata, match_scores), query and media_type | Effective administrator or precise Application permission |
| POST | /search/retail | Search configured retail providers for matching candidates | Effective administrator or precise Application permission |
| POST | /search/resolve | Unified resolve search - queries all active providers and returns ranked candidates | Effective administrator or precise Application permission |
Review Queue
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /review/pending | All items currently in the review queue; from_import_folder is true for a file still in the Import folder (the “From the Import folder” badge); from_merge is true for a file inside a managed folder’s From merge folder (the “From merge” badge) | Effective administrator or precise Application permission |
| GET | /review/count | Count of pending review items. Used for media library badge. | Required |
| GET | /review/{id} | Full detail for a single review item including candidates | Effective administrator or precise Application permission |
| POST | /review/{id}/resolve | Resolve a review item by selecting a candidate or confirming corrected local metadata | Effective administrator or precise Application permission |
| POST | /review/{id}/dismiss | Dismiss a review item without resolving it | Effective administrator or precise Application permission |
| POST | /review/{id}/move-to-tv | Accept a “Found as a TV title” suggestion: re-file the film under the matched TMDB show in the TV library and re-run identity as TV. When TMDB lists the parts as one season of at most six episodes numbered from 1, the file is placed on Season 1, Episode 1 and covers every part; otherwise it falls back to Season 0, Episode 1 (Specials). The response carries season_number, episode_numbers and placement_reason (why it fell back, else null). The file on disk is never moved. | Effective administrator or precise Application permission |
| POST | /review/{id}/skip-universe | Accept the item without a Wikidata QID. The item can still remain browse surfaces-visible if it passes the browse readiness gate. | Effective administrator or precise Application permission |
Persons
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /persons/{id} | Person detail - biographical data, roles, library presence, social links | Required |
| GET | /persons/{id}/aliases | Pseudonym list for a person, including resolved Wikidata aliases | Required |
| GET | /persons/{id}/headshot?size=&v= | The person’s face. size is t (96 px long side), s (320), m (960) or l (2160). Every face is a managed picture with all four copies; a face saved by the older direct download is adopted the first time it is asked for. | Required |
Universes
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /universes | All narrative roots (Universes) in the library; character_count leaves out characters the requesting profile has not reached | Required |
| GET | /universe/{qid} | Universe summary: label, level, and counts of the entities (all, characters, locations, organizations) and relationships visible to the caller; character_count leaves out characters the requesting profile has not reached | Required |
| GET | /universe/{qid}/overview | Everything the Dashboard’s Universe page (/universe/{qid}) reads in one action, limited to what the active profile may see (UniversePageDto). detail is the UniverseClientDetailDto the helper apps get from /api/v1/universe/{qid} (same shelves, same order), except that item_count and media_types also count the standalone works, which the helper-app read leaves out, and its cast and adaptations are empty: the page reads them from connections. connections is {characters, character_count, hidden_character_count, cast, adaptations}, evidence only, every row with sources[] of {kind, label} (kind is wikidata, lore_plugin or other; label is what the page shows, such as Wikidata or an approved lore source’s name). cast[] is {member, sources} and adaptations[] is {adaptation, sources}, where member and adaptation are exactly the helper-app rows, in the same order. characters[] is {qid, label, work_count, no_plot_details, sources} for every Character-type entity linked to a work the profile may see: work_count counts the visible Works it appears in (a TV show is one Work however many episodes name it; spoiler-marked appearances are left out unless every appearance is marked), no_plot_details is true when the character cannot be placed within what the profile has watched or read (then work_count is 0), sources come from the stored appearance provenance plus any approved lore source holding the same Wikidata item; no-plot-details rows last, then most works first, then by name, at most 60, with character_count giving the full number of characters the profile may be shown. A link to a show counts as visible whenever any of its episodes is, even when every episode carries its own QID. Adaptations include edges between Works (a film with a “based on” claim to an owned novel): both Works must be visible to the profile and one must be the Universe’s own, and each Work is named from what the profile owns. Adaptations are one row per pair of Works (a film “based on” a novel and the same pair recorded the other way round as a “derivative work” give one row, labelled “based on”, with every source). The one spoiler rule (SpoilerRule, driven by the profile’s spoiler level, its per-episode progress and each character’s computed “first seen at”) leaves out a character the profile has not reached or that is marked as a spoiler for a Work it has not finished: hidden_character_count says how many, they are named nowhere in the answer (not in characters and not beside an actor in cast[].member.characters), and an actor’s row stays. At the level everything nothing is held back and hidden_character_count is 0. standalone is one DisplayShelfDto titled “Standalone” holding works that belong directly to the Universe and to no Series (null when there are none). missing has one entry per Series that has missing main-sequence entries: collection_id, show_missing (the profile’s own choice for that Series, otherwise the media default from the library preferences, and only when that media type shows missing entries at all), missing_count (always filled) and items[] of {label, raw_ordinal, parsed_ordinal, item_qid, after_work_id} in Series order (empty when show_missing is false; after_work_id is the owned work the entry follows, null when it comes first). enrichment is {state, enriched_work_count, work_count} with state not_started, running or ready, from the per-work universe enrichment mark and identity jobs. graph_entity_count and graph_relationship_count size the visible connection graph. A Universe the profile cannot see, an unknown one and a malformed QID are the same 404. | Required (library read) |
| GET | /universe/{qid}/graph | Graph data in Cytoscape.js format for the Chronicle Explorer visualization | Required |
| GET | /universe/{qid}/paths | Find shortest paths between two entities within the universe graph | Required |
| GET | /universe/{qid}/family-tree | Character family tree rooted at a specified character entity. A root the requesting profile has not reached is 404, and hidden characters are left out of every generation | Required |
| GET | /universe/{qid}/cast | Characters with their linked performers, including era-correct actor data | Required |
| GET | /universe/{qid}/adaptations | Adaptation chain - all works derived from or adapted into each other; each child carries source (where the relationship was learned, such as Wikidata; null on roots). An empty works list when no adaptation relationship is recorded | Required |
| GET | /universe/{qid}/also-appears-in | “Also appears in”: where else one thing from this Universe shows up across the whole Library, grouped by format. Give exactly one of ?entity={entityQid} (a character, place or organisation, matched by its Wikidata QID, never by name) or ?person={personId} (their library credits, the same ones and the same visibility rule as the person page); neither or both is 400. Returns EntityAppearancesDto: subject_kind (character, location, organization, person or entity), subject_label, subject_qid or person_id, groups[] of {media_kind, label, works[]} in the order TV, Movies, Books, Audiobooks, Comics, Music (works by year then title; each work has work_id, title, year, cover_url, route, the item-page path, and role), and total_works. A TV show is one entry however many episodes are owned. Only works the profile may see are listed or counted, and the one spoiler rule applies to a character: an appearance marked as a spoiler for a work (“spoiler for”) is left out unless every appearance is marked and the marked Works are finished, and a character the profile has not reached comes back as {hidden_for_spoilers: true, subject_kind: "character"} with no label, QID or Works (a person is never hidden). For an entity, works_without_character_data counts visible works in this Universe whose character lookup ran but found none, and works_not_yet_checked counts those not looked up yet; both are 0 for a person. An unknown person, a person with no credit on a work the profile may see, or an entity with no visible appearance, is 404 (so a name the profile cannot see credits for is never revealed). | Required |
| GET | /universe/{qid}/lore-delta | Check for Wikidata revision changes since the last enrichment pass; returns a list of entity_qid, label, cached_revision, current_revision, has_changed | Required |
AI
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /ai/status | AI subsystem health - model load state, hardware tier, resource pressure | Required |
| GET | /ai/models | Status of all configured models (loaded, unloaded, downloading, unavailable) | Required |
| POST | /ai/models/{role}/download | Trigger download for a model role (text_fast, text_quality, text_scholar, text_cjk, audio) | Administrator |
| POST | /ai/models/{role}/load | Load a model into memory | Administrator |
| POST | /ai/models/{role}/unload | Unload a model from memory | Administrator |
| GET | /ai/config | The Local AI engine settings: resource profile, limits, the default plugin allowance and the runtime state | Administrator |
| PUT | /ai/config | Update the Local AI engine settings. Which AI features run is not set here: each is an Official plugin switched on at Settings > Plugins, through the approval rule | Administrator |
| GET | /ai/usage | Who used which Local AI ability over the last days (1 to 90, default 7), one row per caller and ability, plus each AI plugin’s requests and busy time this hour against its allowance | Administrator |
| GET | /ai/profile | Hardware profile - benchmark results, tier classification, GPU backend | Required |
| POST | /ai/benchmark | Re-run hardware benchmark and reclassify tier | Administrator |
| GET | /ai/resources | Live system resource usage - CPU load, RAM pressure, active transcoding tasks | Required |
| GET | /ai/enrichment/progress | Background enrichment batch progress | Required |
/ai/config preserves the complete settings graph, including models,
model_catalog, operational_roles, role_requirements, and
scheduling/resource policy. /ai/resources includes numeric cpu_pressure;
clients should use that contract value rather than derive pressure from labels.
Enrichment
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /metadata/pass2/trigger | Manually trigger the optional Pass 2 deferred enrichment flow for one or more entity IDs | Effective administrator or precise Application permission |
| GET | /metadata/pass2/status | Current Pass 2 deferred-enrichment status, including whether two-pass mode is enabled | Required |
Activity
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /activity/recent | Recent system activity entries, newest first | Required |
| GET | /activity/log?category=&search=&offset=&limit= | The Activity log (the Events part of System > Logs): one page of plain-worded events, newest first, as PagedResponse<ActivityLogEntryDto> with total_count. category is added, review, failed, system or all (the default; unknown means all). search matches names and details. limit defaults to 50 (max 250). Each entry has id, occurred_at, category, kind, title, detail, media_type, count (photos and personal videos arrive as one count row) route and cover_url (the small cover of an added title, /stream/entity/work/{id}/cover?size=s, when the row names one). Needs system.activity.read | Required |
| POST | /activity/prune | Prune now with the Logs page retention rule: entries older than activity_retention_days, then the oldest entries past activity_max_events. Returns deleted (both together) and retention_days | Administrator |
| GET | /activity/stats | Aggregated activity statistics - counts by type and outcome | Required |
| GET | /activity/by-types | Filter activity log by one or more activity type codes | Required |
Logs
The System > Logs page (/settings/system/logs) reads these. Logs can name files and other people, so every action is for the server administrator only. Pruning is silent: nothing is raised when it removes something, and the Scheduled tasks list (GET /system/scheduled-tasks) shows when each clean-up last ran.
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /logs/technical | The Engine’s log files (tuvima-*.log, in TUVIMA_LOG_DIR or logs), newest first. Query: from and to (date and time), level (the lowest level wanted: error, warning, information or all), area (Ingestion, Playback, Network, Providers, Security, AI or Other, grouped from the logger name), q (text in the message or logger name), cursor (from the previous page’s next_cursor) and limit (default 100, most 500). Returns lines (each time, level, area, message; an error’s detail follows its first line) and next_cursor (null when there are no older lines). Files are opened for sharing, so the file being written can be read; a line that cannot be read is skipped. 400 for an unknown level or area, or a range that ends before it starts | Administrator |
| POST | /logs/export/preview | How big a log export for these filters would be. Body: from and to (both required), level (errors, warnings_and_above (default) or everything), areas (empty means all), item_or_run (an item id; only lines and events that mention it — the name is kept from when ingestion runs existed), show_file_names (default false) and include_events (default true). The filters go in the body so an id is not left in browser history. Returns line_count, size_bytes, truncated and file_name; the figures are exact because the preview counts the same lines the export writes (it never builds the file in memory). areas may be null or empty (every area). 400 with a sentence for a missing range or an unknown level or area | Administrator |
| POST | /logs/export | Same body. Returns one UTF-8 text file tuvima-logs-{from}-{to}.txt (times in UTC) with a header that lists the filters and says private details are hidden, then the matching log lines and, for an everything export with events included, the system activity events for the range, oldest first. Keys and tokens (bearer values, api_key=/token=/secret=/password= values, JWT-shaped text, long hex and base64 runs), sign-in details (email addresses and the user named in a sign-in message) and file and folder paths are hidden; with show_file_names only the last part of each file name is kept, never the folders. The file is streamed, never built in memory. The filters (time, level, areas, text) run first; then at most 200,000 lines and 50 MB are kept, dropping the oldest, and the header says which limit capped it. Files and folders written as network paths (\\server\share\...) are hidden like any other path, and a whole Authorization header value is hidden whatever its scheme. Cache-Control: no-store | Administrator |
| GET | /logs/retention | The four limits (settings: activity_retention_days, activity_max_events, log_retention_days, log_max_total_mb) and what is stored now (activity_event_count, log_file_count, log_total_bytes) | Administrator |
| PUT | /logs/retention | Change the four limits (body: the settings object). Days 1 to 3650, events 1,000 to 10,000,000, megabytes 100 to 102,400; 400 with a sentence when any is out of range, saving nothing. The next nightly clean-up uses them. Replaces PUT /activity/retention | Administrator |
Plugins
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /plugins | List built-in and dynamic plugins loaded by the Engine. Each carries allowed_to (plain lines, one “Contacts host: what it sends” per declared network destination), sends_data_to (the declared hosts, comma separated; null when nothing leaves the machine) and network_destinations[] (host, sends). Each also carries host_services: state (working, needs_attention or broken), problem and last_problem (plain words, or null), records_used, record_bytes_used, jobs[] (key, name, state of scheduled, running or paused), event_subscription (active, paused or null) and background_service (running, restarting, stopped or null). It is kept in memory, so it starts clean after an Engine restart and whenever the plugin is switched on or off | Administrator or application with plugins.read |
| GET | /plugins/directory | The plugin directory (Available plugins): the saved copy of the list on GitHub with status (fresh, saved_copy, unavailable, disabled, invalid_source), last_checked_at, last_success_at, new_count, install_supported (always false until installing exists) and the listed plugins with their trust_level and, when the list gives them, the network destinations (host, sends). Checks GitHub first only when the switch is on and the last check is more than 24 hours old; with the switch off it makes no request | Administrator or application with plugins.read |
| POST | /plugins/directory/check | Check now: ask GitHub for the list whatever the age of the saved copy. Makes no request with the switch off. Returns the same body as GET /plugins/directory | Signed-in administrator |
| POST | /plugins/directory/seen | Remember the listed plugins as seen, so none counts as new next time. Returns the same body | Signed-in administrator |
| PUT | /plugins/directory/settings | Body { "enabled": true }. Switches the plugin directory on or off (plugin_catalog.enabled); changes nothing else. Returns the saved copy’s body | Signed-in administrator |
| POST | /plugins/directory/{pluginId}/approval-preview | The approval statement for installing a listed plugin, built from what the list declares (name, kind, permissions, trust level and, for a plugin Tuvima has not checked, the plain warning). 404 when the saved copy does not list it. Returns an approval preview | Signed-in administrator |
| POST | /plugins/directory/{pluginId}/install | Installs the listed plugin, or updates the installed copy to the listed version. Downloads the package from GitHub, checks its checksum (and, for verified and official entries, Tuvima’s signature), unpacks it safely and checks it says what the list said. The plugin is left switched off; an update that asks for more than was approved leaves it off with needs_approval_again set. 400 with a plain sentence when the package is refused, 404 when the saved copy does not list it. Returns { plugin_id, name, version, was_update, needs_approval_again, message } | Signed-in administrator |
| GET | /plugins/{pluginId}/removal-impact | What would have no source if the plugin were removed (would_lose, one sentence each) and a note that saved data stays (keeps). 404 for an unknown plugin | Administrator or application with plugins.read |
| GET | /plugin-sources | Every metadata source (built in and from plugins) with origin (official or plugin), whether it is on, what it supplies (for example “movie artwork”), the addresses it sends_data_to, and restart_needed (true when a plugin’s source was switched on or off since the Engine started) | Administrator or application with providers.config.read |
| GET | /plugin-sources/compare?from=&to= | Two sources side by side: both, only_from, only_to, and a line saying existing matches are kept. 404 when either is unknown | Administrator or application with providers.config.read |
| GET | /plugin-sources/{name}/switch-off-impact | What would have no source if this one were switched off (would_lose), what other sources still cover (still_covered), and a line saying everything already in the Library is kept. 404 when unknown | Administrator or application with providers.config.read |
| GET | /notifications | The Dashboard inbox, newest first (items, unread_count). Query limit (default 50, at most 500) | Effective administrator |
| POST | /notifications/read | Marks the given ids read, or every unread message when ids is empty. 204 | Effective administrator |
| GET | /notifications/settings | Each event with the channels it goes to, each channel with its state (healthy, needs_attention, off) and a plain state_label, quiet_hours, and include_details | Effective administrator |
| PUT | /notifications/settings | Changes routes (event id to channel ids; events left out keep theirs), quiet hours (HH:mm times) or include_details. 400 for an unknown event or channel or a bad time | Effective administrator |
| POST | /notifications/channels/{channelId}/test | Sends a test message to the signed-in administrator through that channel; a working test turns a switched-off channel back on | Effective administrator |
| POST | /notifications/channels/{channelId}/enable | Turns a channel that switched itself off back on. 204 | Effective administrator |
| GET | /backup/settings | The schedule, keep_last, each destination with state and last copy, next run and the last monthly restore check | Administrator or application with backup.read |
| PUT | /backup/settings | Changes the schedule, retention or enabled_destinations; parts left out keep their value. 400 for a bad time, frequency or unknown destination | Administrator or application with backup.run |
| POST | /backup/run | Backs up now and copies to each switched-on destination, reading every copy back. Only the Library’s settings, each person’s own state (kept against how to recognise each title) and safe configuration are included, never the catalogue or media files. 409 while another backup is running | Administrator or application with backup.run |
| POST | /backup/destinations | Adds a destination, or changes one when id is given. Body: kind (folder, share, s3 or webdav), name, location (folder path or web address), and for web kinds username, secret (write-only, stored encrypted, left out on an edit means keep it), confirmed_host (must equal the address’s host) plus bucket, region, prefix for S3. https is required except for devices on the local network; link-local addresses are refused. 400 in plain words | Effective administrator, in person |
| DELETE | /backup/destinations/{destinationId} | Stops using an added destination and forgets its login; copies already there are left alone. 404 for the local folder or an unknown id | Effective administrator, in person |
| POST | /backup/destinations/{destinationId}/test | Checks a destination can be reached and written to | Administrator or application with backup.run |
| GET | /backup/destinations/{destinationId}/backups | What a destination holds (plain .zip backups), newest first | Administrator or application with backup.read |
| POST | /backup/restore-from-destination | Fetches a plain backup and stages a restore for the next start with the same checks as an uploaded backup. 400 for a damaged or unsafe backup, a backup from an older version (“This backup is from an older version of the Library and can’t be restored. Make a new backup with this version.”), or a .tvbk copy made by an older version | Administrator or application with backup.restore |
| GET | /access/applications/integration-health | broken_webhooks, webhooks_needing_attention and keys_needing_attention: a switched-on webhook that failed 5 deliveries in a row is broken (3 in a row needs attention); a live key unused for 90 days or expiring within 14 days needs attention. Switched-off items never count. Feeds the red shading of the Integrations menu row | Administrator or application with identity.applications.write |
| GET | /plugins/{pluginId} | Plugin detail, manifest metadata, settings, permissions, and load state | Administrator or application with plugins.read |
| POST | /plugins/{pluginId}/enable | Enable a plugin. Optional body { "approval_hash": "..." }. Nothing switches on until the plugin’s current approval statement has been confirmed: with a record for the current statement it turns on straight away; otherwise the body hash must equal the current statement’s hash (the record is written first), else 409 Conflict with { "error": "approval_required", "approvals": [...] } (see Approvals). A plugin that does not fit this Library version or platform, is a kind this version cannot run, or failed to load keeps its own 409 Conflict problem detail with the plain-English reason and is never asked about. A plugin counts as running only while it is switched on, compatible and its current statement is covered by an approval record: when a saved manifest (PUT /plugins/{pluginId}/manifest, or one edited on disk) changes what it may do, the plugin stays loaded but stops running, enabled is false and needs_approval is true in the summary until it is switched on again through this action. | Administrator or application with plugins.manage |
| POST | /plugins/{pluginId}/disable | Disable a plugin | Administrator or application with plugins.manage |
| PUT | /plugins/{pluginId}/settings | Save plugin user settings JSON | Administrator or application with plugins.manage |
| GET | /plugins/{pluginId}/manifest | Read dynamic plugin manifest JSON. Built-in manifests are compiled and are not returned here. | Administrator or application with plugins.read |
| PUT | /plugins/{pluginId}/manifest | Save dynamic plugin manifest JSON without changing plugin id | Administrator or application with plugins.manage |
| DELETE | /plugins/{pluginId} | Delete a dynamic plugin folder and saved plugin configuration | Administrator or application with plugins.manage |
| POST | /plugins/{pluginId}/health | Run plugin health checks | Administrator or application with plugins.read |
| GET | /plugins/{pluginId}/secrets | List the secrets the plugin declares with a configured yes/no each. Never returns a value. 409 when the plugin’s secrets file is unreadable or damaged, 400 for a plugin id the secrets store cannot use (anything outside lowercase letters, digits, -, _ and .). | Administrator |
| PUT | /plugins/{pluginId}/secrets/{name} | Save one declared secret ({ "value": "..." }). 204 on success, 400 for an undeclared name, an empty value or an unusable plugin id, 404 for an unknown plugin, 409 when the plugin’s secrets file is unreadable or damaged. | Administrator |
| DELETE | /plugins/{pluginId}/secrets/{name} | Remove one secret. 204; 404 for an unknown plugin; 400 and 409 as for the other secrets actions. | Administrator |
| GET | /plugins/{pluginId}/jobs | List recent durable plugin operation rows for one plugin. | Administrator or application with plugins.jobs.read |
| POST | /plugins/jobs/segment-detection/run | Run scheduled playback segment detector plugins immediately | Administrator or application with plugins.jobs.run |
Plugin summaries also carry kind, trust_level, minimum_library_version,
maximum_library_version, supported_platforms, is_compatible and
compatibility_problem; enabled is the effective state (saved as on and
compatible).
Plugin responses preserve manifest capabilities, permissions, tool requirements and per-platform artifacts, AI permissions, settings schema, health checks, durable job counters, and approved-catalog provenance. Dashboard presentation types must not narrow that payload.
Plugin summaries also carry allowed_to (the Engine’s plain words for what the
plugin may do: one line per permission, tool it needs, Local AI use, and keys or
logins it keeps) and sends_data_to ("the internet" when it can connect, else
null). The Dashboard shows these words as they are.
Approvals
Nothing switches on until a server administrator has seen, in plain words, what it can do and whether anything leaves the machine, and confirmed. The Engine writes the words and a hash of exactly what is shown; the confirmation is recorded (who, when, what was shown) in approval_records. This covers turning on a plugin or a Local AI feature, issuing an application key, creating or enabling a webhook, and widening an integration: giving an application’s existing keys more permissions, or switching an application back on while it holds live keys. Turning things off, and taking permissions away, need no approval.
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /approvals/preview | Body { "kind": "plugin|ai_feature|application_credential|webhook", "item_id"?, "webhook"?: { "url", "event_types", "allow_local_network" }, "credential"?: { "application_id", "expires_at" } }. Returns { kind, item_id, title, kind_label, can_do[], sends_data_out, sends_data_to, notes[], statement_hash }. 400 for an unknown kind or missing input, 404 for an unknown plugin. | Administrator |
| GET | /approvals?kind=&item_id= | The recorded approvals for one item, newest first: { approved_by_name, approved_at, statement, statement_hash }. | Administrator |
The action itself enforces the rule, so an application (a non-Dashboard caller holding plugins.manage or ai.manage) cannot skip it. When an approval is missing, or exists only for an older statement, the action answers 409 Conflict with { "error": "approval_required", "approvals": [ <preview>, ... ] } and changes nothing. Show each preview, then repeat the action carrying the hash: approval_hash in the body of POST /plugins/{id}/enable, the credential request and the webhook request, the permissions update and application update requests (PUT /access/applications/{applicationId}/permissions and PUT /access/applications/{applicationId}), or the approvals map (feature key to hash) in PUT /ai/config. Feature keys in PUT /ai/config are read without regard to letter case and judged by their canonical key; the same feature written twice with different letter case is refused with 400. The note that a Local AI model may download first is shown but is not part of the hash, so downloading the model later never asks again. This 409 is distinct from the plain-English compatibility 409 of POST /plugins/{id}/enable, which is a problem detail. An application that approves is recorded under the application’s name.
Admin
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /admin/provider-configs | List all provider configurations | Administrator |
| PUT | /admin/provider-configs/{providerId} | Update a provider configuration | Administrator |
| DELETE | /admin/provider-configs/{providerId} | Remove a provider configuration | Administrator |
Settings
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /settings/test-path | Test whether a filesystem path is accessible by the Engine | Administrator |
| GET | /settings/providers | Provider configuration status - enabled state, last health check, rate limit info | Required |
| GET | /settings/server-general | Core server settings (name, language preferences, country) | Required |
| PUT | /settings/server-general | Update core server settings | Administrator |
| GET | /network/app-addresses | The two addresses a person types into a Tuvima app: at_home (the detected home-network address with the Dashboard port, or null) and away_from_home (the outside origin once a secure outside path is set up, otherwise null). Any signed-in person may read it, because the address is not a secret; it carries nothing else from the network settings or status (the Apps page under Personal > Overview shows it with a QR code of that one address). /network/status stays administrator-only | Required |
| POST | /settings/organization-template/preview | Validate an organization template and return a sample preview without saving | Administrator |
| PUT | /settings/organization-template | Save the organization template after validation | Administrator |
| GET | /settings/writeback/schedule | Batched file write-back status: { enabled, interval_minutes, waiting_count, failed_count, is_running, run_total, run_done, last_run_at, next_run_at }. next_run_at is null when nothing is waiting or write-back is off | Administrator or precise Application permission |
| PUT | /settings/writeback/schedule | Body { "interval_minutes": 1-1440 }; saves run_interval_minutes in config/writeback.json (400 outside the range). The scheduler reads it again on its next pass | Administrator or precise Application permission |
| POST | /settings/writeback/run | Write now: starts a run over everything waiting. 202 with the schedule status, 409 when a run is already going or write-back is turned off | Administrator or precise Application permission |
| GET | /settings/writeback/failed | Files that could not be written: [{ target_kind, asset_id, local_item_id, title, path, reason, attempts, updated_at }] | Administrator or precise Application permission |
Profiles
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /profiles | List profiles granted to the current account. Each item includes has_pin so the Who’s using Tuvima? page can show a lock; the other profile endpoints return has_pin: false | Human session |
| GET | /access/profiles | Managed profiles. Each carries is_server_admin, is_household_admin, feature_ids (read, watch, listen, view), hidden_library_ids (the libraries hidden from the profile; every other library in its areas is visible), access_version (the profile’s authorization version, sent back as expected_access_version when its access is saved) and user (id, email, or null when the profile has no sign-in); a profile that has its own sign-in also carries own_sign_in: account_id, email, must_change_password, is_enabled (never a password or code). A household administrator sees only their own household’s. | Identity users read |
| GET | /profiles/{id} | Profile detail | Required |
| GET | /profiles/{id}/settings/spoilers | The profile’s spoiler level: ProfileSpoilerPreferenceDto {profile_id, spoiler_level} with spoiler_level up_to_progress (“Up to where I am”, the default) or everything. Stored with the profile’s playback settings (general.spoilerLevel). Another person’s profile is 404, even for an administrator | Only the signed-in person’s own profile |
| PUT | /profiles/{id}/settings/spoilers | Choose the level with {spoiler_level}; returns the same DTO. Anything other than the two values is 400; an unknown or another person’s profile is 404. This is the only way to change the level: a bulk PUT /profiles/{id}/settings/playback keeps the stored one | Only the signed-in person’s own profile |
| GET | /profiles/{id}/settings/playback | Return a person’s playback, listening, reading and subtitle settings. 404 for an unknown profile; 403 (“Only an administrator of this household can change these settings.”) for anyone else | The person themselves; a server administrator for anyone; a household administrator for their own household (administrator PIN unlocked when turned on). Kids profiles never manage another profile |
| PUT | /profiles/{id}/settings/playback | Save those settings. Same rules and responses as the GET above. The reading group carries the book reader defaults alongside the older fields: theme (light, sepia, dark, system; default system), font (book, serif, sans; default book), fontSizePx (12 to 32, clamped; default 18), lineHeight (1.2 to 2.4, clamped; default 1.5) and pageWidth (narrow, standard, wide; default standard); readingMode (Paginated or Scroll) is the reader’s layout. Stored settings written before these fields existed read back with the defaults. An unknown theme, font or pageWidth is rejected | Same as the GET above |
| GET | /profiles/{id}/settings/view | Return a profile’s View permissions: view_enabled, access_shared_library, submit_to_shared_library, allow_gallery_sharing, allow_documents, allow_photos, allow_personal_videos and updated_at. A profile with no saved row gets the defaults, with allow_documents off for a child profile. 404 for an unknown profile | Server administrator, or household administrator for their own household |
| PUT | /profiles/{id}/settings/view | Save those permissions (allow_documents is the Documents switch under Settings > Libraries > View > Who can see Documents; leaving it out, or sending null, keeps the saved value, so an older client can never switch Documents on for a child profile by accident; allow_photos and allow_personal_videos are the Photos and Personal videos switches and also keep the saved value when left out, while the other switches are off unless sent). Turning View on provisions the Personal Space. 403 outside the administrator’s household, 404 for an unknown profile | Server administrator, or household administrator for their own household |
| PUT | /profiles/{id}/experience | Save name, avatar color, and navigation preferences without role fields | Exact active profile |
| PUT | /access/profiles/{id} | (Needs a recent sign-in.) Rename a profile and change its look. Body: display_name, avatar_color (#RRGGBB), avatar_icon (a built-in icon key; omit it to keep the current icon, send an empty string to go back to the initial). content_limit (omit to keep the current limit; an empty string means Everything; otherwise G, PG, PG-13 or R; anything else is a 400) and content_limit_allow_unrated (omit to keep). A change to either is recorded as profile.content_limit_changed. Responses carry avatar_icon next to avatar_color, and content_limit and content_limit_allow_unrated; GET /profiles does too. A photo uses the existing POST /profiles/{id}/avatar. | Identity users write |
| DELETE | /access/profiles/{id}?photos=move|delete | Deletes a person and their user too if they have one (a sign-in without a profile cannot exist), without deleting originals. Everything personal goes (likes, My List, progress, bookmarks, taste, waiting restore items, personal activity, reader state, prepared offline copies, the picture file); security and audit history and the photo-folder name reservation stay. photos=move (the default) keeps the person’s personal photos in their household’s Shared Library under a folder and tag named From <name> <date> (managed files move, linked originals are copied); photos=delete deletes them; anything else is 400. A household administrator deletes people of their own household, another household administrator included; a server administrator deletes any. Refused with one plain sentence (409/403) and nothing changed for: the profile the caller is using or their own sign-in opens, a household’s last household-administrator profile (“A household needs at least one household administrator. Make another profile a household administrator first.”), a server administrator’s profile by a household administrator (403), the last server administrator who can sign in, and the Owner profile. Another household’s profile answers 404 like a missing one. Needs a recent sign-in. Audited as profile.deleted (with sign_in_removed). | Identity users write |
| PUT | /access/profiles/{id}/access | (Needs a recent sign-in.) Replaces what a profile may use. Body: feature_ids, hidden_library_ids and expected_access_version (the access_version the caller loaded; the Dashboard always sends it, and when it is no longer current the Engine answers 409 with “Something changed since you looked. Check the list and try again.” and changes nothing, the check and the write sharing one transaction; an application caller that leaves it out keeps the plain replace). Every library in a profile’s areas is visible unless it is listed as hidden; a Vault library id is refused and an unknown library id is refused (a stored one that has left the configuration is dropped quietly). A server administrator may set anything; a household administrator only for profiles of their own household: a request that adds a feature their own profile lacks is refused (403), a feature they do not hold is kept exactly as it is, they can hide any library from the household’s profiles, but a library that is hidden from their own profile and from the person stays hidden (they cannot show what is hidden from themselves). Never on a server administrator’s profile (403); another household’s profile is 404. Raises the profile’s authorization version so its open sessions and paired apps check again. Audited as profile.access_replaced. | Identity users write |
| PUT | /access/profiles/{id}/roles | (Needs a recent sign-in.) Sets the roles of a profile. Body: is_household_admin, is_server_admin (both are the wanted state). A household administrator may turn the household administrator role on or off for profiles of their own household (a household administrator needs a user and is never a Kids profile; a household keeps at least one); only a server administrator changes is_server_admin (the profile must be in the default household, not a Kids profile, and its user must have an email and a password or passkey; the last server administrator who can sign in keeps the role). 409 with one sentence for a refused rule, 403 when not allowed, 404 for another household’s profile. Audited as profile.roles_changed. | Identity users write |
| POST | /access/profiles/{id}/avatar | Set someone else’s profile photo (multipart file, JPEG/PNG/WebP up to 5 MB). Same rules and checks as above; POST /profiles/{id}/avatar still only changes the active profile’s own photo. | Identity users write |
| DELETE | /access/profiles/{id}/avatar | Remove someone’s profile photo. | Identity users write |
UI Settings
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /settings/ui/global | Global UI defaults | Required |
| PUT | /settings/ui/global | Update global UI defaults | Administrator |
| GET | /settings/ui/device/{class} | Device profile settings for a device class (web, mobile, television, automotive) | Required |
| PUT | /settings/ui/device/{class} | Update device profile settings | Administrator |
| GET | /settings/ui/profile/{id} | Per-user UI preference overrides for a profile | Required |
| PUT | /settings/ui/profile/{id} | Update per-user UI preferences | Required |
| GET | /settings/ui/resolved | Effective resolved settings for the current request context (global + device + profile merged) | Required |
| GET | /settings/ui/library-preferences | Current per-media library display preferences (missing-item visibility defaults) | Required |
| PUT | /settings/ui/library-preferences | Save per-media library display preferences to config/ui/library-preferences.json | Administrator |
Reader
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /read/{assetId}/metadata | EPUB metadata - title, author, language, cover | Required |
| GET | /read/{assetId}/toc | Table of contents | Required |
| GET | /read/{assetId}/file | The book or comic file itself (.epub, .cbz, .cbr, .pdf) with HTTP byte-range support (206, Accept-Ranges, ETag, If-Range, 304). Same library-read scope, asset access and profile content limit as the other reader calls, and its own reader_files rate limit counted per signed-in session (default 600 a minute, rate_limiting.reader_files in config/core.json); any other kind of file answers 404. Responses are Cache-Control: private, no-cache with nosniff and a sandbox policy. Browsers reach it through the Dashboard route /engine-book/{assetId}/file, never directly | Required |
| GET | /read/{assetId}/search | Full-text search within EPUB content | Required |
| GET | /reader/{assetId}/bookmarks | List bookmarks for an asset | Required |
| POST | /reader/{assetId}/bookmarks | Create a bookmark | Required |
| DELETE | /reader/bookmarks/{bookmarkId} | Delete a bookmark. Another profile’s bookmark answers 404. | Required |
| GET | /reader/{assetId}/highlights | List highlights. Rows made by the book reader carry cfiRange; rows from the retired reader have it null. | Required |
| POST | /reader/{assetId}/highlights | Create a highlight. The book reader sends cfiRange (an epubcfi(...) range, at most 4096 characters) with chapterIndex, startOffset and endOffset all 0. color must be one of the supported highlight colours, selectedText and noteText at most 4000 characters; anything else answers 400. | Required |
| PUT | /reader/highlights/{highlightId} | Change a highlight’s colour and note (a missing note clears it). Another profile’s highlight answers 404. | Required |
| DELETE | /reader/highlights/{highlightId} | Delete a highlight. Another profile’s highlight answers 404. | Required |
| GET | /reader/{assetId}/statistics | Reading statistics - time spent, completion percentage, sessions | Required |
| POST | /read/{assetId}/whispersync | Sync reading position across devices | Required |
| GET | /read/{assetId}/whispersync | Retrieve last sync position | Required |
| DELETE | /read/{assetId}/whispersync | Clear sync position | Required |
Player
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /player/state | Current playback session, queue, position, and audiobook history | Required |
| POST | /player/queue/replace | Replace the active playback queue | Required |
| POST | /player/queue/items | Add items to the active playback queue | Required |
| POST | /player/command | Apply transport commands such as play, pause, seek, volume, or speed | Required |
| POST | /player/heartbeat | Persist current playback timing and resume progress. Optional offline and client_timestamp mark a replayed offline heartbeat; one older than the server’s record for the asset is ignored | Required |
| GET | /player/audiobooks/{workId}/history | Recent audiobook listening checkpoints | Required |
| GET | /player/audiobooks/{workId}/bookmarks | Saved bookmarks of a video or audiobook: the profile’s own plus those shared with everyone, newest first. A Vault title is not found unless Vault mode is on | Required |
| POST | /player/audiobooks/{workId}/bookmarks | Save a bookmark at a position in seconds, with a short note (200 characters) and an optional description (4,000). isGlobal: true shares it with everyone and is refused with 403 unless the caller is a server administrator | Required |
| DELETE | /player/audiobooks/bookmarks/{bookmarkId} | Delete the profile’s own bookmark, or a shared one when the caller is a server administrator; anyone else gets 404 | Required |
| GET | /player/audiobooks/{workId}/bookmark-packs | The bookmark packs the profile imported on a title, each with its read-only pack bookmarks. A Vault title is not found unless Vault mode is on | Required |
| POST | /player/audiobooks/{workId}/bookmark-packs/export | Build a pack file (BookmarkPackFileDto, the ZIP base64 encoded) from the profile’s own personal bookmarks (assetId, bookmarkIds, name, optional author, runningTimeSeconds, chapters). 400 for a bad request or a title with no ID to recognise it by; 404 for a Vault title, locked or not | Required |
| POST | /player/audiobooks/{workId}/bookmark-packs/inspect | Read a pack file (contentBase64) and say whether it is for this title: Match, DifferentCut (with canAlignByChapter) or WrongTitle; adds nothing | Required |
| POST | /player/audiobooks/{workId}/bookmark-packs/import | Add the pack’s bookmarks as read-only pack bookmarks for the profile. mode is AsIs (a matching pack) or AlignByChapter (a different cut, with the copy’s chapters); a pack for another title, a different cut without alignment, or one already on the title is 400 | Required |
| PUT | /player/bookmark-packs/{packId}/enabled | Switch one of the profile’s packs on or off ({ isEnabled }); 204, or 404 for anyone else’s | Required |
| DELETE | /player/bookmark-packs/{packId} | Remove one of the profile’s packs; 204, or 404 for anyone else’s | Required |
| POST | /player/audiobooks/{workId}/chapters/suggest-names | Suggest display-only audiobook chapter names using local AI | Required |
| GET | /player/audiobooks/{workId}/chapter-overrides | List saved display-only audiobook chapter title overrides | Required |
| POST | /player/audiobooks/{workId}/chapter-overrides | Save one display-only audiobook chapter title override | Required |
| DELETE | /player/audiobooks/{workId}/chapter-overrides/{assetId}/{chapterIndex} | Delete one display-only audiobook chapter title override | Required |
Player and playback contracts carry the complete queue/session state, delivery manifest, resume data, tracks/subtitles/chapters, audiobook history and bookmarks, chapter-title provenance, offline variants, diagnostics, and user playback settings. Text-track contracts retain source and normalized formats, timing mode, ownership/preference/export flags, confidence, and URL.
Companion
A second device on the same profile (for example a phone) can follow where another of that person’s devices is playing, or hold a position set by hand (“episode 3”, “chapter 12”). Each device’s live state is written from its own POST /player/heartbeat calls into a record separate from the shared player, so two devices playing on one profile never overwrite each other. Only the caller’s own profile is ever visible: the profile and the companion’s own device id come from the caller’s token, a different profileId is refused with 404, and there is no administrator bypass. Every item is checked against the caller’s catalogue scope; a device playing something the caller cannot see is left out, and a work outside the caller’s scope is refused with 404. Nothing leaves the machine.
| Method | Path | Description | Scope |
|---|---|---|---|
| GET | /companion/devices | The caller’s devices that are playing or paused right now, newest first. Each carries its device id, name (the paired device’s name when known), client, state, position, duration, work and asset ids and the playing item | queue.read |
| GET | /companion/session | This device’s choice: mode is none, follow or manual. When following, followed holds that device’s latest state, or is null with isFollowedDeviceLive false when it is not live | queue.read |
| PUT | /companion/session/follow | Body { "deviceId": "..." }. Follow one of the caller’s own devices (one that has reported playback for the profile, or one of its paired devices); any other id is 404 | progress.write |
| PUT | /companion/session/manual | Body { "workId": "...", "chapterIndex": 12, "positionSeconds": 30 }. Hold a position set by hand; a negative chapter or position is 400 | progress.write |
| DELETE | /companion/session | Stop following and clear any manual position. 204 | progress.write |
| GET | /companion/context | What the phone companion shows for one work at one position. Query: workId, optional chapterIndex (a chapter held by hand), optional positionSeconds (where a followed audiobook is, used to find its chapter when no chapter is given). Answers the work’s title, show, position in words, synopsis, the profile’s spoilerLevel, a Universe link when the profile can open it, whosHere (characters judged by the one spoiler rule with the viewer standing at that place: shown or no_plot_details, each with its source, performer and how many other owned works it appears in; characters held back are only counted in heldBackCount, never named; hasCharacterDetails is false when the Library holds none), previously (the previous owned episode of a show), the actors’ names, and a picker of episodes or chapters for choosing a place by hand. 404 when the work does not exist or is outside the profile’s scope; a negative chapter or position is 400; a profileId that is not the signed-in profile is 403. At the level Up to here the picker names only episodes and chapters up to the place (later ones are numbered, not titled), and whosHere.moreCount counts characters cut from the list | queue.read and library.read |
A device is live when its last heartbeat was playing or paused and arrived within 45 seconds (the same window that keeps the shared player live). A replayed offline heartbeat never counts, a stopped or finished player is ended and not live. Clients should poll /companion/session about every 5 to 10 seconds while following (the Dashboard asks every 5 seconds, and re-reads /companion/context when the followed work changes and every 15 seconds otherwise); a followed device’s position is at most one heartbeat (10 to 15 seconds) old.
Progress
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /progress/{assetId} | Playback or reading progress for an asset | Required |
| PUT | /progress/{assetId} | Update progress. A paired app replaying progress it recorded offline sends offline: true and client_timestamp; the later of the server’s and the client’s record wins instead of a stale revision being rejected (see Offline media for paired apps) | Required |
| GET | /progress/recent | Recently accessed assets with progress | Required |
| GET | /progress/journey | Full reading/watching journey across all assets for the current profile | Required |
Library Character and Asset Data
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /library/portraits/{portraitId}?size=&v= | Stream one character portrait image (used by Cast and person credits). size is t (96 px long side), s (320), m (960) or l (2160); each copy is made the first time it is asked for. | Required |
| POST | /library/enrichment/universe/trigger | Queue a universe enrichment sweep (takes no QID) | Effective administrator or precise Application permission |
SignalR
| Collection | Path | Direction | Description |
|---|---|---|---|
| Intercom | /intercom | Server -> Client | Real-time push events: ingestion progress, enrichment completion, MediaOperationChanged, CapabilityStateChanged, pipeline state changes, and review queue updates. Authentication required. Server-push only - clients do not send messages to the collection. |
SignalR payloads use MediaEngine.Contracts.Realtime except for the exact
frozen Universe/Chronicle UniverseEnrichmentProgressEvent. New events and new payload fields are not covered by
that exception.
Development
Available in development environments only. These endpoints are removed in production builds. Every one requires a signed-in administrator (with the administrator PIN unlocked when it is turned on); scripts that relied on the development localhost bypass alone are refused.
| Method | Path | Description |
|---|---|---|
| POST | /dev/reset-and-seed | Preserve application configuration, reset catalogued library state, create selected Standard or Stress fixtures, and queue normal ingestion. Deletes only files the Library created (fixtures wherever they were filed, their Engine-made subtitles, emptied folders) and lists the files it kept. 409 with nothing deleted when another reset is running, real-media protection is on, or a background worker or data store writer does not let go within 20 seconds (the message names it); the other reset actions behave the same way. |
| POST | /dev/reset-library-data | Clear catalogued media and derived state without deleting source media or application configuration. |
| POST | /dev/factory-reset | Clear the development database and generated state without deleting configured source media. |
| POST | /dev/seed-library | Seed the library with 22 EPUB test cases covering edge cases (pen names, foreign languages, series grouping, multi-author) |
| POST | /dev/wipe | Lower-level automated harness reset; generated-state scope preserves configuration and unrelated source media. |
| POST | /dev/full-test | Run full ingestion and enrichment pipeline on the seeded test library |
| POST | /dev/reingest-library | Pause file watching, reset generated database/cache/artwork state, scan every configured library source path, and leave watching paused until restart or explicit resume. |
| POST | /dev/integration-test | Run the integration test suite and return an HTML report |
Vault
Every Vault route answers exactly like a missing item (404) to anyone without an unlocked Vault mode session, and every answer is Cache-Control: private, no-store. Search text and chosen records travel in request bodies, never in addresses, and are never logged.
Vault mode itself is switched on the /api/v1/nsfw routes (the name is internal): status, unlock (the profile PIN in the body, only when the profile asks for it), lock, keepalive and PUT idle (body idle_lock_minutes: 5, 15, 30 or 60, or 0 for Never where no PIN is asked). There is no separate Vault PIN and no route to set or reset one; the profile PIN is the only PIN, and wrong tries share its five-try, fifteen-minute pause.
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /api/v1/vault/items/{assetId}/identify/search | Records that might be this Vault video, found by the typed text (body text), or, when empty, by the name patterns’ title and the product code in the file name. Each candidate says whether its length agrees with the file. Status not_connected or lookup_off when nothing was asked. | Unlocked Vault mode session plus effective administrator or metadata.write |
| POST | /api/v1/vault/items/{assetId}/identify/preview | The video’s current values beside what a chosen record (server_id, record_id) would put there, one row per field with a tick. Rows holding a person’s own edit cannot be ticked. | Same |
| POST | /api/v1/vault/items/{assetId}/identify/apply | Applies exactly the fields ticked (fields) from the chosen record, read again from the server by id; records where each field came from. Title, description, release date, cover, studio, performers and tags. | Same |
| GET | /api/v1/vault/items/{assetId}/match | How a Vault video was matched, if it was: server, record, signal, how it was applied, and where each field came from. matched: false is Unmatched. | Unlocked Vault mode session plus effective administrator or metadata.read |
| GET | /api/v1/vault/restored-bookmarks | { waiting, message }: how many saved Vault bookmarks from a restored backup still wait for a matching video (a count of the withheld rows in the waiting area, never a title). | Effective administrator with Vault mode unlocked |
| GET | /api/v1/vault/activity | The quiet Vault activity list (added, matched, unmatched, identified, failed), newest first. The only place Vault ingestion shows. | Effective administrator with Vault mode unlocked |
Related
Access, application events, and telemetry
These routes belong to the Access replacement under final integration. Inbound credentials resolve the current Application; they do not carry a household role. Metadata, provider, and other outbound service secrets remain separate.
| Method | Engine path | Responsibility |
|---|---|---|
| GET | /auth/sign-in-methods | Dashboard-only (service credential). Answers “what can this visitor use to sign in from where they are?” before anyone has signed in. Sends X-Tuvima-Client-Ingress and ?https=true when the visitor came over HTTPS; the header is recorded but where a visitor is never decides which methods appear. Returns { "password": bool, "passkey": bool, "external_providers": [{ "id", "display_name" }], "invitation_code": bool }, computed with the same rules the sign-in actions enforce (the method is switched on; passkeys and providers also need the one public address). password is also false while no enabled account could use a password (an install whose only account is the no-password this-computer owner), so the sign-in page shows no email and password form. It never returns account emails, profile names or counts. Nothing is cached: the Dashboard asks once per sign-in page load. |
| POST | /auth/session/validate | Dashboard-only (service credential). Sends X-Tuvima-Client-Ingress (this_computer or home_network); the place is recorded with the session but is not enforced, so a session is valid from any device that reaches the server. After every interactive sign-in (password, two-step, passkey, outside sign-in, invitation, this computer) the session is “choose a profile” (profile_pending: true, choose_profile: true in the sign-in answer) with the sign-in’s own profile as active_profile_id, the one the Dashboard pre-selects. Until a profile of the sign-in’s household is chosen with POST /auth/session/switch-profile (its PIN when set) the Engine allows only this check, GET /profiles (the household’s profiles) and their pictures, the switch and signing out; every other route answers 403 with code: "profile_required", and the authority shown to the Dashboard lists the household’s profiles and holds no roles, features or actions. A session that must first replace a temporary password is not marked until the new one is set. |
| GET | /auth/this-computer | Dashboard-only (service credential) and only for a request the Dashboard did not receive through a forwarding proxy or tunnel. Returns { "display_name" } of the account that has no password yet; the Dashboard’s GET /auth/login uses it to skip the sign-in page. Any other caller, or no such account, gets the same plain 404, so a caller without the service credential never learns the account exists. Any device that reaches the server can use this account until a password or passkey is added. |
| POST | /auth/this-computer | Same conditions as the GET. Body: device_id, device_name, client. Signs in the this-computer-only account without a password and returns the usual session response with authentication_method ThisComputer. 404 for a forwarded request or with no such account. |
| POST | /auth/confirm | Signed-in caller. Body: password (plus two_step_code when two-step codes are on), or credential_json + state from POST /auth/confirm/passkey-options. Marks the current session as just authenticated (auth_sessions.authenticated_at). Wrong secrets answer 401; a locked credential is refused. |
| GET | /auth/confirm/recent | Signed-in caller. Returns { "recent": bool } for the current session; the Dashboard calls it before a redirect-based action (linking a provider). |
| POST | /auth/confirm/passkey-options | Signed-in caller. Starts a passkey assertion used to confirm. |
| POST | /auth/login (two-step) | When the account has two-step codes on and the password is right, answers 202 with { "two_step_required": true, "pending_token": "..." } and no session. |
| POST | /auth/two-step/verify | Dashboard-only (service credential), rate limited. Body: pending_token, code (6-digit app code or a recovery code), original_client_ingress, original_client_is_https. Issues the session like a password sign-in. Any failure answers 401 with one generic message. The token lasts 5 minutes, works once and is bound to its ingress. |
| POST | /auth/two-step/setup | Signed-in, recent sign-in required. Returns { "secret", "otpauth_uri" } for a new unconfirmed setup. 409 if already on or no password. |
| POST | /auth/two-step/enable | Signed-in, recent sign-in required. Body: code. Turns two-step on and returns new recovery codes. |
| POST | /auth/two-step/disable | Signed-in, recent sign-in required. Body: code (app code or recovery code). |
| POST | /access/accounts/{accountId}/two-step/reset | Administrator, recent sign-in required. Turns two-step off for another account and records account.two_step_reset in the audit log. |
| GET | /auth/sessions | Signed-in person. Lists the sessions of their own account for Settings > Security, with the current one marked. |
| DELETE | /auth/sessions/others | Signed-in person, recent sign-in required. Open to any signed-in person, a member included, never only administrators. Signs out every other session of their own account (the account and current session come from the caller’s own sign-in, never the request); the current session stays. |
| DELETE | /auth/sessions/{sessionId} | Signed-in person, recent sign-in required. Signs out one of their own sessions; 403 for a session that is not theirs, including from an administrator. Someone else’s session uses /access/sessions/{sessionId}. |
| POST | /auth/password/change | Signed-in person, recent sign-in required. Body: current_password, new_password. Rate limited. |
| POST | /auth/password/recovery-codes | Signed-in person, recent sign-in required. Creates a new set of recovery codes, returned once; the earlier set is replaced. |
| POST | /auth/password/recover | Dashboard-only (service credential, rate limited). Recovers the account with a saved one-time recovery code and returns a new set of recovery codes. |
| POST | /auth/password/reset/begin, /auth/password/reset/complete | Dashboard-only (service credential, rate limited). Email password reset. Begin answers 202 whether or not the address has an account; complete sets the new password from the emailed link. |
| GET | /auth/passkeys | Signed-in person. Lists the passkeys on their own account. |
| DELETE | /auth/passkeys/{credentialId} | Signed-in person, recent sign-in required. Removes one passkey. The last usable sign-in method cannot be removed. |
| POST | /auth/passkeys/registration/options, /auth/passkeys/registration/complete | Signed-in person, recent sign-in required. Adds a passkey: start, then finish with the browser’s credential. |
| POST | /auth/passkeys/login/options, /auth/passkeys/login/complete | Dashboard-only (service credential, rate limited). Passkey sign-in. |
| PUT | /auth/profiles/{profileId}/pin | Administrator or household administrator, recent sign-in required. Sets a profile’s PIN (Profile details on the person’s page). |
| POST | /auth/account/secure | Signed-in this-computer-only account. Body: password and/or a passkey (passkey_credential_json, passkey_state, passkey_name), plus device_id, device_name, client, and email, which is required when the account has none (400 “Add an email to secure this account.”; 409 “That email is already used.”) and ignored when it has one. Adds the credential, clears this_computer_only, replaces recovery codes, revokes old sessions and returns a new session. |
| POST | /auth/external-link | Dashboard service credential, signed-in person with a recent sign-in. Body: transaction_ticket from the outside sign-in, and optional email. Links the outside sign-in to the account. An account that has no email takes the provider’s email when it supplies a usable, unused one; otherwise email is required (400 “Add an email to link this sign-in.”; 409 “That email is already used.”). The Dashboard does not send email yet. |
| POST | /setup/v1/begin | Dashboard-only (service credential). Body: original_client_ingress (the Dashboard’s own classification; missing or unknown means remote) and optional setup_code. From this_computer or home_network no code is needed (setup_code is accepted and ignored). remote is refused. Success returns the setup session token (header X-Tuvima-Setup-Session) and ends any earlier setup session. The refusal is 403 with {"reason","message"} where reason is setup_remote_refused. 409 once an administrator exists. |
| POST | /setup/v1/administrator | Setup-session protected. Creates the first administrator. Body adds sign_in (password, the default, or this_computer) and original_client_ingress. With this_computer: only display_name is required (email is ignored; the account has none until it is secured), password must be empty, and the request is refused with 409 and code: "not_available_here" unless setup began, and this request came, from this computer or the home network. The account is created with no password (accounts.this_computer_only = 1), no recovery codes are returned, and no network setting changes. The account works from any device that reaches the server, with administrator rights, until a password or passkey is added. The password path is unchanged. |
| POST | /setup/v1/restore/upload | Setup-session protected. Multipart form with the backup ZIP in backup. Checks it and returns the inspection: operation_id, file_name, created_at, manifest_version, database_epoch, sizes and counts, warning (one plain sentence about what it holds) and contents, the same counts as the restore test. 400 with one plain sentence for an older layout or a damaged file. |
| POST | /access/accounts/{accountId}/temporary-password | Administrator only, and only once the administrator’s own account is secured. Body: temporary_password (12–128 characters, the usual password rules, and not the account’s email or name) and optional must_change_password (default true). With true the password works only to choose a new one and stops working after 7 days; with false it is stored as an ordinary credential (no must_change_password, no expiry). Either way it signs the account out everywhere and returns { user, temporary_password_expires_at }. Audited as account.temporary_password_set or account.password_set. POST /access/users takes its starting password in the same way. |
| GET | /access/households | The households with id, name, profile_count, account_count and created_at, by name. A server administrator (or an application with identity.users.read) sees every household; a household administrator sees only their own; a member, a child profile or a locked administrator gets 403. |
| POST | /access/households | Server administrator only (or an application with identity.users.write); a recent sign-in is required. Body: name (1 to 100 characters, trimmed; 400 otherwise). Adds an empty household and returns it (201). Refused with 409 (“Set an email and password for your own sign-in first. A Library with several households must not be open to anyone who reaches it.”, with code: "secure_administrators_first" in the problem body) until every server administrator’s user has an email and a password or passkey; the same check guards a new household made by POST /access/users (new_household_name), by a new-household invitation and by POST /access/accounts for a person who starts a household. Audited as household.created. |
| PUT | /access/households/{householdId} | Server administrator only. Body: name. Renames the household and returns it; 404 when unknown. Audited as household.renamed. |
| DELETE | /access/households/{householdId} | Server administrator only. Removes a household, but only when no person (profile) and no sign-in belongs to it and its Shared Library in View holds no shared photos (an empty Shared Library goes with it): otherwise 409 (“This household still has shared photos in View. Remove them first.” when shared photos remain) and nothing is changed (no person, sign-in or data is ever moved or deleted by this action; a household that goes with its last household administrator’s sign-in is different: DELETE /access/users/{userId} deletes its photos with it). 204 on success, 404 when unknown. Audited as household.deleted. |
| POST | /access/households/{householdId}/people | Administrator only, once their own account is secured. Body: display_name, avatar_color, avatar_icon (optional, a built-in icon key such as fox; anything else is a 400), is_child, pin (optional, 4–12 digits), content_limit and content_limit_allow_unrated (optional; left out, a child person starts on PG and anyone else on Everything). Adds a person to the household (up to 8; refused with “A household can have up to 8 people.” when full). A child person is a restricted profile. The household’s main sign-ins can open them, not as default. Returns the profile (is_restricted shows the child role). |
| GET | /access/users | Server administrator (or an application with identity.users.read). Every user (sign-in): id, email, profile_id, profile_name, household_id, household_name, has_password, has_two_step, is_enabled, this_computer_only. Never a password, code or token. |
| POST | /access/users | Server administrator (or an application with identity.users.write); a recent sign-in is required. Attaches a new sign-in to a profile that has none (profile_id) or makes it with a new profile (new_profile: name, household_id or new_household_name, is_kids default false; give exactly one of the two household fields). email is required; method is invitation (the response carries invitation: account_id, code, expires_at, public_address, like POST /access/invitations) or temporary_password (send temporary_password; the person replaces it at first sign-in). A new profile is an ordinary member (Kids only with is_kids) with Read, Watch and Listen on, View on only when it joins an existing household, and no library; roles and access are changed on the profile afterwards. A new household is made in the same step as its first user, and that first profile looks after it (and cannot be a Kids profile); a profile that joins a household that already exists is never made a household administrator here. Returns 201 with user (as in the list), invitation and temporary_password_expires_at. 409 with one sentence for: “That profile already has a sign-in.”, “That email already has a sign-in.”, “A household can have up to 8 people.” and the passwords-before-a-second-household sentence above; 404 for an unknown profile or household. A password that is refused leaves nothing behind. Audited as account.created (and household.created, household.admin_granted). |
| PUT | /access/users/{userId}/email | Server administrator (or an application with identity.users.write); a recent sign-in is required. Body: email. Returns the user. The profile, its roles, access and everything in it stay exactly as they were. |
| GET | /access/users/{userId}/deletion-preview | Server administrator. Changes nothing. Returns user_id, email, profile_name, removes_household, household_name (only when the household would go), household_profiles (id, display_name of every profile that would go with it, empty otherwise; their ids are what profiles confirms below), refused_reason (one plain sentence when the delete would be refused, otherwise null) and has_personal_photos (a household removal meets photos or videos in its profiles’ own libraries) and has_shared_photos (the household’s Shared Library holds some); they are deleted with the household, never moved, so the confirmation says so, telling the two apart, and there is no photo choice. |
| DELETE | /access/users/{userId}?profiles=<id>,<id> | Server administrator (or an application with identity.users.write); a recent sign-in is required. Deletes the sign-in only (sessions, paired devices, tokens, passkeys and the rest go with it): the profile and everything in it stay, and it loses any administrator role (an administrator without a sign-in cannot exist). The exception is the last enabled user attached to a household-administrator profile of a household that is not the default one: the household is removed with every profile in it (everything personal included) and with every personal and shared photo and video (users spec D18: they are deleted, never moved into another household, and there is no photos choice; a photos parameter is ignored). profiles is the list from the preview (empty for a sign-in-only delete): if what would go differs the Engine deletes nothing and answers 409 “Something changed since you looked. Check the list and try again.”. A delete that would remove a household and sends no profiles at all is refused the same way (a sign-in-only delete without it still works). Never the caller’s own sign-in (“You can’t delete the sign-in you are using.”), the last server administrator who can sign in (“This is the last administrator who can sign in. Make someone else an administrator first.”), or the default household. 204. Audited as account.deleted (profiles_deleted, household_removed: counts and a flag, never names) and household.deleted. |
| — | Household administrators | A household administrator (not a server administrator) may call the household actions above: add person, temporary password, email change, two-step reset, profile rename, DELETE /access/profiles/{id} (including another household administrator’s profile), PUT /access/profiles/{id}/access, /roles and /admin-protection (their own only), PUT /auth/profiles/{profileId}/pin. Only for their own household; access handed out cannot exceed what their own profile holds. Users are not theirs to manage: GET/POST/DELETE /access/users (making and removing a user) are for a server administrator (D4). GET /access/profiles and /access/libraries return only their own household (and the libraries it holds). Anything else is 403, and another household’s ids answer 404. |
| POST | /auth/invitations/preview | Dashboard-only (service credential, rate limited). Body: code, original_client_ingress, original_client_is_https. Returns { email, expires_at } for a code that can still be used, otherwise a plain 404. Used to show the email, read-only, before choosing a password. |
| POST | /auth/invitations/accept | Dashboard-only (service credential). Body: code (dashes, spaces and case are ignored), password, device fields. Creates the sign-in for the invited account and returns a session. A bad, expired or used code answers 401 with the same message. |
| POST | /auth/password/change-temporary | A signed-in person whose password is temporary. Body: current_password, new_password, device_id, device_name. Replaces the password, ends every session of the account and returns a new normal session. Refused with the administrator message once the temporary password has expired. While must_change_password is true, every other Engine action except POST /auth/session/validate and sign-out answers 403 with code: "password_change_required". |
| GET | /access/devices | Paired phones and TVs for Settings > Security > Devices. A human session only. A server administrator whose administrator PIN is unlocked (or who uses none) sees every active device, a household administrator (with their administrator PIN unlocked, when they use one) sees the devices of the ordinary sign-ins in their own household, and anyone else (including a server administrator who has not unlocked yet, and a household administrator acting as a child profile) sees only their own account’s: the same rule as /access/sessions. Each item: id, device_name, platform (mobile, television, automotive, web), client_name, account_id, account_display_name, profile_id, profile_name, paired_at, last_seen_at, backs_up_to (the name of the person the phone backs up, empty until chosen), backup_profile_id, household_id. |
| PUT | /access/devices/{deviceId}/backup-profile | Chooses (profile_id) or clears (null) whose photos a phone backs up, without a PIN. A server administrator, or the household administrator of the phone’s household; recent sign-in required. 204, 403 for anyone else, 404 when the device is unknown or revoked, or the person is not in the phone’s household. Phone photo uploads (POST /view/uploads with a device token) are stored for this person whoever the phone is browsing as, and answer 409 with code choose_backup_profile until one is chosen. |
| GET | /access/sessions | Signed-in sessions for Settings > Security > Devices. A human administrator only: a server administrator sees every account, a household administrator the accounts of their own household except server administrators’ and other household administrators’ sign-ins; anyone else, and any app, gets 403. Only active (not ended, not expired) sessions are listed, newest first, and accounts with none are left out. Each item: account_id, account_label (the email, else the person’s name, else No email), email (null when the sign-in has none), household_id, profile_display_name (the person a person’s own sign-in opens, otherwise null), sessions (the same items as GET /auth/sessions). |
| DELETE | /access/sessions/{sessionId} | Signs out one session of someone in the caller’s scope (as above). Recent sign-in required. 204 on success, 400 (Use Sign out for your own session) for the caller’s own session, 403 when the session belongs to someone outside the caller’s scope, 404 when it is unknown or already ended. Audited as account.session_revoked_by_administrator. |
| GET | /access/self-service/external-logins | Signed-in person. Lists their linked sign-ins (provider and email) for Settings > Security. |
| DELETE | /access/self-service/external-logins/{loginId} | Signed-in person, recent sign-in required. Unlinks one of their own linked sign-ins. |
| GET | /access/self-service/notices | Signed-in person. Lists the one-time reminders their account has dismissed (dismissed: notice keys, today only secure-account). Read from the caller’s own account only. |
| POST | /access/self-service/notices/{key}/dismiss | Signed-in person. Remembers “Not now” on a reminder for their own account. 204; 400 for an unknown key (nothing saved). |
| POST | /access/me/export | Server administrator only, signed in at the Dashboard (no household administrator, other user, application, service or paired-app caller: all get 403; users spec D16), recent sign-in required. Downloads the server administrator’s own profile and the sign-in that opens it as a zip, tuvima-user-<yyyyMMdd-HHmmss>.zip: their sign-in (email, password hash, two-step secret, passkeys, outside sign-in links), the profile (limits, PIN hash, preferences, Read/Watch/Listen/View) and its own state, and the playlists and collections it built. It carries what is needed to sign in as them, so the file must be kept safe. The manifest has scope: "profile", profile_count, sign_in_count and two_step_left_out. Records account.exported. Never takes an account from the request. |
| GET | /access/admin-unlock | Administrator or household administrator. Reads the administrator unlock state of this session. |
| POST | /access/admin-unlock | Administrator or household administrator, rate limited. Unlocks admin settings for this session with the administrator PIN, when the grant’s administrator protection is on. |
| DELETE | /access/admin-unlock | Administrator or household administrator. Ends this session’s administrator unlock. |
| GET | /access/libraries | Administrator or household administrator. Lists every catalogued Library that is not in the Vault, as id, display_name, category, area (read, watch, listen or view) and hidden_from_you (true only in a household administrator’s view, when the library is hidden from their own profile, so they cannot show it to anyone). The Libraries list on a person’s page ticks the ones that are not hidden. |
| POST | /access/visibility-after-move | Server administrator (surface unlock where one is set); household administrators and applications are refused. Who would stop seeing files if they moved into a library (simpler access D13). Body: target_library_id (a configured, non-Vault library) plus either a folder (source_id, or folder_path with an optional library_id it is in now, matched with the first check’s folder rule so /media/kids2 is not inside /media/kids) or asset_ids (at most 5,000; more is refused, never cut short). Returns people: one row per profile that is not a server administrator’s, whose sign-in (if it has one) is enabled, that sees the files now (area ticked, library not hidden from the profile, not Vault) and would not in the target, as profile_id, display_name (the profile’s name) and file_count (files, not titles). Server administrator profiles are never listed, an empty list means nobody is affected, and content limits and Vault mode are not part of the answer. 400 with a plain message for a Vault or unknown target, an unknown source, an oversized list, or a request that names no files or names them two ways. |
| GET | /access/profiles/{profileId}/admin-protection | The “Ask for my PIN on admin pages” setting of a profile: { enabled, unlock_mode, unlock_minutes, protection_version } (switched off with the default unlock choice when never set). Readable by the person on their own profile and by a server administrator; a household administrator gets 403 for another administrator’s profile and 404 for a profile of another household. |
| PUT | /access/profiles/{profileId}/admin-protection | (Needs a recent sign-in.) Body: enabled, unlock_mode (FixedDuration, UntilProfileSwitch or LockOnLeave), optional unlock_minutes (1 to 120, FixedDuration only). Returns 204. A person changes their own profile’s, a server administrator any; a household administrator never another administrator’s. Only a profile that holds an administrator role has the setting (409 otherwise), and turning it on needs a PIN on the profile (the profile’s own PIN is the one asked for). Audited as profile.admin_protection_changed. |
| POST | /access/accounts/{accountId}/export | Server administrator only, signed in at the Dashboard (a household administrator, application or paired app gets 403), recent sign-in required. Downloads one user’s profile and sign-in as the same zip as /access/me/export, except that when the account is not the caller’s own the file holds no two-step secret (no account_two_step row) and the manifest says two_step_left_out: true. 404 (“That user isn’t in this Library.”) for an unknown account; 409 when the account has no email; 500 (“The file couldn’t be made on this computer.”) for a problem on this computer. Records account.exported with the administrator as actor. |
| POST | /access/households/{householdId}/export | Server administrator only, signed in at the Dashboard (a household administrator, application or paired app gets 403, even for their own household), recent sign-in required. Downloads the whole household as tuvima-household-<yyyyMMdd-HHmmss>.zip: every profile, the sign-ins that open them and each profile’s own state; the manifest has scope: "household". Only the exporting person’s own two-step secret is kept; everyone else’s is left out and two_step_left_out is true. 404 (“That household isn’t in this Library.”) for an unknown household; 403 for anyone but the server administrator. Records household.exported. |
| POST | /access/accounts/import/inspect | Server administrator signed in at the Dashboard, recent sign-in required. Multipart upload (field file, up to 256 MB) of a user file (one profile or a household). Checks it as a restore is checked and returns operation_id; scope (profile or household); household_name (the file’s household, a suggestion for a new one); profiles (each id, name, role, content_limit, content_limit_allow_unrated, sign_in_id and access, as stated in the file so a changed file can be spotted); sign_ins (each id, email, profile_id, email_in_use, and problem, a sentence when that sign-in cannot move at all, for example its outside sign-in is linked to someone here; it can still come without a sign-in); households (each id, name, profiles) to choose from; can_add_household and add_household_blocked_reason (a second household needs every server administrator to have an email and a password); max_profiles_per_household (8); personal_items; two_step_to_set_up (true when someone else made the file and left a two-step secret out; a file for one person that says so yet holds a two-step row is refused); and blocked_reason (null: a changed email is never a reason to refuse). Writes nothing to the data store; the file is kept for an hour for that administrator only. 400 with one sentence when the file is not a user file or is damaged, and 400 (“This file is from an older version of the Library and can’t be imported. Export again with this version.”) for a file made with another data structure. |
| POST | /access/accounts/import/{operationId} | Server administrator signed in at the Dashboard, recent sign-in required. Body (the administrator’s choices): exactly one of target_household_id (an existing household) and new_household_name; for a new household new_household_administrator_profile_id (an imported profile that keeps its sign-in and is not a Kids profile); keep_access (default false: Read, Watch, Listen and View arrive only when true; library access never does); emails (file sign-in id to the email to use instead of the file’s, checked as an email address, 400 otherwise); without_sign_in (file sign-in ids to leave out). Imports the profiles as ordinary members (no administrator role is ever carried, Kids stays Kids) in one transaction; ids already used here are replaced by new ones. Returns household_id, household_name, created_household, profiles (each id, name, sign_in_email, is_household_administrator) and waiting_items. 404 when the kept file is gone or belongs to another administrator; 500 for a problem on this computer; 409 with a plain sentence and nothing written when the email is used here, the household is full (8 people), the household is missing, a second household is added before every server administrator has an email and a password, a new household has nobody to look after it, or a passkey or outside sign-in clashes; a refused import keeps the file so a choice can be changed. Records account.imported (subject: the household). |
| DELETE | /access/devices/{deviceId} | Revokes the device and all its tokens, then sends ApplicationEventRevoked to its live connection so the app signs out at once. 204 on success, 403 unless the device belongs to the caller’s own account, to a sign-in in the household of the caller (a household administrator with their PIN unlocked), or the caller is a server administrator with their administrator PIN unlocked (or none set), 404 when the device is unknown or already revoked. |
| GET/POST/PUT/DELETE | /access/applications and /{applicationId} | Application identity and registered permissions. PUT /access/applications/{applicationId} (enabled state, type, administrator flag) needs approval_hash in the body when it switches a disabled application back on while it holds a live key, or gives its keys more permissions (for example by making it an administrator); otherwise 409 Conflict approval_required and nothing changes. Disabling, renaming and narrowing need nothing. |
| PUT | /access/applications/{applicationId}/permissions | Replace an application’s permission grants. Needs approval_hash in the body when the change adds permissions (409 Conflict approval_required otherwise, nothing saved); the statement is kind application (“Its keys can: …”, “Applies to N existing keys”), recorded under the application’s id. Taking permissions away needs nothing. |
| POST/DELETE | /access/applications/{applicationId}/credentials and /{credentialId} | One-time credential issuance and revocation. Issuing needs approval_hash in the body every time (every key is new), else 409 Conflict approval_required; the approval is recorded under the new credential’s id. Rotation is not gated. |
| GET/POST/PUT/DELETE | /access/applications/{applicationId}/webhooks and /{webhookId} | Validated destinations, event selection, and delivery status. Creating an enabled webhook, or updating one that was off or whose address, events or private-network setting changed, needs approval_hash in the body unless a record for that webhook already holds the same statement; otherwise 409 Conflict approval_required. |
| POST | /access/applications/{applicationId}/webhooks/{webhookId}/rotate | Rotate the protected signing secret; display it once |
| GET | /access/applications/{applicationId}/webhooks/{webhookId}/attempts?limit=N | The webhook’s recent delivery attempts, newest first (default 6, at most 50): attempt_id, event_type, attempt_number, attempted_at, delivered, result (short plain text such as HTTP 503; never a response body), duration_ms, is_test. Attempts are kept for seven days, and each webhook keeps its newest twenty regardless of age. |
| POST | /access/applications/{applicationId}/webhooks/{webhookId}/test | Send test: sends one signed webhook.test event (no library data) to the receiver straight away, with the same address checks as a real delivery and no retries. Records a finished delivery and an attempt with is_test, and returns that attempt. 409 Conflict with a plain message when the webhook is switched off or the application no longer has access. |
| SignalR | /application-events | Bounded subscription/replay with current principal and resource authorization |
| GET | /api/v1/playback/sessions | Current scoped playback sessions |
| GET | /api/v1/playback/history | Durable scoped playback history |
| GET | /api/v1/analytics/playback | Scoped playback totals |
| GET | /api/v1/analytics/users, /libraries, /devices | Aggregation only after scope filtering |
| GET | /api/v1/activity | A person’s own activity record (watched, listened, read, added to My list), newest first, cursor-paged (limit up to 200, next_cursor). Query: scope (me, the default, profile:{id}, household:{id} or everyone; the Engine refuses with 403 what the caller may not see and 404 for an unknown person or household), q (title, subtitle, album and device; also the person’s name when the scope includes others), kind (repeat or comma-separate watch, listen, read, my_list), from/to, outcome (finished or not_finished), device, group=title (one group per work with its attempts and totals) and expand=songs. Consecutive songs less than 30 minutes apart, with no other activity by that person between them, come back as one music_session item (song and album counts, up to nine random album covers, the songs with expand=songs). Each entry has its own snapshot of the item, so deleted items still show. Rows carry person only when the scope includes others. No IP addresses or sign-in details. A paired app gets its own person only. Opening a scope that includes others writes activity.viewed_others once per 30 minutes per actor and scope. A refusal because a server administrator has not yet entered their PIN is a 403 problem with "code": "admin_unlock_required" (also on export, delete-preview and delete), which is what the Dashboard asks for the PIN on; every other refusal has no code. Needs progress.read |
| GET | /api/v1/activity/periods | Counts per year and month (UTC) under the same scope and filters, for the date rail |
| GET | /api/v1/activity/recent?limit=20 | The caller’s own latest activity for the Overview strip, music collapsed into sessions; each tile has a natural shape (portrait for books and comics, landscape for episodes and movies, square for music and audiobooks) |
| GET | /api/v1/activity/summary | The caller’s own lifetime totals (never anyone else’s, whatever the caller’s role; a paired app gets its own person’s). Fields: watch_seconds, read_seconds, listen_seconds (music_seconds plus audiobook_seconds), finished_count (titles with at least one finished attempt), in_progress_count (titles whose latest attempt is not finished) and first_activity_at (UTC, null when there is no activity). Songs and My list adds are not counted as titles. Needs progress.read |
| GET | /api/v1/activity/export.csv | The same filters as a CSV file of exactly the matching entries. Signed-in people only. A scope that includes others needs an effective server administrator (403 otherwise) and writes activity.exported_others every time |
| GET | /api/v1/activity/delete-preview?before=&scope= | How many entries before the date would be deleted. Server administrator only (unlocked when they use an administrator PIN); 403 for everyone else |
| DELETE | /api/v1/activity?before=&scope= | Deletes the entries that started before the date in the scope, and the small pictures nothing uses any more, and writes activity.history_deleted (scope, date and count) in the same transaction: if the audit entry cannot be written nothing is deleted. Server administrator only. This is the only way activity history is deleted; nothing prunes it |
| GET | /api/v1/activity/artwork/{key}?scope= | The small cover copy kept with an entry (Cache-Control: private). 404 unless the caller may see at least one entry that uses it |
| GET | /api/v1/taste?scope= | The taste of each person the scope covers (me, the default, profile:{id}, household:{id} or everyone; 403 for what the caller may not see, 404 for an unknown person or household, 400 for a malformed scope), with the person’s own removals and additions applied on top of what the Library learned. Per person: profile_id, person (the name, only when the scope includes others), is_you, genres, moods and eras (each item {value, weight, source}: weight is 0 to 1 and null for a typed value, source is learned or added), media_mix, summary and last_updated_at; the body also carries suggestions (known genres, moods and decades) for the Add controls. A household administrator is limited to their own household, a server administrator may ask for anyone, a paired app gets its own person only. Opening a scope that includes others writes taste.viewed_others once per 30 minutes per actor and scope. Needs progress.read |
| POST | /api/v1/taste/items | Adds {group, value} (group is genres, moods or eras) to the signed-in person’s own taste. Nobody can change anyone else’s taste (the route carries no person), and a paired app cannot edit: 403. Values are trimmed, lower-cased and at most 60 characters; an era is a decade such as 1990s; otherwise 400. Returns 204 |
| DELETE | /api/v1/taste/items/{group}/{value} | Hides that value from the signed-in person’s own taste (the learned taste is not changed, so it stays hidden until the person adds it back). Same rules as the POST. Returns 204 |
Telemetry read operations have separate registered permissions; cross-session control remains unavailable. Delivery mode and transformed media properties remain unknown without trustworthy observed stream facts. Webhook retries use the same signed envelope and stable delivery identity; receivers must handle repeated deliveries idempotently. A replay gap requires a fresh authorized snapshot before resuming the stream.