Skip to content

Engine API Reference

In this page

Look up the Engine’s HTTP routes, authentication rules, and endpoint responsibilities.

Where this lives in the code

  • src/MediaEngine.Api/Endpoints
  • src/MediaEngine.Web
  • src/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

  1. GET /.well-known/tuvima on 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 optional outside_address, an origin such as https://tuvima.example.com or a Tailscale address, when the owner has set up a secure outside address in Settings → Network → Remote Access; it is null or absent otherwise. Apps use it away from home. The Dashboard fetches it from the Engine action GET /network/outside-address (Dashboard service credential only; returns { "outside_address" }). The old Anywhere readiness action and its 409 refusal no longer exist.
  2. A television or other input-constrained client posts form data or JSON to POST /api/v1/oauth/device_authorization with a stable public client_id, display names, version, device class, requested space-delimited scopes, and capabilities.
  3. The user opens the returned verification_uri on 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.
  4. The device polls POST /api/v1/oauth/token using grant type urn:ietf:params:oauth:grant-type:device_code. authorization_pending, slow_down, access_denied, and expired_token are normal OAuth error responses.
  5. Access tokens last 10 minutes. Refresh tokens rotate on every successful refresh_token grant 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

ResourcePrimary routesRequired scope
DiscoveryGET /.well-known/tuvimaNone
Browse/homeGET /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 behaviourlibrary.read
Item lookupPOST /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 sizelibrary.read
Browse tabsGET /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 cardslibrary.read
View all gridGET /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 appearlibrary.read
ContinueGET /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 didprogress.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}/effectiveAn 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
SearchGET /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 sortGET /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)
DetailsGET /api/v1/details/{entityType}/{id}library.read
Next episodesGET /api/v1/details/show/{id}/next-episodes?after={assetId}&count=3library.read
Collections and UniversesGET /api/v1/collections, /collections/{id}, /collections/{id}/items, GET /api/v1/universe/{qid}library.read
Owned-file coverGET /api/v1/display/artwork/media-assets/{assetId}/effective-cover (checked against the profile’s access to that file)artwork.read
Artwork/media bytesURLs returned by display/detail/manifest models; /api/v1/stream/{assetId} supports ranges and signed /api/v1/stream/hls/... paths serve adaptive packagesartwork.read or playback.read as applicable
ProgressGET/PUT /api/v1/progress/{assetId}, GET /recent, GET /journeyprogress.read / progress.write
QueuesGET /api/v1/player/state, mutations under /api/v1/player/queuequeue.read / queue.write
Playback session/api/v1/player/command, /heartbeat, /session/takeoverplayback.write or progress.write
CompanionGET /api/v1/companion/devices, GET /session, PUT /session/follow, PUT /session/manual, DELETE /session, GET /context (see Companion)queue.read / progress.write
Playback decisionGET /api/v1/playback/{assetId}/manifestplayback.read
Cast sessionPOST /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 deviceplayback.read, plus the catalogue’s own limits for that title (a Kids limit applies)
Cast receiver routesGET/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 networkthe cast grant (see Security)
Downloadsencode/offline routes under /api/v1/playbackdownloads.read / downloads.write
Device playback preferencesGET /api/v1/devices/current/playback-preferencesplayback.read
Device download listGET/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
PhotosGET /api/v1/view/timeline, /places, /people, /people/{key}/items, /items/{id}, /items/{id}/thumbnail, /items/{id}/previewview.personal.read
Photo GalleriesGET /api/v1/view/galleries, /galleries/{id}/itemsview.galleries.read
Photo originals and videoGET /api/v1/view/items/{id}/original, /items/{id}/playbackview.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.

RouteAnswer
GET /timeline?cursor&limitViewClientTimelinePageDto: 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 /galleriesViewGalleryListResponse (owned, shared_with_you). Counts leave documents out.
GET /galleries/{id}/items?cursor&limitViewClientItemPageDto (items, next_cursor, has_more), newest first.
GET /people?cursor&limit, GET /places?cursor&limitThe existing ViewPeoplePageDto and ViewPlacesPageDto (places carry latitude and longitude).
GET /people/{key}/items?cursor&limitViewClientItemPageDto 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}/originalThe 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}/playbackFor 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: when expected_revision is stale, the Engine does not answer 409. A later client timestamp replaces the server record (written against the current revision, last_accessed set to the client timestamp). An equal or earlier timestamp leaves the server record untouched and the 200 response carries the server’s state, so the app compares revision, progress_pct and last_accessed to see which one stands. A non-offline request, or an offline one with no timestamp, still gets 409 on a stale revision.
  • POST /player/heartbeat has 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. offline and client_timestamp are read on this route under those exact snake_case names (the rest of the heartbeat body keeps its camelCase names).
  • A client_timestamp later 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.

