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 }