352 lines
10 KiB
Go
352 lines
10 KiB
Go
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() {
|
|
registerExtension("transcodeOffset", 1)
|
|
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 (timeOffset can't
|
|
// be honored in that case — a direct byte stream has no way to seek
|
|
// by time — so it's silently ignored, matching "raw" meaning
|
|
// "disable transcoding"). Otherwise, transcode whenever the client
|
|
// asked for something the source can't satisfy by itself: a
|
|
// specific format, a bitrate cap below the source's own bitrate, or
|
|
// (the transcodeOffset extension) a start offset — direct-serving
|
|
// bytes has no notion of "start at N seconds", only real
|
|
// transcoding with an ffmpeg seek can do that.
|
|
needsTranscode := formatParam != "" && formatParam != "raw"
|
|
if maxBitRate > 0 && (mf.BitrateKbps == 0 || maxBitRate < mf.BitrateKbps) {
|
|
needsTranscode = true
|
|
}
|
|
if timeOffset > 0 && formatParam != "raw" {
|
|
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
|
|
}
|