RouteBody / answer
GET /api/v1/devices/current/downloads200 {"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/downloadsBody {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:

ParameterMeaning
sequenceGroupSeason key (for example season-2) to read; returns exactly that window. An unknown key falls back to the season being watched.
sequenceOffset / sequenceLimitFirst 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.
sequenceCurrentEpisode id to mark isCurrent in the answer.
sequenceOnly=trueReturn 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 routeAnswer
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

MethodPathDescriptionAuth
GET/system/statusService health, version, uptimeNone
GET/system/activity-statusSanitized leased/running durable operations used to seed the Dashboard navbar activity indicator; excludes media titles and filesystem pathsRequired
GET/system/readinessLaunch 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-statusFile watcher diagnostic - shows monitored folders and last eventRequired
GET/system/resourcesLocal 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-toolsWhich 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-versionLatest 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/stateCounts 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-periodBody { "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/validateBody { "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-tasksEvery 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-assetsScan .data/assets/ for managed files with no database reference and remove them.Administrator
POST/maintenance/storage/runRun storage maintenance on demand. Supports dryRun, cache retention, image retention, and claim compaction batch query parameters.Administrator

Library

MethodPathDescriptionAuth
GET/library/worksAll works with their canonical valuesRequired

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.

MethodPathDescriptionAuth
GET/settings/librariesReturn 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-kindsEvery 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/librariesAdministrator
PUT/settings/librariesValidate 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/toolsThe 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 runAdministrator
POST/settings/tools/checkLook 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 progressAdministrator
PUT/settings/tools/{id}/locationSave 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 returnedAdministrator
POST/settings/tools/{id}/installStart 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 logAdministrator
DELETE/settings/tools/{id}/installCancel a download in progress and remove its temporary files; does nothing if none is running. Returns the tools result; 404 for an unknown idAdministrator
POST/settings/tools/{id}/run-installerStart 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 idAdministrator
GET/settings/noticesThe 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 requestAdministrator
POST/settings/notices/{key}/dismissRemember 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/ocrText 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/ocrSave text recognition on or off, effort (normal or low) and the languages to read withAdministrator
POST/settings/ocr/languages/{code}Download the language data for one listed language; the only network use of text recognitionAdministrator
POST/settings/ocr/rereadQueue every PDF and photo without text to be read again, in batches (202; 409 when already running or not ready)Administrator
GET/settings/incoming-sourcesReturn universal unassigned intake foldersAdministrator
PUT/settings/incoming-sourcesValidate and replace universal incoming foldersAdministrator
POST/settings/libraries/{libraryId}/reorganization/planProduce a read-only, expiring move/rename plan with stable source IDs, conflicts, blocked work, and an exact confirmation fingerprintAdministrator
POST/settings/libraries/{libraryId}/reorganization/executeExecute one confirmed plan once, revalidating source policy, files, collisions, and free space immediately before each operationAdministrator
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 changedAdministrator
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 statusAdministrator
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 itAdministrator
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/jobsThe 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 jobAdministrator
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}/logA page (offset, limit) of the job’s log: every file, old path to new path, with its outcomeAdministrator
GET/settings/libraries/folders/jobs/{id}/log/exportThe 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 PUTAdministrator
GET/settings/server-folders/rootsThe 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 loggedAdministrator
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 triesAdministrator
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 placeAdministrator
POST/library/libraries/{libraryId}/scanScans 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 libraryAdministrator
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 ruleAdministrator
GET/setup/v1/library-kindsSetup-session protected. The same list as GET /settings/library-kinds, so the first-run Libraries step and Add folder draw their types from itSetup session
GET/setup/v1/librariesSetup-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/librariesSetup-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 completeSetup session
GET/setup/v1/server-folders/rootsSetup-session protected. The same list as GET /settings/server-folders/roots, for the folder picker inside setupSetup session
POST/setup/v1/server-folders/browseSetup-session protected. The same action and body as POST /settings/server-folders/browseSetup session
POST/setup/v1/server-folders/validateSetup-session protected. The same action and body as POST /settings/server-folders/validateSetup 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.

MethodPathDescriptionAuth
GET/view/scopesResolve 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-peopleList other households and their people for read-only browsing (“Other people”); never includes the caller’s own householdServer administrator signed in as a person (household administrators, members and apps get 403)
GET/view/photo-viewsPage 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/preferencesRead 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 permissionRequired + trusted profile
GET/view/assetsCursor-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_infoRequired + scope read
GET/view/assets/added-byThe 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 403Required + scope read
GET/view/assets/timeline-indexMonth 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 403Required + scope read
POST/view/uploadsUpload 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 typeRequired + 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, /locationChange 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 suggestionsRequired + asset owner
GET/view/items/{id}/contentStream an authorized original with range supportRequired + asset read
GET/view/items/{id}/thumbnailReturn 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 placeholderRequired + asset read
GET/view/items/{id}/textReturn the readable text of one document (at most 200,000 characters, with shown_truncated) and its document_info for the Documents viewer text panelRequired + asset read + Documents permission
GET/view/items/{id}/document-detailsOne 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-detailsReplace 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 themRequired + asset owner + Documents permission
PUT/view/items/{id}/tagsReplace 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 permissionRequired + asset owner
GET/view/tag-suggestionsType-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 carryRequired
GET/view/catalogueThe 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_manageRequired + 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 itemsRequired + 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 aboveRequired + Documents permission
POST/view/documents/bulkApply 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 deletedRequired + Documents permission + asset owner per document
GET/view/documents/exportDownload 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 400Required + Documents permission
GET/PUT/view/preferences/documents-sortRead 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 403Required + Documents permission
GET/view/documents/inbox-summaryHow many documents wait in the Inbox of the scope (scope, scopeProfileId): count. Counts what GET /view/assets?filing=inbox listsRequired + Documents permission
POST/view/items/{id}/inbox/fileFile 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-rulesList 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 changeRequired + Documents permission
PUT/view/document-rules/orderGive the person’s rules the running order they are listed in; the list must name every rule they have, once each (otherwise 404); 204Required + Documents permission
POST/view/document-rules/testTry a rule (saved or not): up to ten matching document titles from documents the person can open, documents_checked and has_moreRequired + Documents permission
POST/view/document-rules/applyQueue 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 backgroundRequired + Documents permission
PUT/view/items/{id}/favoriteSet 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}/hiddenSet hidden state on an owned assetRequired + asset owner
POST/view/items/{id}/archiveArchive an owned asset without changing its fileRequired + asset owner
POST/view/items/{id}/trashSoft-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 wasRequired + asset owner
POST/view/items/{id}/restoreRestore an archived or trashed owned assetRequired + asset owner
GET/view/trash/summaryHow 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 403Required + signed-in person (Dashboard session) + Mine scope
POST/view/trash/empty-previewWhat 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 nothingRequired + signed-in person (Dashboard session) + Mine scope + asset owner per item
POST/view/trash/emptyPermanently 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 reachableRequired + signed-in person (Dashboard session) + Mine scope + asset owner per item
POST/view/shared/contributions/previewPreview 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 householdShared submit grant + asset owner
POST/view/shared/contributionsCreate an idempotent, revision-bound pending contribution without changing filesShared submit grant + asset owner
GET/view/shared/contributionsPage 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 itemsRequired; 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 itContributor or household administrator (own household) / server administrator
POST/view/shared/contributions/{id}/cancelCancel a caller-owned pending contribution with optimistic revision checkingContributor
POST/view/shared/contributions/{id}/decisionAccept 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}/retryRequeue recoverable accepted item transfers. Same Documents refusal as decisionHousehold administrator (own household) or server administrator
POST/view/shared/items/directCreate an auto-accepted contribution for household items using the same transfer workerHousehold administrator or server administrator
GET, POST/view/galleriesList 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 403Required + 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 403Required + Gallery permission
GET, POST, DELETE/view/galleries/{id}/itemsPage 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 documentRequired + Gallery permission
GET, PUT/view/galleries/{id}/sharesRead 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-targetsReturn only enabled profiles the caller may select as Gallery share recipientsRequired + trusted profile
GET/view/peopleCursor-page named/reviewed, provenance-aware people annotations within an authorized scope; no face-recognition claimRequired + scope read
GET/view/placesCursor-page real GPS/place aggregates within an authorized scope; no map/tile requestRequired + scope read
GET/view/admin/profiles/{profileId}/sourcesReturn 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}/sourcesAdd 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 profileAdministrator
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 acceptsAdministrator
GET, POST/view/admin/shared/sourcesList or add folders of the server’s Shared Library. Adding takes content_kind (required) and records the acting administrator, as aboveAdministrator
PUT/view/admin/shared/sources/{sourceId}Update a Shared Library folder, including content_kindAdministrator
POST/view/admin/profiles/{profileId}/reconcileRecovery/admin reconciliation for a profile’s Personal SpaceAdministrator

