Skip to content

Discover & media (user)

HTTP routes and behavior for discover & media (user).

Catalog endpoints require media:discover. An admin may omit instance_id to browse external book/music metadata and music artwork before connecting an instance. An explicit ID always requires a real instance of the matching service. Requesters, including kids accounts, still need an authorized Chaptarr or Lidarr instance; an omitted ID resolves only their granted default. Authorization runs before provider/cache work and again before returning metadata, artwork, or an error. Request-target resolution, availability, requests, and library reads retain their instance requirements.

GET /api/config advertises this behavior with top-level admin_catalog_browsing: true. It does not change services flags or instance grants. Clients default a missing capability to false, persist it with the session, and refresh it from config; no version-floor change is needed.

GET /api/config also returns hidden_discover_tabs, an array of effective hidden media types (movie, tv, book, music). Discovery settings store hidden_when_unconfigured as a map of those media types to booleans in the existing server_settings JSON; all default false, with no schema migration. For example, PUT /api/admin/discovery-settings with {"hidden_when_unconfigured":{"book":true}} changes only the Books preference. Omitted map entries, row source, language, and unrelated server settings survive concurrent partial updates; unknown media types or invalid values return 400, and unavailable settings storage returns 503. A hide-only write does not mark the automatic row source as an admin decision.

Effective visibility uses the full configured instance inventory before filtering instances by user grants: Radarr restores Movies, Sonarr TV Shows, Chaptarr Books, and Lidarr Music, regardless of health or library contents. Inventory failures return 503 rather than report missing services. A preference applies again after its service is removed. Hiding affects navigation for everyone without granting or revoking access, filtering catalog APIs, or hiding Releases. Old clients can ignore the additive config field; new clients treat its absence as an older server and disable Hide. Visibility writes and successful instance changes broadcast payload-free config_changed events to all sessions; clients re-read their own filtered config on these events, reconnect, and resume, deferring updates until instance setup closes.

Music endpoint Response and filters
GET /api/discover/music/search query, page, optional authorized instance_id; MusicBrainz album/EP title or artist search; opt into singles with include_singles=true
GET /api/discover/music/popular period=this_week (default), this_month, or this_year; page defaults to 1
GET /api/discover/music/new-releases Past 30 calendar days, newest first, no future dates; page defaults to 1
GET /api/discover/music/genre Supported genre slug required; page defaults to 1
GET /api/genres/music {genres:[{id,name,tag}]} with supported genre labels and MusicBrainz mappings
GET /api/media/music/{mbid} Album/EP/single metadata by release-group ID; credits include artist IDs; canonical changes require verified provider redirects
GET /api/discover/music/artists Artist search with query, page, optional authorized instance_id; distinct IDs and disambiguation
GET /api/media/music/artists/{mbid} Artist metadata by exact MusicBrainz artist ID
GET /api/media/music/artists/{mbid}/albums Paginated albums, EPs, and singles by exact artist ID; page, optional authorized instance_id
GET /api/discover/music/artwork/{mbid} Authenticated Cover Art Archive raster image; 404 when absent

Every endpoint accepts instance_id. Music feeds return {results, page, next_page?, source, scope, empty_message?}. next_page follows provider position and exhaustion, not displayed item count: charts and genre searches request 20 provider entries, so filtering can leave a short or empty page with more results. The app suppresses repeated MBIDs across pages while preserving distinct IDs with identical titles. Results and album details use a separate string identity model: {foreign_id, title, artist, release_date?, release_type, artwork?, disambiguation?}. foreign_id is always a release-group ID. Artwork is a server-relative path; append instance_id when selecting a library and use the session bearer token; admins may omit the instance before setup.

ListenBrainz charts supply popularity. Fresh releases are fetched as one date window, filtered to albums/EPs with exact dates, deduplicated, sorted, then paginated locally (no undocumented offset). MusicBrainz search supplies genre tag matching and batched chart type enrichment; an incomplete enrichment fails the page instead of silently thinning it. All provider traffic uses httpx.External(), a contactable User-Agent, bounded deadlines/retries, shared concurrent fills, and a bounded metadata/artwork cache. MusicBrainz and ListenBrainz each have one-second request spacing. TTLs are one hour for feeds, six hours for genre searches, and 24 hours for album metadata/artwork. Availability/request state is not part of that cache. Cover redirects are limited to HTTPS Cover Art Archive and Internet Archive hosts; arbitrary caller URLs and non-raster responses are refused.

