Implements Subsonic spec §8 browse surface so a third-party Subsonic client can browse artist → album → track after a scan. Closes Fable #295.
Endpoints
getMusicFolders — single folder (id=1, "Music")
getIndexes / getArtists — alphabetical artist buckets; getIndexes adds lastModified (max artists.updated_at, unix-ms) and ignoredArticles
getArtist — artist + nested albums (with track counts)
getAlbum — album + full track list, parent artist name
getSong — single track + parent album/artist names
getAlbumList2 — newest | alphabeticalByName | alphabeticalByArtist | random | byGenre backed by sqlc queries; recent | frequent stub to [] pending M2 play history
search3 — independent artist/album/song facets, each with its own count/offset
Shape
Bare UUID strings on wire (no ar-/al-/tr- prefixes)
Dual JSON+XML struct tags on every response type; both formats verified against a live seeded DB
Empty slices serialize as [] not null (clients reject the latter) — enforced at albumRefs and in search3's result initializer
go test -race ./internal/subsonic/... green — covers UUID round-trip, index-letter bucketing (including # for digits, ? for non-ASCII), content-type mapping, date/timestamp conversion, atoi/clamp, and getMusicFolders wire shape
Live smoke against seeded DB — all 8 endpoints + variants (newest/alphaByName/alphaByArtist/random/byGenre/recent/frequent) + error paths (missing id, invalid UUID, missing type/query) return correct Subsonic error codes (10, 70)
Forgejo CI green
Known non-regressions (pre-existing): album duration: 0 (scanner doesn't extract DurationMs yet — tracked separately); a couple of seeded artists bucket under "A" because their sort_name literally starts with "Artist" (test-data artifact, not bug).
Implements Subsonic spec §8 browse surface so a third-party Subsonic client can browse artist → album → track after a scan. Closes Fable #295.
## Endpoints
- `getMusicFolders` — single folder (id=1, "Music")
- `getIndexes` / `getArtists` — alphabetical artist buckets; `getIndexes` adds `lastModified` (max `artists.updated_at`, unix-ms) and `ignoredArticles`
- `getArtist` — artist + nested albums (with track counts)
- `getAlbum` — album + full track list, parent artist name
- `getSong` — single track + parent album/artist names
- `getAlbumList2` — `newest | alphabeticalByName | alphabeticalByArtist | random | byGenre` backed by sqlc queries; `recent | frequent` stub to `[]` pending M2 play history
- `search3` — independent artist/album/song facets, each with its own `count`/`offset`
## Shape
- Bare UUID strings on wire (no `ar-`/`al-`/`tr-` prefixes)
- Dual JSON+XML struct tags on every response type; both formats verified against a live seeded DB
- Empty slices serialize as `[]` not `null` (clients reject the latter) — enforced at `albumRefs` and in `search3`'s result initializer
- OpenSubsonic envelope fields (`type`, `serverVersion`, `openSubsonic:true`, `apiKey`) preserved
## Test plan
- [x] `go vet ./...` clean
- [x] `golangci-lint run` clean (errcheck, etc.)
- [x] `go test -race ./internal/subsonic/...` green — covers UUID round-trip, index-letter bucketing (including `#` for digits, `?` for non-ASCII), content-type mapping, date/timestamp conversion, atoi/clamp, and `getMusicFolders` wire shape
- [x] Live smoke against seeded DB — all 8 endpoints + variants (newest/alphaByName/alphaByArtist/random/byGenre/recent/frequent) + error paths (missing id, invalid UUID, missing type/query) return correct Subsonic error codes (10, 70)
- [ ] Forgejo CI green
Known non-regressions (pre-existing): album `duration: 0` (scanner doesn't extract DurationMs yet — tracked separately); a couple of seeded artists bucket under "A" because their `sort_name` literally starts with "Artist" (test-data artifact, not bug).
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Implements Subsonic spec §8 browse surface so a third-party Subsonic client can browse artist → album → track after a scan. Closes Fable #295.
Endpoints
getMusicFolders— single folder (id=1, "Music")getIndexes/getArtists— alphabetical artist buckets;getIndexesaddslastModified(maxartists.updated_at, unix-ms) andignoredArticlesgetArtist— artist + nested albums (with track counts)getAlbum— album + full track list, parent artist namegetSong— single track + parent album/artist namesgetAlbumList2—newest | alphabeticalByName | alphabeticalByArtist | random | byGenrebacked by sqlc queries;recent | frequentstub to[]pending M2 play historysearch3— independent artist/album/song facets, each with its owncount/offsetShape
ar-/al-/tr-prefixes)[]notnull(clients reject the latter) — enforced atalbumRefsand insearch3's result initializertype,serverVersion,openSubsonic:true,apiKey) preservedTest plan
go vet ./...cleangolangci-lint runclean (errcheck, etc.)go test -race ./internal/subsonic/...green — covers UUID round-trip, index-letter bucketing (including#for digits,?for non-ASCII), content-type mapping, date/timestamp conversion, atoi/clamp, andgetMusicFolderswire shapeKnown non-regressions (pre-existing): album
duration: 0(scanner doesn't extract DurationMs yet — tracked separately); a couple of seeded artists bucket under "A" because theirsort_nameliterally starts with "Artist" (test-data artifact, not bug).