Works

MethodPathDescriptionAuth
GET/works/{id}Work detail with canonical values, editions, and owned assetsRequired
GET/works/{id}/editionsEditions and owned assets for a workRequired
GET/works/{id}/castActor 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 unfilteredRequired

Collections

MethodPathDescriptionAuth
GET/collectionsBrowse-oriented collection list with works and canonical metadataRequired
GET/collections/catalogCollections catalog: system/user/managed collections plus broader rollups where trusted relationships connect multiple shelvesRequired
GET/collections/{id}/summaryOne Collections summary for a detail page without loading the full catalogRequired
GET/collections/{id}/itemsItems for a collection detail page, including generated rollup aggregationRequired
POST/collectionsCreate 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}/itemsAdd a work to a saved/manual collectionEffective 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/reconcileDry-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-manifestOrdered Wikidata series checklist with total, owned, missing, provisional, ambiguous counts and named entriesRequired
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/galleriesList Galleries eligible for the administrator’s Collection editor without exposing individual assetsAdministrator + trusted profile
GET/collections/{id}/personal-mediaReturn count-free Gallery/rule sources after reapplying the viewer’s View authorizationRequired + 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 collectionAdministrator + trusted profile
GET/persons?catalog=true&q=&role=&lane=&sort=&offset=&limit=Paged canonical primary contributors on owned works for Collections / PeopleRequired
GET/persons/role-counts?catalog=trueCanonical owned-library contributor counts grouped by roleRequired

