API en auth
REST-oppervlak
Controllers staan onder api/.../controller/ (met een paar bestandsserverende controllers in
disk/). De gebieden:
| Gebied | Controllers |
|---|---|
| Browsen | films, shows, seizoenen, afleveringen, personen, albums, tracks, chapters, boeken, series, podcasts + podcastafleveringen, credits |
| Playback | play queue, watch status, mediabestanden, stream-tokens, playback-commands |
| Voortgang | leesvoortgang (ReadingProgressController), recent bekeken, ratings per user (RatingController) |
| Playlists & ontdekken | playlists (PlaylistController), opgeslagen weergaven (SavedViewController), discover-rijen (LibraryDiscoverController) — zie hoofdstuk 9 |
| Apparaten, meeluisteren & historie | apparaten (DeviceController), meeluisteren (PlayQueueFollowController), afspeelhistorie (PlaybackHistoryController), sessies delen (PlaybackSharingController) — zie hoofdstuk 9 en hoofdstuk 5 |
| Beheer | scanner (ScannerController, scanLibraries), metadata verversen (MetadataRefreshController, refreshMetadata + per-item refresh*), libraries, directories, gebruikersinstellingen, gebruikersbeheer (UserAdminController) |
| Zoeken & overig | zoeken (SearchController), huidige gebruiker (MeController), serverklok (TimeController) |
| Server | serverinfo, serverstatus, .well-known |
| Bestanden serveren (disk-module) | epub-resources (EpubResourceController-gebied), strippagina's (ComicResourceController: /comic/{mediaFileId}/manifest, /page/{index}, /file), image-downloads + mediabestand-download + transcode-segment-upload (FileController, zie hieronder) |
Fouten worden centraal gemapt in api/.../error/ — RestExceptionHandler voor REST,
GraphQlExceptionResolver voor GraphQL.
De Spring Actuator draait op een aparte management-poort, 8081 (management.server.port in
core.properties), zodat health/metrics niet op de publieke API-poort zitten. Zoeken in de
podcastdirectory wordt geproxied via de gratis iTunes Search API (ItunesSearchService,
api-module; de base-URL is een property, zoals elk extern endpoint).
GraphQL
Het schema staat in api/src/main/resources/graphql/schema.graphqls; de GraphQL-IDE (GraphiQL)
staat onvoorwaardelijk aan — spring.graphql.graphiql.enabled=true in core.properties en
/graphiql is permitAll in OIDCSecurityConfig — niet alleen in dev. Naast queries en mutations
zijn er vier websocket-subscriptions (hoofdstuk 5):
serverActivity— node-heartbeats, queuedieptes, bezige handlers, recente mislukkingen (replay-latest)nowPlaying— actieve playback-sessies, per kijker gefilterd op de sharing-instellingen van de eigenaar (hoofdstuk 5, replay-latest)playbackCommands(playQueueId)— party-mode-afstandsbediening (best-effort, non-replaying); begrensd door de afstandsbedienings-scope van de eigenaardeviceCommands(deviceId)— commando's gericht aan een van de eigen apparaten van de aanroeper; zie hoofdstuk 9
Websocket-auth (GraphQlWebSocketAuthConfig): een browser kan geen Authorization-header op
een websocket-handshake zetten, dus de JWT reist mee in de connection_init-payload
({"Authorization": "Bearer <jwt>"}). De interceptor bewaart de resulterende SecurityContext op
de websocket-sessie en propageert die naar elk subscribe-bericht, zodat @PreAuthorize op
subscription-controllers ongewijzigd werkt.
Websocket-origin (GraphQlWebSocketOriginConfig): Spring GraphQL bewaakt de handshake altijd
met een OriginHandshakeInterceptor, en diens standaardlijst is leeg — wat "alleen dezelfde
origin" betekent. Achter een proxy die TLS termineert spreekt de app zelf gewoon HTTP, dus een
browser op https://<publieke host> is niet dezelfde origin als het http://…:8080-verzoek dat de
app ziet en de handshake krijgt een 403 — terwijl alle gewone HTTP-aanroepen blijven werken. We
bouwen die interceptor daarom opnieuw op met de publieke host uit app.ister.server.url (beide
schema's, elke poort), aangevuld met wat app.ister.server.websocket.allowed-origins toevoegt. Op
forwarded headers leunen we niet: die helpen alleen als de proxy ze stuurt én
server.forward-headers-strategy aanstaat. Een client die geen Origin stuurt (de Dart-client)
had er nooit last van.
Voor afleveringen kent het schema naast Episode.mediaFile een lijst Episode.mediaFileParts van
MediaFilePart { mediaFile, startInMilliseconds, durationInMilliseconds }: de tijd-slice van de
aflevering binnen elk bestand. Voor een gewoon bestand is dat (0, bestandsduur); voor een
aflevering in een multi-episode-bestand (s04e06-e07.mkv,
hoofdstuk 2) de eigen slice — de client opent dezelfde
file-addressed HLS-stream, seekt naar startInMilliseconds en behandelt start + duration als
einde-aflevering. MediaFile.episodes somt elke aflevering op die een bestand bevat, dus
episodes.length > 1 is het "gecombineerd bestand"-signaal. Progress-heartbeats blijven de
absolute bestandspositie rapporteren.
Voorkeuren per gebruiker en attributie
Drie kleine API-oppervlakken die de hoofdstukken hierboven slechts terloops raken:
- Ratings —
setRating(mediaType, mediaId, rating)slaat de 1–10-beoordeling van de aanroepende gebruiker voor een media-item op (rating: nullwist die);RatingMediaTypedekt MOVIE / SHOW / EPISODE / ALBUM / TRACK / BOOK / PODCAST. De waarde wordt per gebruiker teruggelezen via eenrating-veld op het bijbehorende type (bijv.Movie.rating), null als er geen rating is.RatingController. - Track-afspeelstatistieken —
Track.playCountenTrack.lastPlayedAt(ISO-8601) tonen de plays van de aanroepende gebruiker, afgeleid van track-watch-status-rijen (hoofdstuk 5); beide null als de track nooit is afgespeeld. Toplijsten per artiest staan opPerson:topPlayedTracks,recentlyPlayedTracksentopRatedTracks(allemaal per aanroepende gebruiker,limitbegrensd op 1–50, standaard 10, library-gescoped zoals elke andere resolver), plusrecentlyAddedTracks— niet per gebruiker, nieuwst in de library eerst, hetzelfde artiest-predicaat alstracks(artistId:).Album.dateAddedenTrack.dateAdded(ISO-8601) tonen wanneer een scan de rij heeft aangemaakt. ElkePerson-lijst heeft een bijpassendeRankKindvoor ARTIST-afspeelwachtrijen;RECENTLY_ADDEDis alleen voor artiesten (de Discover-ranked*-lijsten geven er een lege pagina voor).PersonController/TrackController. - De muziek van een artiest —
tracks(artistId:)geeft elk nummer waarop de artiest gecrediteerd staat, als primaire of featured artiest, plus de nummers op de albums die zij zelf bezit;albums(appearsOnArtistId:)geeft de albums waarop zij gecrediteerd staat zonder ze te bezitten (verzamelalbums, gastoptredens).Track.artiststoont de credits zelf (TrackCredit: persoon,PRIMARY/FEATURED, positie), terwijlTrack.artistde primaire artiest blijft. Beide queries zijn library-gescoped en pagineren en sorteren als de rest van het browse-oppervlak;filtergaat voor het artiest-argument.TrackController/AlbumController. - Playback-instellingen —
userSettings/updateUserSettingsbevatten per gebruikerpreferredAudioLanguages,preferredSubtitleLanguages,directPlay,transcode,maxVideoHeight,autoSkipIntroenhideSubtitlesMatchingAudio(V44). Ze gelden voor elke client van die gebruiker, en twee ervan sturen pre-transcoding: alleen de voorkeurstalen voor audio en videovarianten totmaxVideoHeightworden op de achtergrond getranscodeerd (hoofdstuk 4 —PassFilterleest niets anders, dusautoSkipIntroenhideSubtitlesMatchingAudiozijn puur client-side voorkeuren). Standaardwaarden vallen terug op de geconfigureerde talen van de server.UserSettingsController. - Attributie —
attributionsgeeft de externe providers terug die daadwerkelijk op deze server in gebruik zijn, voor het attributiescherm van de client:source(eenMetadataSource: TMDB, MUSICBRAINZ, COVER_ART_ARCHIVE, WIKIMEDIA_COMMONS, WIKIPEDIA, WIKIDATA, OPEN_LIBRARY, PODCAST_FEED, LOCAL_FILE), een weer te gevenname/url, een door de provider voorgeschrevennotice(bijv. de non-endorsement-regel van TMDB) en waar relevant een content-license(bijv.CC BY-SA 4.0voor Wikipedia-tekst). ElkeMetadata-rij en afbeelding draagt ook zijn eigensource, zodat een item veld voor veld geattribueerd kan worden (hoofdstuk 3).AttributionController, gebaseerd op migratie V26.
Admins, zichtbaarheid per library en het delen van playback-sessies zijn een eigen oppervlak — zie de beheergids, Gebruikers, delen en toegang, en hoofdstuk 5 voor de interne werking van het delen.
Authenticatie
De primaire auth is OAuth2 JWT via Spring Security's resource server, tegen een
Keycloak-compatibele OIDC-provider (OIDC_URL-env var). De roles-claim van de JWT wordt met een
ROLE_-prefix op Spring-authorities gemapt (OIDCSecurityConfig), dus een realm-rol admin wordt
ROLE_admin en begrenst de admin-only-mutations via @PreAuthorize("hasRole('admin')").
Stream-tokens dekken de plekken waar een mediaspeler geen bearer-header kan meesturen.
HLS-playlist- en segmentverzoeken mogen authenticeren met een kortlevende
?token=-queryparameter (StreamTokenAuthenticationFilter); de server injecteert het token in de
playlist-URI's die hij genereert, zodat de speler het nooit expliciet hoeft te hanteren.
StreamTokenService ruimt verlopen tokens op via een schedule. In multi-node-opstellingen ververst
NodeTokenManager de tokens tussen nodes.
Autorisatie per library op media-URL's
Authenticatie alleen bepaalt niet wat een gebruiker mag ophalen: MediaAccessEnforcementFilter
(core) dwingt per-library-zichtbaarheid af op de id-geadresseerde media-endpoints —
/hls/{mediaFileId}, /epub/{mediaFileId}, /comic/{mediaFileId} en
/images/{imageId}/download. Een geweigerde resource antwoordt 404, niet te onderscheiden van
een resource die niet bestaat. Node-naar-node-verkeer (ROLE_node) mag erdoor, net als resources
zonder library (bijvoorbeeld persoonsportretten).
Image-downloads
FileController (disk-module) serveert ook de artwork zelf: GET /images/{id}/download met een
ETag en conditional GET (If-None-Match → 304). Het cachebeleid is bewust private, max-age
met hervalidatie in plaats van immutable: een gescande library-afbeelding houdt haar id wanneer
het bestand erachter in-place vervangen wordt, dus clients moeten goedkoop kunnen hervalideren —
anders dan de comic- en epub-resources, die wél immutable zijn. Operationele noot: een reverse
proxy vóór de server moet If-None-Match/ETag doorlaten, anders degradeert elk imageverzoek tot
een volledige download. Dezelfde controller verzorgt de node-naar-node-endpoints, uitsluitend
geauthenticeerd met node-tokens: GET /mediaFile/{id}/download (multi-node-bronreads, met
byte-ranges) en GET /mediaFileStream/{id}/download (een geëxtraheerde of sidecar-SRT voor een
remote transcoder) op het download-token; POST /transcode/upload/{id}/{fileName}
(segment-uploads, hoofdstuk 4) en POST /cache/upload/{fileName} (de
geëxtraheerde ondertitel van een helper-node, atomair in de cache-directory van deze node
geschreven) op het upload-token.
Verkleinde artwork. ?width= vraagt om een kleinere variant, wat clients gebruiken voor
grid-tegels en lijstminiaturen — een episode-still van 3840×2160 die 150 px breed geschilderd
wordt kost 33 MB aan gedecodeerde pixels, en een scherm vol daarvan put het GPU-geheugen van een
browser uit. De gevraagde breedte klimt naar één van 160, 240, 320, 480, 640, 960, 1280;
daarboven wordt het origineel geserveerd, omdat hercoderen daar weinig oplevert. Varianten worden
bij het eerste verzoek gemaakt en op schijf gecachet onder TMP_DIR/image-thumbs/ (zie
onderhoud); transparantie blijft behouden
als png, de rest wordt jpeg. Elke schaalfout — een bron die al smal genoeg is, een geanimeerde
gif, geen AWT in de native image — valt terug op het originele bestand in plaats van een fout, en
een onbekende ?width= op een oudere server wordt simpelweg genegeerd. De ETag krijgt een
-w{breedte}-achtervoegsel zodat elke variant zelfstandig hervalideert.
Epub lezen
De epub-lezer van de client laadt boeken lazy via
GET /epub/{mediaFileId}/resource/{*entryPath} (EpubResourceController — de
{*entryPath}-wildcard vangt het zip-entry-pad inclusief slashes), dat individuele zip-entries
serveert met Range- en ETag-ondersteuning. Het accepteert dezelfde
stream-tokens, plus een cookie-fallback: subresources (CSS, afbeeldingen, fonts) worden door de
browser-engine zelf geladen, die het token niet kan meesturen — de cookie die bij het eerste
verzoek gezet wordt, dekt die af.
De leespositie is een WatchStatusEntity met readingLocation (een epubcfi) en readingProgress,
gesynct via de GraphQL-mutation updateReadingProgress of POST /reading-progress. Beide paden
roepen ContinueWatchingService.onWatchStatusChanged aan in dezelfde transactie — verplicht voor
elke watch-status-write (hoofdstuk 5).