Implement the OpenSubsonic transcoding extension (getTranscodeDecision/getTranscodeStream)
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
This commit is contained in:
@@ -55,7 +55,7 @@ func TestGetOpenSubsonicExtensions(t *testing.T) {
|
|||||||
// set once Phase 3 is done.
|
// set once Phase 3 is done.
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"apiKeyAuthentication", "formPost", "getPodcastEpisode", "topSongsByArtistId", "transcodeOffset",
|
"apiKeyAuthentication", "formPost", "getPodcastEpisode", "topSongsByArtistId", "transcodeOffset",
|
||||||
"indexBasedQueue", "playbackReport", "songLyrics",
|
"indexBasedQueue", "playbackReport", "songLyrics", "transcoding",
|
||||||
} {
|
} {
|
||||||
if !names[want] {
|
if !names[want] {
|
||||||
t.Errorf("extension %q not advertised: %v", want, names)
|
t.Errorf("extension %q not advertised: %v", want, names)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user