From 857e9a0546c89a5827deb81c981bb93394239a24 Mon Sep 17 00:00:00 2001 From: Filipe Herculano Date: Wed, 16 Sep 2026 16:54:35 -0400 Subject: [PATCH 1/2] feat: getPlaylist/listPlaylists over GET so the edge can cache them (v0.21.0) --- CHANGELOG.md | 6 +++ README.md | 4 +- src/init.luau | 18 ++++----- tests/getPlaylist.spec.luau | 80 +++++++++++++++++++++++++++++++++++++ wally.toml | 2 +- 5 files changed, 97 insertions(+), 13 deletions(-) create mode 100644 tests/getPlaylist.spec.luau diff --git a/CHANGELOG.md b/CHANGELOG.md index 0327be4..86b0eff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v0.21.0 + +### Changed + +- **`getPlaylist` and `listPlaylists` now use GET** — the requests carry the same parameters in the query string and hit the same endpoint, but GET lets CloudFront cache the response at the edge (60s TTL). When many servers boot against the same playlist at once, they now share one origin request instead of each hitting the API. No call-site changes; POST remains accepted server-side for older SDK versions. + ## v0.20.1 ### Added diff --git a/README.md b/README.md index 4e667d9..f880b98 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Add to your `wally.toml`: ```toml [server-dependencies] -AudioScape = "this-fifo/audioscape-sdk@0.20.1" +AudioScape = "this-fifo/audioscape-sdk@0.21.0" ``` Then run: @@ -382,7 +382,7 @@ end ### `AudioScape:getPlaylist(options)` -Fetch a configured playlist and its tracks. Playlists are created in the [Developer Portal](https://developer.audioscape.ai/configure). +Fetch a configured playlist and its tracks. Playlists are created in the [Developer Portal](https://developer.audioscape.ai/configure). Responses are edge-cached for 60 seconds, so many servers fetching the same playlist share one API request; portal edits show up within a minute. ```lua local result, err = AudioScape:getPlaylist({ diff --git a/src/init.luau b/src/init.luau index 56254d9..fa6dec1 100644 --- a/src/init.luau +++ b/src/init.luau @@ -735,7 +735,8 @@ local function buildQuery(params: { [string]: any }): string end -- GET sibling of request() for endpoints CloudFront edge-caches --- (browse, sfxBrowse, sfxTaxonomy, similar, sfxSimilar). Flagged as a v1.0.0 +-- (browse, sfxBrowse, sfxTaxonomy, similar, sfxSimilar, getPlaylist, +-- listPlaylists). Flagged as a v1.0.0 -- cleanup candidate to fold both into one method-aware helper. local function requestGet( self: AudioScapeInstance, @@ -1313,19 +1314,16 @@ function AudioScape.getPlaylist(self: AudioScapeInstance, options: PlaylistOptio return nil, "getPlaylist requires a non-empty playlist_id string" end - local body: { [string]: any } = { + -- GET so CloudFront can serve repeat fetches of the same playlist from + -- the edge (60s TTL): every server booting against the same playlist_id + -- shares one origin request instead of each hitting the API. + return requestGet(self, "/v1/playlist", { playlist_id = options.playlist_id, - } - - return request(self, "/v1/playlist", body, options.playerId) + }, options.playerId) end function AudioScape.listPlaylists(self: AudioScapeInstance, playerId: number?): (ListPlaylistsResult?, string?) - local body: { [string]: any } = { - action = "list", - } - - return request(self, "/v1/playlist", body, playerId) + return requestGet(self, "/v1/playlist", { action = "list" }, playerId) end --[[ ────────────────────────────────────────── diff --git a/tests/getPlaylist.spec.luau b/tests/getPlaylist.spec.luau new file mode 100644 index 0000000..c7a8d4e --- /dev/null +++ b/tests/getPlaylist.spec.luau @@ -0,0 +1,80 @@ +--!strict +-- getPlaylist / listPlaylists go over GET (v0.21.0) so CloudFront can cache +-- them at the edge. Covers the wire format: method, path, query string, +-- headers, no JSON body, and the pre-flight playlist_id validation. + +local serde = require("@lune/serde") +local mockHttp = require("./_harness/mockHttp") +local loadSdk = require("./_harness/loadSdk").loadSdk + +local http = mockHttp.new() +local sdk = loadSdk({ http = http }) +local client = (sdk :: any).new("test-key") + +-- getPlaylist: GET /v1/playlist?playlist_id=... +http:queueResponse({ + Success = true, + StatusCode = 200, + Body = serde.encode("json", { + playlist = { id = "kpop", name = "K-Pop", genre = "pop", playback_mode = "shuffle", track_count = 0 }, + tracks = {}, + meta = {}, + }), +}) +local result, err = client:getPlaylist({ playlist_id = "kpop", playerId = 12345 }) +assert(result and not err, "getPlaylist should succeed; err: " .. tostring(err)) +assert(#http.requests == 1, "exactly one HTTP call") + +local recorded = http.requests[1] +assert(recorded.method == "GET", "getPlaylist uses GET, got " .. recorded.method) +assert(recorded.url:find("/v1/playlist?", 1, true), "URL is /v1/playlist with a query string; got " .. recorded.url) +assert(recorded.url:find("playlist_id=kpop", 1, true), "playlist_id in query; got " .. recorded.url) +assert(recorded.body == nil, "GET sends no body") +assert(recorded.headers and recorded.headers["x-api-key"] == "test-key", "x-api-key set") +assert(recorded.headers and recorded.headers["X-Player-Id"] == "12345", "X-Player-Id forwarded") +assert(recorded.headers and recorded.headers["Content-Type"] == nil, "no Content-Type on GET") + +-- playlist_id is URL-encoded, not JSON-encoded +http:reset() +http:queueResponse({ Success = true, StatusCode = 200, Body = "{}" }) +client:getPlaylist({ playlist_id = "top100 weekly/theblock" }) +recorded = http.requests[1] +assert( + recorded.url:find("playlist_id=top100%20weekly%2Ftheblock", 1, true), + "playlist_id url-encoded; got " .. recorded.url +) + +-- pre-flight: empty / non-string playlist_id rejected without HTTP +http:reset() +local rejected, badErr = client:getPlaylist({ playlist_id = "" }) +assert( + rejected == nil and badErr and badErr:find("non-empty playlist_id", 1, true), + "empty id rejected; got " .. tostring(badErr) +) +assert(#http.requests == 0, "no HTTP for invalid playlist_id") + +-- listPlaylists: GET /v1/playlist?action=list +http:reset() +http:queueResponse({ Success = true, StatusCode = 200, Body = serde.encode("json", { playlists = {}, meta = {} }) }) +local list, listErr = client:listPlaylists(777) +assert(list and not listErr, "listPlaylists should succeed; err: " .. tostring(listErr)) +recorded = http.requests[1] +assert(recorded.method == "GET", "listPlaylists uses GET, got " .. recorded.method) +assert(recorded.url:find("/v1/playlist?action=list", 1, true), "action=list in query; got " .. recorded.url) +assert(recorded.body == nil, "GET sends no body") +assert(recorded.headers and recorded.headers["X-Player-Id"] == "777", "X-Player-Id forwarded") + +-- listPlaylists without a player: header omitted +http:reset() +http:queueResponse({ Success = true, StatusCode = 200, Body = "{}" }) +client:listPlaylists() +recorded = http.requests[1] +assert(recorded.headers and recorded.headers["X-Player-Id"] == nil, "no X-Player-Id without a player") + +-- 5xx still maps to the shared server-error message +do + http:reset() + http:queueResponse({ Success = false, StatusCode = 503, Body = "" }) + local r, e = client:getPlaylist({ playlist_id = "kpop" }) + assert(r == nil and e == "Server error — try again later (503)", "got: " .. tostring(e)) +end diff --git a/wally.toml b/wally.toml index 46c9912..01fdfa5 100644 --- a/wally.toml +++ b/wally.toml @@ -1,6 +1,6 @@ [package] name = "this-fifo/audioscape-sdk" -version = "0.20.1" +version = "0.21.0" registry = "https://github.com/UpliftGames/wally-index" realm = "server" license = "MIT" From 1a7b81b2f838ee706efaef28d709423507692605 Mon Sep 17 00:00:00 2001 From: Filipe Herculano Date: Wed, 16 Sep 2026 17:17:56 -0400 Subject: [PATCH 2/2] fix: SFX sound banks classify seeds through /v1/sfx/similar, not the music lookup --- CHANGELOG.md | 4 ++ src/AudioScapeSoundBank.luau | 65 ++++++++++++++++++++++------- tests/soundBank.spec.luau | 24 ++++++----- tests/soundBankSfx.spec.luau | 80 ++++++++++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 26 deletions(-) create mode 100644 tests/soundBankSfx.spec.luau diff --git a/CHANGELOG.md b/CHANGELOG.md index 86b0eff..cda3a58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - **`getPlaylist` and `listPlaylists` now use GET** — the requests carry the same parameters in the query string and hit the same endpoint, but GET lets CloudFront cache the response at the edge (60s TTL). When many servers boot against the same playlist at once, they now share one origin request instead of each hitting the API. No call-site changes; POST remains accepted server-side for older SDK versions. +### Fixed + +- **SFX sound banks now recognise SFX-only seeds.** `createSoundBank({ kind = "sfx" })` classified its seeds through the music-catalog lookup, so a seed that exists only in the SFX catalog was treated as unknown and its pool collapsed to the seed alone (`source = "none"`). SFX seeds are now classified through `/v1/sfx/similar`, which supplies the pool in the same request, so resolving an SFX bank also makes one request fewer. + ## v0.20.1 ### Added diff --git a/src/AudioScapeSoundBank.luau b/src/AudioScapeSoundBank.luau index 0c8bc6a..b462c9b 100644 --- a/src/AudioScapeSoundBank.luau +++ b/src/AudioScapeSoundBank.luau @@ -130,12 +130,41 @@ function AudioScapeSoundBank.new(catalog: CatalogClient, options: SoundBankOptio -- Count locally and emit one rolled-up event per flush instead. _pickCounts = {}, _unavailable = {}, + -- Neighbours fetched while classifying SFX seeds (see _classifySeeds), + -- so _neighbours doesn't repeat the same /v1/sfx/similar call. + _neighbourCache = {}, }, AudioScapeSoundBank) return (self :: any) :: SoundBankInstance end --- Which seeds does the catalog already know? One request per 100 seeds. +local function neighbourIds(result: any): { string } + local ids: { string } = {} + if result and result.tracks then + for _, track in result.tracks :: { any } do + table.insert(ids, tostring(track.asset_id)) + end + end + return ids +end + +function AudioScapeSoundBank._neighbourExtras(self: any): { [string]: any } + return { + limit = self._poolSize, + -- Near-identical uploads are the opposite of variation, so drop them. + dedupe = true, + playerId = self._playerId, + } +end + +-- Which seeds does the catalog already know? +-- +-- Music seeds: one /v1/search lookup per 100 seeds. SFX seeds: that lookup +-- only covers the music catalog, so an SFX-only asset came back unknown and +-- was bridged or dropped even though /v1/sfx/similar knows it. SFX seeds are +-- classified through sfxSimilar instead — a 200 proves the seed is in the SFX +-- catalog and already carries its neighbours (cached for _neighbours); a 404 +-- means unknown and falls through to bridging like before. function AudioScapeSoundBank._classifySeeds(self: any): ({ [string]: boolean }, string?) local ids: { string } = {} local seen: { [string]: boolean } = {} @@ -148,6 +177,19 @@ function AudioScapeSoundBank._classifySeeds(self: any): ({ [string]: boolean }, end local known: { [string]: boolean } = {} + if self._kind == "sfx" then + for _, id in ids do + local result, err = self._catalog:sfxSimilar(id, self:_neighbourExtras()) + if result then + known[id] = true + self._neighbourCache[id] = neighbourIds(result) + elseif err and not err:find("(404)", 1, true) then + return known, err + end + end + return known, nil + end + for start = 1, #ids, LOOKUP_BATCH_SIZE do local batch: { string } = {} for i = start, math.min(start + LOOKUP_BATCH_SIZE - 1, #ids) do @@ -217,26 +259,18 @@ function AudioScapeSoundBank._bridgeSeeds(self: any, ids: { string }): { [string end function AudioScapeSoundBank._neighbours(self: any, anchorId: string): { string } - local extras = { - limit = self._poolSize, - -- Near-identical uploads are the opposite of variation, so drop them. - dedupe = true, - playerId = self._playerId, - } + local cached = self._neighbourCache[anchorId] + if cached then + return cached + end + local extras = self:_neighbourExtras() local result if self._kind == "sfx" then result = self._catalog:sfxSimilar(anchorId, extras) else result = self._catalog:similar(anchorId, extras) end - - local ids: { string } = {} - if result and result.tracks then - for _, track in result.tracks :: { any } do - table.insert(ids, tostring(track.asset_id)) - end - end - return ids + return neighbourIds(result) end -- Resolve every seed into a pool. Safe to call more than once; it rebuilds. @@ -247,6 +281,7 @@ end -- never reviewed is the same mistake as auto-refreshing their gunshot. Widening -- a slot is a decision made in the portal, where they can hear it first. function AudioScapeSoundBank.resolveAsync(self: any): (boolean, string?) + self._neighbourCache = {} local known, err = self:_classifySeeds() if err then return false, err diff --git a/tests/soundBank.spec.luau b/tests/soundBank.spec.luau index 8a885da..f7671db 100644 --- a/tests/soundBank.spec.luau +++ b/tests/soundBank.spec.luau @@ -1,5 +1,6 @@ --!strict --- Unit tests for AudioScapeSoundBank. +-- Unit tests for AudioScapeSoundBank (music-kind banks: /v1/search lookup +-- then similar; SFX-kind classification lives in soundBankSfx.spec.luau). -- -- The bridging path (GetAudioMetadataAsync for seeds our catalog doesn't -- hold) needs a real engine and is covered in the Open Cloud smoke instead; @@ -55,7 +56,7 @@ do http:queueResponse(lookupResponse({ "111" })) http:queueResponse(similarResponse({ "222", "333", "444" })) - local bank = AudioScape:createSoundBank({ seeds = { footstep = "rbxassetid://111" } }) + local bank = AudioScape:createSoundBank({ seeds = { footstep = "rbxassetid://111" }, kind = "music" }) local ok, err = bank:resolveAsync() assert(ok, "resolve failed: " .. tostring(err)) @@ -75,6 +76,7 @@ do local bank = AudioScape:createSoundBank({ seeds = { footstep = "rbxassetid://111" }, mode = "replace", + kind = "music", }) bank:resolveAsync() @@ -91,7 +93,7 @@ do http:queueResponse(lookupResponse({ "111" })) http:queueResponse(similarResponse({ "222", "333", "444", "555", "666" })) - local bank = AudioScape:createSoundBank({ seeds = { s = "111" }, poolSize = 3 }) + local bank = AudioScape:createSoundBank({ seeds = { s = "111" }, poolSize = 3, kind = "music" }) bank:resolveAsync() assert(#bank:getPool("s") == 3, "pool capped at poolSize; got " .. tostring(#bank:getPool("s"))) end @@ -103,7 +105,7 @@ do -- the mock has no AssetService for, so it pcalls out and yields no anchor. http:queueResponse(lookupResponse({})) - local bank = AudioScape:createSoundBank({ seeds = { s = "999" }, mode = "replace" }) + local bank = AudioScape:createSoundBank({ seeds = { s = "999" }, mode = "replace", kind = "music" }) local ok = bank:resolveAsync() assert(ok, "resolve succeeds even when nothing can be expanded") @@ -118,7 +120,7 @@ do http:queueResponse(lookupResponse({ "111" })) http:queueResponse(similarResponse({ "222", "333" })) - local bank = AudioScape:createSoundBank({ seeds = { s = "111" } }) + local bank = AudioScape:createSoundBank({ seeds = { s = "111" }, kind = "music" }) bank:resolveAsync() local previous: string? = nil @@ -135,7 +137,7 @@ do local AudioScape, http = newSdk() http:queueResponse(lookupResponse({})) - local bank = AudioScape:createSoundBank({ seeds = { s = "999" } }) + local bank = AudioScape:createSoundBank({ seeds = { s = "999" }, kind = "music" }) bank:resolveAsync() assert(bank:pick("s") == "999", "single-asset pool returns the only asset") assert(bank:pick("s") == "999", "and keeps returning it rather than nil") @@ -145,7 +147,7 @@ end do local AudioScape, http = newSdk() http:queueResponse(lookupResponse({})) - local bank = AudioScape:createSoundBank({ seeds = { s = "1" } }) + local bank = AudioScape:createSoundBank({ seeds = { s = "1" }, kind = "music" }) bank:resolveAsync() assert(bank:pick("nope") == nil, "unknown pool name yields nil") assert(#bank:getPool("nope") == 0, "unknown pool yields an empty list") @@ -157,7 +159,7 @@ do http:queueResponse(lookupResponse({ "111" })) http:queueResponse(similarResponse({ "222", "333" })) - local bank = AudioScape:createSoundBank({ seeds = { s = "111" } }) + local bank = AudioScape:createSoundBank({ seeds = { s = "111" }, kind = "music" }) bank:resolveAsync() assert(bank:reportUnavailable("222"), "first report returns true") @@ -174,7 +176,7 @@ do http:queueResponse(lookupResponse({ "111" })) http:queueResponse(similarResponse({ "222" })) - local bank = AudioScape:createSoundBank({ seeds = { s = "111" } }) + local bank = AudioScape:createSoundBank({ seeds = { s = "111" }, kind = "music" }) bank:resolveAsync() bank:reportUnavailable("111") bank:reportUnavailable("222") @@ -188,7 +190,7 @@ do http:queueResponse(lookupResponse({ "111" })) http:queueResponse(similarResponse({ "222" })) - local bank = AudioScape:createSoundBank({ seeds = { s = "111" } }) + local bank = AudioScape:createSoundBank({ seeds = { s = "111" }, kind = "music" }) bank:resolveAsync() -- resolveAsync emits one audio_resolve event per seed. @@ -283,7 +285,7 @@ do http:queueResponse(lookupResponse({ "111" })) -- seed IS in the catalog http:queueResponse(similarResponse({ "222", "333" })) - local bank = AudioScape:createSoundBank({ seeds = { s = "111" }, mode = "replace" }) + local bank = AudioScape:createSoundBank({ seeds = { s = "111" }, mode = "replace", kind = "music" }) bank:resolveAsync() assert(bank.Pools.s.source == "catalog", "resolved from catalog") diff --git a/tests/soundBankSfx.spec.luau b/tests/soundBankSfx.spec.luau new file mode 100644 index 0000000..d60f11b --- /dev/null +++ b/tests/soundBankSfx.spec.luau @@ -0,0 +1,80 @@ +--!strict +-- SFX-kind sound banks classify seeds through /v1/sfx/similar (v0.21.0). +-- The music-catalog lookup on /v1/search can't see SFX-only assets, so it +-- reported them unknown; now a 200 from sfxSimilar marks the seed known and +-- doubles as the neighbour fetch, and a 404 falls through to bridging. + +local serde = require("@lune/serde") +local mockHttp = require("./_harness/mockHttp") +local loadSdk = require("./_harness/loadSdk").loadSdk + +local function similarResponse(ids: { string }): mockHttp.Response + local tracks = {} + for _, id in ids do + table.insert(tracks, { asset_id = id, name = "Neighbour " .. id }) + end + return { + Success = true, + StatusCode = 200, + Body = serde.encode("json", { tracks = tracks, meta = { total = #tracks, limit = 5, offset = 0 } }), + } +end + +local function newSdk(): (any, any) + local http = mockHttp.new() + local sdk = loadSdk({ http = http }) + local AudioScape: any = sdk + AudioScape.setApiKey("test-key") + return AudioScape, http +end + +-- 1. SFX-only seed: one sfxSimilar call classifies it AND fills the pool. +do + local AudioScape, http = newSdk() + http:queueResponse(similarResponse({ "222", "333", "444" })) + local bank = AudioScape:createSoundBank({ seeds = { impact = "111" }, kind = "sfx", poolSize = 5 }) + local ok, err = bank:resolveAsync() + assert(ok, "resolve failed: " .. tostring(err)) + assert(#http.requests == 1, "exactly one HTTP call; got " .. #http.requests) + local recorded = http.requests[1] + assert(recorded.method == "GET", "sfxSimilar is GET") + assert(recorded.url:find("/v1/sfx/similar", 1, true), "classified via /v1/sfx/similar; got " .. recorded.url) + assert(recorded.url:find("asset_id=111", 1, true), "seed is the anchor; got " .. recorded.url) + assert(not recorded.url:find("/v1/search", 1, true), "no music-catalog lookup for an sfx bank") + assert(bank.Pools.impact.source == "catalog", "seed known to the SFX catalog; got " .. bank.Pools.impact.source) + local pool = bank:getPool("impact") + assert(pool[1] == "111", "extend mode keeps the seed first; got " .. tostring(pool[1])) + assert(#pool == 4, "seed + 3 neighbours; got " .. #pool) +end + +-- 2. sfxSimilar 404 → seed unknown; bridging can't run under Lune, so "none". +do + local AudioScape, http = newSdk() + http:queueResponse({ Success = false, StatusCode = 404, Body = serde.encode("json", { error = "asset_id not found" }) }) + local bank = AudioScape:createSoundBank({ seeds = { impact = "999" }, kind = "sfx" }) + local ok, err = bank:resolveAsync() + assert(ok, "a 404 is a classification, not a failure: " .. tostring(err)) + assert(bank.Pools.impact.source == "none", "unknown seed reports 'none'; got " .. bank.Pools.impact.source) + local pool = bank:getPool("impact") + assert(#pool == 1 and pool[1] == "999", "pool falls back to the seed alone") +end + +-- 3. Any other sfxSimilar error still surfaces from resolveAsync. +do + local AudioScape, http = newSdk() + http:queueResponse({ Success = false, StatusCode = 503, Body = "" }) + local bank = AudioScape:createSoundBank({ seeds = { impact = "111" }, kind = "sfx" }) + local ok, err = bank:resolveAsync() + assert(not ok and err and err:find("(503)", 1, true), "5xx propagates; got " .. tostring(err)) +end + +-- 4. Replace mode drops a seed the SFX catalog knows, same as music. +do + local AudioScape, http = newSdk() + http:queueResponse(similarResponse({ "222", "333" })) + local bank = AudioScape:createSoundBank({ seeds = { impact = "111" }, kind = "sfx", mode = "replace" }) + local ok, err = bank:resolveAsync() + assert(ok, "resolve failed: " .. tostring(err)) + local pool = bank:getPool("impact") + assert(#pool == 2 and pool[1] == "222", "catalog seed replaced by neighbours; got " .. tostring(pool[1])) +end