Linthra is a self-hosted / user-owned music player. It plays music you
already have — on your device or on a server you run — and is built around one
extension point, the MusicSource (lib/core/services/music_source.dart),
so new backends slot in without touching the UI or storage layers.
Each provider declares what it can do through a small capability model
(lib/core/sources/music_provider.dart), so the UI only ever offers actions a
source actually supports:
| Capability | Meaning |
|---|---|
canStream |
Tracks play by resolving a stream URL at play time. |
canCache |
Tracks can be downloaded for offline use (token-free cache). |
canFavoriteTracks |
The heart can be toggled for this source's tracks. |
canReadFavoriteState |
The source exposes a readable liked/favourite state. |
canSyncFavorites |
Favourites mirror two-way with this source's server. |
canListPlaylists |
The source's (server) playlists can be imported and listed. |
canCreatePlaylist / canEditPlaylist / canDeletePlaylist |
Playlists of this kind can be created / edited / deleted. |
canSyncPlaylists |
Playlists mirror with this source's server. |
canLyrics |
Lyrics can be fetched for the source's tracks. |
canCast |
A track's playback URL is network-reachable, so it can cast. |
A track carries an opaque scheme: URI (subsonic:<id>, jellyfin:<id>,
plex:<ratingKey>, or a file path) and never an authenticated URL —
stream/download URLs are minted on demand at play/download time and discarded,
so no secret reaches the persisted catalog.
| Provider | sourceId | Stream | Cache | Favorites | Playlists | Lyrics | Cast |
|---|---|---|---|---|---|---|---|
| Local music | local |
✅ | — | ✅ local | ✅ local | ✅ | — |
| Jellyfin | jellyfin |
✅ | ✅ | ✅ synced | ✅ synced | ✅ | ✅ |
| Navidrome / Subsonic | subsonic |
✅ | ✅ | ✅ synced | ✅ synced | ✅ | ✅ |
| Plex | plex |
✅ | ✅ | 🔜 | 🔜 | ✅ | 🔜 |
✅ implemented · 🔜 planned follow-up · — not applicable. "local" favourites/playlists stay on-device; "synced" ones mirror with the server (server is the source of truth on refresh).
Because two servers can expose the same music, Linthra unifies the catalog: it stores every provider's copy of a song but displays one row per logical track, and plays the copy from your active/default provider (the server you most recently signed into), falling back to another source when that provider doesn't have the song. De-duplication is conservative and never deletes data. See unified-library.md for the model, the matching rules, and the "Playing from …" source indicator.
Pick a folder with the Android Storage Access Framework picker and scan it.
Tracks play from their on-device path. The scan reads each file's audio tags
(title/artist/album/track number/duration) so a local library indexes and groups
like a server source, falling back cleanly to the file name and
…/Artist/Album/Track folders when a tag is missing — see
local-music.md. The chosen folder and the scanned catalog
survive a restart. No broad storage permission is requested (tags are read
through the same folder grant). A file's embedded cover art is read during
the same scan and cached privately, so local tracks show their artwork too.
A track's lyrics are read on demand from a sidecar file sitting next to the
audio — Song.lrc (synced) or Song.txt (plain) beside Song.mp3 — located
through the same folder grant (never a raw /storage/… path); when present they
appear in the normal Lyrics panel, synced highlighting included, and a track
with none (or one that can't be read) keeps the calm "no lyrics" state. Reading
embedded lyrics tags from the audio file is a planned follow-up.
On-device files cannot be cast (a receiver can't reach a file on your phone).
Connect to your own Jellyfin server, test the connection, sign in, sync your library, and stream — including over an HTTPS/Cloudflare-proxied domain. A sync also imports your playlists and adopts your liked/favourite tracks by default, and synced lyrics work; tracks can be marked for offline use and cast to a Chromecast. The access token is stored encrypted on-device; the password is never persisted. See jellyfin-compatibility.md for connectivity, and jellyfin-sync.md for what syncs (playlists
- favourites), the documented limitations, and the token-free guarantees.
Linthra speaks the Subsonic-compatible REST API, so it works with Navidrome and other Subsonic-compatible servers.
- Configure a server (
Settings → Navidrome / Subsonic): server URL, username, password. HTTPS reverse-proxy / self-hosted domains are supported; no personal domain is hardcoded. - Test connection and sign in — both verify the credentials against the
server's
pingendpoint. A successful sign-in starts the first sync automatically (tracks, playlists, and favourites), exactly like Jellyfin — no extra "Sync" tap needed. It runs once per server/account: reconnecting the same account later doesn't trigger an unsolicited full re-sync, and if the first sync fails (server unreachable right after sign-in) the account stays connected and the sync retries on the next fresh connection — with manual sync always available. - Sync library (“Sync Navidrome library”): artists, albums, and tracks are
fetched (walking the ID3 album lists) and upserted into the local catalog
under the
subsonicsource id. The same sync also imports playlists and adopts server favourites (below), best-effort. - Favourites / hearts: the heart on a Subsonic track mirrors two-way with
the server. Hearting sends
star, un-hearting sendsunstar, and a Sync library (or app launch) readsgetStarred2to reflect stars set on another client. A failed push keeps the local heart and reconciles on the next sync; the local favourite state is never lost silently. - Playlists: your Navidrome playlists are imported and listed alongside
local ones (with a subtle “· Navidrome” source tag), preserving remote ids and
track order, and never duplicating on repeated sync. Creating a playlist for
Navidrome tracks can create it on the server; adding, removing, reordering, and
renaming a synced playlist update the server (Subsonic replaces the full
ordered song list in one idempotent
createPlaylistcall). Deleting a synced playlist removes it from the server only behind the same explicit confirmation as every delete — a local-only playlist delete never touches the server. - Stream a track: tapping an uncached Subsonic track streams it directly, resolving the URL at play time. A cached copy is preferred automatically.
- Offline cache: a Subsonic track can be downloaded for offline use (the
original file via
download.view). - Cast: a Subsonic track casts to a Chromecast as a live stream.
- Cover art: album/artist/track artwork shows across the app. The catalog
stores only a credential-free
subsonic-cover:<coverArtId>reference; the authenticatedgetCoverArtURL is resolved on demand at render time (the salt+token are woven in then, never persisted), exactly like stream URLs.- Media session (lock screen / Android Auto). The platform session loads
MediaItem.artUriin its own process — where the render-time resolver can't reach and a credentialed URL must never go — so Linthra fetches a server-downscaled cover itself, caches it to a private file keyed by a hash of the credential-free reference, and pre-warms the now-playing + next covers off the playback path. The handler then hands the session a credential-freecontent://URI (MediaArtworkFileProvider, authority…linthra.mediaartwork, serving only that hashed-filename cover cache —res/xml/media_artwork_paths.xml). The provider isexported="false"and grants the media hosts (Android Auto / SystemUI / Bluetooth) read-only access to each cover URI when Linthra opens it — so their own processes can read it — andaudio_servicealso decodes it in-process; the embedded album-art bitmap is downscaled (artDownscale*) so it survives delivery to the car. The credential is used once and never stored, logged, exposed, or put in the URI / filename. A failed/slow fetch leaves the card art-less and never blocks playback. Pending real-car confirmation; seedocs/android-auto.md. - Cast still omits Subsonic artwork (a credentialed URL must not reach the receiver).
- Media session (lock screen / Android Auto). The platform session loads
- Lyrics: synced or plain lyrics are fetched on demand via the OpenSubsonic
getLyricsBySongIdextension (how Navidrome exposes embedded/sidecar lyrics), with a fallback to the legacygetLyrics(plain text, matched by artist + title) for servers without the extension. When a server has none, the lyrics panel keeps its calm "no lyrics" state; the credential is never logged or put in an error.
Enter the server root (e.g. http://192.168.1.50:4533) — Linthra appends
the /rest/<method>.view API paths itself, so you never type /rest (and a
trailing /rest you paste is stripped). A bare host defaults to HTTPS; an
explicit http:// is kept for a LAN server, and reverse-proxy subpaths are
preserved. A shipped Android network-security config permits cleartext so a LAN
http:// server is reachable.
No production server? A one-command local Navidrome (Docker Compose) and a manual
test checklist live in
navidrome-dev-setup.md /
tools/dev/navidrome/.
Subsonic's modern auth sends, on every request,
u=<user>&t=<token>&s=<salt> where token = md5(password + salt). Linthra
computes one random salt and its token at sign-in and stores only those
(encrypted on-device, via flutter_secure_storage). The password is used to
derive the token and then discarded — never persisted, never logged. This
mirrors how Jellyfin stores a derived access token rather than the password.
Concretely, Linthra:
- never stores the plaintext password;
- never logs the password, salt, or token;
- never stores an authenticated stream/download URL in
Track.urior the database (the URI is the opaquesubsonic:<id>); - never stores an authenticated cover-art URL in
Track.artworkUrior the database (it stores the credential-freesubsonic-cover:<id>reference, and weaves the salt+token in only when an image is actually rendered); - never puts a credential in a cache filename or cache metadata (the offline
audio cache file extension comes from the response content type, not the URL;
the media-session artwork cache filename is a hash of the credential-free
subsonic-cover:reference — never the server URL or auth query); - never surfaces a credential or credentialed URL in a UI error message;
- resolves stream/download URLs only at play/download time, and cover-art URLs only at render time.
These are declared unsupported in the capability model today, so their actions stay hidden/disabled rather than failing:
- Synced lyrics from the legacy endpoint — the OpenSubsonic
getLyricsBySongIdpath returns synced lyrics today; the legacygetLyricsfallback is treated as plain text, so any LRC-style timestamps embedded in its value aren't time-synced yet. That refinement is a follow-up. - Per-track cast content type — the cast receiver is sent a generic
audio/mpeghint; an exact per-track type / transcode profile is a follow-up. - In-app browse/search by artist/album — the synced catalog lists tracks; richer browsing is shared work across all providers.
A read-only Plex Media Server provider built behind
the same MusicSource seam — see plex.md for the full design and
issue #178 for the history. Connect from Settings with your Plex account
(the in-app Connect with Plex sign-in) or, under Manual setup (advanced), a
server URL + Plex token. Linthra verifies the server, persists the session
encrypted at rest, and — unlike Jellyfin/Subsonic, which sync the whole
server — asks the user to pick which music libraries to include (the
selection is saved with the session and scopes every fetch; connected with
nothing selected simply means an empty library).
Syncing follows the selection. Choosing a library kicks a background catalog sync automatically (rapid checkbox changes coalesce into one re-run), and a Sync Plex library button reruns it on demand; the synced tracks then appear in the Library like any other source's. Because the selection scopes the Plex library, a sync replaces the catalog's Plex slice even when the result is empty — deselecting a library really removes its tracks. A library deleted on the server is pruned from the selection on the next refresh. Disconnecting removes the Plex session and the synced Plex rows (without a session, streaming can't resolve a play URL again); reconnecting to the same server keeps the library selection, while a different server starts clean.
Underneath, the full plumbing is wired: recognition of plex:<ratingKey> track
URIs in the playback router (two-step play resolution, minting the tokenized
stream URL only at play time), and render-time resolution of credential-free
plex-thumb: cover references. Streaming, lyrics, and offline caching are
supported, alongside the plex.tv Connect with Plex sign-in; favorites,
playlists, and cast stay unsupported — declared off so their actions stay
hidden rather than offered and failing. Plex follows the same token-safety
rules as every other provider (token encrypted at rest, never logged, never
shown again after saving, never woven into a persisted URI or cache filename).
Linthra also reports a playback timeline, so it appears as an active player in the server's Now Playing dashboard. That report is one-way, though, so the player does not yet react to remote play/pause/skip commands from other Plex apps — receiving those is the separate Plex Companion protocol, designed in plex-remote-control.md.
The MusicSource seam is designed so more self-hosted / user-owned backends
can be added the same way Jellyfin and Subsonic were:
- WebDAV — play from a WebDAV share (Nextcloud, etc.).
- SMB / NAS — browse and stream from a network file share.
- DLNA / UPnP — discover and play from a media server on the LAN.
Each would implement MusicSource (and the narrow stream/download seams),
declare its capabilities, and add a settings section — with the same rule that
credentials are stored securely and never woven into a persisted URI.
Linthra stays focused on music you own or host yourself. It does not, and will not:
- play from Spotify or other closed streaming services;
- bypass DRM of any kind;
- perform unauthorized downloads;
- rip or extract content from closed streaming services.