Library Item

MethodPathDescriptionAuth
GET/library/items/{entityId}/detailFull 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}/historyProcessing 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 revisionRequired
PUT/library/items/{entityId}/editor-preferences/{profileId}Atomically save supported display overrides and profile-owned editor preferences; returns 409 for a stale revisionEffective administrator or precise Application permission
POST/library/items/deleteDelete 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 goneEffective 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-previewPreview 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 returnedEffective 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-destinationsThe 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-previewPreview 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 intoServer administrator or Application with metadata.write, plus resource access to every item and the destination
POST/library/items/moveMove 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 itServer 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-filesThe 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 pathEffective administrator (administrator PIN unlocked where set); no application permission
POST/library/excluded-files/{id}/add-backAdd 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 404Effective administrator (administrator PIN unlocked where set); no application permission
POST/library/batch-edit/previewHow 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 touchedEffective administrator (administrator PIN unlocked where set), plus the same per-item metadata-write check a single edit gets
POST/library/batch-editApply 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 emptiedEffective administrator (administrator PIN unlocked where set), plus the same per-item metadata-write check a single edit gets

Library Overview

MethodPathDescriptionAuth
GET/library/overviewAggregated 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/countsEach 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 absentEffective 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 libraryEffective administrator
GET/library/libraries/{libraryId}/activityWhat 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 seeAdministrator
POST/library/unmatched/libraries/{libraryId}/retryRetry 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}/keepKeep 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}/retryRetry now for one Unmatched file that is not kept (affected is 0 for a kept file). 404 when the file is not UnmatchedEffective administrator
POST/library/unmatched/items/{assetId}/keepKeep as is: stops the file’s retries and clears it from the count and filterEffective administrator
POST/library/unmatched/items/{assetId}/look-up-againLook up again: clears the kept mark and looks the file up nowEffective administrator
GET/library/first-checksThe 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 areEffective administrator
POST/library/first-checks/{sourceId}/keep-goingKeep 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 checkEffective administrator

Metadata

MethodPathDescriptionAuth
GET/metadata/claims/{entityId}All claims for an entity, grouped by field, with source and confidenceRequired
GET/metadata/conflictsAll unresolved metadata conflicts across the libraryEffective administrator or precise Application permission
GET/metadata/{entityId}/series-placementThe 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 placementEffective administrator or precise Application permission (metadata.read), plus catalogue access
PUT/metadata/{entityId}/series-placementBody { "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-versionsBounded 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 storedEffective administrator or precise Application permission
GET/metadata/assets/{assetId}/file-writeWhat 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-discrepanciesField-level mismatches between the canonical value and file-embedded metadataRequired
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-coverageReplace 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 entryEffective administrator or precise Application permission
GET/metadata/{entityId}/artworkArtwork context for the media editor, including variants by artwork type and preferred selectionsEffective administrator or precise Application permission
GET/metadata/{entityId}/artwork/{scopeId}Artwork variants for a specific editor scopeEffective administrator or precise Application permission
POST/metadata/{entityId}/artwork/{scopeId}/{assetType}Upload a user-owned artwork variant for the selected typeEffective administrator or precise Application permission
POST/metadata/{entityId}/artwork/{scopeId}/{assetType}/from-urlAdd an artwork variant from a provider or user-supplied image URLEffective administrator or precise Application permission
POST/metadata/{entityId}/artwork/{assetType}Compatibility upload route for an artwork typeEffective administrator or precise Application permission
PUT/metadata/artwork/{variantId}/preferredMake an artwork variant the preferred image for its artwork typeEffective 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

MethodPathDescriptionAuth
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-thumbThe 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}/logoThe 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

MethodPathDescriptionAuth
POST/ingestion/scanDry-run scan of configured library folders. Reports what would be ingested without making changes.Administrator
POST/ingestion/rescanScans 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-scanScan library folders and update known file paths. Triggers ingestion for new files.Administrator
POST/ingestion/uploadUpload 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 workAdministrator
GET/ingestion/import-folder/waitingWhat 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-progressThe 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/presentationThe 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-groupsPaged 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}/childrenLazy, paged tracks, episodes, issues or parts of one group.Effective administrator or precise Application permission
GET/ingestion/recent-additionsPaged 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-folderReturns 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.

