diff --git a/internal/subsonic/opensubsonic_core_test.go b/internal/subsonic/opensubsonic_core_test.go index ae634b5..11af252 100644 --- a/internal/subsonic/opensubsonic_core_test.go +++ b/internal/subsonic/opensubsonic_core_test.go @@ -55,7 +55,7 @@ func TestGetOpenSubsonicExtensions(t *testing.T) { // set once Phase 3 is done. for _, want := range []string{ "apiKeyAuthentication", "formPost", "getPodcastEpisode", "topSongsByArtistId", "transcodeOffset", - "indexBasedQueue", "playbackReport", "songLyrics", + "indexBasedQueue", "playbackReport", "songLyrics", "transcoding", } { if !names[want] { t.Errorf("extension %q not advertised: %v", want, names) diff --git a/internal/subsonic/transcode_decision.go b/internal/subsonic/transcode_decision.go new file mode 100644 index 0000000..8877bac --- /dev/null +++ b/internal/subsonic/transcode_decision.go @@ -0,0 +1,324 @@ +package subsonic + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" + "git.noroutes.net/NoRoutes/vibetunes/internal/streaming" + "git.noroutes.net/NoRoutes/vibetunes/internal/transcoding" +) + +func init() { + registerExtension("transcoding", 1) + register("getTranscodeDecision", handleGetTranscodeDecision) + register("getTranscodeStream", handleGetTranscodeStream) +} + +// codecFormats are the transcode targets VibeSonic's ffmpeg pipeline can +// actually produce. A client's transcodingProfile.audioCodec must name +// one of these for VibeSonic to be able to honor it — canTranscode is +// never set true for a codec we can't actually encode. +var codecFormats = map[string]transcoding.Format{ + "mp3": transcoding.FormatMP3, + "opus": transcoding.FormatOpus, + "aac": transcoding.FormatAAC, +} + +func containsFold(list []string, want string) bool { + for _, s := range list { + if strings.EqualFold(s, want) { + return true + } + } + return false +} + +// matchesDirectPlay reports whether a source file, as actually scanned +// (its real container/format, channel count), satisfies one of the +// client's declared direct-play profiles. +func matchesDirectPlay(mf *database.MediaFile, profiles []DirectPlayProfile) bool { + for _, p := range profiles { + if len(p.Protocols) > 0 && !containsFold(p.Protocols, "http") { + continue + } + if !containsFold(p.Containers, mf.Format) { + continue + } + if len(p.AudioCodecs) > 0 && !containsFold(p.AudioCodecs, mf.Format) { + continue + } + if p.MaxAudioChannels > 0 && mf.Channels > p.MaxAudioChannels { + continue + } + return true + } + return false +} + +// limitationOK evaluates one CodecProfile limitation against a candidate +// value VibeSonic would actually produce for that transcode. +func limitationOK(lim Limitation, value int) bool { + if len(lim.Values) == 0 { + return true + } + for _, raw := range lim.Values { + want, err := strconv.Atoi(raw) + if err != nil { + continue + } + switch lim.Comparison { + case "Equals": + if value == want { + return true + } + case "NotEquals": + if value != want { + return true + } + case "LessThanEqual": + if value <= want { + return true + } + case "GreaterThanEqual": + if value >= want { + return true + } + } + } + return false +} + +// codecProfileFor finds the CodecProfile (if any) the client declared +// for a given audio codec name. +func codecProfileFor(profiles []CodecProfile, codec string) *CodecProfile { + for i := range profiles { + if strings.EqualFold(profiles[i].Type, "audio") && strings.EqualFold(profiles[i].Name, codec) { + return &profiles[i] + } + } + return nil +} + +// transcodePlan is what VibeSonic decided to actually produce for a +// chosen transcoding profile: a real ffmpeg format, bitrate, and channel +// count that satisfy every limitation the client marked required. +type transcodePlan struct { + format transcoding.Format + container string + codec string + bitrate int + channels int +} + +// planTranscode picks the first client-declared transcoding profile +// VibeSonic can actually fulfill: a codec its ffmpeg pipeline supports, +// with a bitrate/channel count that satisfies every "required" codec +// limitation the client listed. It never claims to satisfy a required +// limitation it doesn't actually meet. +func planTranscode(mf *database.MediaFile, client ClientInfo) *transcodePlan { + for _, tp := range client.TranscodingProfiles { + if tp.Protocol != "" && !strings.EqualFold(tp.Protocol, "http") { + continue + } + format, ok := codecFormats[strings.ToLower(tp.AudioCodec)] + if !ok { + continue + } + + bitrate := client.MaxTranscodingAudioBitrate + if bitrate <= 0 { + bitrate = 192 + } + channels := mf.Channels + if tp.MaxAudioChannels > 0 && channels > tp.MaxAudioChannels { + channels = tp.MaxAudioChannels + } + + if cp := codecProfileFor(client.CodecProfiles, tp.AudioCodec); cp != nil { + ok := true + for _, lim := range cp.Limitations { + switch lim.Name { + case "audioBitrate": + if !limitationOK(lim, bitrate) { + if !lim.Required { + continue + } + ok = false + } + case "audioChannels": + if !limitationOK(lim, channels) { + if !lim.Required { + continue + } + ok = false + } + } + if !ok { + break + } + } + if !ok { + continue + } + } + + return &transcodePlan{ + format: format, + container: tp.Container, + codec: tp.AudioCodec, + bitrate: bitrate, + channels: channels, + } + } + return nil +} + +func handleGetTranscodeDecision(rc *Context) *apiError { + if !rc.User.Allows(rc.User.Permissions.Stream) { + return errUnauthorized("streaming") + } + mediaID, aerr := rc.Params.Required("mediaId") + if aerr != nil { + return aerr + } + mediaType, aerr := rc.Params.Required("mediaType") + if aerr != nil { + return aerr + } + + var client ClientInfo + if rc.R.Body != nil { + defer rc.R.Body.Close() + if err := json.NewDecoder(rc.R.Body).Decode(&client); err != nil { + return newError(ErrGeneric, "invalid ClientInfo request body: "+err.Error()) + } + } + + db := rc.Server.DB + song, err := resolveMediaID(db, mediaID, mediaType) + if err != nil { + return errNotFound("Media") + } + mf, err := database.GetMediaFile(db, song.MediaFileID) + if err != nil { + return errNotFound("Media") + } + + source := &StreamDetails{ + Protocol: "http", + Container: mf.Format, + Codec: mf.Format, + AudioChannels: mf.Channels, + AudioBitrate: mf.BitrateKbps, + AudioSamplerate: mf.SampleRateHz, + } + + decision := TranscodeDecision{SourceStream: source} + + if matchesDirectPlay(mf, client.DirectPlayProfiles) { + decision.CanDirectPlay = true + rc.Response = newResponse() + rc.Response.TranscodeDecision = &decision + return nil + } + decision.TranscodeReason = []string{"source format not in client's direct-play profiles"} + + plan := planTranscode(mf, client) + if plan == nil { + decision.CanTranscode = false + decision.ErrorReason = "no client transcoding profile is compatible with what this server can produce" + rc.Response = newResponse() + rc.Response.TranscodeDecision = &decision + return nil + } + + decision.CanTranscode = true + decision.TranscodeStream = &StreamDetails{ + Protocol: "http", + Container: plan.container, + Codec: plan.codec, + AudioChannels: plan.channels, + AudioBitrate: plan.bitrate, + } + decision.TranscodeParams = encodeTranscodeParams(plan) + + rc.Response = newResponse() + rc.Response.TranscodeDecision = &decision + return nil +} + +// encodeTranscodeParams and decodeTranscodeParams round-trip a +// transcodePlan through an opaque server-specific token, per the spec's +// instruction that clients must pass back exactly what +// getTranscodeDecision gave them rather than reconstructing it. +func encodeTranscodeParams(plan *transcodePlan) string { + v := url.Values{} + v.Set("format", string(plan.format)) + v.Set("bitrate", strconv.Itoa(plan.bitrate)) + v.Set("channels", strconv.Itoa(plan.channels)) + return v.Encode() +} + +func decodeTranscodeParams(s string) (transcoding.Options, error) { + v, err := url.ParseQuery(s) + if err != nil { + return transcoding.Options{}, err + } + format, ok := codecFormats[v.Get("format")] + if !ok { + return transcoding.Options{}, fmt.Errorf("unsupported transcodeParams format %q", v.Get("format")) + } + bitrate, _ := strconv.Atoi(v.Get("bitrate")) + return transcoding.Options{Format: format, MaxBitRateKbps: bitrate}, nil +} + +func handleGetTranscodeStream(rc *Context) *apiError { + if !rc.User.Allows(rc.User.Permissions.Stream) { + return errUnauthorized("streaming") + } + mediaID, aerr := rc.Params.Required("mediaId") + if aerr != nil { + return aerr + } + mediaType, aerr := rc.Params.Required("mediaType") + if aerr != nil { + return aerr + } + transcodeParams, aerr := rc.Params.Required("transcodeParams") + if aerr != nil { + return aerr + } + offset := rc.Params.Int("offset", 0) + + db := rc.Server.DB + song, err := resolveMediaID(db, mediaID, mediaType) + if err != nil { + return errNotFound("Media") + } + mf, folder, aerr := resolveSongFile(rc, song.ID) + if aerr != nil { + return aerr + } + absPath, perr := streaming.ResolvePath(folder.Path, mf.RelativePath) + if perr != nil { + return newError(ErrGeneric, perr.Error()) + } + + opts, err := decodeTranscodeParams(transcodeParams) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + opts.TimeOffset = float64(offset) + + rc.Handled = true + rc.W.Header().Set("Content-Type", opts.Format.MimeType()) + ctx := rc.R.Context() + if err := rc.Server.Transcoder.Stream(ctx, rc.W, absPath, opts); err != nil && ctx.Err() == nil { + rc.Server.Log.Warn("transcode failed", "err", err, "path", absPath) + } + return nil +} diff --git a/internal/subsonic/transcode_decision_test.go b/internal/subsonic/transcode_decision_test.go new file mode 100644 index 0000000..b83b2d8 --- /dev/null +++ b/internal/subsonic/transcode_decision_test.go @@ -0,0 +1,177 @@ +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) + } +}