Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions API_DOCS.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
88 changes: 88 additions & 0 deletions Scripts/smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
175 changes: 175 additions & 0 deletions WaveBox.Core/src/ApiResponse/Subsonic/SubsonicBrowsing.cs
Original file line number Diff line number Diff line change
@@ -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<SubsonicMusicFolder> 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<SubsonicIndex> Index { get; set; }

// Loose media files directly inside a music folder root
[JsonPropertyName("child"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<SubsonicChild> Child { get; set; }
}

public class SubsonicIndex {
[JsonPropertyName("name")]
public string Name { get; set; }

[JsonPropertyName("artist"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<SubsonicIndexArtist> 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<SubsonicChild> 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<SubsonicGenre> 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<SubsonicChild> 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; }
}
}
55 changes: 55 additions & 0 deletions WaveBox.Core/src/ApiResponse/Subsonic/SubsonicDtoRegistry.cs
Original file line number Diff line number Diff line change
@@ -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<SubsonicResponse>();
Root<SubsonicResponseBody>();
Root<SubsonicError>();
Root<SubsonicLicense>();
Root<SubsonicExtension>();
Root<SubsonicTokenInfo>();
Root<SubsonicScanStatus>();
Root<SubsonicArtistInfo>();
Root<SubsonicMusicFolders>();
Root<SubsonicMusicFolder>();
Root<SubsonicIndexes>();
Root<SubsonicIndex>();
Root<SubsonicIndexArtist>();
Root<SubsonicDirectory>();
Root<SubsonicChild>();
Root<SubsonicGenres>();
Root<SubsonicGenre>();
Root<SubsonicVideos>();
Root<SubsonicLyrics>();
Root<SubsonicArtistsID3>();
Root<SubsonicIndexID3>();
Root<SubsonicArtistID3>();
Root<SubsonicArtistWithAlbumsID3>();
Root<SubsonicAlbumID3>();
Root<SubsonicAlbumWithSongsID3>();
Root<SubsonicAlbumList>();
Root<SubsonicAlbumList2>();
Root<SubsonicSongs>();
Root<SubsonicNowPlaying>();
Root<SubsonicNowPlayingEntry>();
Root<SubsonicStarred>();
Root<SubsonicStarred2>();
Root<SubsonicSearchResult2>();
Root<SubsonicSearchResult3>();
Root<SubsonicPlaylists>();
Root<SubsonicPlaylist>();
Root<SubsonicPlaylistWithSongs>();
Root<SubsonicUsers>();
Root<SubsonicUser>();
}
}
}
Loading
Loading