diff --git a/API_DOCS.md b/API_DOCS.md index a7969b9..65d6018 100644 --- a/API_DOCS.md +++ b/API_DOCS.md @@ -1,5 +1,45 @@ # 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. + +**Folder vs tag browsing** — the two Subsonic browse modes are kept separate, as clients +expect: the folder-based endpoints (getIndexes, getMusicDirectory, getAlbumList, search2, +getStarred) operate on the real directory tree — getAlbumList hands out the folder that +holds each album's songs, and search2 matches folder names — while the ID3 endpoints +(getArtists/getArtist/getAlbum, getAlbumList2, search3, getStarred2) use tag organization +with WaveBox's AlbumArtist as the ID3 artist. All ids are WaveBox's global item ids; +`getMusicDirectory` primarily takes folder ids but also resolves album/artist ids as a +fallback for clients that mix modes. + ## 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..274e0c5 100755 --- a/Scripts/smoke-test.sh +++ b/Scripts/smoke-test.sh @@ -121,6 +121,94 @@ 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" $? + +# 12b. getAlbumList (folder flavor) entries must be traversable directories in the folder tree +LIST_DIR_ID=$(curl -s "$REST/getAlbumList?$SUB&type=newest" | python3 -c "import json,sys; d=json.load(sys.stdin)['subsonic-response']['albumList']; print(d['album'][0]['id'] if d.get('album') else '')") +curl -s "$REST/getMusicDirectory?$SUB&id=$LIST_DIR_ID" | python3 -c "import json,sys; d=json.load(sys.stdin)['subsonic-response']['directory']; assert any(c['title']=='Test Song' for c in d['child']), d" +check "subsonic getAlbumList entries browse as folders" $? + +# 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/AlbumFolder.cs b/WaveBox.Core/src/Model/AlbumFolder.cs new file mode 100644 index 0000000..8235012 --- /dev/null +++ b/WaveBox.Core/src/Model/AlbumFolder.cs @@ -0,0 +1,12 @@ +using System; + +namespace WaveBox.Core.Model { + // Result row mapping an album to the folder that holds its songs (MIN(FolderId) when a + // multi-disc album spans subfolders). Lets folder-flavored Subsonic endpoints hand out + // browsable folder ids for tag-derived album lists. ORM-mapped: rooted in ModelTypeRegistry. + public class AlbumFolder { + public int? AlbumId { get; set; } + + public int? FolderId { 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..e384358 100644 --- a/WaveBox.Core/src/ModelTypeRegistry.cs +++ b/WaveBox.Core/src/ModelTypeRegistry.cs @@ -14,12 +14,14 @@ public static class ModelTypeRegistry { public static void EnsurePreserved() { Root(); Root(); + Root(); Root(); Root(); Root(); 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..9b84985 100644 --- a/WaveBox.Core/src/Repository/AlbumRepository.cs +++ b/WaveBox.Core/src/Repository/AlbumRepository.cs @@ -92,6 +92,62 @@ 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); + } + + // Representative folder per album (the folder holding its songs), one row per AlbumId + public IList FoldersByAlbum() { + return this.database.GetList( + "SELECT AlbumId, MIN(FolderId) AS FolderId FROM Song " + + "WHERE AlbumId IS NOT NULL AND FolderId IS NOT NULL GROUP BY AlbumId"); + } + + // 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/FolderRepository.cs b/WaveBox.Core/src/Repository/FolderRepository.cs index 9da339c..d43629d 100644 --- a/WaveBox.Core/src/Repository/FolderRepository.cs +++ b/WaveBox.Core/src/Repository/FolderRepository.cs @@ -126,6 +126,16 @@ public IList ListOfSubFolders(int folderId) { return this.database.GetList("SELECT * FROM Folder WHERE ParentFolderId = ? ORDER BY FolderName COLLATE NOCASE", folderId); } + // Substring search on folder name, media-folder roots excluded + public IList SearchFolders(string query) { + if ((object)query == null) { + return new List(); + } + return this.database.GetList( + "SELECT * FROM Folder WHERE ParentFolderId IS NOT NULL AND FolderName LIKE ? ORDER BY FolderName COLLATE NOCASE", + "%" + query + "%"); + } + public int? GetParentFolderId(string path) { string parentFolderPath = Directory.GetParent(path).FullName; 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..62e5557 100644 --- a/WaveBox.Core/src/Repository/Interfaces/IAlbumRepository.cs +++ b/WaveBox.Core/src/Repository/Interfaces/IAlbumRepository.cs @@ -15,6 +15,12 @@ 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 FoldersByAlbum(); + IList SongCountsByAlbum(); + IList AlbumCountsByAlbumArtist(); } } diff --git a/WaveBox.Core/src/Repository/Interfaces/IFolderRepository.cs b/WaveBox.Core/src/Repository/Interfaces/IFolderRepository.cs index fa9fa7a..3c5126b 100644 --- a/WaveBox.Core/src/Repository/Interfaces/IFolderRepository.cs +++ b/WaveBox.Core/src/Repository/Interfaces/IFolderRepository.cs @@ -12,6 +12,7 @@ public interface IFolderRepository { IList ListOfSongs(int folderId, bool recursive = false); IList