Backend BFF for the chino product of the zaentrum platform. A read-only Go API that fronts the catalog and streaming services for the chino web, mobile, and TV clients: browse, search, continue-watching, watchlists, playback progress, telemetry, and a bug-report pipeline into OpenProject.
- Go + chi router +
slog - OIDC JWT validation via
go-oidc - Postgres (pgx) for user-state (playback progress, watch history, watchlists)
- Prometheus metrics
- Distroless container image
chino-api is a pure read consumer: it talks to the catalog service for metadata
and chino-stream for playback bytes. Writes never originate here.
The full surface is the embedded OpenAPI spec, served at GET /api/openapi.yaml
and rendered from internal/http/openapi.yaml. Highlights:
| Path | Auth | Notes |
|---|---|---|
GET /api/healthz |
none | liveness / readiness |
GET /api/openapi.yaml |
none | OpenAPI 3 spec |
GET /api/config |
none | client app config |
GET /api/v1/me |
bearer JWT | echoes the caller's sub |
DELETE /api/v1/me |
bearer JWT (in the header) | deletes the signed-in person's data and their account (below) |
GET /api/v1/items |
bearer JWT | catalog browse |
GET /api/v1/items/{id} |
bearer JWT | item detail, cast and crew by role, its trailers and extras |
GET /api/v1/search |
bearer JWT | one search: films, series and episodes ranked together, and people apart (below) |
GET /api/v1/people |
bearer JWT | people search by name |
GET /api/v1/people/{id} |
bearer JWT | a person, their details and filmography |
GET /api/v1/people/{id}/profile |
bearer JWT or stream token | a person's portrait (profile_url) |
GET /api/v1/me/continue-watching |
bearer JWT | resume list |
POST /api/v1/me/available |
bearer JWT | which of the titles a client keeps the viewer may watch now, in one request (below) |
GET /api/v1/me/watchlists |
bearer JWT | named watchlists |
GET/POST /api/v1/items/{id}/progress |
bearer JWT | playback progress, a file's (below); the resume point, none for a finished title (below); a late position never over a newer one (below) |
GET /api/v1/items/{id}/subtitles |
bearer JWT | an item's sidecar subtitles at their URLs, a styled one's script beside it, and the fonts of its package (below) |
GET /api/v1/play/subs/{id}.vtt · .ass |
bearer JWT or stream token | a subtitle's file; its styled original (ASS or SSA) |
GET /api/v1/play/fonts/{id}.{ext} |
bearer JWT or stream token | a font of the package, for a player that draws styled subtitles |
GET /api/v1/subtitles/languages |
bearer JWT | the instance's subtitle languages, from katalog-manager, kept 5 minutes (below) |
POST /api/v1/play/events |
bearer JWT or stream token | playback telemetry (the stream token for sendBeacon) |
GET /api/v1/events |
bearer JWT or stream token | live catalog notifications (SSE) |
GET /api/v1/notices |
bearer JWT | what addons told the signed-in person, from portal-api — best effort (below) |
POST /api/v1/notices/{noticeId}/read · POST /api/v1/notices/read-all · DELETE /api/v1/notices/{noticeId} |
bearer JWT | one notice read, all read, one deleted — forwarded to portal-api |
POST /api/v1/feedback |
bearer JWT | bug report → OpenProject (503 when unconfigured) |
POST /api/v1/admin/items/{id}/package |
bearer JWT with the admin role | an item's packaging, forwarded to katalog-manager with the bearer |
GET /api/v1/admin/items/{id}/package |
bearer JWT with the admin role | the item's processing steps, from katalog-manager |
DELETE /api/v1/admin/accounts/{sub}/data |
bearer JWT with the admin role and the account deletion token | what chino keeps of an account an admin deletes on the portal's People page, for portal-api |
GET /api/v1/items/{id}/play/info |
bearer JWT or stream token | how the item plays for the client's caps; a packaged title's qualities |
GET /api/v1/items/{id}/play/master.m3u8 |
bearer JWT or stream token | the HLS master for the client's caps and q |
POST /api/v1/items/{id}/play/prewarm |
bearer JWT or stream token | warm the variant the client starts on |
GET /api/v1/items/{id}/play/{vN|aN|sN}/playlist.m3u8 |
bearer JWT or stream token | packaged video / audio / WebVTT rendition, its segments next to it |
GET /api/v1/items/{id}/extras/{extraId}/play/master.m3u8 |
bearer JWT or stream token | an extra's HLS master, its play_path (below) |
GET /api/v1/items/{id}/extras/{extraId}/play/info |
bearer JWT or stream token | how an extra plays for the client's caps, as a title's /play/info |
GET /api/v1/items/{id}/extras/{extraId}/play/{vN|aN|sN}/playlist.m3u8 |
bearer JWT or stream token | an extra's renditions, as a packaged title's |
GET /api/v1/search?q= is katalog-api's search for the viewer: the titles
(films, series and episodes) ranked together by how they match the query,
best first, and the people whose names match it, ranked apart, in one
response: {"items": [...], "people": [...]}. types= names the types of
title (movie,series,episode by default; a client that shows no episode
results asks for movie,series), limit= how many titles (30, at most 100),
people= how many people (12, at most 50; 0 none). Each title comes as the
lists send it, poster URLs and watched_at included, with match: how it
matched (exact, prefix, words, alternative, similar, plot; the
plot only when no title matched). An episode names its series (id,
title, poster_url, and matched: the series matched by its own title
too), so that a client can show the two as one card. A person comes as
/api/v1/people sends them, with match. Against a katalog-api without the
search, the types' lists of the query come one after the other, each of up
to limit titles, and the people as /api/v1/people finds them.
A viewer whose access token carries max_rating, a whole number of years, is
capped at that age (a kid's account); a token without the claim is not, and a
claim that is no whole number of years holds its viewer to the strictest cap,
0 (chino-api says so once in its log). chino-api passes the cap to katalog-api
as max_rating on every catalog request, so the lists, search, people,
continue watching and history are served what the cap allows. A stream token
minted for a capped viewer carries the cap in its user part
(<subject>;max_rating=<age>, signed with the rest; chino-stream and
katalog-manager verify it as before), so the media routes hold the viewer to
it too.
Every route of one title (detail, segments, more like this, subtitles, a
series' episodes and next episode, artwork, playback, trickplay, a sidecar
subtitle, its styled original and a package's font by the title they belong
to, and the viewer's own progress, watched,
watchlist and like of the title) answers a capped viewer 404 "not found" for a
title its cap does not allow, and for an id that names none, before anything
goes upstream: it asks katalog-api's GET /api/v1/visible, kept for 30
seconds. The lists chino-api keeps (watchlists and their counts, likes,
memberships) and chino-stream's Zap feed and packaged ids leave such titles
out. When katalog-api cannot say what a cap allows, a capped viewer is served
nothing (502). A viewer without a cap is served as before. Items carry
min_age, certification and certification_country as katalog-api sends
them, for a badge.
The tests of chino-api's own lists need a PostgreSQL in which they may create
and drop schemas, named by CHINO_API_TEST_DATABASE_URL, and are skipped
without it.
A person deletes their own account from the app — the app stores ask every
app that makes accounts to offer it — with DELETE /api/v1/me and their
bearer, in the Authorization header (a ?token= bearer is refused: a link
someone could be sent). chino-api deletes what it keeps of them — playback
progress, watch history, named watchlists and their items, likes, the legacy
watchlist rows — and asks portal-api, with the person's bearer and the
account deletion token (ACCOUNT_DELETION_TOKEN, sent as
X-Account-Deletion-Token), to delete their account in the platform's realm
(DELETE /api/portal/me). The rows are deleted in a transaction committed
only once portal-api has deleted the account, or found it gone already:
| Answer | When | Deleted |
|---|---|---|
200 {"account":"deleted","deleted":{…}} |
portal-api deleted the account | the data and the account; deleted counts the rows |
200 {"account":"gone",…} |
the account was gone already | the data |
409 {"error":"refused","message":…} |
portal-api refuses: the last admin, an administrator of Keycloak itself | nothing; message says why, for the person |
502 {"error":"account_not_deleted",…} |
portal-api did not answer, or refused the token | nothing: asking again starts over |
501 {"error":"account_deletion_unavailable",…} |
no token: accounts here live in an identity provider the platform does not manage | nothing |
The other direction: an admin deleting someone on the portal's People page has
portal-api ask for that person's data first, with the admin's bearer and the
same token: DELETE /api/v1/admin/accounts/{sub}/data. Bug reports already
sent to OpenProject are not deleted with an account, and the request log keeps
no bearer (it shows REDACTED).
A client offers it as Delete Account, asks first and says what goes
(their history, lists and progress, and their account), sends the request with
its bearer, and signs out on 200; on 409 it shows message, on 501 that
accounts are deleted by whoever runs the server.
An addon can tell one person something — "your title is ready". portal-api
keeps the notices and shows each person their own; the apps read and change
them here, and chino-api forwards the viewer's bearer to portal-api's
/api/portal/me/notices and keeps nothing of them. It is best effort, as the
slots are: GET /api/v1/notices always answers 200 —
{"notices": [...], "unread": n, "available": true}, or with no portal-api, or
one that does not answer, an empty list and "available": false — so a home
screen that shows notices never fails for them.
| Route | Answers |
|---|---|
GET /api/v1/notices |
the person's notices, newest first (they keep their newest 100), unread, available |
POST /api/v1/notices/{noticeId}/read |
200 {"unread": n}; reading it again keeps when it was first read |
POST /api/v1/notices/read-all |
200 {"read": n, "unread": 0} |
DELETE /api/v1/notices/{noticeId} |
204 |
A notice the person does not have is 404 {"error":"not_found"} — someone
else's is as one there is not; a change portal-api did not make is 502 (it
did not answer) or 503 (none configured), {"error":"notices_unavailable"},
and the notice stays as it was. A notice carries id, addon, addonTitle
and addonIcon (whom it is from), title (at most 80 characters), body (at
most 280, line breaks allowed), link and itemId ("" for none),
createdAt, and readAt (null while unread); the spec has the schema.
A client shows the title, the body and whom it is from as plain text, never as
markup; follows link only to its own server — a path, or an http(s) URL on
the server's origin; opens itemId with the item routes, which hold a capped
viewer to their cap; and shows the unread count where the person looks first.
The play routes proxy chino-stream, query and all: the stream token, caps
(what the client decodes, e.g. avc:2160,hvc:2160,aac,eac3) and q ride on
to every playlist and segment, and so does v=<versionId>, which chino-stream
writes onto a package's URIs to pin the session to that version. For a
packaged title chino-stream serves each client one codec family (HEVC when it
decodes it, else H.264) at the heights its decoder takes, the audio groups it
decodes and the subtitle group the package has; q=<name> from /play/info's
qualities serves one rung, auto (the default) the ladder. The rules and the
/play/info shape are in chino-stream's README and in the spec (PlayInfo,
PlayQuality). A quality menu shows qualities when it has two or more
entries, by label, and reloads the master with q=<name>. pictureArea
(PictureArea) says where the picture is in a frame that keeps black bars
in it - left, top, width and height, fractions of the frame served -
for a player that fits the picture to the screen; it is left out where
there is none to say.
A web player on another origin, a Cast receiver among them, plays from the
routes that take a stream token (playlists, segments, subtitles, artwork,
portraits): their answers, errors too, carry Access-Control-Allow-Origin: *,
and they answer a preflight (GET, the Range header) without a credential.
Their credential is in the URL, never a cookie, so this lets no other site use
a viewer's session. The routes that take a bearer in a header send no CORS
headers, and chino-api passes no upstream's Access-Control-* headers on.
GET /api/v1/items/{id}/subtitles lists the subtitles of the file an item
plays, each at its url in the format it serves, which every client plays.
A styled subtitle (an ASS or an SSA script) is packaged as WebVTT like every
other, and its package keeps the script as it was beside it, with the fonts
the original carried. A player that draws styles (a libass renderer such as
JASSUB on the web) opts into them; every other client reads the list as
before.
- An entry whose package keeps its styled original offers it in
styled:{"url": "/api/v1/play/subs/<id>.ass", "format": "ass"}. fontsare the fonts of the package, in their order, each itsid, itsformat(ttf,otf,ttc,woff,woff2; omitted for none of them), thenamesthe script asks for it by, and itsurl,/api/v1/play/fonts/<id>.<format>(.binfor a font of no format). A JASSUB player hands them on asavailableFonts, each name lower-cased.- Both fields are omitted where there is nothing to offer —
fontsalso when no subtitle of the list offersstyled, the fonts being for those alone — so a title without them is listed byte for byte as before. The item (GET /api/v1/items/{id}) says a subtitle'sstyled_formatas katalog-api does, and has no fonts. - The script and the fonts are proxied to chino-stream in the stream-token
group, as a subtitle's
.vttis, and a capped viewer is held to the title each belongs to. chino-stream serves each from its title's package alone, cached by the browser alone (private) — a script for good, a font for a day, then revalidated by its ETag — and never sniffed (nosniff).
{"subtitles": [
{"id": "3c1f…", "lang": "en", "label": "English", "format": "webvtt", "default": true,
"url": "/api/v1/play/subs/3c1f….vtt",
"styled": {"url": "/api/v1/play/subs/3c1f….ass", "format": "ass"}}],
"fonts": [
{"id": "9b0e…", "format": "ttf", "names": ["Example Sans", "Example Sans Regular", "ExampleSans-Regular"],
"url": "/api/v1/play/fonts/9b0e….ttf"}]}GET /api/v1/subtitles/languages answers the instance's subtitle languages,
the languages every title is to have a subtitle in as katalog-manager's
settings name them: {"languages": ["en", "de", "zh-Hans"]}, BCP 47 tags as
katalog-manager gives them, in the setting's order, and [] when none is set.
Nothing else is in the answer.
- They are the instance's, not the viewer's: chino-api asks katalog-manager's
GET /api/library/subtitles/languageswith the viewer's bearer and keeps the answer for every viewer for 5 minutes, so an admin's change of the setting reaches the clients within them. - After the 5 minutes each request asks again. When katalog-manager does not answer then (an error, no answer within 5 seconds, a status other than 2xx, a body that is not its answer), the last answer is served while it is less than an hour old. A failure is never kept, and does not make the last answer younger.
- With no answer of the last hour the answer is
502, a line of text and no list.[]says none is set; a502says nothing of the setting.
POST /api/v1/play/events takes the players' events in batches,
{"sessionId": "…", "events": [{"ts": <ms>, "kind": "…", "itemId": "…", "payload": {…}}]},
and keeps none of them: each is a JSON line in the log (client_ts, kind,
itemId, each payload field as p_<field>, credentials REDACTED) and is
counted on GET /metrics, the cluster-internal scrape. Every kind counts in
chino_player_events_total{kind}, a kind the server does not know too. A
payload value is a JSON number or its string, as the TV and mobile apps send
every value.
Five kinds measure how playback goes, the baseline the live rungs of ADR-0013 are judged against:
| Kind | What the server reads | Metric |
|---|---|---|
first_frame |
startup_ms (the player opening, or Try again, to its first frame), mode, resume; with hidden or click_to_play true it is logged, not observed |
chino_player_startup_seconds{mode,resume}, buckets 0.25, 0.5, 1, 2, 3, 5, 8, 12, 20, 30 s |
startup_timeout |
mode, phase: what the player still waited for when it gave up |
chino_player_startup_timeouts_total{mode,phase} |
rebuffer |
mode, duration_ms: a stall that began mid-play |
chino_player_rebuffers_total{mode}, each one; chino_player_rebuffer_seconds{mode}, buckets 0.1, 0.25, 0.5, 1, 2, 4, 8, 15, 30, 60 s |
bandwidth |
bps, the player's estimate while it plays, which leaves out the wait for a segment's first byte; frag_ttfb_ms, that wait for the last video segment (the round trip and the origin's time to answer) |
chino_player_bandwidth_mbps{device}, buckets 1, 2, 4, 8, 15, 25, 40, 80 Mbit/s; chino_player_segment_ttfb_seconds{device}, buckets 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 4, 8 s |
level_switch |
nothing: the rendition on screen changed | chino_player_events_total only |
- Labels come from fixed sets, never a user, an item or an address.
modeispackaged,passthrough,remuxortranscode;resumetrueorfalse;phaserequest(the stream not asked for yet),playlist,init,first_segmentordecode(the segments in, no frame); anything else, or nothing, isunknown.deviceistv,mobileordesktop: by theclientthe event names (the TV and mobile apps send one), else by the request'sUser-Agent; what is not recognised as a TV, a phone or a tablet isdesktop. - Values are checked: a negative one, or one that is no number, is not observed (the rebuffer is still counted); one beyond reason is observed at a cap: 600 s for a start or a first byte, 3600 s for a stall, 10 Gbit/s.
- Besides:
chino_player_media_errors_total{code}(media_error),chino_player_quality_switches_total{from,to}(quality_switch),chino_player_resume_outcomes_total{outcome}(resume_accept,resume_decline),chino_player_stall_recoveries_total(stall_recover),chino_player_progress_saves_total, andchino_api_http_requests_totalandchino_api_http_request_duration_secondsby route and status.
What chino-web sends, and how often, is in its README.
A movie's or a series' extras - its trailers, teasers, featurettes and other
bonus material, each a file of its own that the catalog has packaged for
streaming - come with its detail, GET /api/v1/items/{id}, beside
trailers, in the order a viewer sees them:
{
"id": "9c4e7a12-3b5d-4f60-8a91-0e2d4c6b8f13", "type": "movie", "title": "A Film",
"trailers": [{"site": "YouTube", "external_id": "x1", "url": "https://www.youtube.com/watch?v=x1", "title": "Official Trailer"}],
"extras": [
{"id": "1b5c2a8e-6f0d-4c3e-9a51-2d7f0c4b8e01", "kind": "trailer", "title": "Trailer", "language": "en",
"duration_ms": 33000, "local": true,
"play_path": "/api/v1/items/9c4e7a12-3b5d-4f60-8a91-0e2d4c6b8f13/extras/1b5c2a8e-6f0d-4c3e-9a51-2d7f0c4b8e01/play/master.m3u8"}
]
}trailersstays what it was: the title's links to online videos,urland all. Installed clients read every entry as such a link (mobile and TV needurl, every client opens it outside the app), so a trailer this server plays is never in it: it is an extra.- An extra is
id,kind(trailer, teaser, featurette, behind-the-scenes, making-of, deleted-scene, interview, gag-reel, short, other; a client skips kinds it does not know),titleand, when known,languageandduration_ms; a series' extra of one season hasseason_number(0 the specials).localis alwaystrue.extrasis omitted when none plays, and while the catalog has no extras yet. play_pathis the extra's HLS master. A client asks for it as for a title's master, with?stream=<token>&caps=<caps>(q=<rung id>for one rung), and the master's URIs carry the query on. chino-stream serves it from the extra's package, HEVC only as a title's is, and only under its own title; a client that decodes none of its rungs gets the on-the-fly master, transcoded from the package, its rung (high,mediumorlow) and audio tracks under the extra's own routes. Its routes are in the spec.- The routes of a title's extras hold a capped viewer to the title's rating: the 404 of the title there too.
- Its
/play/info(/api/v1/items/{id}/extras/{extraId}/play/info, the same query) is chino-stream's of the extra, as a title's is: how it plays for the client'scapsandq, packaged or on the fly, and its ownpictureArea. - An extra has no progress, watched, segments, trickplay or
/play/prewarm: a player of extras sends none of them, and Continue Watching is not touched. - When an extra is packaged,
GET /api/v1/eventssends its title a note, phaseextra.packaged, and the detail lists it on the next fetch. The extras' topic (<prefix>catalog.extra.packaged) is tailed by a consumer group of its own once it exists on the cluster, asked every minute until then, so a cluster not provisioned for extras yet loses none of the other notes.
A file can hold several episodes - a double-length finale listed as two - and is never split: it is packaged once, and the catalog names it on the episodes it holds. The first of them, the holder, keeps the file; each other is covered, keeps its own title, overview and artwork, and plays the holder's package. An episode's detail, the item, a series' episodes and every list carry it under the catalog's names:
{"id": "e15", "type": "episode", "title": "Finale (1)", "season_number": 5, "episode_number": 15,
"covers": ["e16"], "episodeEnd": 16}
{"id": "e16", "type": "episode", "title": "Finale (2)", "season_number": 5, "episode_number": 16,
"coveredBy": "e15"}covers(the others, in episode order) andepisodeEnd(the last episode number the file holds) are on the holder,coveredBy(the holder's id) on each covered episode. Each is omitted on every other item, and while the catalog sends none of them.- Progress and watched are the file's. A write for any episode of the file -
POST /api/v1/items/{id}/progress,POSTandDELETE /api/v1/me/items/{id}/watched- is written for each of them, the same position, the same watched or unwatched. Reads stay per episode, and each has the file's state. Which episodes a file holds chino-api asks the catalog, and keeps a minute; when it cannot say, the write is the episode's alone, as before. - The next episode after a file is the one after its last episode:
GET /api/v1/series/{id}/next-episode, and the up-next cards of Continue Watching, never offer an episode of the file just watched. Continue Watching has one card for a file: the holder's while it is unfinished. - Playback needs nothing new: a covered episode's play routes, with its own id, serve the holder's package, and a capped viewer is held to the covered episode's rating.
- A client can show the holder as
S05E15–16(episode_numbertoepisodeEnd), and a covered episode as part of it. - In a series' episode list (
GET /api/v1/series/{id}/episodes) the holder carriesfile_duration_ms: how long its file is, as chino-stream measured it from the package. A client shows the file as one entry of that length around the episodes it holds;duration_msstays each episode's own.
A title whose saved position is in its last 5 %, or its last 2 minutes
where that is longer, is finished - never by more than its second half, so
a short is not finished at its first second, and never where its length is
not known. One rule (store.Finished) decides it wherever a resume point or
the watched state is served:
POST /api/v1/items/{id}/progressof a finished position marks the title watched, as the players' ownPOST /api/v1/me/items/{id}/watcheddoes, whichever client played it - but with"dismiss": true: a client taking a title off Continue Watching saves it at its end, unwatched.GET /api/v1/items/{id}/progresshas no resume point for it:position_secis 0, with"finished": true, and the clients show Play from the start and the watched mark - no Resume, no Start over.GET /api/v1/me/continue-watchingdrops a finished film, and puts the next episode in a finished one's place: with that one's own resume point where it has one, a card like any other in progress; else at 0,up_next.
A phone keeps downloads (packaged titles fetched from the playback routes) and
plays them without a connection; GET /api/config's features.downloads
says whether it may offer them. Two routes serve it.
POST /api/v1/me/availablewith{"ids": [...]}(at most 500) answers{"ids": [...]}: the ids asked about that the caller may watch now, in their order, each once. A capped viewer is held to its cap as on every route of one title - a title above it, an unrated one the catalog's setting hides and an id of no title are left out; for a viewer without a cap the catalog decides, a title it holds. The phone asks it as the app starts and comes to the front, and before a download plays, and deletes the downloads the answer leaves out: the library decides, for every account, and a parent lowering a child's cap takes what is above it off the child's phone. An answer that leaves out every id asked also says how many titles of the library the caller may watch,{"ids": [], "titles": n}: above 0 the empty answer is the library's word, even for an account whose only download it is; 0 is a catalog that shows the caller nothing (imported anew, reset), and the phone does not take a title off on that alone. When the catalog cannot say, the answer is 502 and no ids, and the phone deletes nothing.POST /api/v1/items/{id}/progresstakesplayed_ms_ago, the age of the position when it is sent, in milliseconds: a position the phone kept while it had no connection. It is the position atnow()minus the age (the phone's clock does not matter), written only where the saved one is older, and a finished one marks the title watched only where it was written. A film finished on the TV stays finished when the phone's earlier second comes in later. Without the field a position is now's, written over any, as every other client's is.POST /api/v1/me/items/{id}/watchedtakes the sameplayed_ms_ago, as an optional JSON body: a watched mark the phone kept while it had no connection. The title is watched atnow()minus the age, or later where it was watched again since. Without a body the mark is now's, as before.
go run ./cmd/server
# in another shell
curl -sS http://localhost:8080/api/healthzDisable OIDC for local poking:
OIDC_ENABLED=false go run ./cmd/server
curl -sS http://localhost:8080/api/v1/itemsWhere a client cannot set an Authorization header (an <img> or <video>
URL, sendBeacon, an EventSource), it authenticates with ?stream=<token>,
a stream token from POST /api/v1/me/stream-token. The bearer in the URL
(?token=) is deprecated: it is still accepted in this release and goes in
the next. The request log shows credential values as REDACTED.
When PG_URL is empty, progress and telemetry endpoints answer gracefully but
do not persist — keeps local dev simple. When OPENPROJECT_TOKEN is empty the
feedback endpoint answers 503 and clients keep the feature off: GET /api/config
says so in features.feedback (true only with a token), so a client can hide
its bug-report entry points before anyone tries.
features.downloads says whether a client may offer downloads, a packaged
title kept on a phone to play without a connection: true unless
DOWNLOADS_ENABLED is false (or 0, no, off). A download is fetched from
the playback routes, as a stream is, so the switch changes nothing else on the
server; a client offers no download while it is false.
Configured entirely through environment variables (see internal/config):
| Var | Purpose |
|---|---|
ADDR |
listen address (default :8080) |
OIDC_ISSUER / OIDC_AUDIENCE / OIDC_ENABLED |
OIDC JWT validation |
KATALOG_BASE_URL |
catalog metadata service |
STREAM_BASE_URL |
chino-stream (HLS / trickplay / play info) |
ARTWORK_BASE_URL |
katalog-manager, for artwork |
KATALOG_MANAGER_URL |
katalog-manager, where the admin packaging routes go with the admin's bearer and the subtitle languages are asked for with the viewer's (default http://katalog-manager-api; ANALYZER_BASE_URL, its former name, is read when it is unset) |
PG_URL |
Postgres URL for user-state (optional) |
ADMIN_ROLE |
the realm role (realm_access.roles of the access token) that opens /api/v1/admin/* (default zaentrum-admin, as katalog-manager and the portal) |
ADMIN_SUBJECTS |
deprecated: comma-separated OIDC sub values let through /api/v1/admin/* besides the role; empty by default, and it goes in a coming release |
OPENPROJECT_URL / OPENPROJECT_TOKEN / OPENPROJECT_PROJECT_ID / OPENPROJECT_BUG_TYPE_ID |
feedback pipeline (optional) |
DOWNLOADS_ENABLED |
whether /api/config lets the apps offer downloads (features.downloads; default true) |
STREAM_SIGNING_KEY |
shared HMAC secret for signed ?stream= URLs (optional) |
ACCOUNT_DELETION_TOKEN |
the token chino-api and portal-api delete an account with (Secret zaentrum-people, key deletion-token, on the platform); empty: DELETE /api/v1/me answers 501 |
PORTAL_BASE_URL |
portal-api, for addon slots, notices and account deletion (default http://portal-api) |
cmd/server/main.go process entry
internal/config/ env wiring
internal/http/router.go chi router + middleware
internal/http/openapi.{go,yaml} embedded OpenAPI spec
internal/http/ browse / watchlists / progress / feedback / ... handlers
internal/auth/ Bearer JWT verifier + stream-token middleware
internal/katalog/ catalog + people read clients
internal/store/ Postgres store + SQL migrations
internal/openproject/ minimal OpenProject client (feedback)
internal/metrics/ Prometheus surface
k8s/ sample Deployment / Service / Route / ServiceMonitor / Dashboard
Dockerfile distroless multi-stage build
docker build -t zaentrum/chino-api .The k8s/ manifests are samples. Build and push the image to your own registry,
adjust the env values and hostnames for your environment, and deploy.