refactor: consolidate specid <-> filesystem mapping and always use abs paths (#309)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// Package ctrlsubsonic provides HTTP handlers for subsonic api
|
||||
// Package ctrlsubsonic provides HTTP handlers for subsonic API
|
||||
package ctrlsubsonic
|
||||
|
||||
import (
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"go.senan.xyz/gonic/jukebox"
|
||||
"go.senan.xyz/gonic/paths"
|
||||
"go.senan.xyz/gonic/podcasts"
|
||||
"go.senan.xyz/gonic/scrobble"
|
||||
"go.senan.xyz/gonic/server/ctrlbase"
|
||||
@@ -27,12 +26,24 @@ const (
|
||||
CtxParams
|
||||
)
|
||||
|
||||
type MusicPath struct {
|
||||
Alias, Path string
|
||||
}
|
||||
|
||||
func PathsOf(paths []MusicPath) []string {
|
||||
var r []string
|
||||
for _, p := range paths {
|
||||
r = append(r, p.Path)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
type Controller struct {
|
||||
*ctrlbase.Controller
|
||||
CachePath string
|
||||
CoverCachePath string
|
||||
MusicPaths []MusicPath
|
||||
PodcastsPath string
|
||||
MusicPaths paths.MusicPaths
|
||||
CacheAudioPath string
|
||||
CoverCachePath string
|
||||
Jukebox *jukebox.Jukebox
|
||||
Scrobblers []scrobble.Scrobbler
|
||||
Podcasts *podcasts.Podcasts
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"go.senan.xyz/gonic"
|
||||
"go.senan.xyz/gonic/db"
|
||||
"go.senan.xyz/gonic/mockfs"
|
||||
"go.senan.xyz/gonic/paths"
|
||||
"go.senan.xyz/gonic/server/ctrlbase"
|
||||
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
|
||||
"go.senan.xyz/gonic/transcode"
|
||||
@@ -159,9 +158,9 @@ func makec(t *testing.T, roots []string, audio bool) *Controller {
|
||||
m.ScanAndClean()
|
||||
m.ResetDates()
|
||||
|
||||
var absRoots paths.MusicPaths
|
||||
var absRoots []MusicPath
|
||||
for _, root := range roots {
|
||||
absRoots = append(absRoots, paths.MusicPath{Alias: "", Path: filepath.Join(m.TmpDir(), root)})
|
||||
absRoots = append(absRoots, MusicPath{Path: filepath.Join(m.TmpDir(), root)})
|
||||
}
|
||||
|
||||
base := &ctrlbase.Controller{DB: m.DB()}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -15,11 +14,11 @@ import (
|
||||
|
||||
"go.senan.xyz/gonic/db"
|
||||
"go.senan.xyz/gonic/multierr"
|
||||
"go.senan.xyz/gonic/paths"
|
||||
"go.senan.xyz/gonic/scanner"
|
||||
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
|
||||
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
|
||||
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
|
||||
"go.senan.xyz/gonic/server/ctrlsubsonic/specidpaths"
|
||||
)
|
||||
|
||||
func lowerUDecOrHash(in string) string {
|
||||
@@ -30,7 +29,7 @@ func lowerUDecOrHash(in string) string {
|
||||
return string(lower)
|
||||
}
|
||||
|
||||
func getMusicFolder(musicPaths paths.MusicPaths, p params.Params) string {
|
||||
func getMusicFolder(musicPaths []MusicPath, p params.Params) string {
|
||||
idx, err := p.GetInt("musicFolderId")
|
||||
if err != nil {
|
||||
return ""
|
||||
@@ -90,9 +89,12 @@ func (c *Controller) ServeScrobble(r *http.Request) *spec.Response {
|
||||
func (c *Controller) ServeGetMusicFolders(r *http.Request) *spec.Response {
|
||||
sub := spec.NewResponse()
|
||||
sub.MusicFolders = &spec.MusicFolders{}
|
||||
sub.MusicFolders.List = make([]*spec.MusicFolder, len(c.MusicPaths))
|
||||
for i, path := range c.MusicPaths {
|
||||
sub.MusicFolders.List[i] = &spec.MusicFolder{ID: i, Name: path.DisplayAlias()}
|
||||
for i, mp := range c.MusicPaths {
|
||||
alias := mp.Alias
|
||||
if alias == "" {
|
||||
alias = filepath.Base(mp.Path)
|
||||
}
|
||||
sub.MusicFolders.List = append(sub.MusicFolders.List, &spec.MusicFolder{ID: i, Name: alias})
|
||||
}
|
||||
return sub
|
||||
}
|
||||
@@ -289,17 +291,18 @@ func (c *Controller) ServeGetRandomSongs(r *http.Request) *spec.Response {
|
||||
return sub
|
||||
}
|
||||
|
||||
var errNotATrack = errors.New("not a track")
|
||||
|
||||
func (c *Controller) ServeJukebox(r *http.Request) *spec.Response { // nolint:gocyclo
|
||||
params := r.Context().Value(CtxParams).(params.Params)
|
||||
user := r.Context().Value(CtxUser).(*db.User)
|
||||
trackPaths := func(ids []specid.ID) ([]string, error) {
|
||||
var paths []string
|
||||
for _, id := range ids {
|
||||
var track db.Track
|
||||
if err := c.DB.Preload("Album").Preload("TrackStar", "user_id=?", user.ID).Preload("TrackRating", "user_id=?", user.ID).First(&track, id.Value).Error; err != nil {
|
||||
r, err := specidpaths.Locate(c.DB, c.PodcastsPath, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find track by id: %w", err)
|
||||
}
|
||||
paths = append(paths, track.AbsPath())
|
||||
paths = append(paths, r.AbsPath())
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
@@ -322,20 +325,15 @@ func (c *Controller) ServeJukebox(r *http.Request) *spec.Response { // nolint:go
|
||||
return nil, fmt.Errorf("get playlist: %w", err)
|
||||
}
|
||||
for _, path := range playlist {
|
||||
cwd, _ := os.Getwd()
|
||||
path, _ = filepath.Rel(cwd, path)
|
||||
var track db.Track
|
||||
err := c.DB.
|
||||
Preload("Album").
|
||||
Where(`(albums.root_dir || ? || albums.left_path || albums.right_path || ? || tracks.filename)=?`,
|
||||
string(filepath.Separator), string(filepath.Separator), path).
|
||||
Joins(`JOIN albums ON tracks.album_id=albums.id`).
|
||||
First(&track).
|
||||
Error
|
||||
file, err := specidpaths.Lookup(c.DB, PathsOf(c.MusicPaths), c.PodcastsPath, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch track: %w", err)
|
||||
}
|
||||
ret = append(ret, spec.NewTrackByTags(&track, track.Album))
|
||||
track, ok := file.(*db.Track)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%q: %w", path, errNotATrack)
|
||||
}
|
||||
ret = append(ret, spec.NewTrackByTags(track, track.Album))
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
|
||||
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
|
||||
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
|
||||
"go.senan.xyz/gonic/server/ctrlsubsonic/specidpaths"
|
||||
"go.senan.xyz/gonic/transcode"
|
||||
)
|
||||
|
||||
@@ -57,32 +58,6 @@ func streamGetTransPrefProfile(dbc *db.DB, userID int, client string) (mime stri
|
||||
|
||||
var errUnknownMediaType = fmt.Errorf("media type is unknown")
|
||||
|
||||
// TODO: there is a mismatch between abs paths for podcasts and music. if they were the same, db.AudioFile
|
||||
// could have an AbsPath() method. and we wouldn't need to pass podcastsPath or return 3 values
|
||||
func streamGetAudio(dbc *db.DB, podcastsPath string, user *db.User, id specid.ID) (db.AudioFile, string, error) {
|
||||
switch t := id.Type; t {
|
||||
case specid.Track:
|
||||
var track db.Track
|
||||
if err := dbc.Preload("Album").Preload("Artist").First(&track, id.Value).Error; err != nil {
|
||||
return nil, "", fmt.Errorf("find track: %w", err)
|
||||
}
|
||||
if track.Artist != nil && track.Album != nil {
|
||||
log.Printf("%s requests %s - %s from %s", user.Name, track.Artist.Name, track.TagTitle, track.Album.TagTitle)
|
||||
}
|
||||
return &track, path.Join(track.AbsPath()), nil
|
||||
|
||||
case specid.PodcastEpisode:
|
||||
var podcast db.PodcastEpisode
|
||||
if err := dbc.First(&podcast, id.Value).Error; err != nil {
|
||||
return nil, "", fmt.Errorf("find podcast: %w", err)
|
||||
}
|
||||
return &podcast, path.Join(podcastsPath, podcast.Path), nil
|
||||
|
||||
default:
|
||||
return nil, "", fmt.Errorf("%w: %q", errUnknownMediaType, t)
|
||||
}
|
||||
}
|
||||
|
||||
func streamUpdateStats(dbc *db.DB, userID, albumID int, playTime time.Time) error {
|
||||
var play db.Play
|
||||
err := dbc.
|
||||
@@ -134,6 +109,7 @@ var (
|
||||
errCoverEmpty = errors.New("no cover found for that folder")
|
||||
)
|
||||
|
||||
// TODO: can we use specidpaths.Locate here?
|
||||
func coverGetPath(dbc *db.DB, podcastPath string, id specid.ID) (string, error) {
|
||||
switch id.Type {
|
||||
case specid.Album:
|
||||
@@ -281,12 +257,17 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R
|
||||
return spec.NewError(10, "please provide an `id` parameter")
|
||||
}
|
||||
|
||||
file, audioPath, err := streamGetAudio(c.DB, c.PodcastsPath, user, id)
|
||||
file, err := specidpaths.Locate(c.DB, c.PodcastsPath, id)
|
||||
if err != nil {
|
||||
return spec.NewError(70, "error finding media: %v", err)
|
||||
return spec.NewError(0, "error looking up id %s: %v", id, err)
|
||||
}
|
||||
|
||||
if track, ok := file.(*db.Track); ok && track.Album != nil {
|
||||
audioFile, ok := file.(db.AudioFile)
|
||||
if !ok {
|
||||
return spec.NewError(0, "type of id does not contain audio")
|
||||
}
|
||||
|
||||
if track, ok := audioFile.(*db.Track); ok && track.Album != nil {
|
||||
defer func() {
|
||||
if err := streamUpdateStats(c.DB, user.ID, track.Album.ID, time.Now()); err != nil {
|
||||
log.Printf("error updating track status: %v", err)
|
||||
@@ -294,7 +275,7 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R
|
||||
}()
|
||||
}
|
||||
|
||||
if pe, ok := file.(*db.PodcastEpisode); ok {
|
||||
if pe, ok := audioFile.(*db.PodcastEpisode); ok {
|
||||
defer func() {
|
||||
if err := streamUpdatePodcastEpisodeStats(c.DB, pe.ID); err != nil {
|
||||
log.Printf("error updating podcast episode status: %v", err)
|
||||
@@ -305,8 +286,8 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R
|
||||
maxBitRate, _ := params.GetInt("maxBitRate")
|
||||
format, _ := params.Get("format")
|
||||
|
||||
if format == "raw" || maxBitRate >= file.AudioBitrate() {
|
||||
http.ServeFile(w, r, audioPath)
|
||||
if format == "raw" || maxBitRate >= audioFile.AudioBitrate() {
|
||||
http.ServeFile(w, r, file.AbsPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -315,7 +296,7 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R
|
||||
return spec.NewError(0, "couldn't find transcode preference: %v", err)
|
||||
}
|
||||
if pref == nil {
|
||||
http.ServeFile(w, r, audioPath)
|
||||
http.ServeFile(w, r, file.AbsPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -330,7 +311,7 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R
|
||||
log.Printf("trancoding to %q with max bitrate %dk", profile.MIME(), profile.BitRate())
|
||||
|
||||
w.Header().Set("Content-Type", profile.MIME())
|
||||
if err := c.Transcoder.Transcode(r.Context(), profile, audioPath, w); err != nil && !errors.Is(err, transcode.ErrFFmpegKilled) {
|
||||
if err := c.Transcoder.Transcode(r.Context(), profile, file.AbsPath(), w); err != nil && !errors.Is(err, transcode.ErrFFmpegKilled) {
|
||||
return spec.NewError(0, "error transcoding: %v", err)
|
||||
}
|
||||
|
||||
|
||||
77
server/ctrlsubsonic/specidpaths/specidpaths.go
Normal file
77
server/ctrlsubsonic/specidpaths/specidpaths.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package specidpaths
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"go.senan.xyz/gonic/db"
|
||||
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
|
||||
)
|
||||
|
||||
var ErrNotAbs = errors.New("not abs")
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
type Result interface {
|
||||
SID() *specid.ID
|
||||
AbsPath() string
|
||||
}
|
||||
|
||||
// Locate maps a specid to its location on the filesystem
|
||||
func Locate(dbc *db.DB, podcastsPath string, id specid.ID) (Result, error) {
|
||||
switch id.Type {
|
||||
case specid.Track:
|
||||
var track db.Track
|
||||
if err := dbc.Preload("Album").Where("id=?", id.Value).Find(&track).Error; err == nil {
|
||||
return &track, nil
|
||||
}
|
||||
case specid.PodcastEpisode:
|
||||
var pe db.PodcastEpisode
|
||||
if err := dbc.Where("id=?", id.Value).Find(&pe).Error; err == nil {
|
||||
pe.AbsP = filepath.Join(podcastsPath, pe.Path)
|
||||
return &pe, err
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// Locate maps a location on the filesystem to a specid
|
||||
func Lookup(dbc *db.DB, musicPaths []string, podcastsPath string, path string) (Result, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
return nil, ErrNotAbs
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, podcastsPath) {
|
||||
path, _ = filepath.Rel(podcastsPath, path)
|
||||
var pe db.PodcastEpisode
|
||||
if err := dbc.Where(`path=?`, path).First(&pe).Error; err == nil {
|
||||
return &pe, nil
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
var musicPath string
|
||||
for _, mp := range musicPaths {
|
||||
if strings.HasPrefix(path, mp) {
|
||||
musicPath = mp
|
||||
}
|
||||
}
|
||||
if musicPath == "" {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
relPath, _ := filepath.Rel(musicPath, path)
|
||||
relDir, filename := filepath.Split(relPath)
|
||||
leftPath, rightPath := filepath.Split(filepath.Clean(relDir))
|
||||
|
||||
q := dbc.
|
||||
Where(`albums.root_dir=? AND albums.left_path=? AND albums.right_path=? AND tracks.filename=?`, musicPath, leftPath, rightPath, filename).
|
||||
Joins(`JOIN albums ON tracks.album_id=albums.id`).
|
||||
Preload("Album")
|
||||
|
||||
var track db.Track
|
||||
if err := q.First(&track).Error; err == nil {
|
||||
return &track, nil
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
Reference in New Issue
Block a user