Skip to content

Add an OpenSubsonic-compatible API - #220

Merged
einsteinx2 merged 2 commits into
masterfrom
subsonic-api
Jul 26, 2026
Merged

Add an OpenSubsonic-compatible API#220
einsteinx2 merged 2 commits into
masterfrom
subsonic-api

Conversation

@einsteinx2

@einsteinx2 einsteinx2 commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Summary

Adds an OpenSubsonic-compatible API at /rest, so any Subsonic client (Symfonium, DSub, play:Sub, substreamer, …) can browse, search, and stream from WaveBox. The Subsonic layer is a thin translation over the existing repositories — no new features, just the wire format for what WaveBox already does.

Architecture

  • Routing: /rest is its own Kestrel branch (app.Map before the legacy terminal handler). It deliberately bypasses UriWrapper, which throws on the duplicate query keys Subsonic clients send (songId=1&songId=2) and never URL-decodes; the Subsonic dispatcher uses Request.Query/ReadFormAsync instead. The legacy /api pipeline is untouched.
  • XML + JSON from one DTO set: XML (the Subsonic default) is rendered by a ~150-line reflection walker over the same [JsonPropertyName] metadata the JSON source-gen context uses — scalars become attributes, lists become repeated elements — so the two formats cannot drift. JSONP is supported too.
  • NativeAOT-safe: Subsonic DTOs are rooted in SubsonicDtoRegistry (same pattern as ModelTypeRegistry), JSON goes through a new SubsonicJsonContext rooted at the single envelope type. dotnet publish produces zero trim/AOT warnings, and the full smoke test passes against the published native binary.
  • IDs and browse modes: WaveBox's global item-id space means bare ids work everywhere (serialized as strings per spec). The two Subsonic browse modes stay separate, as clients like iSub expect: folder endpoints (getIndexes, getMusicDirectory, getAlbumList, search2, getStarred) operate on the real directory tree — getAlbumList hands out the folder holding each album's songs, search2 matches folder names — while ID3 endpoints (getArtists/getArtist/getAlbum, getAlbumList2, search3, getStarred2) use tag organization with AlbumArtist as the ID3 artist. getMusicDirectory resolves album/artist ids only as a fallback for clients that mix modes.

Authentication

Passwords are stored PBKDF2-hashed, so classic Subsonic token auth (t=md5(password+salt)) is impossible and returns error 42. Supported instead:

  • API keys (OpenSubsonic apiKeyAuthentication extension): new User.ApiKey column, generated/revoked via the custom API (/api/users/{id}?action=generateApiKey), tokenInfo endpoint, correct 43/44 error codes.
  • Password auth (p= / p=enc:HEX): verified against the PBKDF2 hash, with a 10-minute in-memory verified-credential cache so stream-heavy clients don't pay 2500 PBKDF2 iterations per request. Cache is evicted on any password change (both APIs).

First schema migration