MethodPathDescriptionAuth
POST/imports/uploadsStart 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}/completeChecks 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

MethodPathDescriptionAuth
GET/operationsDurable 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/summaryCounts by durable operation status.Effective administrator or precise Application permission
POST/operations/{id}/retryRequeue a durable operation for another attempt.Effective administrator or precise Application permission
POST/operations/{id}/cancelCancel a durable operation.Effective administrator or precise Application permission
GET/assets/{id}/capabilitiesExplicit capability/readiness states for one media asset.Effective administrator or precise Application permission
GET/capabilities/summaryCounts by capability and status.Effective administrator or precise Application permission

MethodPathDescriptionAuth
POST/search/universeSearch 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_typeEffective administrator or precise Application permission
POST/search/retailSearch configured retail providers for matching candidatesEffective administrator or precise Application permission
POST/search/resolveUnified resolve search - queries all active providers and returns ranked candidatesEffective administrator or precise Application permission

Review Queue

MethodPathDescriptionAuth
GET/review/pendingAll 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/countCount of pending review items. Used for media library badge.Required
GET/review/{id}Full detail for a single review item including candidatesEffective administrator or precise Application permission
POST/review/{id}/resolveResolve a review item by selecting a candidate or confirming corrected local metadataEffective administrator or precise Application permission
POST/review/{id}/dismissDismiss a review item without resolving itEffective administrator or precise Application permission
POST/review/{id}/move-to-tvAccept 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-universeAccept 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

MethodPathDescriptionAuth
GET/persons/{id}Person detail - biographical data, roles, library presence, social linksRequired
GET/persons/{id}/aliasesPseudonym list for a person, including resolved Wikidata aliasesRequired
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

MethodPathDescriptionAuth
GET/universesAll narrative roots (Universes) in the library; character_count leaves out characters the requesting profile has not reachedRequired
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 reachedRequired
GET/universe/{qid}/overviewEverything 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}/graphGraph data in Cytoscape.js format for the Chronicle Explorer visualizationRequired
GET/universe/{qid}/pathsFind shortest paths between two entities within the universe graphRequired
GET/universe/{qid}/family-treeCharacter 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 generationRequired
GET/universe/{qid}/castCharacters with their linked performers, including era-correct actor dataRequired
GET/universe/{qid}/adaptationsAdaptation 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 recordedRequired
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-deltaCheck for Wikidata revision changes since the last enrichment pass; returns a list of entity_qid, label, cached_revision, current_revision, has_changedRequired

AI

MethodPathDescriptionAuth
GET/ai/statusAI subsystem health - model load state, hardware tier, resource pressureRequired
GET/ai/modelsStatus of all configured models (loaded, unloaded, downloading, unavailable)Required
POST/ai/models/{role}/downloadTrigger download for a model role (text_fast, text_quality, text_scholar, text_cjk, audio)Administrator
POST/ai/models/{role}/loadLoad a model into memoryAdministrator
POST/ai/models/{role}/unloadUnload a model from memoryAdministrator
GET/ai/configThe Local AI engine settings: resource profile, limits, the default plugin allowance and the runtime stateAdministrator
PUT/ai/configUpdate 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 ruleAdministrator
GET/ai/usageWho 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 allowanceAdministrator
GET/ai/profileHardware profile - benchmark results, tier classification, GPU backendRequired
POST/ai/benchmarkRe-run hardware benchmark and reclassify tierAdministrator
GET/ai/resourcesLive system resource usage - CPU load, RAM pressure, active transcoding tasksRequired
GET/ai/enrichment/progressBackground enrichment batch progressRequired

/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

MethodPathDescriptionAuth
POST/metadata/pass2/triggerManually trigger the optional Pass 2 deferred enrichment flow for one or more entity IDsEffective administrator or precise Application permission
GET/metadata/pass2/statusCurrent Pass 2 deferred-enrichment status, including whether two-pass mode is enabledRequired

Activity

MethodPathDescriptionAuth
GET/activity/recentRecent system activity entries, newest firstRequired
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.readRequired
POST/activity/prunePrune 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_daysAdministrator
GET/activity/statsAggregated activity statistics - counts by type and outcomeRequired
GET/activity/by-typesFilter activity log by one or more activity type codesRequired

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.

MethodPathDescriptionAuth
GET/logs/technicalThe 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 startsAdministrator
POST/logs/export/previewHow 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 areaAdministrator
POST/logs/exportSame 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-storeAdministrator
GET/logs/retentionThe 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/retentionChange 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/retentionAdministrator

Plugins

