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 }