Subsonic: media retrieval endpoints (stream, download, getCoverArt, getLyrics, getAvatar, hls, getCaptions) with real transcoding, embedded lyrics extraction, and folder permission enforcement

This commit is contained in:
2026-08-23 00:15:00 -05:00
parent b6ec06f272
commit c7b472ab99
11 changed files with 792 additions and 1 deletions
+342
View File
@@ -0,0 +1,342 @@
package subsonic
import (
"context"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
"git.noroutes.net/NoRoutes/vibetunes/internal/transcoding"
)
func init() {
register("stream", handleStream)
register("download", handleDownload)
register("getCoverArt", handleGetCoverArt)
register("getLyrics", handleGetLyrics)
register("getAvatar", handleGetAvatar)
register("hls", handleHLS)
register("getCaptions", handleGetCaptions)
}
// resolveSongFile loads a song's media file and folder, checking the
// requesting user's folder permission. Every media-retrieval endpoint
// goes through this so the authorization check can never be forgotten
// in one of them.
func resolveSongFile(rc *Context, songID string) (mf *database.MediaFile, folder *database.MusicFolder, aerr *apiError) {
db := rc.Server.DB
song, err := database.GetSong(db, songID)
if err != nil {
return nil, nil, errNotFound("Song")
}
mf, err = database.GetMediaFile(db, song.MediaFileID)
if err != nil {
return nil, nil, errNotFound("Song")
}
if canSee, cerr := UserCanSeeMediaFile(db, rc.User, mf); cerr != nil {
return nil, nil, newError(ErrGeneric, cerr.Error())
} else if !canSee {
return nil, nil, errUnauthorized("this song")
}
folder, err = database.GetMusicFolder(db, mf.MusicFolderID)
if err != nil {
return nil, nil, newError(ErrGeneric, err.Error())
}
return mf, folder, nil
}
func handleStream(rc *Context) *apiError {
if !rc.User.Allows(rc.User.Permissions.Stream) {
return errUnauthorized("streaming")
}
id, aerr := rc.Params.Required("id")
if aerr != nil {
return aerr
}
mf, folder, aerr := resolveSongFile(rc, id)
if aerr != nil {
return aerr
}
absPath, perr := streaming.ResolvePath(folder.Path, mf.RelativePath)
if perr != nil {
return newError(ErrGeneric, perr.Error())
}
formatParam := rc.Params.Get("format")
maxBitRate := rc.Params.Int("maxBitRate", 0)
timeOffset := rc.Params.Int("timeOffset", 0)
rc.Handled = true
// format=raw explicitly forces the original bytes. Otherwise,
// transcode only if the client actually asked for something the
// source doesn't already satisfy: a specific format, or a bitrate
// cap below the source's own bitrate.
needsTranscode := formatParam != "" && formatParam != "raw"
if maxBitRate > 0 && (mf.BitrateKbps == 0 || maxBitRate < mf.BitrateKbps) {
needsTranscode = true
}
if !needsTranscode {
if err := streaming.ServeFile(rc.W, rc.R, absPath, mf.RelativePath); err != nil {
http.Error(rc.W, "could not read media file", http.StatusInternalServerError)
}
return nil
}
format := transcoding.ParseFormat(formatParam)
rc.W.Header().Set("Content-Type", format.MimeType())
ctx := rc.R.Context()
if err := rc.Server.Transcoder.Stream(ctx, rc.W, absPath, transcoding.Options{
Format: format, MaxBitRateKbps: maxBitRate, TimeOffset: float64(timeOffset),
}); err != nil && ctx.Err() == nil {
rc.Server.Log.Warn("transcode failed", "err", err, "path", absPath)
}
return nil
}
func handleDownload(rc *Context) *apiError {
if !rc.User.Allows(rc.User.Permissions.Download) {
return errUnauthorized("downloading")
}
id, aerr := rc.Params.Required("id")
if aerr != nil {
return aerr
}
mf, folder, aerr := resolveSongFile(rc, id)
if aerr != nil {
return aerr
}
absPath, perr := streaming.ResolvePath(folder.Path, mf.RelativePath)
if perr != nil {
return newError(ErrGeneric, perr.Error())
}
rc.Handled = true
filename := filepath.Base(mf.RelativePath)
rc.W.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(filename, `"`, "")+`"`)
if err := streaming.ServeFile(rc.W, rc.R, absPath, filename); err != nil {
http.Error(rc.W, "could not read media file", http.StatusInternalServerError)
}
return nil
}
func handleGetCoverArt(rc *Context) *apiError {
if !rc.User.Allows(rc.User.Permissions.CoverArt) {
return errUnauthorized("cover art")
}
id, aerr := rc.Params.Required("id")
if aerr != nil {
return aerr
}
size := rc.Params.Int("size", 0)
db := rc.Server.DB
art, err := database.GetCoverArt(db, id)
if err != nil {
return errNotFound("Cover art")
}
var sourcePath, contentHash string
var isTemp bool
if art.Source == "folder" {
sourcePath, contentHash = art.SourcePath, art.ContentHash
} else {
mf, merr := database.GetMediaFile(db, art.MediaFileID.String)
if merr != nil {
return errNotFound("Cover art")
}
folder, ferr := database.GetMusicFolder(db, mf.MusicFolderID)
if ferr != nil {
return newError(ErrGeneric, ferr.Error())
}
absPath, perr := streaming.ResolvePath(folder.Path, mf.RelativePath)
if perr != nil {
return newError(ErrGeneric, perr.Error())
}
prober := &metadata.Prober{FFprobePath: rc.Server.Config.FFprobePath}
ctx, cancel := contextWithTimeout(rc.R.Context())
defer cancel()
tags, terr := prober.Probe(ctx, absPath)
if terr != nil || !tags.HasEmbeddedArt {
return errNotFound("Cover art")
}
extracted, eerr := rc.Server.CoverArt.ExtractEmbedded(ctx, absPath, tags.EmbeddedArtStream)
if eerr != nil {
return newError(ErrGeneric, eerr.Error())
}
sourcePath, contentHash, isTemp = extracted, art.ContentHash, true
}
if isTemp {
defer os.Remove(sourcePath)
}
cachedPath, gerr := rc.Server.CoverArt.GetOrCreate(sourcePath, contentHash, size)
if gerr != nil {
return newError(ErrGeneric, gerr.Error())
}
rc.Handled = true
rc.W.Header().Set("Cache-Control", "public, max-age=604800, immutable")
if err := streaming.ServeFile(rc.W, rc.R, cachedPath, "cover.jpg"); err != nil {
http.Error(rc.W, "could not read cover art", http.StatusInternalServerError)
}
return nil
}
func contextWithTimeout(parent context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(parent, 15*time.Second)
}
func handleGetLyrics(rc *Context) *apiError {
title := rc.Params.Get("title")
artist := rc.Params.Get("artist")
out := &Lyrics{Artist: artist, Title: title}
if title != "" {
db := rc.Server.DB
if song, err := database.FindSongByTitleArtist(db, title, artist); err == nil {
if text, found, _ := database.SongLyricsPlainText(db, song.ID); found {
out.Value = text
}
}
}
rc.Response = newResponse()
rc.Response.Lyrics = out
return nil
}
// handleGetAvatar: VibeSonic has no user-avatar feature (Phase 1/2
// scope), so honestly reports "not found" rather than fabricating an
// image.
func handleGetAvatar(rc *Context) *apiError {
if _, aerr := rc.Params.Required("username"); aerr != nil {
return aerr
}
return errNotFound("Avatar")
}
func handleHLS(rc *Context) *apiError {
if !rc.User.Allows(rc.User.Permissions.Stream) {
return errUnauthorized("streaming")
}
id, aerr := rc.Params.Required("id")
if aerr != nil {
return aerr
}
mf, folder, aerr := resolveSongFile(rc, id)
if aerr != nil {
return aerr
}
absPath, perr := streaming.ResolvePath(folder.Path, mf.RelativePath)
if perr != nil {
return newError(ErrGeneric, perr.Error())
}
bitRate := rc.Params.Int("bitRate", 0)
opts := transcoding.Options{Format: transcoding.FormatAAC, MaxBitRateKbps: bitRate}
if segStr := rc.Params.Get("segment"); segStr != "" {
segIndex, serr := strconv.Atoi(segStr)
if serr != nil {
return newError(ErrGeneric, "invalid segment")
}
session, herr := rc.Server.Transcoder.BuildHLS(rc.R.Context(), absPath, rc.Server.hlsCacheDir(), opts)
if herr != nil {
return newError(ErrGeneric, herr.Error())
}
path := session.SegmentPath(segIndex)
if path == "" {
return errNotFound("HLS segment")
}
rc.Handled = true
rc.W.Header().Set("Content-Type", "video/mp2t")
if err := streaming.ServeFile(rc.W, rc.R, path, "segment.ts"); err != nil {
http.Error(rc.W, "could not read segment", http.StatusInternalServerError)
}
return nil
}
session, herr := rc.Server.Transcoder.BuildHLS(rc.R.Context(), absPath, rc.Server.hlsCacheDir(), opts)
if herr != nil {
return newError(ErrGeneric, herr.Error())
}
authQuery := authQueryString(rc)
playlist := session.Playlist(func(i int) string {
return "hls.view?id=" + url.QueryEscape(id) + "&segment=" + strconv.Itoa(i) +
bitRateQuerySuffix(bitRate) + "&" + authQuery
})
rc.Handled = true
rc.W.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
_, _ = rc.W.Write([]byte(playlist))
return nil
}
func bitRateQuerySuffix(bitRate int) string {
if bitRate <= 0 {
return ""
}
return "&bitRate=" + strconv.Itoa(bitRate)
}
// authQueryString re-embeds the caller's own authentication parameters
// into generated segment URLs, so a bare HLS player (which won't know
// Subsonic's auth scheme) can fetch subsequent segments the same way it
// fetched the playlist.
func authQueryString(rc *Context) string {
var parts []string
for _, name := range []string{"u", "p", "t", "s", "v", "c"} {
if v := rc.Params.Get(name); v != "" {
parts = append(parts, name+"="+url.QueryEscape(v))
}
}
return strings.Join(parts, "&")
}
func (s *Server) hlsCacheDir() string {
return filepath.Join(s.Config.DataDir, "cache", "hls")
}
func handleGetCaptions(rc *Context) *apiError {
id, aerr := rc.Params.Required("id")
if aerr != nil {
return aerr
}
parts := strings.SplitN(id, ":cap:", 2)
if len(parts) != 2 {
return errNotFound("Captions")
}
songID, indexStr := parts[0], parts[1]
index, ierr := strconv.Atoi(indexStr)
if ierr != nil {
return errNotFound("Captions")
}
mf, folder, aerr := resolveSongFile(rc, songID)
if aerr != nil {
return aerr
}
absPath, perr := streaming.ResolvePath(folder.Path, mf.RelativePath)
if perr != nil {
return newError(ErrGeneric, perr.Error())
}
captions := findSidecarCaptions(absPath)
if index < 0 || index >= len(captions) {
return errNotFound("Captions")
}
rc.Handled = true
if err := streaming.ServeFile(rc.W, rc.R, captions[index], filepath.Base(captions[index])); err != nil {
http.Error(rc.W, "could not read captions", http.StatusInternalServerError)
}
return nil
}