Files
vibesonic/internal/subsonic/transcode_decision.go
T
owenandClaude Sonnet 5 6fb46a989e 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
2026-08-23 08:23:08 -05:00

325 lines
8.4 KiB
Go

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
}