Adds a real capability-matching decision engine: getTranscodeDecision compares the source file's actual scanned format/channels against the client's declared directPlayProfiles first, and only when nothing matches does it search transcodingProfiles for a codec VibeSonic's ffmpeg pipeline can actually encode (mp3/opus/aac), applying any codecProfile limitations (bitrate/channel required constraints) the client listed. canTranscode is never set true for a codec the server has no encoder for, and canDirectPlay is never set true without a matching client profile. getTranscodeStream takes the opaque transcodeParams token the decision returned (never re-derived) plus a real time offset and streams actual transcoded audio via the existing seek-capable transcoding.Manager. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
178 lines
6.0 KiB
Go
178 lines
6.0 KiB
Go
package subsonic
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"testing"
|
|
|
|
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
|
)
|
|
|
|
func postTranscodeDecision(t *testing.T, srv string, q map[string]string, client ClientInfo) *http.Response {
|
|
t.Helper()
|
|
body, err := json.Marshal(client)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
format := q["f"]
|
|
if format == "" {
|
|
format = "xml"
|
|
}
|
|
url := srv + "/rest/getTranscodeDecision.view?u=" + q["u"] + "&p=" + q["p"] + "&v=1.16.1&c=vibesonic-test&f=" + format + "&mediaId=" + q["mediaId"] + "&mediaType=" + q["mediaType"]
|
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func TestTranscodeDecisionDirectPlay(t *testing.T) {
|
|
s, srv := newTestRecorderServer(t)
|
|
createTestUser(t, s, "alice", "password123", false)
|
|
seedLibrary(t, s)
|
|
song := firstSong(t, s)
|
|
|
|
client := ClientInfo{
|
|
Name: "TestClient",
|
|
Platform: "linux",
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{"http"}},
|
|
},
|
|
}
|
|
resp := postTranscodeDecision(t, srv.URL, map[string]string{"u": "alice", "p": "password123", "mediaId": song.ID, "mediaType": "song", "f": "json"}, client)
|
|
j := decodeJSON(t, resp)
|
|
d, ok := j["transcodeDecision"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("transcodeDecision missing: %v", j)
|
|
}
|
|
if d["canDirectPlay"] != true {
|
|
t.Fatalf("canDirectPlay = %v, want true (client declared mp3 direct play, fixture songs are mp3)", d)
|
|
}
|
|
if _, has := d["transcodeStream"]; has {
|
|
t.Error("transcodeStream should be absent when direct play is possible")
|
|
}
|
|
}
|
|
|
|
func TestTranscodeDecisionFallsBackToTranscode(t *testing.T) {
|
|
s, srv := newTestRecorderServer(t)
|
|
createTestUser(t, s, "alice", "password123", false)
|
|
seedLibrary(t, s)
|
|
song := firstSong(t, s)
|
|
|
|
client := ClientInfo{
|
|
Name: "TestClient",
|
|
Platform: "linux",
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}, Protocols: []string{"http"}},
|
|
},
|
|
TranscodingProfiles: []TranscodingProfile{
|
|
{Container: "ogg", AudioCodec: "opus", Protocol: "http", MaxAudioChannels: 2},
|
|
},
|
|
MaxTranscodingAudioBitrate: 96,
|
|
}
|
|
resp := postTranscodeDecision(t, srv.URL, map[string]string{"u": "alice", "p": "password123", "mediaId": song.ID, "mediaType": "song", "f": "json"}, client)
|
|
j := decodeJSON(t, resp)
|
|
d := j["transcodeDecision"].(map[string]any)
|
|
if d["canDirectPlay"] != nil && d["canDirectPlay"] != false {
|
|
t.Errorf("canDirectPlay = %v, want false (source is mp3, client only direct-plays flac)", d["canDirectPlay"])
|
|
}
|
|
if d["canTranscode"] != true {
|
|
t.Fatalf("canTranscode = %v, want true", d)
|
|
}
|
|
ts, ok := d["transcodeStream"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("transcodeStream missing: %v", d)
|
|
}
|
|
if ts["codec"] != "opus" || ts["audioBitrate"] != float64(96) {
|
|
t.Errorf("transcodeStream = %v, want opus @ 96kbps", ts)
|
|
}
|
|
params, ok := d["transcodeParams"].(string)
|
|
if !ok || params == "" {
|
|
t.Fatalf("transcodeParams missing: %v", d)
|
|
}
|
|
|
|
// getTranscodeStream must accept exactly the transcodeParams token
|
|
// getTranscodeDecision returned and actually produce transcoded
|
|
// audio bytes.
|
|
streamResp, err := http.Get(srv.URL + "/rest/getTranscodeStream.view?u=alice&p=password123&v=1.16.1&c=vibesonic-test&mediaId=" + song.ID + "&mediaType=song&transcodeParams=" + url.QueryEscape(params))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer streamResp.Body.Close()
|
|
if streamResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("getTranscodeStream status = %d", streamResp.StatusCode)
|
|
}
|
|
data, _ := io.ReadAll(streamResp.Body)
|
|
if len(data) == 0 {
|
|
t.Fatal("getTranscodeStream returned no bytes")
|
|
}
|
|
if ct := streamResp.Header.Get("Content-Type"); ct != "audio/ogg" {
|
|
t.Errorf("Content-Type = %q, want audio/ogg for an opus transcode", ct)
|
|
}
|
|
}
|
|
|
|
func TestTranscodeDecisionNoCompatibleProfile(t *testing.T) {
|
|
s, srv := newTestRecorderServer(t)
|
|
createTestUser(t, s, "alice", "password123", false)
|
|
seedLibrary(t, s)
|
|
song := firstSong(t, s)
|
|
|
|
client := ClientInfo{
|
|
Name: "PickyClient",
|
|
Platform: "linux",
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}, Protocols: []string{"http"}},
|
|
},
|
|
TranscodingProfiles: []TranscodingProfile{
|
|
{Container: "wma", AudioCodec: "wmav2", Protocol: "http"},
|
|
},
|
|
}
|
|
resp := postTranscodeDecision(t, srv.URL, map[string]string{"u": "alice", "p": "password123", "mediaId": song.ID, "mediaType": "song", "f": "json"}, client)
|
|
j := decodeJSON(t, resp)
|
|
d := j["transcodeDecision"].(map[string]any)
|
|
if d["canTranscode"] != nil && d["canTranscode"] != false {
|
|
t.Errorf("canTranscode = %v, want false (server has no wmav2 encoder)", d["canTranscode"])
|
|
}
|
|
if d["errorReason"] == nil || d["errorReason"] == "" {
|
|
t.Error("expected a non-empty errorReason when no profile is compatible")
|
|
}
|
|
}
|
|
|
|
func TestTranscodeDecisionRequiresStreamPermission(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
u := createTestUser(t, s, "alice", "password123", false)
|
|
perms := u.Permissions
|
|
perms.Stream = false
|
|
if err := database.SetUserPermissions(db, u.ID, perms); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := newSubsonicTestServer(t, s)
|
|
seedLibrary(t, s)
|
|
song := firstSong(t, s)
|
|
|
|
resp := postTranscodeDecision(t, srv, map[string]string{"u": "alice", "p": "password123", "mediaId": song.ID, "mediaType": "song"}, ClientInfo{})
|
|
x := decodeXML(t, resp)
|
|
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized {
|
|
t.Fatalf("expected error 50, got %+v", x)
|
|
}
|
|
}
|
|
|
|
func TestTranscodeDecisionUnknownMedia(t *testing.T) {
|
|
s, srv := newTestRecorderServer(t)
|
|
createTestUser(t, s, "alice", "password123", false)
|
|
|
|
resp := postTranscodeDecision(t, srv.URL, map[string]string{"u": "alice", "p": "password123", "mediaId": "sg_doesnotexist", "mediaType": "song"}, ClientInfo{})
|
|
x := decodeXML(t, resp)
|
|
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound {
|
|
t.Fatalf("expected error 70, got %+v", x)
|
|
}
|
|
}
|