From db43eb1e4ac4dc7932cd5aeae6f80b992895037b Mon Sep 17 00:00:00 2001 From: Ben Baron Date: Sun, 26 Jul 2026 11:33:26 -0500 Subject: [PATCH 1/2] Add an OpenSubsonic-compatible API 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 Claude-Session: https://claude.ai/code/session_01JiffacwfmGB5gxsgjeZRyN --- API_DOCS.md | 32 ++ Scripts/smoke-test.sh | 83 +++++ .../ApiResponse/Subsonic/SubsonicBrowsing.cs | 175 +++++++++ .../Subsonic/SubsonicDtoRegistry.cs | 55 +++ .../src/ApiResponse/Subsonic/SubsonicID3.cs | 86 +++++ .../src/ApiResponse/Subsonic/SubsonicLists.cs | 62 ++++ .../ApiResponse/Subsonic/SubsonicPlaylists.cs | 43 +++ .../ApiResponse/Subsonic/SubsonicResponse.cs | 160 ++++++++ .../ApiResponse/Subsonic/SubsonicSearch.cs | 27 ++ .../ApiResponse/Subsonic/SubsonicSystem.cs | 43 +++ .../src/ApiResponse/Subsonic/SubsonicUsers.cs | 63 ++++ WaveBox.Core/src/Model/GroupCount.cs | 13 + WaveBox.Core/src/Model/Playlist.cs | 11 + WaveBox.Core/src/Model/User.cs | 27 ++ WaveBox.Core/src/ModelTypeRegistry.cs | 1 + .../src/Repository/AlbumRepository.cs | 49 +++ .../src/Repository/GenreRepository.cs | 14 + .../Repository/Interfaces/IAlbumRepository.cs | 5 + .../Repository/Interfaces/IGenreRepository.cs | 2 + WaveBox.Core/src/SubsonicJsonContext.cs | 13 + WaveBox.Server/res/wavebox.db | Bin 75776 -> 76800 bytes WaveBox.Server/res/wavebox.sql | 4 +- WaveBox.Server/src/ApiHandler/ArtStream.cs | 137 +++++++ .../src/ApiHandler/Handlers/ArtApiHandler.cs | 122 +------ .../Handlers/TranscodeApiHandler.cs | 57 +-- .../ApiHandler/Handlers/UsersApiHandler.cs | 39 ++ WaveBox.Server/src/Injection/ServerModule.cs | 3 + WaveBox.Server/src/Program.cs | 8 + WaveBox.Server/src/Static/Database.cs | 23 ++ .../Handlers/SubsonicAnnotationHandlers.cs | 143 ++++++++ .../Handlers/SubsonicBrowsingHandlers.cs | 275 ++++++++++++++ .../Subsonic/Handlers/SubsonicListHandlers.cs | 341 ++++++++++++++++++ .../Handlers/SubsonicMediaHandlers.cs | 163 +++++++++ .../Handlers/SubsonicPlaylistHandlers.cs | 141 ++++++++ .../Handlers/SubsonicSearchHandlers.cs | 95 +++++ .../Handlers/SubsonicSystemHandlers.cs | 69 ++++ .../Subsonic/Handlers/SubsonicUserHandlers.cs | 165 +++++++++ WaveBox.Server/src/Subsonic/SubsonicAuth.cs | 147 ++++++++ .../src/Subsonic/SubsonicDispatcher.cs | 179 +++++++++ WaveBox.Server/src/Subsonic/SubsonicMapper.cs | 253 +++++++++++++ .../src/Subsonic/SubsonicRequest.cs | 81 +++++ WaveBox.Server/src/Subsonic/SubsonicWriter.cs | 64 ++++ .../src/Subsonic/SubsonicXmlSerializer.cs | 174 +++++++++ .../src/Transcoding/TranscodeStreamer.cs | 69 ++++ 44 files changed, 3540 insertions(+), 176 deletions(-) create mode 100644 WaveBox.Core/src/ApiResponse/Subsonic/SubsonicBrowsing.cs create mode 100644 WaveBox.Core/src/ApiResponse/Subsonic/SubsonicDtoRegistry.cs create mode 100644 WaveBox.Core/src/ApiResponse/Subsonic/SubsonicID3.cs create mode 100644 WaveBox.Core/src/ApiResponse/Subsonic/SubsonicLists.cs create mode 100644 WaveBox.Core/src/ApiResponse/Subsonic/SubsonicPlaylists.cs create mode 100644 WaveBox.Core/src/ApiResponse/Subsonic/SubsonicResponse.cs create mode 100644 WaveBox.Core/src/ApiResponse/Subsonic/SubsonicSearch.cs create mode 100644 WaveBox.Core/src/ApiResponse/Subsonic/SubsonicSystem.cs create mode 100644 WaveBox.Core/src/ApiResponse/Subsonic/SubsonicUsers.cs create mode 100644 WaveBox.Core/src/Model/GroupCount.cs create mode 100644 WaveBox.Core/src/SubsonicJsonContext.cs create mode 100644 WaveBox.Server/src/ApiHandler/ArtStream.cs create mode 100644 WaveBox.Server/src/Subsonic/Handlers/SubsonicAnnotationHandlers.cs create mode 100644 WaveBox.Server/src/Subsonic/Handlers/SubsonicBrowsingHandlers.cs create mode 100644 WaveBox.Server/src/Subsonic/Handlers/SubsonicListHandlers.cs create mode 100644 WaveBox.Server/src/Subsonic/Handlers/SubsonicMediaHandlers.cs create mode 100644 WaveBox.Server/src/Subsonic/Handlers/SubsonicPlaylistHandlers.cs create mode 100644 WaveBox.Server/src/Subsonic/Handlers/SubsonicSearchHandlers.cs create mode 100644 WaveBox.Server/src/Subsonic/Handlers/SubsonicSystemHandlers.cs create mode 100644 WaveBox.Server/src/Subsonic/Handlers/SubsonicUserHandlers.cs create mode 100644 WaveBox.Server/src/Subsonic/SubsonicAuth.cs create mode 100644 WaveBox.Server/src/Subsonic/SubsonicDispatcher.cs create mode 100644 WaveBox.Server/src/Subsonic/SubsonicMapper.cs create mode 100644 WaveBox.Server/src/Subsonic/SubsonicRequest.cs create mode 100644 WaveBox.Server/src/Subsonic/SubsonicWriter.cs create mode 100644 WaveBox.Server/src/Subsonic/SubsonicXmlSerializer.cs create mode 100644 WaveBox.Server/src/Transcoding/TranscodeStreamer.cs diff --git a/API_DOCS.md b/API_DOCS.md index a7969b9..abc059b 100644 --- a/API_DOCS.md +++ b/API_DOCS.md @@ -1,5 +1,37 @@ # WaveBox API +## OpenSubsonic API + +In addition to the custom API documented below, WaveBox exposes an +[OpenSubsonic](https://opensubsonic.netlify.app/)-compatible API under `/rest/`, so any +Subsonic client (Symfonium, DSub, play:Sub, substreamer, …) can browse, search, and stream +from a WaveBox server. Both response formats are supported: XML (the Subsonic default) and +JSON (`f=json`), plus JSONP (`f=jsonp&callback=...`). + +**Authentication** — passwords are stored PBKDF2-hashed, so classic Subsonic *token* auth +(`t`/`s`, an MD5 over the plaintext password) cannot be supported and returns error code 42. +Clients must use one of: + +* **API key** (recommended, OpenSubsonic `apiKeyAuthentication` extension): `?apiKey=...`. + Generate a key with the custom API: `/api/users/{userId}?action=generateApiKey` + (self-service, or any user when admin); revoke with `action=revokeApiKey`. +* **Password auth**: `?u=user&p=password` or `?u=user&p=enc:HEX` — enable + "legacy/plaintext authentication" in clients that default to token auth. Successful + verifications are cached in memory for ten minutes so PBKDF2 doesn't run per request. + +**Supported endpoints** — ping, getLicense, getOpenSubsonicExtensions, tokenInfo, +getScanStatus, getMusicFolders, getIndexes, getMusicDirectory, getArtists, getArtist, +getAlbum, getSong, getGenres, getVideos, getArtistInfo(2) (empty), getLyrics, getCoverArt, +stream (with `maxBitRate`/`format` transcoding via ffmpeg), download, getAlbumList(2) +(random, newest, recent, frequent, alphabetical*, byYear, byGenre, starred), getRandomSongs, +getSongsByGenre, getNowPlaying, getStarred(2), search2, search3, getPlaylists, getPlaylist, +createPlaylist, updatePlaylist, deletePlaylist, star, unstar, scrobble (also forwards to +Last.fm when linked), getUser, getUsers, changePassword, createUser, updateUser, deleteUser. + +Everything else (podcasts, ratings, jukebox, shares, internet radio, bookmarks, play queue, +chat) returns Subsonic error code 0 with a "not supported" message. All ids are WaveBox's +global item ids; `getMusicDirectory` accepts folder, album, and artist ids interchangeably. + ## The basics: Every call to the WaveBox API requires authentication data, whether it is the "s" session key parameter sent in GET or POST parameters, or the wavebox_session cookie diff --git a/Scripts/smoke-test.sh b/Scripts/smoke-test.sh index 60d15e1..85dadfd 100755 --- a/Scripts/smoke-test.sh +++ b/Scripts/smoke-test.sh @@ -121,6 +121,89 @@ else check "range request returns 206" 1 "(no song id)" fi +# --- OpenSubsonic API (/rest) --- +REST="http://localhost:$PORT/rest" +SUB="u=test&p=test&f=json" + +# 7. ping: XML is the default response format +curl -s "$REST/ping.view?u=test&p=test" | grep -q 'xmlns="http://subsonic.org/restapi"' +check "subsonic ping returns XML envelope" $? +curl -s "$REST/ping.view?u=test&p=test" | grep -q 'status="ok"' +check "subsonic ping XML status ok" $? + +# 8. ping: JSON envelope on f=json +curl -s "$REST/ping?$SUB" | python3 -c "import json,sys; d=json.load(sys.stdin)['subsonic-response']; assert d['status']=='ok' and d['openSubsonic'] is True, d" +check "subsonic ping JSON envelope" $? + +# 9. Auth errors: wrong password -> 40, token auth -> 42 +curl -s "$REST/ping?u=test&p=wrong&f=json" | python3 -c "import json,sys; assert json.load(sys.stdin)['subsonic-response']['error']['code']==40" +check "subsonic wrong password returns error 40" $? +curl -s "$REST/ping?u=test&t=deadbeef&s=salt&f=json" | python3 -c "import json,sys; assert json.load(sys.stdin)['subsonic-response']['error']['code']==42" +check "subsonic token auth returns error 42" $? + +# 10. ID3 browsing: getArtists -> getArtist -> getAlbum with the fixture song +SUB_ARTIST_ID=$(curl -s "$REST/getArtists?$SUB" | python3 -c "import json,sys; d=json.load(sys.stdin)['subsonic-response']['artists']; print(d['index'][0]['artist'][0]['id'] if d.get('index') else '')") +[ -n "$SUB_ARTIST_ID" ]; check "subsonic getArtists finds fixture artist" $? +SUB_ALBUM_ID=$(curl -s "$REST/getArtist?$SUB&id=$SUB_ARTIST_ID" | python3 -c "import json,sys; d=json.load(sys.stdin)['subsonic-response']['artist']; print(d['album'][0]['id'] if d.get('album') else '')") +[ -n "$SUB_ALBUM_ID" ]; check "subsonic getArtist lists fixture album" $? +curl -s "$REST/getAlbum?$SUB&id=$SUB_ALBUM_ID" | python3 -c "import json,sys; d=json.load(sys.stdin)['subsonic-response']['album']; s=d['song'][0]; assert s['title']=='Test Song' and s['duration']>0 and isinstance(s['id'],str), s" +check "subsonic getAlbum returns fixture song" $? + +# 11. search3 finds the fixture +curl -s "$REST/search3?$SUB&query=Test" | python3 -c "import json,sys; d=json.load(sys.stdin)['subsonic-response']['searchResult3']; assert d.get('song') and d.get('album') and d.get('artist'), d" +check "subsonic search3 finds fixture" $? + +# 12. getAlbumList2 newest +curl -s "$REST/getAlbumList2?$SUB&type=newest" | python3 -c "import json,sys; d=json.load(sys.stdin)['subsonic-response']['albumList2']; assert len(d['album'])>0, d" +check "subsonic getAlbumList2 newest" $? + +# 13. Raw stream with Range -> 206 +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Range: bytes=100-199" "$REST/stream?u=test&p=test&id=$SONG_ID&format=raw") +[ "$STATUS" = "206" ]; check "subsonic stream range returns 206" $? "(got $STATUS)" + +# 14. Transcoded stream (only when ffmpeg is available) +if command -v ffmpeg >/dev/null 2>&1; then + SIZE=$(curl -s -o /dev/null -w "%{size_download}" "$REST/stream?u=test&p=test&id=$SONG_ID&maxBitRate=32&format=mp3") + [ "$SIZE" -gt 0 ]; check "subsonic transcoded stream returns audio" $? "(got $SIZE bytes)" +else + echo "SKIP: subsonic transcoded stream (no ffmpeg)" +fi + +# 15. Playlists: duplicate songId keys must both apply; update remove/add keeps counts right +curl -s "$REST/createPlaylist?$SUB&name=SmokeList&songId=$SONG_ID&songId=$SONG_ID" | python3 -c "import json,sys; d=json.load(sys.stdin)['subsonic-response']['playlist']; assert d['songCount']==2 and len(d['entry'])==2, d" +check "subsonic createPlaylist with duplicate ids" $? +SUB_PL_ID=$(curl -s "$REST/getPlaylists?$SUB" | python3 -c "import json,sys; pl=[p for p in json.load(sys.stdin)['subsonic-response']['playlists']['playlist'] if p['name']=='SmokeList']; print(pl[0]['id'] if pl else '')") +curl -s "$REST/updatePlaylist?$SUB&playlistId=$SUB_PL_ID&songIndexToRemove=0&songIdToAdd=$SONG_ID" > /dev/null +curl -s "$REST/getPlaylist?$SUB&id=$SUB_PL_ID" | python3 -c "import json,sys; d=json.load(sys.stdin)['subsonic-response']['playlist']; assert d['songCount']==2 and len(d['entry'])==2, d" +check "subsonic updatePlaylist remove+add" $? +curl -s "$REST/deletePlaylist?$SUB&id=$SUB_PL_ID" | python3 -c "import json,sys; assert json.load(sys.stdin)['subsonic-response']['status']=='ok'" +check "subsonic deletePlaylist" $? + +# 16. Star / getStarred2 round-trip +curl -s "$REST/star?$SUB&id=$SONG_ID" > /dev/null +curl -s "$REST/getStarred2?$SUB" | python3 -c "import json,sys; d=json.load(sys.stdin)['subsonic-response']['starred2']; assert len(d.get('song',[]))==1 and d['song'][0]['starred'], d" +check "subsonic star/getStarred2 round-trip" $? +curl -s "$REST/unstar?$SUB&id=$SONG_ID" > /dev/null + +# 17. Scrobble -> getNowPlaying + recent album list +curl -s "$REST/scrobble?$SUB&id=$SONG_ID&time=$(($(date +%s) * 1000))" > /dev/null +curl -s "$REST/getNowPlaying?$SUB" | python3 -c "import json,sys; d=json.load(sys.stdin)['subsonic-response']['nowPlaying']; assert d['entry'][0]['username']=='test', d" +check "subsonic scrobble registers now playing" $? +curl -s "$REST/getAlbumList2?$SUB&type=recent" | python3 -c "import json,sys; d=json.load(sys.stdin)['subsonic-response']['albumList2']; assert len(d['album'])>0, d" +check "subsonic getAlbumList2 recent after scrobble" $? + +# 18. API key lifecycle: generate via legacy admin API, then apiKey auth + conflict detection +ADMIN_SESSION=$(curl -s "http://localhost:$PORT/api/login?u=admin&p=admin" | python3 -c "import json,sys; print(json.load(sys.stdin).get('sessionId') or '')") +ADMIN_ID=$(curl -s "http://localhost:$PORT/api/users?s=$ADMIN_SESSION" | python3 -c "import json,sys; us=json.load(sys.stdin)['users']; print([u for u in us if u['userName']=='admin'][0]['userId'])") +APIKEY=$(curl -s "http://localhost:$PORT/api/users/$ADMIN_ID?s=$ADMIN_SESSION&action=generateApiKey" | python3 -c "import json,sys; print(json.load(sys.stdin)['users'][0].get('apiKey') or '')") +[ -n "$APIKEY" ]; check "generateApiKey via legacy API" $? +curl -s "$REST/ping?apiKey=$APIKEY&f=json" | python3 -c "import json,sys; assert json.load(sys.stdin)['subsonic-response']['status']=='ok'" +check "subsonic apiKey auth" $? +curl -s "$REST/tokenInfo?apiKey=$APIKEY&f=json" | python3 -c "import json,sys; assert json.load(sys.stdin)['subsonic-response']['tokenInfo']['username']=='admin'" +check "subsonic tokenInfo" $? +curl -s "$REST/ping?apiKey=$APIKEY&u=test&f=json" | python3 -c "import json,sys; assert json.load(sys.stdin)['subsonic-response']['error']['code']==43" +check "subsonic conflicting auth returns error 43" $? + echo "" if [ "$FAILURES" -gt 0 ]; then echo "$FAILURES smoke test(s) FAILED" diff --git a/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicBrowsing.cs b/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicBrowsing.cs new file mode 100644 index 0000000..59e4cf3 --- /dev/null +++ b/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicBrowsing.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace WaveBox.Core.ApiResponse.Subsonic { + public class SubsonicMusicFolders { + [JsonPropertyName("musicFolder"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList MusicFolder { get; set; } + } + + public class SubsonicMusicFolder { + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("name"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Name { get; set; } + } + + public class SubsonicIndexes { + // Milliseconds since epoch (Subsonic quirk: this one field is ms, durations are seconds) + [JsonPropertyName("lastModified")] + public long LastModified { get; set; } + + [JsonPropertyName("ignoredArticles")] + public string IgnoredArticles { get; set; } = ""; + + [JsonPropertyName("index"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Index { get; set; } + + // Loose media files directly inside a music folder root + [JsonPropertyName("child"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Child { get; set; } + } + + public class SubsonicIndex { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("artist"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Artist { get; set; } + } + + // Folder-style "artist" entry used by getIndexes, search2, and getStarred: just an id + // (a folder id in getIndexes) and a display name. + public class SubsonicIndexArtist { + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("starred"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Starred { get; set; } + } + + public class SubsonicDirectory { + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("parent"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Parent { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("child"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Child { get; set; } + } + + // The shared Subsonic media/directory-entry DTO ("Child" in the Subsonic schema), used by + // getMusicDirectory, getSong, album/song lists, search results, playlists, and now playing. + public class SubsonicChild { + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("parent"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Parent { get; set; } + + [JsonPropertyName("isDir")] + public bool IsDir { get; set; } + + [JsonPropertyName("title")] + public string Title { get; set; } + + [JsonPropertyName("album"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Album { get; set; } + + [JsonPropertyName("artist"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Artist { get; set; } + + [JsonPropertyName("track"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Track { get; set; } + + [JsonPropertyName("year"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Year { get; set; } + + [JsonPropertyName("genre"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Genre { get; set; } + + [JsonPropertyName("coverArt"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string CoverArt { get; set; } + + [JsonPropertyName("size"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Size { get; set; } + + [JsonPropertyName("contentType"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string ContentType { get; set; } + + [JsonPropertyName("suffix"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Suffix { get; set; } + + [JsonPropertyName("duration"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Duration { get; set; } + + [JsonPropertyName("bitRate"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? BitRate { get; set; } + + [JsonPropertyName("path"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Path { get; set; } + + [JsonPropertyName("isVideo"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? IsVideo { get; set; } + + [JsonPropertyName("discNumber"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? DiscNumber { get; set; } + + [JsonPropertyName("created"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Created { get; set; } + + [JsonPropertyName("starred"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Starred { get; set; } + + [JsonPropertyName("albumId"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string AlbumId { get; set; } + + [JsonPropertyName("artistId"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string ArtistId { get; set; } + + [JsonPropertyName("type"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Type { get; set; } + } + + public class SubsonicGenres { + [JsonPropertyName("genre"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Genre { get; set; } + } + + public class SubsonicGenre { + [JsonPropertyName("songCount")] + public int SongCount { get; set; } + + [JsonPropertyName("albumCount")] + public int AlbumCount { get; set; } + + // The genre name: XML text content, JSON "value" property (Subsonic convention) + [JsonPropertyName("value"), SubsonicXmlText] + public string Value { get; set; } + } + + public class SubsonicVideos { + [JsonPropertyName("video"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Video { get; set; } + } + + public class SubsonicLyrics { + [JsonPropertyName("artist"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Artist { get; set; } + + [JsonPropertyName("title"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Title { get; set; } + + [JsonPropertyName("value"), SubsonicXmlText, JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Value { get; set; } + } +} diff --git a/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicDtoRegistry.cs b/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicDtoRegistry.cs new file mode 100644 index 0000000..c063d3a --- /dev/null +++ b/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicDtoRegistry.cs @@ -0,0 +1,55 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace WaveBox.Core.ApiResponse.Subsonic { + // The Subsonic XML serializer walks DTO properties via reflection, which the trimmer/NativeAOT + // can't see (same situation as the sqlite-net ORM and ModelTypeRegistry). Rooting every + // Subsonic DTO type here preserves full member metadata. Add any new Subsonic DTO to + // EnsurePreserved or its XML rendering will silently lose properties under NativeAOT. + public static class SubsonicDtoRegistry { + private static void Root<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>() { + } + + public static void EnsurePreserved() { + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + Root(); + } + } +} diff --git a/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicID3.cs b/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicID3.cs new file mode 100644 index 0000000..b511478 --- /dev/null +++ b/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicID3.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace WaveBox.Core.ApiResponse.Subsonic { + // ID3-flavored browsing DTOs (getArtists/getArtist/getAlbum). WaveBox maps the Subsonic + // "ID3 artist" concept onto AlbumArtist, which matches how tag-based clients expect + // compilations to group. + public class SubsonicArtistsID3 { + [JsonPropertyName("ignoredArticles")] + public string IgnoredArticles { get; set; } = ""; + + [JsonPropertyName("index"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Index { get; set; } + } + + public class SubsonicIndexID3 { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("artist"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Artist { get; set; } + } + + public class SubsonicArtistID3 { + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("coverArt"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string CoverArt { get; set; } + + [JsonPropertyName("albumCount"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? AlbumCount { get; set; } + + [JsonPropertyName("starred"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Starred { get; set; } + } + + public class SubsonicArtistWithAlbumsID3 : SubsonicArtistID3 { + [JsonPropertyName("album"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Album { get; set; } + } + + public class SubsonicAlbumID3 { + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("artist"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Artist { get; set; } + + [JsonPropertyName("artistId"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string ArtistId { get; set; } + + [JsonPropertyName("coverArt"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string CoverArt { get; set; } + + [JsonPropertyName("songCount")] + public int SongCount { get; set; } + + [JsonPropertyName("duration")] + public int Duration { get; set; } + + [JsonPropertyName("created"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Created { get; set; } + + [JsonPropertyName("year"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Year { get; set; } + + [JsonPropertyName("genre"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Genre { get; set; } + + [JsonPropertyName("starred"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Starred { get; set; } + } + + public class SubsonicAlbumWithSongsID3 : SubsonicAlbumID3 { + [JsonPropertyName("song"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Song { get; set; } + } +} diff --git a/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicLists.cs b/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicLists.cs new file mode 100644 index 0000000..6681a05 --- /dev/null +++ b/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicLists.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace WaveBox.Core.ApiResponse.Subsonic { + public class SubsonicAlbumList { + [JsonPropertyName("album"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Album { get; set; } + } + + public class SubsonicAlbumList2 { + [JsonPropertyName("album"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Album { get; set; } + } + + // Shared by getRandomSongs (randomSongs) and getSongsByGenre (songsByGenre) + public class SubsonicSongs { + [JsonPropertyName("song"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Song { get; set; } + } + + public class SubsonicNowPlaying { + [JsonPropertyName("entry"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Entry { get; set; } + } + + public class SubsonicNowPlayingEntry : SubsonicChild { + [JsonPropertyName("username")] + public string Username { get; set; } + + [JsonPropertyName("minutesAgo"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? MinutesAgo { get; set; } + + [JsonPropertyName("playerId"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? PlayerId { get; set; } + + [JsonPropertyName("playerName"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string PlayerName { get; set; } + } + + public class SubsonicStarred { + [JsonPropertyName("artist"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Artist { get; set; } + + [JsonPropertyName("album"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Album { get; set; } + + [JsonPropertyName("song"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Song { get; set; } + } + + public class SubsonicStarred2 { + [JsonPropertyName("artist"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Artist { get; set; } + + [JsonPropertyName("album"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Album { get; set; } + + [JsonPropertyName("song"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Song { get; set; } + } +} diff --git a/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicPlaylists.cs b/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicPlaylists.cs new file mode 100644 index 0000000..f0f4c91 --- /dev/null +++ b/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicPlaylists.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace WaveBox.Core.ApiResponse.Subsonic { + public class SubsonicPlaylists { + [JsonPropertyName("playlist"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Playlist { get; set; } + } + + // WaveBox playlists have no owner or visibility columns; owner is synthesized as the + // requesting user and playlists are reported as public (every WaveBox user sees them all). + public class SubsonicPlaylist { + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("owner"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Owner { get; set; } + + [JsonPropertyName("public")] + public bool Public { get; set; } = true; + + [JsonPropertyName("songCount")] + public int SongCount { get; set; } + + [JsonPropertyName("duration")] + public int Duration { get; set; } + + [JsonPropertyName("created"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Created { get; set; } + + [JsonPropertyName("changed"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Changed { get; set; } + } + + public class SubsonicPlaylistWithSongs : SubsonicPlaylist { + [JsonPropertyName("entry"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Entry { get; set; } + } +} diff --git a/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicResponse.cs b/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicResponse.cs new file mode 100644 index 0000000..cd05639 --- /dev/null +++ b/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicResponse.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace WaveBox.Core.ApiResponse.Subsonic { + // Marks the DTO property whose value is written as XML element text content + // (genre names, lyrics bodies) instead of an XML attribute. JSON renders it + // as a regular "value" property, matching the Subsonic JSON convention. + [AttributeUsage(AttributeTargets.Property)] + public class SubsonicXmlTextAttribute : Attribute { + } + + // The one serialization root for the whole Subsonic surface. Every response, success or + // error, is this envelope with exactly one payload property set on the body. + public class SubsonicResponse { + [JsonPropertyName("subsonic-response")] + public SubsonicResponseBody Body { get; set; } + + public SubsonicResponse() { + } + + public SubsonicResponse(SubsonicResponseBody body) { + Body = body; + } + } + + public class SubsonicResponseBody { + // Highest Subsonic API version whose endpoints are covered here + public const string ApiVersion = "1.16.1"; + + [JsonPropertyName("status")] + public string Status { get; set; } = "ok"; + + [JsonPropertyName("version")] + public string Version { get; set; } = ApiVersion; + + [JsonPropertyName("type")] + public string Type { get; set; } = "WaveBox"; + + [JsonPropertyName("serverVersion")] + public string ServerVersion { get; set; } + + [JsonPropertyName("openSubsonic")] + public bool OpenSubsonic { get; set; } = true; + + // Exactly one of the following is non-null per response; nulls are suppressed so the + // envelope stays clean. XML renders each as a child element of . + + [JsonPropertyName("error"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicError Error { get; set; } + + [JsonPropertyName("license"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicLicense License { get; set; } + + [JsonPropertyName("openSubsonicExtensions"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList OpenSubsonicExtensions { get; set; } + + [JsonPropertyName("tokenInfo"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicTokenInfo TokenInfo { get; set; } + + [JsonPropertyName("scanStatus"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicScanStatus ScanStatus { get; set; } + + [JsonPropertyName("musicFolders"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicMusicFolders MusicFolders { get; set; } + + [JsonPropertyName("indexes"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicIndexes Indexes { get; set; } + + [JsonPropertyName("directory"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicDirectory Directory { get; set; } + + [JsonPropertyName("genres"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicGenres Genres { get; set; } + + [JsonPropertyName("artists"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicArtistsID3 Artists { get; set; } + + [JsonPropertyName("artist"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicArtistWithAlbumsID3 Artist { get; set; } + + [JsonPropertyName("album"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicAlbumWithSongsID3 Album { get; set; } + + [JsonPropertyName("song"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicChild Song { get; set; } + + [JsonPropertyName("videos"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicVideos Videos { get; set; } + + [JsonPropertyName("artistInfo"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicArtistInfo ArtistInfo { get; set; } + + [JsonPropertyName("artistInfo2"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicArtistInfo ArtistInfo2 { get; set; } + + [JsonPropertyName("lyrics"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicLyrics Lyrics { get; set; } + + [JsonPropertyName("nowPlaying"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicNowPlaying NowPlaying { get; set; } + + [JsonPropertyName("starred"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicStarred Starred { get; set; } + + [JsonPropertyName("starred2"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicStarred2 Starred2 { get; set; } + + [JsonPropertyName("albumList"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicAlbumList AlbumList { get; set; } + + [JsonPropertyName("albumList2"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicAlbumList2 AlbumList2 { get; set; } + + [JsonPropertyName("randomSongs"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicSongs RandomSongs { get; set; } + + [JsonPropertyName("songsByGenre"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicSongs SongsByGenre { get; set; } + + [JsonPropertyName("searchResult2"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicSearchResult2 SearchResult2 { get; set; } + + [JsonPropertyName("searchResult3"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicSearchResult3 SearchResult3 { get; set; } + + [JsonPropertyName("playlists"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicPlaylists Playlists { get; set; } + + [JsonPropertyName("playlist"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicPlaylistWithSongs Playlist { get; set; } + + [JsonPropertyName("user"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicUser User { get; set; } + + [JsonPropertyName("users"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SubsonicUsers Users { get; set; } + } + + public class SubsonicError { + [JsonPropertyName("code")] + public int Code { get; set; } + + [JsonPropertyName("message")] + public string Message { get; set; } + + // Subsonic error codes used by WaveBox + public const int Generic = 0; + public const int MissingParameter = 10; + public const int ClientTooOld = 20; + public const int ServerTooOld = 30; + public const int WrongCredentials = 40; + public const int TokenAuthNotSupported = 41; + public const int MechanismNotSupported = 42; + public const int ConflictingMechanisms = 43; + public const int InvalidApiKey = 44; + public const int NotAuthorized = 50; + public const int NotFound = 70; + } +} diff --git a/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicSearch.cs b/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicSearch.cs new file mode 100644 index 0000000..7bd711f --- /dev/null +++ b/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicSearch.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace WaveBox.Core.ApiResponse.Subsonic { + public class SubsonicSearchResult2 { + [JsonPropertyName("artist"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Artist { get; set; } + + [JsonPropertyName("album"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Album { get; set; } + + [JsonPropertyName("song"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Song { get; set; } + } + + public class SubsonicSearchResult3 { + [JsonPropertyName("artist"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Artist { get; set; } + + [JsonPropertyName("album"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Album { get; set; } + + [JsonPropertyName("song"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Song { get; set; } + } +} diff --git a/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicSystem.cs b/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicSystem.cs new file mode 100644 index 0000000..e0f7680 --- /dev/null +++ b/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicSystem.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace WaveBox.Core.ApiResponse.Subsonic { + public class SubsonicLicense { + [JsonPropertyName("valid")] + public bool Valid { get; set; } = true; + + [JsonPropertyName("email"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Email { get; set; } + + [JsonPropertyName("licenseExpires"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string LicenseExpires { get; set; } + } + + public class SubsonicExtension { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("versions")] + public IList Versions { get; set; } + } + + public class SubsonicTokenInfo { + [JsonPropertyName("username")] + public string Username { get; set; } + } + + public class SubsonicScanStatus { + [JsonPropertyName("scanning")] + public bool Scanning { get; set; } + + [JsonPropertyName("count"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Count { get; set; } + } + + // Intentionally empty: WaveBox has no last.fm-style artist metadata, and every field of the + // Subsonic artistInfo schema is optional. Clients calling getArtistInfo(2) get a valid, + // empty object rather than an error. + public class SubsonicArtistInfo { + } +} diff --git a/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicUsers.cs b/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicUsers.cs new file mode 100644 index 0000000..e571aa3 --- /dev/null +++ b/WaveBox.Core/src/ApiResponse/Subsonic/SubsonicUsers.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace WaveBox.Core.ApiResponse.Subsonic { + public class SubsonicUsers { + [JsonPropertyName("user"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList User { get; set; } + } + + // WaveBox roles are coarse (Test < Guest < User < Admin), so the fine-grained Subsonic role + // flags are derived: User-level grants the everyday roles, Admin grants administration. + // Features WaveBox doesn't have (jukebox, sharing, podcasts, uploads) are always false. + public class SubsonicUser { + [JsonPropertyName("username")] + public string Username { get; set; } + + [JsonPropertyName("email"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Email { get; set; } + + [JsonPropertyName("scrobblingEnabled")] + public bool ScrobblingEnabled { get; set; } + + [JsonPropertyName("adminRole")] + public bool AdminRole { get; set; } + + [JsonPropertyName("settingsRole")] + public bool SettingsRole { get; set; } + + [JsonPropertyName("downloadRole")] + public bool DownloadRole { get; set; } + + [JsonPropertyName("uploadRole")] + public bool UploadRole { get; set; } + + [JsonPropertyName("playlistRole")] + public bool PlaylistRole { get; set; } + + [JsonPropertyName("coverArtRole")] + public bool CoverArtRole { get; set; } + + [JsonPropertyName("commentRole")] + public bool CommentRole { get; set; } + + [JsonPropertyName("podcastRole")] + public bool PodcastRole { get; set; } + + [JsonPropertyName("streamRole")] + public bool StreamRole { get; set; } + + [JsonPropertyName("jukeboxRole")] + public bool JukeboxRole { get; set; } + + [JsonPropertyName("shareRole")] + public bool ShareRole { get; set; } + + [JsonPropertyName("videoConversionRole")] + public bool VideoConversionRole { get; set; } + + [JsonPropertyName("folder"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList Folder { get; set; } + } +} diff --git a/WaveBox.Core/src/Model/GroupCount.cs b/WaveBox.Core/src/Model/GroupCount.cs new file mode 100644 index 0000000..dfcd9cb --- /dev/null +++ b/WaveBox.Core/src/Model/GroupCount.cs @@ -0,0 +1,13 @@ +using System; + +namespace WaveBox.Core.Model { + // Result row for GROUP BY aggregate queries (e.g. song count and total duration per album, + // album count per album artist or genre). ORM-mapped: rooted in ModelTypeRegistry. + public class GroupCount { + public int? GroupId { get; set; } + + public int Count { get; set; } + + public long Total { get; set; } + } +} diff --git a/WaveBox.Core/src/Model/Playlist.cs b/WaveBox.Core/src/Model/Playlist.cs index 7377818..a6db9d4 100644 --- a/WaveBox.Core/src/Model/Playlist.cs +++ b/WaveBox.Core/src/Model/Playlist.cs @@ -244,6 +244,17 @@ public void RemoveMediaItemAtIndexes(IList indices) { } finally { Injection.Get().CloseSqliteConnection(conn); } + + // Refresh the playlist's cached count/duration to match the remaining rows; + // stale counts corrupt the positions of later inserts + IList remaining = ListOfMediaItems(); + int duration = 0; + foreach (IMediaItem remainingItem in remaining) { + duration += remainingItem.Duration ?? 0; + } + PlaylistCount = remaining.Count; + PlaylistDuration = duration; + UpdateDatabase(); } public void MoveMediaItem(int fromIndex, int toIndex) { diff --git a/WaveBox.Core/src/Model/User.cs b/WaveBox.Core/src/Model/User.cs index 637d575..2554477 100644 --- a/WaveBox.Core/src/Model/User.cs +++ b/WaveBox.Core/src/Model/User.cs @@ -48,6 +48,12 @@ public class User : IGroupingItem { [JsonPropertyName("lastfmSession")] public string LastfmSession { get; set; } + // Persistent key for OpenSubsonic apiKey authentication. IgnoreWrite keeps ORM inserts + // from referencing the column (it is added by a schema migration and written only via + // the raw SQL in UpdateApiKey); databases created before the migration simply read null. + [JsonPropertyName("apiKey"), IgnoreWrite] + public string ApiKey { get; set; } + [JsonPropertyName("createTime")] public long? CreateTime { get; set; } @@ -143,6 +149,27 @@ public bool UpdateLastfmSession(string sessionKey) { return false; } + // Set or clear (null) this user's OpenSubsonic API key + public bool UpdateApiKey(string apiKey) { + ISQLiteConnection conn = null; + try { + conn = Injection.Get().GetSqliteConnection(); + int affected = conn.Execute("UPDATE User SET ApiKey = ? WHERE UserId = ?", apiKey, this.UserId); + + if (affected > 0) { + this.ApiKey = apiKey; + + return Injection.Get().UpdateUserCache(this); + } + } catch (Exception e) { + logger.Error(e); + } finally { + Injection.Get().CloseSqliteConnection(conn); + } + + return false; + } + // Update a user's username public bool UpdateUsername(string username) { ISQLiteConnection conn = null; diff --git a/WaveBox.Core/src/ModelTypeRegistry.cs b/WaveBox.Core/src/ModelTypeRegistry.cs index 498e529..1cc0b7c 100644 --- a/WaveBox.Core/src/ModelTypeRegistry.cs +++ b/WaveBox.Core/src/ModelTypeRegistry.cs @@ -20,6 +20,7 @@ public static void EnsurePreserved() { Root(); Root(); Root(); + Root(); Root(); Root(); Root(); diff --git a/WaveBox.Core/src/Repository/AlbumRepository.cs b/WaveBox.Core/src/Repository/AlbumRepository.cs index 3bbb472..22ff628 100644 --- a/WaveBox.Core/src/Repository/AlbumRepository.cs +++ b/WaveBox.Core/src/Repository/AlbumRepository.cs @@ -92,6 +92,55 @@ public IList AllWithNoMusicBrainzId() { return this.database.GetList("SELECT * FROM Album WHERE MusicBrainzId IS NULL"); } + // Albums by date added, newest first (Item.Timestamp is stamped when the item id is generated) + public IList NewestAlbums(int limit, int offset) { + return this.database.GetList( + "SELECT Album.*, AlbumArtist.AlbumArtistName, ArtItem.ArtId FROM Album " + + "LEFT JOIN AlbumArtist ON Album.AlbumArtistId = AlbumArtist.AlbumArtistId " + + "LEFT JOIN ArtItem ON Album.AlbumId = ArtItem.ItemId " + + "JOIN Item ON Item.ItemId = Album.AlbumId " + + "ORDER BY Item.Timestamp DESC LIMIT ? OFFSET ?", + limit, offset); + } + + // Albums by last play time, most recent first (album-level PLAYED rows in the Stat table) + public IList RecentAlbums(int limit, int offset) { + return this.database.GetList( + "SELECT Album.*, AlbumArtist.AlbumArtistName, ArtItem.ArtId FROM Stat " + + "JOIN Album ON Album.AlbumId = Stat.ItemId " + + "LEFT JOIN AlbumArtist ON Album.AlbumArtistId = AlbumArtist.AlbumArtistId " + + "LEFT JOIN ArtItem ON Album.AlbumId = ArtItem.ItemId " + + "WHERE Stat.StatType = ? " + + "GROUP BY Album.AlbumId ORDER BY MAX(Stat.Timestamp) DESC LIMIT ? OFFSET ?", + (int)StatType.PLAYED, limit, offset); + } + + // Albums by play count, most played first + public IList FrequentAlbums(int limit, int offset) { + return this.database.GetList( + "SELECT Album.*, AlbumArtist.AlbumArtistName, ArtItem.ArtId FROM Stat " + + "JOIN Album ON Album.AlbumId = Stat.ItemId " + + "LEFT JOIN AlbumArtist ON Album.AlbumArtistId = AlbumArtist.AlbumArtistId " + + "LEFT JOIN ArtItem ON Album.AlbumId = ArtItem.ItemId " + + "WHERE Stat.StatType = ? " + + "GROUP BY Album.AlbumId ORDER BY COUNT(Stat.StatId) DESC LIMIT ? OFFSET ?", + (int)StatType.PLAYED, limit, offset); + } + + // Song count and total duration per album, one row per AlbumId + public IList SongCountsByAlbum() { + return this.database.GetList( + "SELECT AlbumId AS GroupId, COUNT(*) AS Count, IFNULL(SUM(Duration), 0) AS Total FROM Song " + + "WHERE AlbumId IS NOT NULL GROUP BY AlbumId"); + } + + // Album count per album artist, one row per AlbumArtistId + public IList AlbumCountsByAlbumArtist() { + return this.database.GetList( + "SELECT AlbumArtistId AS GroupId, COUNT(*) AS Count, 0 AS Total FROM Album " + + "WHERE AlbumArtistId IS NOT NULL GROUP BY AlbumArtistId"); + } + public int CountAlbums() { return this.database.GetScalar("SELECT COUNT(AlbumId) FROM Album"); } diff --git a/WaveBox.Core/src/Repository/GenreRepository.cs b/WaveBox.Core/src/Repository/GenreRepository.cs index dd820ef..407c305 100644 --- a/WaveBox.Core/src/Repository/GenreRepository.cs +++ b/WaveBox.Core/src/Repository/GenreRepository.cs @@ -23,6 +23,20 @@ public Genre GenreForId(int? genreId) { return this.database.GetSingle("SELECT * FROM Genre WHERE GenreId = ?", genreId); } + // Song count per genre, one row per GenreId + public IList SongCountsByGenre() { + return this.database.GetList( + "SELECT GenreId AS GroupId, COUNT(*) AS Count, 0 AS Total FROM Song " + + "WHERE GenreId IS NOT NULL GROUP BY GenreId"); + } + + // Distinct album count per genre, one row per GenreId + public IList AlbumCountsByGenre() { + return this.database.GetList( + "SELECT GenreId AS GroupId, COUNT(DISTINCT AlbumId) AS Count, 0 AS Total FROM Song " + + "WHERE GenreId IS NOT NULL AND AlbumId IS NOT NULL GROUP BY GenreId"); + } + private static List memCachedGenres = new List(); public Genre GenreForName(string genreName) { if ((object)genreName == null) { diff --git a/WaveBox.Core/src/Repository/Interfaces/IAlbumRepository.cs b/WaveBox.Core/src/Repository/Interfaces/IAlbumRepository.cs index 5b748bc..f095e9c 100644 --- a/WaveBox.Core/src/Repository/Interfaces/IAlbumRepository.cs +++ b/WaveBox.Core/src/Repository/Interfaces/IAlbumRepository.cs @@ -15,6 +15,11 @@ public interface IAlbumRepository { IList RangeAlbums(char start, char end); IList LimitAlbums(int index, int duration = Int32.MinValue); IList AllWithNoMusicBrainzId(); + IList NewestAlbums(int limit, int offset); + IList RecentAlbums(int limit, int offset); + IList FrequentAlbums(int limit, int offset); + IList SongCountsByAlbum(); + IList AlbumCountsByAlbumArtist(); } } diff --git a/WaveBox.Core/src/Repository/Interfaces/IGenreRepository.cs b/WaveBox.Core/src/Repository/Interfaces/IGenreRepository.cs index 806d965..e8eb013 100644 --- a/WaveBox.Core/src/Repository/Interfaces/IGenreRepository.cs +++ b/WaveBox.Core/src/Repository/Interfaces/IGenreRepository.cs @@ -10,6 +10,8 @@ public interface IGenreRepository { IList ListOfAlbums(int genreId); IList ListOfSongs(int genreId); IList ListOfFolders(int genreId); + IList SongCountsByGenre(); + IList AlbumCountsByGenre(); bool InsertGenre(Genre genre, bool replace); } } diff --git a/WaveBox.Core/src/SubsonicJsonContext.cs b/WaveBox.Core/src/SubsonicJsonContext.cs new file mode 100644 index 0000000..fad5356 --- /dev/null +++ b/WaveBox.Core/src/SubsonicJsonContext.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using WaveBox.Core.ApiResponse.Subsonic; + +namespace WaveBox.Core { + // Source-generated System.Text.Json context for the Subsonic API surface (NativeAOT-safe). + // SubsonicResponse is the single serialization root: every Subsonic DTO must be reachable + // from its property graph or its metadata is never generated and serialization fails at + // runtime under AOT. + [JsonSourceGenerationOptions(WriteIndented = false)] + [JsonSerializable(typeof(SubsonicResponse))] + public partial class SubsonicJsonContext : JsonSerializerContext { + } +} diff --git a/WaveBox.Server/res/wavebox.db b/WaveBox.Server/res/wavebox.db index 6f76e0d17feb3747f06cadbbcfacc89442e00b93..b0ebca0ba60d0c6abd1dde35cc7f5ec96de76c0b 100644 GIT binary patch delta 230 zcmZp;z|wGoWrDOIGXn#IHxR>s=0qK1R%Qmh-X9xN=1%5is%2nh2xVZL%y@_)bTZ!* zcdjODMRsvfQO1_Y&F$0F7)5jx91AkNQ!5ohTq8m>b>-Q_9i=C4)R36Gaq1Zs1_nmM z&5A6#jGNa@533O4WN2n!IL|1`n8w7(6vuRiS%SHip?R_*OQw&PBb&IgC}VbJUP@|3 zX>n>%JjB>gAk*79$kj2#RUy>RGceRu!PC#hH9`SNje@_Q0!W<(M2qJ3^8$>9;)_IB E062|4)&Kwi delta 131 zcmZp;!P0PnWrDOIBLf427ZAgM`a~ULRz?QhN9r3>=1%4X3bQg!W?-Dmc!;TXGT#*U z%?2!XjBL8{Y~qg6lQ(KeOx`&43|Et$BD=V#C}WHL=J+XUjEtI_*G&(r5aD1r&%kh= dQIs)_iIXXg=?t?3bM0hBmhIcm3osgr0|3b1AYT9g diff --git a/WaveBox.Server/res/wavebox.sql b/WaveBox.Server/res/wavebox.sql index 1b08357..47e83bd 100644 --- a/WaveBox.Server/res/wavebox.sql +++ b/WaveBox.Server/res/wavebox.sql @@ -139,7 +139,8 @@ CREATE TABLE "User" ( "PasswordSalt" TEXT NOT NULL, "LastfmSession" TEXT, "CreateTime" INTEGER NOT NULL, - "DeleteTime" INTEGER + "DeleteTime" INTEGER, + "ApiKey" TEXT ); CREATE TABLE "Song" ( "ItemId" INTEGER UNIQUE NOT NULL, @@ -207,5 +208,6 @@ CREATE INDEX "stat_Timestamp" ON "Stat" ("Timestamp"); CREATE INDEX "song_FolderIdFileName" ON "song" ("FolderId","FileName"); CREATE INDEX "song_ItemId" ON "Song" ("ItemId"); CREATE INDEX "favorite_userId" ON "Favorite" ("FavoriteUserId"); +CREATE UNIQUE INDEX "user_ApiKey" ON "User" ("ApiKey"); CREATE UNIQUE INDEX "album_AlbumNameArtistId" ON "Album" ("AlbumName", "AlbumArtistId"); COMMIT; diff --git a/WaveBox.Server/src/ApiHandler/ArtStream.cs b/WaveBox.Server/src/ApiHandler/ArtStream.cs new file mode 100644 index 0000000..d5349c0 --- /dev/null +++ b/WaveBox.Server/src/ApiHandler/ArtStream.cs @@ -0,0 +1,137 @@ +using System; +using System.IO; +using System.Linq; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; +using WaveBox.Core; +using WaveBox.Core.Extensions; +using WaveBox.Core.Model; +using WaveBox.Core.Model.Repository; +using WaveBox.Server.Extensions; + +namespace WaveBox.ApiHandler { + // Art resolution and resizing, shared by the legacy /api/art handler and Subsonic getCoverArt. + // Extracted verbatim from ArtApiHandler: art bytes are not stored in the database — they are + // re-read from the song's tag or the folder's image file on every request. + public static class ArtStream { + private static readonly WaveBox.Core.Logging.ILog logger = WaveBox.Core.Logging.LogManager.GetLogger(typeof(ArtStream)); + + public static Stream CreateStream(Art art) { + if ((object)art.ArtId == null) { + return null; + } + + int? itemId = Injection.Get().ItemIdForArtId((int)art.ArtId); + + if ((object)itemId == null) { + return null; + } + + ItemType type = Injection.Get().ItemTypeForItemId((int)itemId); + + Stream stream = null; + + if (type == ItemType.Song) { + stream = StreamForSong((int)itemId); + } else if (type == ItemType.Folder) { + stream = StreamForFolder((int)itemId); + } + + return stream; + } + + /// + /// Aspect-fit resize into a size x size box (Lanczos), optional Gaussian blur, always re-encoded as JPEG + /// + public static Stream ResizeImage(Stream stream, int size, double blurSigma) { + using (var image = SixLabors.ImageSharp.Image.Load(stream)) { + float nPercentW = ((float)size / (float)image.Width); + float nPercentH = ((float)size / (float)image.Height); + float nPercent = nPercentH < nPercentW ? nPercentH : nPercentW; + + int destWidth = (int)(image.Width * nPercent); + int destHeight = (int)(image.Height * nPercent); + + image.Mutate(x => { + x.Resize(destWidth, destHeight, KnownResamplers.Lanczos3); + if (blurSigma > 0.0) { + x.GaussianBlur((float)blurSigma); + } + }); + + MemoryStream output = new MemoryStream(); + image.SaveAsJpeg(output); + output.Position = 0; + return output; + } + } + + public static Stream StreamForSong(int songId) { + Song song = Injection.Get().SongForId(songId); + Stream stream = null; + + // Open the image from the tag + TagLib.File f = null; + try { + f = TagLib.File.Create(song.FilePath()); + byte[] data = f.Tag.Pictures[0].Data.Data; + + stream = new MemoryStream(data); + } catch (TagLib.CorruptFileException e) { + logger.IfInfo(song.FileName + " has a corrupt tag so can't return the art. " + e); + } catch (Exception e) { + logger.Error("Error processing file: ", e); + } + + return stream; + } + + public static Stream StreamForFolder(int folderId) { + Folder folder = Injection.Get().FolderForId(folderId); + Stream stream = null; + + string artPath = FolderArtPath(folder); + + if ((object)artPath != null) { + stream = new FileStream(artPath, FileMode.Open, FileAccess.Read); + } + + return stream; + } + + public static string FolderArtPath(Folder folder) { + string artPath = null; + + foreach (string fileName in Injection.Get().FolderArtNames) { + string path = folder.FolderPath + Path.DirectorySeparatorChar + fileName; + if (System.IO.File.Exists(path)) { + // Use this one + artPath = path; + } + } + + if ((object)artPath == null) { + // Check for any images + FolderContainsImages(folder.FolderPath, out artPath); + } + + return artPath; + } + + public static bool FolderContainsImages(string dir, out string firstImageFoundPath) { + string[] validImageExtensions = new string[] { ".jpg", ".jpeg", ".png", ".gif", ".bmp" }; + string ext = null; + firstImageFoundPath = null; + + foreach (string file in Directory.GetFiles(dir)) { + ext = Path.GetExtension(file).ToLower(); + if (validImageExtensions.Contains(ext) && !Path.GetFileName(file).StartsWith(".")) { + firstImageFoundPath = file; + } + } + + // Return true if firstImageFoundPath exists + return ((object)firstImageFoundPath != null); + } + } +} diff --git a/WaveBox.Server/src/ApiHandler/Handlers/ArtApiHandler.cs b/WaveBox.Server/src/ApiHandler/Handlers/ArtApiHandler.cs index 1f2fd13..7b14b59 100644 --- a/WaveBox.Server/src/ApiHandler/Handlers/ArtApiHandler.cs +++ b/WaveBox.Server/src/ApiHandler/Handlers/ArtApiHandler.cs @@ -52,7 +52,7 @@ public void Process(UriWrapper uri, IHttpProcessor processor, User user) { // Grab art stream Art art = Injection.Get().ArtForId((int)uri.Id); - Stream stream = CreateStream(art); + Stream stream = ArtStream.CreateStream(art); // If the stream could not be produced, return error if ((object)stream == null) { @@ -68,7 +68,7 @@ public void Process(UriWrapper uri, IHttpProcessor processor, User user) { // Parse size if valid if (size != Int32.MaxValue) { try { - Stream resized = ResizeImage(stream, size, blurSigma); + Stream resized = ArtStream.ResizeImage(stream, size, blurSigma); stream.Close(); stream = resized; } catch (Exception e) { @@ -87,123 +87,5 @@ public void Process(UriWrapper uri, IHttpProcessor processor, User user) { // Close the file so we don't get sharing violations on future accesses stream.Close(); } - - /// - /// Aspect-fit resize into a size x size box (Lanczos), optional Gaussian blur, always re-encoded as JPEG - /// - private Stream ResizeImage(Stream stream, int size, double blurSigma) { - using (var image = SixLabors.ImageSharp.Image.Load(stream)) { - float nPercentW = ((float)size / (float)image.Width); - float nPercentH = ((float)size / (float)image.Height); - float nPercent = nPercentH < nPercentW ? nPercentH : nPercentW; - - int destWidth = (int)(image.Width * nPercent); - int destHeight = (int)(image.Height * nPercent); - - image.Mutate(x => { - x.Resize(destWidth, destHeight, KnownResamplers.Lanczos3); - if (blurSigma > 0.0) { - x.GaussianBlur((float)blurSigma); - } - }); - - MemoryStream output = new MemoryStream(); - image.SaveAsJpeg(output); - output.Position = 0; - return output; - } - } - - private Stream CreateStream(Art art) { - if ((object)art.ArtId == null) { - return null; - } - - int? itemId = Injection.Get().ItemIdForArtId((int)art.ArtId); - - if ((object)itemId == null) { - return null; - } - - ItemType type = Injection.Get().ItemTypeForItemId((int)itemId); - - Stream stream = null; - - if (type == ItemType.Song) { - stream = StreamForSong((int)itemId); - } else if (type == ItemType.Folder) { - stream = StreamForFolder((int)itemId); - } - - return stream; - } - - private Stream StreamForSong(int songId) { - Song song = Injection.Get().SongForId(songId); - Stream stream = null; - - // Open the image from the tag - TagLib.File f = null; - try { - f = TagLib.File.Create(song.FilePath()); - byte[] data = f.Tag.Pictures[0].Data.Data; - - stream = new MemoryStream(data); - } catch (TagLib.CorruptFileException e) { - logger.IfInfo(song.FileName + " has a corrupt tag so can't return the art. " + e); - } catch (Exception e) { - logger.Error("Error processing file: ", e); - } - - return stream; - } - - private Stream StreamForFolder(int folderId) { - Folder folder = Injection.Get().FolderForId(folderId); - Stream stream = null; - - string artPath = FolderArtPath(folder); - - if ((object)artPath != null) { - stream = new FileStream(artPath, FileMode.Open, FileAccess.Read); - } - - return stream; - } - - private string FolderArtPath(Folder folder) { - string artPath = null; - - foreach (string fileName in Injection.Get().FolderArtNames) { - string path = folder.FolderPath + Path.DirectorySeparatorChar + fileName; - if (System.IO.File.Exists(path)) { - // Use this one - artPath = path; - } - } - - if ((object)artPath == null) { - // Check for any images - FolderContainsImages(folder.FolderPath, out artPath); - } - - return artPath; - } - - private bool FolderContainsImages(string dir, out string firstImageFoundPath) { - string[] validImageExtensions = new string[] { ".jpg", ".jpeg", ".png", ".gif", ".bmp" }; - string ext = null; - firstImageFoundPath = null; - - foreach (string file in Directory.GetFiles(dir)) { - ext = Path.GetExtension(file).ToLower(); - if (validImageExtensions.Contains(ext) && !Path.GetFileName(file).StartsWith(".")) { - firstImageFoundPath = file; - } - } - - // Return true if firstImageFoundPath exists - return ((object)firstImageFoundPath != null); - } } } diff --git a/WaveBox.Server/src/ApiHandler/Handlers/TranscodeApiHandler.cs b/WaveBox.Server/src/ApiHandler/Handlers/TranscodeApiHandler.cs index 6147913..dccd427 100644 --- a/WaveBox.Server/src/ApiHandler/Handlers/TranscodeApiHandler.cs +++ b/WaveBox.Server/src/ApiHandler/Handlers/TranscodeApiHandler.cs @@ -207,63 +207,10 @@ public void Process(UriWrapper uri, IHttpProcessor processor, User user) { transcoder = transcodeService.TranscodeVideo(item, transType, quality, isDirect, width, height, maintainAspect, offsetSeconds, lengthSeconds); } - // If a transcoder was generated... - if ((object)transcoder != null) { - length = (long)transcoder.EstimatedOutputSize; - - // Wait up 5 seconds for file or basestream to appear - for (int i = 0; i < 20; i++) { - if (transcoder.IsDirect) { - logger.IfInfo("Checking if base stream exists"); - if ((object)transcoder.TranscodeProcess != null && (object)transcoder.TranscodeProcess.StandardOutput.BaseStream != null) { - // The base stream exists, so the transcoding process has started - logger.IfInfo("Base stream exists, starting transfer"); - stream = transcoder.TranscodeProcess.StandardOutput.BaseStream; - break; - } - } else { - logger.IfInfo("Checking if file exists (" + transcoder.OutputPath + ")"); - if (File.Exists(transcoder.OutputPath)) { - // The file exists, so the transcoding process has started - stream = new FileStream(transcoder.OutputPath, FileMode.Open, FileAccess.Read); - break; - } - } - Thread.Sleep(250); - } - } - - // Send the file if either there is no transcoder and the original file exists OR - // it's a direct transcoder and the base stream exists OR - // it's a file transcoder and the transcoded file exists - if ((object)transcoder == null && File.Exists(item.FilePath()) || - (transcoder.IsDirect && (object)stream != null) || - (!transcoder.IsDirect && File.Exists(transcoder.OutputPath))) { - logger.IfInfo("Sending direct stream"); - string mimeType = (object)transcoder == null ? item.FileType.MimeType() : transcoder.MimeType; - processor.Transcoder = transcoder; - - if (uri.Parameters.ContainsKey("offsetSeconds")) { - logger.IfInfo("Writing file at offsetSeconds " + uri.Parameters["offsetSeconds"]); - } - - DateTime lastModified = transcoder.IsDirect ? DateTime.UtcNow : new FileInfo(transcoder.OutputPath).LastWriteTimeUtc; - - // Direct write file - processor.WriteFile(stream, startOffset, length, mimeType, null, estimateContentLength, lastModified, limitToSize); - stream.Close(); + // Wait for the transcoder's output and send it (shared with Subsonic stream) + if (TranscodeStreamer.Send(transcodeService, transcoder, processor, startOffset, limitToSize, estimateContentLength)) { logger.IfInfo("Successfully sent direct stream"); - - if (uri.Parameters.ContainsKey("offsetSeconds")) { - logger.IfInfo("DONE writing file at offsetSeconds " + uri.Parameters["offsetSeconds"]); - } - } else { - processor.WriteErrorHeader(); } - - // Spin off a thread to consume the transcoder in 30 seconds. - Thread consume = new Thread(() => transcodeService.ConsumedTranscode(transcoder)); - consume.Start(); } catch (Exception e) { logger.Error(e); } diff --git a/WaveBox.Server/src/ApiHandler/Handlers/UsersApiHandler.cs b/WaveBox.Server/src/ApiHandler/Handlers/UsersApiHandler.cs index 29d7f4e..0ba6036 100644 --- a/WaveBox.Server/src/ApiHandler/Handlers/UsersApiHandler.cs +++ b/WaveBox.Server/src/ApiHandler/Handlers/UsersApiHandler.cs @@ -25,7 +25,10 @@ public bool CheckPermission(User user, string action) { return user.HasPermission(Role.Admin); // Write // update - so user can update their own username/password, but not role + // generateApiKey/revokeApiKey - self-service (admin may manage any user's key) case "update": + case "generateApiKey": + case "revokeApiKey": return user.HasPermission(Role.User); // Read case "read": @@ -170,6 +173,9 @@ public void Process(UriWrapper uri, IHttpProcessor processor, User user) { return; } + // Drop any cached Subsonic credential verification for this user + Injection.Get().Evict(deleteUser.UserName); + // Return deleted user logger.IfInfo(String.Format("Successfully deleted user [id: {0}, username: {1}]", deleteUser.UserId, deleteUser.UserName)); listOfUsers.Add(deleteUser); @@ -187,6 +193,10 @@ public void Process(UriWrapper uri, IHttpProcessor processor, User user) { return; } + // Drop any cached Subsonic credential verification before mutating credentials + // (keyed by the pre-update username) + Injection.Get().Evict(updateUser.UserName); + // If user isn't an admin, verify that they are attempting to update themselves if (!user.HasPermission(Role.Admin) && user.UserId != updateUser.UserId) { processor.WriteJson(new UsersResponse("Permission denied", null)); @@ -225,6 +235,35 @@ public void Process(UriWrapper uri, IHttpProcessor processor, User user) { return; } + // generateApiKey / revokeApiKey - manage the user's OpenSubsonic API key + if (uri.Action == "generateApiKey" || uri.Action == "revokeApiKey") { + User keyUser = Injection.Get().UserForId((int)uri.Id); + if (keyUser.UserName == null) { + processor.WriteJson(new UsersResponse("Invalid user ID for action '" + uri.Action + "'", null)); + return; + } + + // Non-admins may only manage their own key + if (!user.HasPermission(Role.Admin) && user.UserId != keyUser.UserId) { + processor.WriteJson(new UsersResponse("Permission denied", null)); + return; + } + + string apiKey = uri.Action == "generateApiKey" + ? Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)).ToLowerInvariant() + : null; + if (!keyUser.UpdateApiKey(apiKey)) { + processor.WriteJson(new UsersResponse("Action '" + uri.Action + "' failed to update API key", null)); + return; + } + + logger.IfInfo(String.Format("Successfully {0} API key for user [id: {1}, username: {2}]", apiKey == null ? "revoked" : "generated", keyUser.UserId, keyUser.UserName)); + listOfUsers.Add(keyUser); + + processor.WriteJson(new UsersResponse(null, listOfUsers)); + return; + } + // Invalid action processor.WriteJson(new UsersResponse("Invalid action specified", null)); return; diff --git a/WaveBox.Server/src/Injection/ServerModule.cs b/WaveBox.Server/src/Injection/ServerModule.cs index 28a2db2..019a813 100644 --- a/WaveBox.Server/src/Injection/ServerModule.cs +++ b/WaveBox.Server/src/Injection/ServerModule.cs @@ -18,6 +18,9 @@ public static IServiceCollection AddWaveBoxServer(this IServiceCollection servic services.AddSingleton(); services.AddSingleton(); + // Subsonic API (stateless per-request auth with a verified-credential cache) + services.AddSingleton(); + // Web client with 5 second timeout (was TimedWebClient/LinuxWebClient under Mono) services.AddSingleton(sp => new HttpClientWebClient(5000)); diff --git a/WaveBox.Server/src/Program.cs b/WaveBox.Server/src/Program.cs index 7ae8a5b..401a35a 100644 --- a/WaveBox.Server/src/Program.cs +++ b/WaveBox.Server/src/Program.cs @@ -46,6 +46,9 @@ public static void Main(string[] args) { // Root ORM-mapped model types so trimming/NativeAOT preserves their reflection metadata ModelTypeRegistry.EnsurePreserved(); + // Root Subsonic DTO types for the reflection-based Subsonic XML serializer + WaveBox.Core.ApiResponse.Subsonic.SubsonicDtoRegistry.EnsurePreserved(); + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); short port = ReadPortFromConf(); @@ -57,6 +60,7 @@ public static void Main(string[] args) { builder.Services.AddWaveBoxCore().AddWaveBoxServer(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddHostedService(); // Standards-compliant gzip/deflate for text responses (replaces the hand-rolled negotiation) @@ -88,6 +92,10 @@ public static void Main(string[] args) { app.UseResponseCompression(); + // Subsonic-compatible API branch; registered before the terminal legacy handler + WaveBox.Subsonic.SubsonicDispatcher subsonicDispatcher = app.Services.GetRequiredService(); + app.Map("/rest", branch => branch.Run(subsonicDispatcher.ProcessAsync)); + // Single terminal handler: /api dispatch plus web UI, matching the legacy server's routing ApiDispatcher dispatcher = app.Services.GetRequiredService(); app.Run(dispatcher.ProcessAsync); diff --git a/WaveBox.Server/src/Static/Database.cs b/WaveBox.Server/src/Static/Database.cs index 2a06c75..135711b 100644 --- a/WaveBox.Server/src/Static/Database.cs +++ b/WaveBox.Server/src/Static/Database.cs @@ -77,6 +77,10 @@ public void DatabaseSetup() { } } + // Upgrade databases created before newer columns existed (the bundled template's + // Version table is empty, so schema state is detected per-column instead) + this.UpgradeSchema(); + if (!File.Exists(QuerylogPath)) { try { logger.IfInfo("Query log database file doesn't exist; Creating it : " + QUERY_LOG_FILE_NAME); @@ -101,6 +105,25 @@ public void DatabaseSetup() { } } + private void UpgradeSchema() { + ISQLiteConnection conn = null; + try { + conn = GetSqliteConnection(); + + // Subsonic API keys: User.ApiKey column + unique index + int hasApiKey = conn.ExecuteScalar("SELECT COUNT(*) FROM pragma_table_info('User') WHERE name = 'ApiKey'"); + if (hasApiKey == 0) { + logger.IfInfo("Upgrading database schema: adding User.ApiKey"); + conn.Execute("ALTER TABLE User ADD COLUMN ApiKey TEXT"); + } + conn.Execute("CREATE UNIQUE INDEX IF NOT EXISTS user_ApiKey ON User(ApiKey)"); + } catch (Exception e) { + logger.Error(e); + } finally { + CloseSqliteConnection(conn); + } + } + public ISQLiteConnection GetSqliteConnection() { if (isPoolingEnabled) { return mainPool.GetSqliteConnection(); diff --git a/WaveBox.Server/src/Subsonic/Handlers/SubsonicAnnotationHandlers.cs b/WaveBox.Server/src/Subsonic/Handlers/SubsonicAnnotationHandlers.cs new file mode 100644 index 0000000..19f8aee --- /dev/null +++ b/WaveBox.Server/src/Subsonic/Handlers/SubsonicAnnotationHandlers.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using WaveBox.Api; +using WaveBox.Core; +using WaveBox.Core.ApiResponse.Subsonic; +using WaveBox.Core.Extensions; +using WaveBox.Core.Model; +using WaveBox.Core.Model.Repository; +using WaveBox.Service; +using WaveBox.Service.Services; + +namespace WaveBox.Subsonic.Handlers { + public static class SubsonicAnnotationHandlers { + private static readonly WaveBox.Core.Logging.ILog logger = WaveBox.Core.Logging.LogManager.GetLogger(typeof(SubsonicAnnotationHandlers)); + + public static void Star(SubsonicRequest req, HttpContextProcessor processor, User user) { + List ids = AllTargetIds(req); + if (ids.Count == 0) { + SubsonicWriter.WriteError(req, processor, SubsonicError.MissingParameter, "Required parameter id is missing"); + return; + } + + IFavoriteRepository favoriteRepository = Injection.Get(); + IItemRepository itemRepository = Injection.Get(); + + // Skip ids that are already starred so repeated star calls don't stack duplicates + HashSet alreadyStarred = new HashSet( + favoriteRepository.FavoritesForUserId((int)user.UserId) + .Where(f => f.FavoriteItemId != null) + .Select(f => (int)f.FavoriteItemId)); + + foreach (int id in ids) { + if (alreadyStarred.Contains(id)) { + continue; + } + ItemType itemType = itemRepository.ItemTypeForItemId(id); + if (itemType == ItemType.Unknown) { + SubsonicWriter.WriteError(req, processor, SubsonicError.NotFound, "No item exists with id " + id); + return; + } + favoriteRepository.AddFavorite((int)user.UserId, id, itemType); + } + + SubsonicWriter.Write(req, processor, SubsonicWriter.Body()); + } + + public static void Unstar(SubsonicRequest req, HttpContextProcessor processor, User user) { + List ids = AllTargetIds(req); + if (ids.Count == 0) { + SubsonicWriter.WriteError(req, processor, SubsonicError.MissingParameter, "Required parameter id is missing"); + return; + } + + IFavoriteRepository favoriteRepository = Injection.Get(); + IList favorites = favoriteRepository.FavoritesForUserId((int)user.UserId); + + foreach (int id in ids) { + foreach (Favorite favorite in favorites.Where(f => f.FavoriteItemId == id && f.FavoriteId != null)) { + favoriteRepository.DeleteFavorite((int)favorite.FavoriteId); + } + } + + SubsonicWriter.Write(req, processor, SubsonicWriter.Body()); + } + + // star/unstar accept media ids (id), ID3 album ids (albumId), and ID3 artist ids + // (artistId); the global id space makes them interchangeable + private static List AllTargetIds(SubsonicRequest req) { + List ids = new List(); + ids.AddRange(req.GetIntList("id")); + ids.AddRange(req.GetIntList("albumId")); + ids.AddRange(req.GetIntList("artistId")); + return ids; + } + + public static void Scrobble(SubsonicRequest req, HttpContextProcessor processor, User user) { + IList ids = req.GetIntList("id"); + if (ids.Count == 0) { + SubsonicWriter.WriteError(req, processor, SubsonicError.MissingParameter, "Required parameter id is missing"); + return; + } + + // time values are milliseconds since epoch, parallel to the id values + IList times = req.GetAll("time"); + bool submission = req.GetBool("submission", true); + + ISongRepository songRepository = Injection.Get(); + IStatRepository statRepository = Injection.Get(); + NowPlayingService nowPlayingService = (NowPlayingService)ServiceManager.GetInstance("nowplaying"); + List lastfmScrobbles = new List(); + + for (int i = 0; i < ids.Count; i++) { + Song song = songRepository.SongForId(ids[i]); + if (song == null || song.ItemId == null) { + continue; + } + + long timestamp = DateTime.UtcNow.ToUnixTime(); + long parsedMs; + if (i < times.Count && Int64.TryParse(times[i], out parsedMs)) { + timestamp = parsedMs / 1000; + } + + // Register with now playing regardless; a submission also records play stats + // (song, album, artist, folder — same as the legacy stats endpoint) + if (nowPlayingService != null && song.Duration != null && song.Duration > 0) { + nowPlayingService.Register(user, song, timestamp); + } + + if (submission) { + statRepository.RecordStat((int)song.ItemId, StatType.PLAYED, timestamp); + if ((object)song.AlbumId != null) { + statRepository.RecordStat((int)song.AlbumId, StatType.PLAYED, timestamp); + } + if ((object)song.ArtistId != null) { + statRepository.RecordStat((int)song.ArtistId, StatType.PLAYED, timestamp); + } + if ((object)song.FolderId != null) { + statRepository.RecordStat((int)song.FolderId, StatType.PLAYED, timestamp); + } + + lastfmScrobbles.Add(new LfmScrobbleData((int)song.ItemId, timestamp)); + } + } + + // Pass submissions through to Last.fm when the user has linked an account + if (submission && lastfmScrobbles.Count > 0 && user.LastfmSession != null) { + Thread lastfmThread = new Thread(() => { + try { + new Lastfm(user).Scrobble(lastfmScrobbles, LfmScrobbleType.SUBMIT); + } catch (Exception e) { + logger.Error(e); + } + }); + lastfmThread.Start(); + } + + SubsonicWriter.Write(req, processor, SubsonicWriter.Body()); + } + } +} diff --git a/WaveBox.Server/src/Subsonic/Handlers/SubsonicBrowsingHandlers.cs b/WaveBox.Server/src/Subsonic/Handlers/SubsonicBrowsingHandlers.cs new file mode 100644 index 0000000..24d1250 --- /dev/null +++ b/WaveBox.Server/src/Subsonic/Handlers/SubsonicBrowsingHandlers.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WaveBox.Api; +using WaveBox.Core; +using WaveBox.Core.ApiResponse.Subsonic; +using WaveBox.Core.Extensions; +using WaveBox.Core.Model; +using WaveBox.Core.Model.Repository; + +namespace WaveBox.Subsonic.Handlers { + public static class SubsonicBrowsingHandlers { + public static void GetMusicFolders(SubsonicRequest req, HttpContextProcessor processor, User user) { + List folders = new List(); + foreach (Folder root in Injection.Get().MediaFolders()) { + folders.Add(new SubsonicMusicFolder { + Id = root.FolderId == null ? null : root.FolderId.ToString(), + Name = root.FolderName + }); + } + + SubsonicResponseBody body = SubsonicWriter.Body(); + body.MusicFolders = new SubsonicMusicFolders { MusicFolder = folders }; + SubsonicWriter.Write(req, processor, body); + } + + public static void GetIndexes(SubsonicRequest req, HttpContextProcessor processor, User user) { + IFolderRepository folderRepository = Injection.Get(); + int? musicFolderId = req.GetInt("musicFolderId"); + + // Top-level entries across the requested media folder(s) plus loose media at the roots + List topLevel = new List(); + List looseMedia = new List(); + foreach (Folder root in folderRepository.MediaFolders()) { + if (musicFolderId != null && root.FolderId != musicFolderId) { + continue; + } + topLevel.AddRange(folderRepository.ListOfSubFolders((int)root.FolderId)); + looseMedia.AddRange(folderRepository.ListOfSongs((int)root.FolderId).Select(SubsonicMapper.ChildFromSong)); + looseMedia.AddRange(folderRepository.ListOfVideos((int)root.FolderId).Select(SubsonicMapper.ChildFromVideo)); + } + topLevel.Sort((x, y) => StringComparer.OrdinalIgnoreCase.Compare(x.FolderName, y.FolderName)); + + List indexes = new List(); + foreach (KeyValuePair> bucket in SubsonicMapper.GroupByIndex(topLevel, f => f.FolderName)) { + indexes.Add(new SubsonicIndex { + Name = bucket.Key, + Artist = bucket.Value.Select(f => new SubsonicIndexArtist { + Id = f.FolderId == null ? null : f.FolderId.ToString(), + Name = f.FolderName + }).ToList() + }); + } + + SubsonicResponseBody body = SubsonicWriter.Body(); + body.Indexes = new SubsonicIndexes { + LastModified = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + IgnoredArticles = "", + Index = indexes, + Child = looseMedia.Count > 0 ? looseMedia : null + }; + SubsonicWriter.Write(req, processor, body); + } + + // Accepts folder ids (the true directory tree) but also album and album-artist ids, + // because getAlbumList/search2 hand out album entries as browsable directories to + // folder-mode clients. The global item id space makes the type resolvable from the id. + public static void GetMusicDirectory(SubsonicRequest req, HttpContextProcessor processor, User user) { + int? id = req.GetInt("id"); + if (id == null) { + SubsonicWriter.WriteError(req, processor, SubsonicError.MissingParameter, "Required parameter id is missing"); + return; + } + + SubsonicDirectory directory = null; + ItemType itemType = Injection.Get().ItemTypeForItemId((int)id); + + if (itemType == ItemType.Folder) { + IFolderRepository folderRepository = Injection.Get(); + Folder folder = folderRepository.FolderForId((int)id); + if (folder != null && folder.FolderId != null) { + List children = new List(); + children.AddRange(folderRepository.ListOfSubFolders((int)folder.FolderId).Select(SubsonicMapper.ChildFromFolder)); + children.AddRange(folderRepository.ListOfSongs((int)folder.FolderId).Select(SubsonicMapper.ChildFromSong)); + children.AddRange(folderRepository.ListOfVideos((int)folder.FolderId).Select(SubsonicMapper.ChildFromVideo)); + + directory = new SubsonicDirectory { + Id = folder.FolderId.ToString(), + Parent = folder.ParentFolderId == null ? null : folder.ParentFolderId.ToString(), + Name = folder.FolderName, + Child = children + }; + } + } else if (itemType == ItemType.Album) { + Album album = Injection.Get().AlbumForId((int)id); + if (album != null && album.AlbumId != null) { + directory = new SubsonicDirectory { + Id = album.AlbumId.ToString(), + Parent = album.AlbumArtistId == null ? null : album.AlbumArtistId.ToString(), + Name = album.AlbumName, + Child = album.ListOfSongs().Select(SubsonicMapper.ChildFromSong).ToList() + }; + } + } else if (itemType == ItemType.AlbumArtist) { + AlbumArtist albumArtist = Injection.Get().AlbumArtistForId(id); + if (albumArtist != null && albumArtist.AlbumArtistId != null) { + directory = new SubsonicDirectory { + Id = albumArtist.AlbumArtistId.ToString(), + Name = albumArtist.AlbumArtistName, + Child = albumArtist.ListOfAlbums().Select(SubsonicMapper.ChildFromAlbum).ToList() + }; + } + } + + if (directory == null) { + SubsonicWriter.WriteError(req, processor, SubsonicError.NotFound, "Directory not found"); + return; + } + + SubsonicResponseBody body = SubsonicWriter.Body(); + body.Directory = directory; + SubsonicWriter.Write(req, processor, body); + } + + public static void GetArtists(SubsonicRequest req, HttpContextProcessor processor, User user) { + IList albumArtists = Injection.Get().AllAlbumArtists(); + IDictionary albumCounts = SubsonicMapper.ToLookup(Injection.Get().AlbumCountsByAlbumArtist()); + + List indexes = new List(); + foreach (KeyValuePair> bucket in SubsonicMapper.GroupByIndex(albumArtists, a => a.AlbumArtistName)) { + indexes.Add(new SubsonicIndexID3 { + Name = bucket.Key, + Artist = bucket.Value.Select(a => { + GroupCount count; + int? albumCount = a.AlbumArtistId != null && albumCounts.TryGetValue((int)a.AlbumArtistId, out count) ? count.Count : (int?)0; + // includeCoverArt: false — ArtId is a DB lookup per artist, too costly for the full index + return SubsonicMapper.ArtistID3FromAlbumArtist(a, albumCount, false); + }).ToList() + }); + } + + SubsonicResponseBody body = SubsonicWriter.Body(); + body.Artists = new SubsonicArtistsID3 { IgnoredArticles = "", Index = indexes }; + SubsonicWriter.Write(req, processor, body); + } + + public static void GetArtist(SubsonicRequest req, HttpContextProcessor processor, User user) { + int? id = req.GetInt("id"); + if (id == null) { + SubsonicWriter.WriteError(req, processor, SubsonicError.MissingParameter, "Required parameter id is missing"); + return; + } + + AlbumArtist albumArtist = Injection.Get().AlbumArtistForId(id); + if (albumArtist == null || albumArtist.AlbumArtistId == null) { + SubsonicWriter.WriteError(req, processor, SubsonicError.NotFound, "Artist not found"); + return; + } + + IList albums = albumArtist.ListOfAlbums(); + IDictionary songCounts = SubsonicMapper.ToLookup(Injection.Get().SongCountsByAlbum()); + + SubsonicArtistWithAlbumsID3 dto = new SubsonicArtistWithAlbumsID3(); + SubsonicMapper.FillArtistID3(dto, albumArtist, albums.Count, true); + dto.Album = albums.Select(a => SubsonicMapper.AlbumID3FromAlbum(a, songCounts)).ToList(); + + SubsonicResponseBody body = SubsonicWriter.Body(); + body.Artist = dto; + SubsonicWriter.Write(req, processor, body); + } + + public static void GetAlbum(SubsonicRequest req, HttpContextProcessor processor, User user) { + int? id = req.GetInt("id"); + if (id == null) { + SubsonicWriter.WriteError(req, processor, SubsonicError.MissingParameter, "Required parameter id is missing"); + return; + } + + Album album = Injection.Get().AlbumForId((int)id); + if (album == null || album.AlbumId == null) { + SubsonicWriter.WriteError(req, processor, SubsonicError.NotFound, "Album not found"); + return; + } + + // Already disc/track-sorted with artist/genre/art joined in + IList songs = album.ListOfSongs(); + + SubsonicAlbumWithSongsID3 dto = new SubsonicAlbumWithSongsID3(); + SubsonicMapper.FillAlbumID3(dto, album, null); + dto.SongCount = songs.Count; + dto.Duration = songs.Sum(s => s.Duration ?? 0); + dto.Created = songs.Count > 0 ? SubsonicMapper.Iso8601(songs[0].LastModified) : null; + dto.Genre = songs.Select(s => s.GenreName).FirstOrDefault(g => g != null); + dto.Song = songs.Select(SubsonicMapper.ChildFromSong).ToList(); + + SubsonicResponseBody body = SubsonicWriter.Body(); + body.Album = dto; + SubsonicWriter.Write(req, processor, body); + } + + public static void GetSong(SubsonicRequest req, HttpContextProcessor processor, User user) { + int? id = req.GetInt("id"); + if (id == null) { + SubsonicWriter.WriteError(req, processor, SubsonicError.MissingParameter, "Required parameter id is missing"); + return; + } + + Song song = Injection.Get().SongForId((int)id); + if (song == null || song.ItemId == null) { + SubsonicWriter.WriteError(req, processor, SubsonicError.NotFound, "Song not found"); + return; + } + + SubsonicResponseBody body = SubsonicWriter.Body(); + body.Song = SubsonicMapper.ChildFromSong(song); + SubsonicWriter.Write(req, processor, body); + } + + public static void GetGenres(SubsonicRequest req, HttpContextProcessor processor, User user) { + IGenreRepository genreRepository = Injection.Get(); + IDictionary songCounts = SubsonicMapper.ToLookup(genreRepository.SongCountsByGenre()); + IDictionary albumCounts = SubsonicMapper.ToLookup(genreRepository.AlbumCountsByGenre()); + + List genres = new List(); + foreach (Genre genre in genreRepository.AllGenres()) { + if (genre.GenreId == null) { + continue; + } + GroupCount count; + genres.Add(new SubsonicGenre { + Value = genre.GenreName, + SongCount = songCounts.TryGetValue((int)genre.GenreId, out count) ? count.Count : 0, + AlbumCount = albumCounts.TryGetValue((int)genre.GenreId, out count) ? count.Count : 0 + }); + } + + SubsonicResponseBody body = SubsonicWriter.Body(); + body.Genres = new SubsonicGenres { Genre = genres }; + SubsonicWriter.Write(req, processor, body); + } + + public static void GetVideos(SubsonicRequest req, HttpContextProcessor processor, User user) { + IList