MethodPathDescriptionAuth
GET/pluginsList 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 offAdministrator or application with plugins.read
GET/plugins/directoryThe 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 requestAdministrator or application with plugins.read
POST/plugins/directory/checkCheck 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/directorySigned-in administrator
POST/plugins/directory/seenRemember the listed plugins as seen, so none counts as new next time. Returns the same bodySigned-in administrator
PUT/plugins/directory/settingsBody { "enabled": true }. Switches the plugin directory on or off (plugin_catalog.enabled); changes nothing else. Returns the saved copy’s bodySigned-in administrator
POST/plugins/directory/{pluginId}/approval-previewThe 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 previewSigned-in administrator
POST/plugins/directory/{pluginId}/installInstalls 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-impactWhat 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 pluginAdministrator or application with plugins.read
GET/plugin-sourcesEvery 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 unknownAdministrator or application with providers.config.read
GET/plugin-sources/{name}/switch-off-impactWhat 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 unknownAdministrator or application with providers.config.read
GET/notificationsThe Dashboard inbox, newest first (items, unread_count). Query limit (default 50, at most 500)Effective administrator
POST/notifications/readMarks the given ids read, or every unread message when ids is empty. 204Effective administrator
GET/notifications/settingsEach 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_detailsEffective administrator
PUT/notifications/settingsChanges 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 timeEffective administrator
POST/notifications/channels/{channelId}/testSends a test message to the signed-in administrator through that channel; a working test turns a switched-off channel back onEffective administrator
POST/notifications/channels/{channelId}/enableTurns a channel that switched itself off back on. 204Effective administrator
GET/backup/settingsThe schedule, keep_last, each destination with state and last copy, next run and the last monthly restore checkAdministrator or application with backup.read
PUT/backup/settingsChanges the schedule, retention or enabled_destinations; parts left out keep their value. 400 for a bad time, frequency or unknown destinationAdministrator or application with backup.run
POST/backup/runBacks 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 runningAdministrator or application with backup.run
POST/backup/destinationsAdds 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 wordsEffective 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 idEffective administrator, in person
POST/backup/destinations/{destinationId}/testChecks a destination can be reached and written toAdministrator or application with backup.run
GET/backup/destinations/{destinationId}/backupsWhat a destination holds (plain .zip backups), newest firstAdministrator or application with backup.read
POST/backup/restore-from-destinationFetches 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 versionAdministrator or application with backup.restore
GET/access/applications/integration-healthbroken_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 rowAdministrator or application with identity.applications.write
GET/plugins/{pluginId}Plugin detail, manifest metadata, settings, permissions, and load stateAdministrator or application with plugins.read
POST/plugins/{pluginId}/enableEnable 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}/disableDisable a pluginAdministrator or application with plugins.manage
PUT/plugins/{pluginId}/settingsSave plugin user settings JSONAdministrator or application with plugins.manage
GET/plugins/{pluginId}/manifestRead dynamic plugin manifest JSON. Built-in manifests are compiled and are not returned here.Administrator or application with plugins.read
PUT/plugins/{pluginId}/manifestSave dynamic plugin manifest JSON without changing plugin idAdministrator or application with plugins.manage
DELETE/plugins/{pluginId}Delete a dynamic plugin folder and saved plugin configurationAdministrator or application with plugins.manage
POST/plugins/{pluginId}/healthRun plugin health checksAdministrator or application with plugins.read
GET/plugins/{pluginId}/secretsList 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}/jobsList recent durable plugin operation rows for one plugin.Administrator or application with plugins.jobs.read
POST/plugins/jobs/segment-detection/runRun scheduled playback segment detector plugins immediatelyAdministrator 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.

MethodPathDescriptionAuth
POST/approvals/previewBody { "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

MethodPathDescriptionAuth
GET/admin/provider-configsList all provider configurationsAdministrator
PUT/admin/provider-configs/{providerId}Update a provider configurationAdministrator
DELETE/admin/provider-configs/{providerId}Remove a provider configurationAdministrator

Settings

MethodPathDescriptionAuth
POST/settings/test-pathTest whether a filesystem path is accessible by the EngineAdministrator
GET/settings/providersProvider configuration status - enabled state, last health check, rate limit infoRequired
GET/settings/server-generalCore server settings (name, language preferences, country)Required
PUT/settings/server-generalUpdate core server settingsAdministrator
GET/network/app-addressesThe 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-onlyRequired
POST/settings/organization-template/previewValidate an organization template and return a sample preview without savingAdministrator
PUT/settings/organization-templateSave the organization template after validationAdministrator
GET/settings/writeback/scheduleBatched 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 offAdministrator or precise Application permission
PUT/settings/writeback/scheduleBody { "interval_minutes": 1-1440 }; saves run_interval_minutes in config/writeback.json (400 outside the range). The scheduler reads it again on its next passAdministrator or precise Application permission
POST/settings/writeback/runWrite now: starts a run over everything waiting. 202 with the schedule status, 409 when a run is already going or write-back is turned offAdministrator or precise Application permission
GET/settings/writeback/failedFiles that could not be written: [{ target_kind, asset_id, local_item_id, title, path, reason, attempts, updated_at }]Administrator or precise Application permission

Profiles

MethodPathDescriptionAuth
GET/profilesList 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: falseHuman session
GET/access/profilesManaged 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 detailRequired
GET/profiles/{id}/settings/spoilersThe 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 administratorOnly the signed-in person’s own profile
PUT/profiles/{id}/settings/spoilersChoose 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 oneOnly the signed-in person’s own profile
GET/profiles/{id}/settings/playbackReturn 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 elseThe 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/playbackSave 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 rejectedSame as the GET above
GET/profiles/{id}/settings/viewReturn 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 profileServer administrator, or household administrator for their own household
PUT/profiles/{id}/settings/viewSave 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 profileServer administrator, or household administrator for their own household
PUT/profiles/{id}/experienceSave name, avatar color, and navigation preferences without role fieldsExact 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|deleteDeletes 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}/avatarSet 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}/avatarRemove someone’s profile photo.Identity users write