Run go test ./internal/musicdiscovery -run TestLiveProviders -music-live -v for live music providers and go test ./internal/musicdiscovery -run TestLivePublicSearchAndResolution -music-live -v for public music search. go test ./internal/request -run TestLiveDisposableCatalogDelivery -catalog-canary=/path/to/private-manifest.json -v exercises real disposable loopback services. The private JSON has optional chaptarr and lidarr objects containing url and key; each configured service needs an empty book/album library, writable roots, profiles, and no indexers or download clients. The Chaptarr case requires acknowledgement within one second and verifies one monitored eBook and audiobook for gr:48297245; an explicit existingBookFixture: true instead requires exactly two unmonitored, file-free records of that same identity and verifies real monitoring/search writes. That fixture does not prove a new author/book metadata import, which still depends on Chaptarr’s metadata service. The Lidarr case verifies saved intent through a simulated outage and database reopen. Ordinary suites use local fixtures; production libraries are never canary targets. TMDB/Trakt source and language preferences apply only to movies and TV.

GET /api/discover/books/trending # Hardcover's trending list (top 50) via the OAuth/API-token connection selected for the caller's Chaptarr instance; ?instance_id= optional; {connected:false, books:[]} when none is connected; cached 30 min per instance
GET /api/discover/books/images/* # Hardcover cover-art relay for the web client
GET /api/discover/books/search # retired: HTTP 410 catalog_retired; no provider traffic
GET /api/discover/books/{feed} # retired (every other feed): HTTP 410 catalog_retired; no provider traffic
GET /api/genres/book # retired: HTTP 410 catalog_retired; no provider traffic
GET /api/media/book/{workId} # retired: HTTP 410 catalog_retired; no provider traffic
GET /api/media/book/{workId}/request-target # retired: HTTP 410 catalog_retired; no provider traffic
GET /api/discover/trending | /discover/movies/popular | /discover/tv/popular
GET /api/discover/movies/featured | /discover/tv/featured # the configured headline row; ?page=N continues the same feed
GET /api/discover/movies/top-rated | upcoming | now-playing
GET /api/discover/tv/on-the-air | top-rated | upcoming # Airing This Week, Top Rated, Coming Soon (premieres in the next three months)
GET /api/discover/movies | /api/discover/tv # filterable discover: an allowlist of TMDB params, validated here
GET /api/search # multi-search
GET /api/search/keyword | /api/search/company # type-ahead lookups behind the browse filters; query required
GET /api/media/movie/{id} | /api/media/tv/{id} # detail (+ /recommendations, /similar); movie body appends videos, credits + per-region release_dates; TV body appends videos, credits + external_ids
GET /api/media/person/{id} | /api/media/person/{id}/credits
GET /api/genres/movie | /api/genres/tv | /api/providers/movie | /api/providers/tv | /api/providers/regions | /api/languages
GET /api/trakt/trending | popular | lists | lists/{user}/{slug}/items
GET /api/trakt/calendar | anticipated | recommendations
GET /api/trakt/images/{host}/* # Trakt artwork relay for the web client

TMDB and Trakt are proxied server-side: client devices never hold those keys. /featured serves whichever feed the admin selected (see Discovery settings), normalized to the TMDB page shape plus a source field so one client parser handles every source and the row can name what it is showing. It takes page too: page 1 is the row itself (refilled to twenty when the language filter thins it), and each later page is the next upstream page of the same feed, filtered but never refilled, so a grid can continue the row without a second route. The envelope’s total_pages says how far the feed goes; for Trakt, whose page count this server does not read, another page is reported for as long as the one served had entries. The discovery and recommendation feeds also honor the admin’s english_only preference; search and detail lookups never do.

Kids accounts. Every body these routes write passes the caller’s content policy (internal/contentpolicy) after the shared-cache read: the cache stays server-wide and unfiltered (the English-only preference is one setting for everyone and is baked in before caching; a child’s limits are theirs alone). For a kids account the list feeds, search (including people flagged adult and their known_for), the headline feed from either source, Similar and Recommended, and every Trakt passthrough drop the titles above the caps, in a hidden genre, flagged adult, or: while unrated titles are hidden: carrying no certification in the policy’s region; total_pages still describes the upstream feed, so pages arrive thinner and the client keeps walking them. The genre lists drop hidden genres. A movie or show detail outside the limits is 404 {"error":"not available"} (the detail body carries the ratings: release_dates, and content_ratings now appended to TV: and primes the rating cache); an adult-film performer’s person page is 404 and their credits are filtered. The browse routes push the limits upstream (include_adult=false, without_genres, and for movies with unrated titles hidden certification_country + certification.lte) under their own cache key, so a child’s pages arrive full. Ratings come from /movie/{id}/release_dates (theatrical first) and /tv/{id}/content_ratings, cached a day per title, through one server-wide bound of eight concurrent lookups with a single retry on 429. Failures are never thinner rows: a body the filter cannot read or a rating lookup that fails is 502 {"error":"content limits could not be applied"}, and a policy that cannot be read or ranked is 503. Admins and unrestricted users see the verbatim body.

/discover/movies and /discover/tv are the browse feeds. Each forwards an allowlist of TMDB discover parameters and drops everything else: page, sort_by, with_genres, vote_average.gte, vote_count.gte, with_original_language, with_watch_providers + watch_region, with_keywords (comma-joined ids that must all match), with_companies (pipe-joined ids of which any may match), and the media type’s own date keys (primary_release_year, primary_release_date.gte, primary_release_date.lte for movies; first_air_date_year, first_air_date.gte, first_air_date.lte for TV). language is never forwarded: the TMDB client fixes it to en-US. Values TMDB would refuse are answered 400 here instead of surfacing as a 502 that reads like an outage: sort_by must be one of that type’s fields (original_title, popularity, primary_release_date, revenue, title, vote_average, vote_count for movies; first_air_date, name, original_name, popularity, vote_average, vote_count for TV) followed by .asc or .desc, and dates must be YYYY-MM-DD. A rating sort with no vote_count.gte of its own gets a floor of 200 votes, because ordered by rating alone TMDB’s catalogue is a wall of one-vote 10.0s. On every list route page is clamped to TMDB’s 1..500, and the body reports the page actually served.

With english_only on, discover queries (the browse feeds and both Coming Soon rows) carry with_original_language=en upstream, so every page arrives full with an exact page count. A browse query that names a language of its own (with_original_language=ko) is an explicit ask, like a search term: it is forwarded as given and that page is never English-filtered. The list feeds (popular, top rated, now playing, on the air, trending, recommendations, similar) have no such parameter and are filtered after the fact, so their pages can arrive thinner while total_pages still describes the upstream feed; a client keeps paging and drops what it has already shown.

Trakt’s artwork CDN (media.trakt.tv today; walter*.trakt.tv before July 2026: Trakt migrates these hosts) is public but sends no CORS headers, so a browser-rendered client cannot fetch it directly the way it can TMDB’s CDN. /api/trakt/images/{host}/* relays any *.trakt.tv host (strictly validated, images/… paths only, no query passthrough) so the web app loads Trakt posters same-origin and a CDN migration on Trakt’s side cannot blank them again; native clients keep hitting the CDN directly.

Retired book discovery routes above return HTTP 410 {code:"catalog_retired", error, message, search_path:"/dashboard/books"} after authentication/permission checks. They never contact Open Library. Native discovery uses the authorized Chaptarr proxy and search_books, which has a ten-second lookup budget. Saved history and verified native bindings remain intact. Unresolved Open Library delivery jobs migrate to Needs attention without approving, deleting, or rerouting them; clients offer cancellation and native search.

/api/discover/books/trending is the one live book feed. It is authorized like the other requester book reads (media:discover plus, for non-admins, a grant on the resolved Chaptarr instance; admins need an instance too, because the credential lives on it) and then reads that instance’s selected Hardcover connection only on a cache miss. Without a connection it answers {connected:false, books:[]} so the app can offer an admin the Settings link; with one it runs Hardcover’s books_trending (limit 50) followed by one books hydration query, re-sorts into Hardcover’s order, and returns {connected:true, source, scope, books:[{hardcover_id, foreign_id:"hc:<id>", title, authors, year, rating, ratings_count, readers_count, description, image_url, series, series_position, isbn13s}]}. Cover URLs are Hardcover’s CDN, so they are client-reachable as-is; native clients fetch them directly, while the web app routes them through /api/discover/books/images/* (see below). The list is cached per instance for 30 minutes and concurrent misses are coalesced, because Hardcover’s rate-limit budget is ten requests per window; a connection change invalidates that instance’s entry and prevents an older in-flight fetch from repopulating it. OAuth refreshes are serialized per shared connection; status reads and cache hits never renew credentials. Hardcover failing to answer is a 502, and a token Hardcover no longer accepts is a 502 that says so; neither ever reads as an empty list. Books carry no content ratings, so kids accounts see the same list as any other grant holder.

/api/discover/books/images/* relays Hardcover’s cover CDN (assets.hardcover.app, the host is hardcoded rather than taken from the request; plain file-segment paths only, no query passthrough) for the same reason the Trakt relay exists: the CDN sends no CORS headers. The difference is what the fallback costs. A browser can still display a cover it may not read the bytes of, by handing the URL to a DOM <img> element; but that makes every cover a platform view, and Flutter web composites only a handful of those correctly against canvas-drawn content. Past that handful the availability pill and rating painted over the artwork were drawn underneath it and vanished, so the Trending Books row showed badges on roughly its first six cards and none after. Same-origin bytes put covers back on the canvas path where the badges layer correctly. Native clients keep hitting the CDN directly, and a web client with no server URL yet still falls back to the direct CDN URL; a cover without a badge beats no cover.

Book availability remains instance-scoped and is computed from live Chaptarr state. Add include_live=false to either form of /api/requests/delivery-status for an immediate saved-state read; the default retains live availability. Saved-only book and music responses explicitly set status_known:false and never infer file availability from a completed delivery. A saved or historical source reference cannot authorize new matching or library access.

View the maintained source for this page.