Implements Subsonic spec §6 streaming lifecycle so a third-party client can actually play music against the library tree shipped in #295. Closes Fable #296.
Endpoints
stream — serves track bytes via http.ServeContent, which handles Range, If-Modified-Since, and ETag for free. Accept-Ranges: bytes set explicitly so clients probe-then-seek.
download — same byte path with Content-Disposition: attachment; filename="..." so browsers save.
getCoverArt — resolves id → album (trying the id directly, then as a track → album_id). Prefers albums.cover_art_path when set (scanner will populate in a later milestone); falls back to a sidecar image next to the first track's file. Supported names in priority order: cover.{jpg,jpeg,png} → folder.{jpg,jpeg,png}. Returns Subsonic 70 when no art found.
scrobble — submission=false records a now-playing hint into an in-memory nowPlayingMap keyed by user id. submission=true and missing-submission are no-ops; M2 will add real play/skip/seek/complete event ingestion.
No transcoding in v1
Per agreed scope, maxBitRate and format params are intentionally ignored. Original bytes are served as-is. Real ffmpeg pipeline is M6. Documented inline in the handler.
Wire-level side effect
coverArtID now returns the album UUID unconditionally (previously empty string when cover_art_path was nil). Browse responses advertise a coverArt id on every album/song — worst case clients get a Subsonic 70 when no sidecar exists. Net effect: scanner can skip cover extraction during M1 and clients still display art when the library has sidecar files.
Test plan
go build ./... clean
go vet ./... clean
golangci-lint run ./internal/subsonic/... clean
go test -race ./... green
New unit tests: findSidecarCover priority (cover.* wins over folder.*), imageContentType mapping, nowPlayingMap write/read round-trip
Live smoke deferred — port 5432 in use by another local project. Range/Content-Length correctness relies on stdlib http.ServeContent, which is well-tested. End-to-end client verification happens in the next task (Fable #297 — M1 milestone gate with Feishin/Symfonium).
Forgejo CI green
Implements Subsonic spec §6 streaming lifecycle so a third-party client can actually play music against the library tree shipped in #295. Closes Fable #296.
## Endpoints
- `stream` — serves track bytes via `http.ServeContent`, which handles `Range`, `If-Modified-Since`, and `ETag` for free. `Accept-Ranges: bytes` set explicitly so clients probe-then-seek.
- `download` — same byte path with `Content-Disposition: attachment; filename="..."` so browsers save.
- `getCoverArt` — resolves id → album (trying the id directly, then as a track → album_id). Prefers `albums.cover_art_path` when set (scanner will populate in a later milestone); falls back to a sidecar image next to the first track's file. Supported names in priority order: `cover.{jpg,jpeg,png}` → `folder.{jpg,jpeg,png}`. Returns Subsonic 70 when no art found.
- `scrobble` — `submission=false` records a now-playing hint into an in-memory `nowPlayingMap` keyed by user id. `submission=true` and missing-submission are no-ops; M2 will add real play/skip/seek/complete event ingestion.
## No transcoding in v1
Per agreed scope, `maxBitRate` and `format` params are intentionally ignored. Original bytes are served as-is. Real ffmpeg pipeline is M6. Documented inline in the handler.
## Wire-level side effect
`coverArtID` now returns the album UUID unconditionally (previously empty string when `cover_art_path` was nil). Browse responses advertise a coverArt id on every album/song — worst case clients get a Subsonic 70 when no sidecar exists. Net effect: scanner can skip cover extraction during M1 and clients still display art when the library has sidecar files.
## Test plan
- [x] `go build ./...` clean
- [x] `go vet ./...` clean
- [x] `golangci-lint run ./internal/subsonic/...` clean
- [x] `go test -race ./...` green
- [x] New unit tests: `findSidecarCover` priority (cover.* wins over folder.*), `imageContentType` mapping, `nowPlayingMap` write/read round-trip
- [ ] Live smoke deferred — port 5432 in use by another local project. Range/Content-Length correctness relies on stdlib `http.ServeContent`, which is well-tested. End-to-end client verification happens in the next task (Fable #297 — M1 milestone gate with Feishin/Symfonium).
- [ ] Forgejo CI green
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 §6 streaming lifecycle so a third-party client can actually play music against the library tree shipped in #295. Closes Fable #296.
Endpoints
stream— serves track bytes viahttp.ServeContent, which handlesRange,If-Modified-Since, andETagfor free.Accept-Ranges: bytesset explicitly so clients probe-then-seek.download— same byte path withContent-Disposition: attachment; filename="..."so browsers save.getCoverArt— resolves id → album (trying the id directly, then as a track → album_id). Prefersalbums.cover_art_pathwhen set (scanner will populate in a later milestone); falls back to a sidecar image next to the first track's file. Supported names in priority order:cover.{jpg,jpeg,png}→folder.{jpg,jpeg,png}. Returns Subsonic 70 when no art found.scrobble—submission=falserecords a now-playing hint into an in-memorynowPlayingMapkeyed by user id.submission=trueand missing-submission are no-ops; M2 will add real play/skip/seek/complete event ingestion.No transcoding in v1
Per agreed scope,
maxBitRateandformatparams are intentionally ignored. Original bytes are served as-is. Real ffmpeg pipeline is M6. Documented inline in the handler.Wire-level side effect
coverArtIDnow returns the album UUID unconditionally (previously empty string whencover_art_pathwas nil). Browse responses advertise a coverArt id on every album/song — worst case clients get a Subsonic 70 when no sidecar exists. Net effect: scanner can skip cover extraction during M1 and clients still display art when the library has sidecar files.Test plan
go build ./...cleango vet ./...cleangolangci-lint run ./internal/subsonic/...cleango test -race ./...greenfindSidecarCoverpriority (cover.* wins over folder.*),imageContentTypemapping,nowPlayingMapwrite/read round-triphttp.ServeContent, which is well-tested. End-to-end client verification happens in the next task (Fable #297 — M1 milestone gate with Feishin/Symfonium).