UI Settings

MethodPathDescriptionAuth
GET/settings/ui/globalGlobal UI defaultsRequired
PUT/settings/ui/globalUpdate global UI defaultsAdministrator
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 settingsAdministrator
GET/settings/ui/profile/{id}Per-user UI preference overrides for a profileRequired
PUT/settings/ui/profile/{id}Update per-user UI preferencesRequired
GET/settings/ui/resolvedEffective resolved settings for the current request context (global + device + profile merged)Required
GET/settings/ui/library-preferencesCurrent per-media library display preferences (missing-item visibility defaults)Required
PUT/settings/ui/library-preferencesSave per-media library display preferences to config/ui/library-preferences.jsonAdministrator

Reader

MethodPathDescriptionAuth
GET/read/{assetId}/metadataEPUB metadata - title, author, language, coverRequired
GET/read/{assetId}/tocTable of contentsRequired
GET/read/{assetId}/fileThe 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 directlyRequired
GET/read/{assetId}/searchFull-text search within EPUB contentRequired
GET/reader/{assetId}/bookmarksList bookmarks for an assetRequired
POST/reader/{assetId}/bookmarksCreate a bookmarkRequired
DELETE/reader/bookmarks/{bookmarkId}Delete a bookmark. Another profile’s bookmark answers 404.Required
GET/reader/{assetId}/highlightsList highlights. Rows made by the book reader carry cfiRange; rows from the retired reader have it null.Required
POST/reader/{assetId}/highlightsCreate 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}/statisticsReading statistics - time spent, completion percentage, sessionsRequired
POST/read/{assetId}/whispersyncSync reading position across devicesRequired
GET/read/{assetId}/whispersyncRetrieve last sync positionRequired
DELETE/read/{assetId}/whispersyncClear sync positionRequired

Player

MethodPathDescriptionAuth
GET/player/stateCurrent playback session, queue, position, and audiobook historyRequired
POST/player/queue/replaceReplace the active playback queueRequired
POST/player/queue/itemsAdd items to the active playback queueRequired
POST/player/commandApply transport commands such as play, pause, seek, volume, or speedRequired
POST/player/heartbeatPersist 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 ignoredRequired
GET/player/audiobooks/{workId}/historyRecent audiobook listening checkpointsRequired
GET/player/audiobooks/{workId}/bookmarksSaved 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 onRequired
POST/player/audiobooks/{workId}/bookmarksSave 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 administratorRequired
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 404Required
GET/player/audiobooks/{workId}/bookmark-packsThe 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 onRequired
POST/player/audiobooks/{workId}/bookmark-packs/exportBuild 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 notRequired
POST/player/audiobooks/{workId}/bookmark-packs/inspectRead a pack file (contentBase64) and say whether it is for this title: Match, DifferentCut (with canAlignByChapter) or WrongTitle; adds nothingRequired
POST/player/audiobooks/{workId}/bookmark-packs/importAdd 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 400Required
PUT/player/bookmark-packs/{packId}/enabledSwitch one of the profile’s packs on or off ({ isEnabled }); 204, or 404 for anyone else’sRequired
DELETE/player/bookmark-packs/{packId}Remove one of the profile’s packs; 204, or 404 for anyone else’sRequired
POST/player/audiobooks/{workId}/chapters/suggest-namesSuggest display-only audiobook chapter names using local AIRequired
GET/player/audiobooks/{workId}/chapter-overridesList saved display-only audiobook chapter title overridesRequired
POST/player/audiobooks/{workId}/chapter-overridesSave one display-only audiobook chapter title overrideRequired
DELETE/player/audiobooks/{workId}/chapter-overrides/{assetId}/{chapterIndex}Delete one display-only audiobook chapter title overrideRequired

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.