Database.UpgradeSchema() adds User.ApiKey using a column-existence check (the bundled template's Version table is empty, so version numbers were unusable). The template wavebox.db and wavebox.sql are updated in lockstep; the upgrade path was verified against a pre-migration database.

Endpoints (~45)

ping, getLicense, getOpenSubsonicExtensions, tokenInfo, getScanStatus · getMusicFolders, getIndexes, getMusicDirectory, getArtists, getArtist, getAlbum, getSong, getGenres, getVideos, getArtistInfo(2), getLyrics · getCoverArt (with resize), stream (with maxBitRate/format ffmpeg transcode negotiation), download · getAlbumList(2) — random, newest, recent/frequent (new read-only aggregates over the existing Stat table), alphabetical, byYear, byGenre, starred · getRandomSongs, getSongsByGenre, getNowPlaying, getStarred(2) · search2/search3 (incl. the empty-query offline-sync convention) · playlist CRUD · star/unstar/scrobble (with Last.fm passthrough) · getUser(s), changePassword, create/update/deleteUser. Everything WaveBox doesn't have (podcasts, ratings, jukebox, shares, radio, bookmarks, play queue) returns a proper Subsonic error envelope.

Refactors & fixes

  • Extracted ArtStream (from ArtApiHandler) and TranscodeStreamer (from TranscodeApiHandler) so both APIs share art resolution and transcode streaming; the legacy handlers now delegate to them verbatim.
  • Fixed a latent bug in Playlist.RemoveMediaItemAtIndexes: it never refreshed the cached PlaylistCount/PlaylistDuration, corrupting later insert positions (also affected the legacy /api/playlists remove action).

Test plan

  • Scripts/smoke-test.sh extended with ~20 Subsonic checks (XML/JSON envelopes, auth error codes 40/42/43/44, ID3 browse chain, search3, album lists, 206 range requests, duplicate-key createPlaylist, star/scrobble round-trips, full apiKey lifecycle) — all pass against the published NativeAOT binary
  • Legacy /api smoke checks still pass (login, scan, tag metadata, range request)
  • Schema upgrade verified on a database created before the ApiKey column existed
  • Transcode check guards on ffmpeg presence (not installed on the dev machine — decision logic mirrors the legacy transcode handler; worth exercising in CI/with a real client)
  • Real-client sweep (Symfonium recommended as the strictest OpenSubsonic conformance oracle)

🤖 Generated with Claude Code

https://claude.ai/code/session_01JiffacwfmGB5gxsgjeZRyN

einsteinx2 and others added 2 commits July 26, 2026 11:33
Expose an OpenSubsonic API at /rest so Subsonic clients (Symfonium,
DSub, play:Sub, ...) can browse, search, and stream from WaveBox,
reusing the existing repositories and business logic.

- Serve XML (Subsonic default), JSON (f=json), and JSONP from one DTO
  set: SubsonicXmlSerializer walks the same [JsonPropertyName] metadata
  the SubsonicJsonContext source-gen uses, with DTOs rooted in
  SubsonicDtoRegistry for NativeAOT (zero new trim warnings)
- Route /rest as its own Kestrel branch with proper query/form parsing;
  the legacy UriWrapper throws on the duplicate keys Subsonic clients
  send and is bypassed entirely
- Auth: OpenSubsonic apiKey extension (new User.ApiKey column,
  generate/revoke via /api/users, tokenInfo, errors 42/43/44) plus
  plaintext/enc: password fallback with a 10-minute verified-credential
  cache to keep PBKDF2 off the per-request hot path; token auth is
  impossible with hashed-only storage and returns error 42
- First schema migration: Database.UpgradeSchema() adds User.ApiKey by
  column-existence check (the template Version table is empty); the
  bundled template db and wavebox.sql are updated in lockstep
- ~45 endpoints: ID3 + folder browsing, search2/3, getAlbumList(2)
  incl. newest/recent/frequent via new Item/Stat queries, streaming
  with maxBitRate/format transcode negotiation, cover art with resize,
  playlists, star/unstar/scrobble with Last.fm passthrough, now
  playing, lyrics, user management; unsupported features return proper
  Subsonic errors
- Extract ArtStream and TranscodeStreamer from the legacy art/transcode
  handlers so both APIs share the media plumbing
- Fix Playlist.RemoveMediaItemAtIndexes leaving stale count/duration,
  which corrupted later insert positions (also affected /api/playlists)
- Extend smoke-test.sh with Subsonic coverage (envelope formats, auth
  errors, browsing, search, range requests, duplicate-key playlists,
  star/scrobble round-trips, apiKey lifecycle)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JiffacwfmGB5gxsgjeZRyN
Clients like iSub offer both browse modes explicitly and expect the
folder endpoints to reflect the real directory tree, not tag
organization.

- getAlbumList now hands out the folder holding each album's songs
  (new AlbumRepository.FoldersByAlbum mapping) instead of tag album ids
- search2 searches folder names (new FolderRepository.SearchFolders):
  children of media roots are "artist" hits, deeper folders "album"
  hits; search3 remains tag-based
- getStarred surfaces starred folders and maps tag-starred albums to
  their folders; getStarred2 stays purely tag-based and ignores
  folder favorites
- getMusicDirectory keeps album/artist id resolution only as a
  fallback for clients that mix modes
- smoke test asserts getAlbumList entries traverse via
  getMusicDirectory in the folder tree

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JiffacwfmGB5gxsgjeZRyN
@einsteinx2
einsteinx2 merged commit d21c8d5 into master Jul 26, 2026
6 checks passed
@einsteinx2
einsteinx2 deleted the subsonic-api branch July 26, 2026 19:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant