Imported from Yodeling-Cat/jellyfin-mobile (
AGENTS.md). Install upstream withnpx skills add Yodeling-Cat/jellyfin-mobile. Copyright stays with the author.
AGENTS.md
CLAUDE.md is a symlink to this file. Edit AGENTS.md.
What this project is
A native Kotlin Multiplatform Jellyfin client for Android, iOS and desktop (Windows, macOS and Linux on the JVM), with a shared Compose Multiplatform UI. It talks to Jellyfin servers directly over the Jellyfin HTTP API.
It replaces jellyfin-android, which wrapped the
Jellyfin web client in a WebView. That project is checked out locally at
../jellyfin-android and is the reference source for ported components.
Read PLAN.md before doing architectural work — it holds the migration inventory (what to port from the old app, what to delete), the phase roadmap, and the open decisions.
The repo is currently close to the untouched KMP wizard template. Treat Greeting.kt,
GreetingUtil.kt, Platform.kt and their tests as scaffolding to delete, not as examples to follow.
Non-negotiables
- iOS is a first-class target. Never put shared logic behind an Android-only dependency.
If code is in
commonMain, it must compile foriosArm64/iosSimulatorArm64and fordesktop. Verify with./gradlew :shared:compileKotlinIosSimulatorArm64and:shared:compileKotlinDesktop, not just the Android build. iOS is the constraint that bites: a JVM-only library still compiles for desktop and fails only there. On a Windows host that compile task works (Kotlin/Native cross-compiles the klib) and will catch any Kotlin error incommonMain/iosMain— butlinkDebugFrameworkIosSimulatorArm64is SKIPPED andiosSimulatorArm64Testcannot run. Linking, running, and iOS tests need macOS, so anything beyond type-checking has to happen on a Mac or in CI. - Desktop is a target, not a port.
jvm("desktop")builds the samecommonMaininto a Compose Desktop app; nothing about it is a separate codebase. Unlike iOS it builds, links, runs and tests on this host, so./gradlew :desktopApp:runis the fastest way to see a shared-UI change at all — and:shared:desktopTestrunscommonTestin seconds where the Android host test needs a Robolectric-shaped build. It is also the only place libVLC can be exercised:VlcjPlayerEngineTestplays a real file and checks the frames, which is the closest thing we have to a test of the iOS engine's design. libVLC is a native library and never on the classpath: on Windows the build downloads it and packages it into the app (:desktopApp:bundleVlc, ~104 MB), the.debdeclares it as a package dependency instead (declareVlcDebDependency), and macOS still falls back to the machine's own VLC — PLAN.md §6.5. The tests always use the machine's VLC, because a test JVM has no packaged app resources to look in, soVlcjPlayerEngineTestskips itself where VLC is not installed.KeepScreenOn,PlaybackHardwareandSystemBarAppearanceremain documented no-ops there. - Do not add
org.jellyfin.sdk:*. The official Kotlin SDK is JVM/Android-only (issue #208). We use our own Ktor client incommonMain. This is a deliberate decision, not an oversight — see PLAN.md §1. - GPL-2.0. jellyfin-android is GPL-2.0 and we derive from it. Every file ported or adapted from
it gets a provenance header:
Do not paste in code from non-GPL-compatible sources.// Derived from jellyfin-android, GPL-2.0 // https://github.com/jellyfin/jellyfin-android/blob/master/app/src/main/java/<path> - The API client is hand-written, deliberately.
network/JellyfinApi.ktexposes only the endpoints we actually use, each with a comment recording why its parameters are what they are. There is no generator and no generated source set. Adding an endpoint means looking it up in the spec (below) and writing the method.
Layout
| Path | Contents |
|---|---|
shared/src/commonMain |
Shared logic and the Compose UI |
shared/src/androidMain |
Media3/ExoPlayer, MediaCodecList device profiling, MediaSession, WorkManager |
shared/src/iosMain |
AVFoundation/VLCKit playback, Now Playing, URLSession |
shared/src/desktopMain |
The desktop window, AWT/JVM actuals, per-OS data directories |
androidApp/ |
Android entry point only — keep it thin |
iosApp/ |
iOS entry point + SwiftUI glue only — keep it thin |
desktopApp/ |
main() and the packaging config only — keep it thin |
Feature code belongs in shared, not in androidApp/iosApp/desktopApp. The window itself is in
shared too, for the reason MainViewController is: the entry-point modules cannot read Res, so
anything with a translated string in it has to live here. Split shared into Gradle modules only
when it becomes unwieldy; don't pre-modularize.
Commands
./gradlew :androidApp:assembleDebug
./gradlew :androidApp:installDebug
./gradlew :shared:testAndroidHostTest
./gradlew :shared:iosSimulatorArm64Test
./gradlew :desktopApp:run
./gradlew :shared:desktopTest
./gradlew :desktopApp:packageDistributionForCurrentOS
./gradlew ktlintFormat
./gradlew ktlintCheck
iOS app builds run from Xcode against iosApp/.
Conventions
- Package root is
org.jellyfin.mobile. The template's doubledorg.jellyfin.mobile.jellyfinand theapplicationIdare placeholders — fix them, and note thatorg.jellyfin.mobileis the published jellyfin-android app id (see PLAN.md §6 open decision 3 before shipping). - Don't leak wire models into the UI. Generated DTOs (
BaseItemDtoand friends) stop at the repository boundary; Compose consumes our owndomainmodels. expect/actualis for genuine platform capability (player, codecs, filesystem, notifications). It is not a workaround for a library that "doesn't work on iOS" — find a KMP library instead.- Prefer
Flowover callbacks; suspend functions over blocking calls. NoLiveData(the old app uses it heavily — convert on port). - No user-facing string literals in Kotlin. Everything the user reads lives in
shared/src/commonMain/composeResources/values*/strings.xml, reached through the generatedorg.jellyfin.mobile.resources.Res. Only English exists so far; the 74-locale catalog will be imported from jellyfin-android's Weblate output, so keep the file's format compatible — that import should stay a copy rather than a conversion.- Placeholders are always indexed and always
%1$s,%2$s, … and arguments are passed as strings (count.toString()). Compose Resources formats these itself on iOS rather than going through the platform, and the indexed string form is the only one reliable on both targets. - Anything whose wording depends on a count is a
<plurals>, read withpluralStringResource. Do not append an "s" in Kotlin.
- Placeholders are always indexed and always
- Text produced outside a composition is a
UiText, not aString. Repositories, mappers and view models cannot callstringResource, so they name the string and the UI resolves it withUiText.resolve().UiText.Rawis for text the server wrote — a genre, a library name, a media stream's label, an exception message — andUiText.Resource/Pluralfor ours. An exception that reaches the screen implementsLocalizedErrorso the user gets a sentence and the log keeps the diagnostic. This is whydomainimportscomponents-resources:StringResourceis a resource identifier, readable outside a composition, not a Compose UI type — anImageVectoris the other thing, which is why library icons still resolve up inNavigationDrawer. - Navigation routes must serialize, so a
UiTextcannot ride in one. Carry what the heading is built from and rebuild it at the destination —SectionRoutecarrieskindandlibraryName, andSectionKind.title()is the one place either end reads. - No
println— use Kermit. - Colour, type and shape come from the theme, never from a literal at the call site.
ui/theme/AppTheme.ktpasses Material all three, soMaterialTheme.colorScheme,.typographyand.shapesare the only sources — aColor(0xFF…)or aRoundedCornerShape(8.dp)in a screen is a bug. The colour schemes inColorSchemes.ktare generated bytools/palette.py, which also checks every foreground clears 4.5:1 on the surface behind it: change a hue there and re-run it, don't edit a hex by hand. Two deliberate exceptions, both commented where they live: the player is white-on-black in either scheme because it sits over video, and the card badges are fixed because they sit on artwork. - Icons come from Material Icons, reached through the named aliases in
ui/components/Icons.ktrather than imported at the call site. Two things to know before touching that file:material-icons-*is deprecated upstream and is no longer a transitive dependency of Material 3, and JetBrains stopped publishing the multiplatform artifact after 1.7.3 (December 2024). SocomposeMaterialIconsis pinned there whilecomposeMultiplatformmoves on. It resolves and compiles today, but it will not gain icons or fixes, and a future Compose release may break it — at which point the aliases are the only file that needs to change.material-icons-extendedcarries thousands of icons and relies on dead-code elimination to not ship them all; check the APK and framework size if that ever stops being true. - Run
./gradlew ktlintFormatbefore committing. ktlint runs with the compose-rules ruleset, so it checks Compose conventions (modifier parameter, parameter order, state hoisting) as well as formatting. Rules live in.editorconfig, which the IDE reads too — every deviation from the defaults there carries a comment explaining why, so add one if you need another. Prefer a targeted@Suppress("ktlint:compose:<rule>")with a rationale over disabling a rule for the whole repo. - Match the surrounding code's style. When porting, keep the original's comments explaining server quirks; those comments are the valuable part.
Finding API endpoints
Never guess an endpoint path or parameter name. Look it up in the vendored spec:
api-spec/jellyfin-openapi-12.0.0.json
This is the pinned OpenAPI 3.0.4 spec (x-jellyfin-version: 12.0.0, 294 paths). Sources, all
byte-identical at time of pinning:
- https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json
- https://api.jellyfin.org/openapi/jellyfin-openapi-unstable.json
jellyfin-sdk-kotlin/openapi.json— stored via Git LFS; a plainraw.githubusercontent.comfetch returns a 132-byte LFS pointer, not the spec. Use themedia.githubusercontent.com/media/…URL instead.
Browsable rendering: https://api.jellyfin.org/. A live server also serves its own spec at
/api-docs/openapi.json — useful for checking what a specific server version supports.
To inspect the vendored spec, query it rather than reading it (it's 1.9 MB):
python -c "import json;s=json.load(open('api-spec/jellyfin-openapi-12.0.0.json',encoding='utf-8'));print('\n'.join(p for p in s['paths'] if 'Resume' in p))"
Gotchas found so far. Each of these cost real debugging time — check the list before assuming an endpoint behaves like its neighbours.
Shapes and routes
- Legacy
/Users/{userId}/Items/...routes are gone. Current equivalents takeuserIdas a query parameter:/UserItems/Resume,/Items/Latest,/UserViews,/Items. /Items/Latestreturns a bare JSON array ofBaseItemDto, not aBaseItemDtoQueryResultlike almost every other list endpoint./Items/Latesttakes nostartIndex, so it cannot be paged at all. Paging "recently added" needs/ItemswithsortBy=DateCreated&sortOrder=Descendinginstead — but note that route does not group episodes under their series the way/Items/Latestdoes, so constrainincludeItemTypesor a TV row and its full list will disagree about what they contain.- People are not reachable through
/Items. They areBaseItemKind.Personbut do not live in a library folder, so a recursive query never returns them however it is filtered./Personsis the only route that does. It also does not reliably setTypeon its results — assert the kind at the call site rather than inferring it. /Items/Suggestionsnames its item-type filtertype, not theincludeItemTypesevery neighbouring route takes, and accepts nofields,enableImageTypesorimageTypeLimitat all. It is built from the user's viewing history, so an empty result is the normal answer for a fresh account rather than a fault./Itemscannot filter a search to box sets.searchTermtogether withincludeItemTypes=BoxSetreturns an empty body that is not even JSON — Ktor surfaces it asNoTransformationFoundException, not as an HTTP error — on a library where the term plainly matches a box set. It is that exact pair: the filter works without a term, the term works without the filter, and both work if a second item type rides along in the sameincludeItemTypes. Find box sets by scanning an untyped search instead. Note this makes them unpageable, sincestartIndexthen counts the unfiltered list.- Unrecognised
includeItemTypesvalues are silently ignored rather than rejected, so a typo'd item type returns everything instead of failing. Check names againstBaseItemKindin the spec. - Use
/Items/Filters, the legacy route, for a library's filter options — not its successor/Items/Filters2. Filters2 returns genres with ids and adds audio/subtitle languages but dropsOfficialRatingsandYears. The legacy route also returns genres as plain names, which is the form/Items?genres=wants them back in. - The A–Z picker is two parameters, not one: a letter is
nameStartsWith, and the#bucket isnameLessThan=a(everything sorting before "a" — digits, brackets). From jellyfin-android'sAlphaBrowser, which is the reference for this. /Movies/Recommendationsreturns a bare JSON array ofRecommendationDto, like/Items/Latest. Its rows have no heading — the client builds one fromRecommendationTypeandBaselineItemName. There is no TV equivalent; jellyfin-web substitutes Next Up./GenrestakessortBy;/Studiosdoes not. Both takeincludeItemTypes, and on both it describes the items carrying the genre or studio, not the genre or studio itself. Like/Persons, neither is reachable through/Items./Shows/Upcomingreturns a flat list in air-date order, so grouping by day is the client's job — and a day can straddle a page boundary.adjacentToincludes the item you asked about./Shows/{seriesId}/Episodes?adjacentTo={id}answers with up to three episodes — the one before, the one asked for, and the one after — and only two at either end of a series. Find the item in the list and take what is either side of it;first()andlast()offer the episode playing as its own neighbour on the first and last episodes. It is the only route that crosses a season boundary for you./Playlists/{id}/Itemstakes noadjacentToat all, and/Itemstakes one.- Box sets are not in the library they group. They live in their own
boxsetsview, so a Collections tab scoped withparentIdof the movie library returns nothing. - A playlist's order only comes from
/Playlists/{id}/Items./Items?parentId=<playlistId>returns the same entries sorted bysortBy, which defaults to name — the one thing a playlist is not. That route also carriesPlaylistItemId, which is what tells two appearances of the same item apart; without it a repeated entry can only resolve to its first appearance. - A playlist is not itself playable. It has no media source, so
PlaybackInfoon one fails. Playing a playlist means playing its first entry.
Not in the spec
- The navigation drawer's custom links come from
/web/config.json, a static file of the web client rather than an API route — jellyfin-web reads its ownmenuLinksout of it, and that is the only place an administrator can put a link to a companion service (Jellyseerr, Ombi). Nothing versions it with the API,--nowebclientomits it entirely, and a proxy can answer it with a 401 that must not be mistaken for an expired session. SeeMenuLinksRepository. - A library's tabs are a client decision. jellyfin-web keeps a table of them per collection type
in
src/apps/modern/features/libraries/constants/views/{movies,tvshows}.ts, along with hardcoded per-tab capability flags (isAlphabetPickerEnabled,isBtnFilterEnabled). Nothing in the API describes them.LibraryTabis our copy of that table. - The streaming-quality ladder is a client decision too.
PlaybackInfotakes onemaxStreamingBitrateinteger and returns no menu of choices, so there is nothing to ask the server for — jellyfin-web and jellyfin-android each hardcode the same list of bitrates and filter it by the source's own resolution.QualityOptionis our copy, ported fromplayer/qualityoptions/. "Auto" is the absence of the parameter, not a value in the list. Note the device profile carries aMaxStreamingBitrateof its own, so the request body contains that key either way. CollectionTypeis a fixed enum; libraries are not. An administrator decides how many exist, what each is called and what type it is — two movie libraries, or one named "Films", are both normal. Onlyplaylistsandboxsetsare made by the server itself. Never hardcode a library list; read/UserViews.
Counts and paging
- With
enableTotalRecordCount=false, the server fillsTotalRecordCountwith the size of the page it just returned, not zero. Trusting it on a later page silently replaces a real total. Only ask for a count on the first page, and only use it there. /Personshas noenableTotalRecordCountat all. Detect the end of the list from a short page.- To answer "is there more than N?" without making the server count every match, request
N + 1and compare sizes.enableTotalRecordCountcosts a full scan for a yes/no question.
Serialization
JsonNamingStrategyapplies to property names only, never to enum entries. Any enum whose wire form differs from its Kotlin name needs an explicit@SerialName—MediaStreamProtocolis lowercase (http,hls) and would not round-trip otherwise.- A naming strategy also rewrites names set by
@SerialName, so annotating a field to keep it camelCase does nothing underJellyfinJson— it looks like it worked and the field silently decodes to its default. Anything not in the API's PascalCase needs its ownJson;WebConfigJsonis that, andJellyfinJsonTestpins the behaviour. - Ktor 3 defaults
expectSuccesstofalse, so an error response reaches the JSON decoder and surfaces as a deserialization failure rather than an HTTP error.HttpClientFactorysets it true.
Playback
PlaybackInfomatches media source ids with the dashes stripped, and silently ignores the stream indices you send if you omit the id entirely.- Stream URLs are fetched by the playback engine, not by our
HttpClient, so they carry noAuthorizationheader by default.StreamAuthorizeradds it — and only when host and port match the signed-in server, because a media source can point at a tuner, a remote share, or a CDN.
Working with the old app
../jellyfin-android is a reference, not a dependency. When porting:
- Read the original in full before rewriting — the value is usually in edge cases and comments, not the happy path.
player/deviceprofile/is the highest-value asset in that repo. Port it faithfully; resist "cleaning it up". Its apparent weirdness is device-quirk handling.sessionbrowser/page/*is effectively the written specification for our library queries — each file encodes the correct API parameters for one browse view.- Anything in
webapp/orbridge/(exceptExternalPlayer.kt) is WebView glue. Never port it.
When unsure
The open decisions in PLAN.md §6 (iOS player engine, Chromecast, upstream relationship, minSdk) are unresolved. Don't silently pick one — surface it.