MethodPathDescriptionScope
GET/companion/devicesThe 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 itemqueue.read
GET/companion/sessionThis 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 livequeue.read
PUT/companion/session/followBody { "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 404progress.write
PUT/companion/session/manualBody { "workId": "...", "chapterIndex": 12, "positionSeconds": 30 }. Hold a position set by hand; a negative chapter or position is 400progress.write
DELETE/companion/sessionStop following and clear any manual position. 204progress.write
GET/companion/contextWhat 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 listqueue.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

MethodPathDescriptionAuth
GET/progress/{assetId}Playback or reading progress for an assetRequired
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/recentRecently accessed assets with progressRequired
GET/progress/journeyFull reading/watching journey across all assets for the current profileRequired

Library Character and Asset Data

MethodPathDescriptionAuth
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/triggerQueue a universe enrichment sweep (takes no QID)Effective administrator or precise Application permission

SignalR

CollectionPathDirectionDescription
Intercom/intercomServer -> ClientReal-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.

MethodPathDescription
POST/dev/reset-and-seedPreserve 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-dataClear catalogued media and derived state without deleting source media or application configuration.
POST/dev/factory-resetClear the development database and generated state without deleting configured source media.
POST/dev/seed-librarySeed the library with 22 EPUB test cases covering edge cases (pen names, foreign languages, series grouping, multi-author)
POST/dev/wipeLower-level automated harness reset; generated-state scope preserves configuration and unrelated source media.
POST/dev/full-testRun full ingestion and enrichment pipeline on the seeded test library
POST/dev/reingest-libraryPause 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-testRun 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.

MethodPathDescriptionAuth
POST/api/v1/vault/items/{assetId}/identify/searchRecords 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/previewThe 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/applyApplies 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}/matchHow 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/activityThe quiet Vault activity list (added, matched, unmatched, identified, failed), newest first. The only place Vault ingestion shows.Effective administrator with Vault mode unlocked

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.

MethodEngine pathResponsibility
GET/auth/sign-in-methodsDashboard-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/validateDashboard-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-computerDashboard-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-computerSame 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/confirmSigned-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/recentSigned-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-optionsSigned-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/verifyDashboard-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/setupSigned-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/enableSigned-in, recent sign-in required. Body: code. Turns two-step on and returns new recovery codes.
POST/auth/two-step/disableSigned-in, recent sign-in required. Body: code (app code or recovery code).
POST/access/accounts/{accountId}/two-step/resetAdministrator, recent sign-in required. Turns two-step off for another account and records account.two_step_reset in the audit log.
GET/auth/sessionsSigned-in person. Lists the sessions of their own account for Settings > Security, with the current one marked.
DELETE/auth/sessions/othersSigned-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/changeSigned-in person, recent sign-in required. Body: current_password, new_password. Rate limited.
POST/auth/password/recovery-codesSigned-in person, recent sign-in required. Creates a new set of recovery codes, returned once; the earlier set is replaced.
POST/auth/password/recoverDashboard-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/completeDashboard-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/passkeysSigned-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/completeSigned-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/completeDashboard-only (service credential, rate limited). Passkey sign-in.
PUT/auth/profiles/{profileId}/pinAdministrator or household administrator, recent sign-in required. Sets a profile’s PIN (Profile details on the person’s page).
POST/auth/account/secureSigned-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-linkDashboard 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/beginDashboard-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/administratorSetup-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/uploadSetup-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-passwordAdministrator 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/householdsThe 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/householdsServer 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}/peopleAdministrator 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/usersServer 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/usersServer 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}/emailServer 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-previewServer 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 administratorsA 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/previewDashboard-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/acceptDashboard-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-temporaryA 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/devicesPaired 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-profileChooses (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/sessionsSigned-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-loginsSigned-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/noticesSigned-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}/dismissSigned-in person. Remembers “Not now” on a reminder for their own account. 204; 400 for an unknown key (nothing saved).
POST/access/me/exportServer 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-unlockAdministrator or household administrator. Reads the administrator unlock state of this session.
POST/access/admin-unlockAdministrator 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-unlockAdministrator or household administrator. Ends this session’s administrator unlock.
GET/access/librariesAdministrator 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-moveServer 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-protectionThe “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}/exportServer 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}/exportServer 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/inspectServer 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}/permissionsReplace 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}/rotateRotate the protected signing secret; display it once
GET/access/applications/{applicationId}/webhooks/{webhookId}/attempts?limit=NThe 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}/testSend 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-eventsBounded subscription/replay with current principal and resource authorization
GET/api/v1/playback/sessionsCurrent scoped playback sessions
GET/api/v1/playback/historyDurable scoped playback history
GET/api/v1/analytics/playbackScoped playback totals
GET/api/v1/analytics/users, /libraries, /devicesAggregation only after scope filtering
GET/api/v1/activityA 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/periodsCounts per year and month (UTC) under the same scope and filters, for the date rail
GET/api/v1/activity/recent?limit=20The 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/summaryThe 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.csvThe 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/itemsAdds {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.