Add an OpenSubsonic-compatible API - #220
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
/restis its own Kestrel branch (app.Mapbefore the legacy terminal handler). It deliberately bypassesUriWrapper, which throws on the duplicate query keys Subsonic clients send (songId=1&songId=2) and never URL-decodes; the Subsonic dispatcher usesRequest.Query/ReadFormAsyncinstead. The legacy/apipipeline is untouched.[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.SubsonicDtoRegistry(same pattern asModelTypeRegistry), JSON goes through a newSubsonicJsonContextrooted at the single envelope type.dotnet publishproduces zero trim/AOT warnings, and the full smoke test passes against the published native binary.AlbumArtistas the ID3 artist.getMusicDirectoryresolves 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:apiKeyAuthenticationextension): newUser.ApiKeycolumn, generated/revoked via the custom API (/api/users/{id}?action=generateApiKey),tokenInfoendpoint, correct 43/44 error codes.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()addsUser.ApiKeyusing a column-existence check (the bundled template'sVersiontable is empty, so version numbers were unusable). The templatewavebox.dbandwavebox.sqlare 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/formatffmpeg transcode negotiation), download · getAlbumList(2) — random, newest, recent/frequent (new read-only aggregates over the existingStattable), 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
ArtStream(fromArtApiHandler) andTranscodeStreamer(fromTranscodeApiHandler) so both APIs share art resolution and transcode streaming; the legacy handlers now delegate to them verbatim.Playlist.RemoveMediaItemAtIndexes: it never refreshed the cachedPlaylistCount/PlaylistDuration, corrupting later insert positions (also affected the legacy/api/playlistsremove action).Test plan
Scripts/smoke-test.shextended 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/apismoke checks still pass (login, scan, tag metadata, range request)ApiKeycolumn existed🤖 Generated with Claude Code
https://claude.ai/code/session_01JiffacwfmGB5gxsgjeZRyN