Skip to main content

API and auth

REST surface

Controllers live under api/.../controller/ (with a few file-serving controllers in disk/). The areas:

AreaControllers
Browsemovies, shows, seasons, episodes, persons, albums, tracks, chapters, books, series, podcasts + podcast episodes, credits
Playbackplay queue, watch status, media files, stream tokens, playback commands
Progressreading progress (ReadingProgressController), recently watched, per-user ratings (RatingController)
Playlists & discoveryplaylists (PlaylistController), saved views (SavedViewController), discover rows (LibraryDiscoverController) — see chapter 9
Devices, follow & historydevices (DeviceController), listen-along (PlayQueueFollowController), playback history (PlaybackHistoryController), session sharing (PlaybackSharingController) — see chapter 9 and chapter 5
Managementscanner (ScannerController, scanLibraries), metadata refresh (MetadataRefreshController, refreshMetadata + per-item refresh*), libraries, directories, user settings, user admin (UserAdminController)
Search & miscsearch (SearchController), current user (MeController), server clock (TimeController)
Serverserver info, server status, .well-known
File serving (disk module)epub resources (EpubResourceController area), comic pages (ComicResourceController: /comic/{mediaFileId}/manifest, /page/{index}, /file), image downloads + media-file download + transcode segment upload (FileController, see below)

Errors are mapped centrally in api/.../error/RestExceptionHandler for REST, GraphQlExceptionResolver for GraphQL.

The Spring Actuator runs on a separate management port, 8081 (management.server.port in core.properties), keeping health/metrics off the public API port. Podcast directory search is proxied through the free iTunes Search API (ItunesSearchService, api module; base URL is a property like every external endpoint).

GraphQL

The schema lives at api/src/main/resources/graphql/schema.graphqls; the GraphQL IDE (GraphiQL) is enabled unconditionallyspring.graphql.graphiql.enabled=true in core.properties and /graphiql is permitAll in OIDCSecurityConfig — not just in dev. Besides queries and mutations there are four websocket subscriptions (chapter 5):

  • serverActivity — node heartbeats, queue depths, busy handlers, recent failures (replay-latest)
  • nowPlaying — active playback sessions, filtered per viewer by the owner's sharing settings (chapter 5, replay-latest)
  • playbackCommands(playQueueId) — party-mode remote control (best-effort, non-replaying); gated by the owner's remote-control sharing scope
  • deviceCommands(deviceId) — commands addressed to one of the caller's own devices; see chapter 9

Websocket auth (GraphQlWebSocketAuthConfig): a browser cannot set an Authorization header on a websocket handshake, so the JWT travels in the connection_init payload ({"Authorization": "Bearer <jwt>"}). The interceptor stores the resulting SecurityContext on the websocket session and propagates it to every subscribe message, so @PreAuthorize works unchanged on subscription controllers.

Websocket origin (GraphQlWebSocketOriginConfig): Spring GraphQL always guards the handshake with an OriginHandshakeInterceptor, and its default allow-list is empty, which means "same origin only". Behind a TLS-terminating proxy the app itself speaks plain HTTP, so a browser on https://<public host> is not the same origin as the http://…:8080 request the app sees and the handshake gets a 403 — while every plain HTTP call keeps working. We therefore rebuild that interceptor with the public host from app.ister.server.url (both schemes, any port), plus whatever app.ister.server.websocket.allowed-origins adds. Forwarded headers are not relied on: they only help when the proxy sends them and server.forward-headers-strategy is set. A client that sends no Origin (the Dart client) was never affected.

For episodes the schema carries, next to Episode.mediaFile, an Episode.mediaFileParts list of MediaFilePart { mediaFile, startInMilliseconds, durationInMilliseconds }: the episode's time slice within each file. For a normal file that is (0, file duration); for an episode inside a multi-episode file (s04e06-e07.mkv, chapter 2) it is the episode's own slice — the client opens the same file-addressed HLS stream, seeks to startInMilliseconds and treats start + duration as end-of-episode. MediaFile.episodes lists every episode a file contains, so episodes.length > 1 is the "combined file" signal. Progress heartbeats keep reporting the absolute file position.

Per-user preferences and attribution

Three small API surfaces that the chapters above only touch in passing:

  • RatingssetRating(mediaType, mediaId, rating) stores the calling user's 1–10 rating for a media item (rating: null clears it); RatingMediaType covers MOVIE / SHOW / EPISODE / ALBUM / TRACK / BOOK / PODCAST. The value is read back per user through a rating field on the corresponding type (e.g. Movie.rating), null when unrated. RatingController.
  • Track play statisticsTrack.playCount and Track.lastPlayedAt (ISO-8601) expose the calling user's plays, derived from track watch-status rows (chapter 5); both null when never played. Per-artist top lists live on Person: topPlayedTracks, recentlyPlayedTracks and topRatedTracks (all per calling user, limit clamped to 1–50, default 10, library-scoped like every other resolver), plus recentlyAddedTracks — not per user, newest in the library first, the same artist predicate as tracks(artistId:). Album.dateAdded and Track.dateAdded (ISO-8601) expose when the row was created by a scan. Every Person list has a matching RankKind for ARTIST play queues; RECENTLY_ADDED is artist-only (the Discover ranked* lists return an empty page for it). PersonController / TrackController.
  • An artist's musictracks(artistId:) returns every track the artist is credited on, as primary or featured artist, plus the tracks on the albums they own; albums(appearsOnArtistId:) returns the albums they are credited on without owning them (compilations, guest appearances). Track.artists lists the credits themselves (TrackCredit: person, PRIMARY/FEATURED, position) while Track.artist stays the primary artist. Both queries are library-scoped and page and sort like the rest of the browse surface; filter takes precedence over the artist argument. TrackController / AlbumController.
  • Playback settingsuserSettings / updateUserSettings hold each user's preferredAudioLanguages, preferredSubtitleLanguages, directPlay, transcode, maxVideoHeight, autoSkipIntro and hideSubtitlesMatchingAudio (V44). They apply to every client of that user, and two of them steer pre-transcoding: only the preferred audio languages and video variants up to maxVideoHeight are transcoded in the background (chapter 4PassFilter reads nothing else, so autoSkipIntro and hideSubtitlesMatchingAudio are purely client-side preferences). Defaults fall back to the server's configured languages. UserSettingsController.
  • Attributionattributions returns the external providers actually in use on this server, for the client's attribution screen: source (a MetadataSource: TMDB, MUSICBRAINZ, COVER_ART_ARCHIVE, WIKIMEDIA_COMMONS, WIKIPEDIA, WIKIDATA, OPEN_LIBRARY, PODCAST_FEED, LOCAL_FILE), a display name/url, a provider-mandated notice (e.g. TMDB's non-endorsement line) and a content license where relevant (e.g. CC BY-SA 4.0 for Wikipedia text). Each Metadata row and image also carries its own source so a single item can be attributed field by field (chapter 3). AttributionController, backed by migration V26.

Admins, per-library visibility, and playback-session sharing are their own surface — see the admin guide, Users, sharing, and access, and chapter 5 for the sharing internals.

Authentication

Primary auth is OAuth2 JWT via Spring Security's resource server, against a Keycloak-compatible OIDC provider (OIDC_URL env var). The JWT's roles claim is mapped to Spring authorities with a ROLE_ prefix (OIDCSecurityConfig), so a realm role admin becomes ROLE_admin and gates the admin-only mutations via @PreAuthorize("hasRole('admin')").

Stream tokens cover the places a media player cannot send a bearer header. HLS playlist and segment requests may authenticate with a short-lived ?token= query parameter (StreamTokenAuthenticationFilter); the server injects the token into the playlist URIs it generates, so the player never handles it explicitly. StreamTokenService sweeps expired tokens on a schedule. In multi-node setups, NodeTokenManager refreshes the inter-node tokens.

Per-library authorization on media URLs

Authentication alone does not decide what a user may fetch: MediaAccessEnforcementFilter (core) enforces per-library visibility on the id-addressed media endpoints — /hls/{mediaFileId}, /epub/{mediaFileId}, /comic/{mediaFileId} and /images/{imageId}/download. A denied resource answers 404, indistinguishable from a resource that does not exist. Node-to-node traffic (ROLE_node) passes through, as do resources without a library (person portraits, for example).

Image downloads

FileController (disk module) also serves the artwork itself: GET /images/{id}/download with an ETag and conditional GET (If-None-Match → 304). The cache policy is deliberately private, max-age with revalidation rather than immutable: a scanned library image keeps its id when the file behind it is replaced in place, so clients must be able to revalidate cheaply — unlike the comic and epub resources, which are immutable. Operational note: a reverse proxy in front of the server must pass If-None-Match/ETag through, or every image request degrades to a full download. The same controller handles the node-to-node endpoints, authenticated with node tokens only: GET /mediaFile/{id}/download (multi-node source reads, with byte ranges) and GET /mediaFileStream/{id}/download (an extracted or sidecar SRT for a remote transcoder) on the download token; POST /transcode/upload/{id}/{fileName} (segment uploads, chapter 4) and POST /cache/upload/{fileName} (a helper node's extracted subtitle, written atomically into this node's cache directory) on the upload token.

Downscaled artwork. ?width= asks for a smaller variant, which is what clients use for grid tiles and list thumbnails — a 3840×2160 episode still painted 150 px wide costs 33 MB of decoded pixels, and a screen full of them exhausts a browser's GPU memory. The requested width snaps up to one of 160, 240, 320, 480, 640, 960, 1280; above that the original is served, since re-encoding saves little there. Variants are generated on first request and cached on disk under TMP_DIR/image-thumbs/ (see maintenance); transparency is preserved as png, everything else becomes jpeg. Every failure to scale — an already-narrow source, an animated gif, no AWT in the native image — falls back to the original file rather than an error, and an unknown ?width= on an older server is simply ignored. The ETag carries a -w{width} suffix so each variant revalidates on its own.

Epub reading

The client's epub reader loads books lazily through GET /epub/{mediaFileId}/resource/{*entryPath} (EpubResourceController — the {*entryPath} wildcard captures the zip-entry path including slashes), which serves individual zip entries with Range and ETag support. It accepts the same stream tokens, plus a cookie fallback: subresources (CSS, images, fonts) are loaded by the browser engine itself, which cannot append the token — the cookie set on the first request covers those.

Reading position is a WatchStatusEntity carrying readingLocation (an epubcfi) and readingProgress, synced via the updateReadingProgress GraphQL mutation or POST /reading-progress. Both paths call ContinueWatchingService.onWatchStatusChanged in the same transaction — mandatory for every watch-status write (chapter 5).