feat: add multi folder support

closes #50
This commit is contained in:
sentriz
2021-11-03 23:15:09 +00:00
parent fa587fc7de
commit 40cd031b05
32 changed files with 744 additions and 606 deletions

View File

@@ -30,7 +30,6 @@ const (
func main() { func main() {
set := flag.NewFlagSet(gonic.Name, flag.ExitOnError) set := flag.NewFlagSet(gonic.Name, flag.ExitOnError)
confListenAddr := set.String("listen-addr", "0.0.0.0:4747", "listen address (optional)") confListenAddr := set.String("listen-addr", "0.0.0.0:4747", "listen address (optional)")
confMusicPath := set.String("music-path", "", "path to music")
confPodcastPath := set.String("podcast-path", "", "path to podcasts") confPodcastPath := set.String("podcast-path", "", "path to podcasts")
confCachePath := set.String("cache-path", "", "path to cache") confCachePath := set.String("cache-path", "", "path to cache")
confDBPath := set.String("db-path", "gonic.db", "path to database (optional)") confDBPath := set.String("db-path", "gonic.db", "path to database (optional)")
@@ -40,6 +39,10 @@ func main() {
confGenreSplit := set.String("genre-split", "\n", "character or string to split genre tag data on (optional)") confGenreSplit := set.String("genre-split", "\n", "character or string to split genre tag data on (optional)")
confHTTPLog := set.Bool("http-log", true, "http request logging (optional)") confHTTPLog := set.Bool("http-log", true, "http request logging (optional)")
confShowVersion := set.Bool("version", false, "show gonic version") confShowVersion := set.Bool("version", false, "show gonic version")
var confMusicPaths musicPaths
set.Var(&confMusicPaths, "music-path", "path to music")
_ = set.String("config-path", "", "path to config (optional)") _ = set.String("config-path", "", "path to config (optional)")
if err := ff.Parse(set, os.Args[1:], if err := ff.Parse(set, os.Args[1:],
@@ -62,8 +65,13 @@ func main() {
log.Printf(" %-15s %s\n", f.Name, value) log.Printf(" %-15s %s\n", f.Name, value)
}) })
if _, err := os.Stat(*confMusicPath); os.IsNotExist(err) { if len(confMusicPaths) == 0 {
log.Fatal("please provide a valid music directory") log.Fatalf("please provide a music directory")
}
for _, confMusicPath := range confMusicPaths {
if _, err := os.Stat(confMusicPath); os.IsNotExist(err) {
log.Fatalf("music directory %q not found", confMusicPath)
}
} }
if _, err := os.Stat(*confPodcastPath); os.IsNotExist(err) { if _, err := os.Stat(*confPodcastPath); os.IsNotExist(err) {
log.Fatal("please provide a valid podcast directory") log.Fatal("please provide a valid podcast directory")
@@ -90,13 +98,20 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("error opening database: %v\n", err) log.Fatalf("error opening database: %v\n", err)
} }
defer db.Close() defer dbc.Close()
err = dbc.Migrate(db.MigrationContext{
OriginalMusicPath: confMusicPaths[0],
})
if err != nil {
log.Panicf("error migrating database: %v\n", err)
}
proxyPrefixExpr := regexp.MustCompile(`^\/*(.*?)\/*$`) proxyPrefixExpr := regexp.MustCompile(`^\/*(.*?)\/*$`)
*confProxyPrefix = proxyPrefixExpr.ReplaceAllString(*confProxyPrefix, `/$1`) *confProxyPrefix = proxyPrefixExpr.ReplaceAllString(*confProxyPrefix, `/$1`)
server, err := server.New(server.Options{ server, err := server.New(server.Options{
DB: db, DB: dbc,
MusicPath: *confMusicPath, MusicPaths: confMusicPaths,
CachePath: cacheDirAudio, CachePath: cacheDirAudio,
CoverCachePath: cacheDirCovers, CoverCachePath: cacheDirCovers,
ProxyPrefix: *confProxyPrefix, ProxyPrefix: *confProxyPrefix,
@@ -125,3 +140,14 @@ func main() {
log.Panicf("error in job: %v", err) log.Panicf("error in job: %v", err)
} }
} }
type musicPaths []string
func (m musicPaths) String() string {
return strings.Join(m, ", ")
}
func (m *musicPaths) Set(value string) error {
*m = append(*m, value)
return nil
}

View File

@@ -139,10 +139,7 @@ func (c *Controller) ServeChangeOwnPasswordDo(r *http.Request) *Response {
func (c *Controller) ServeLinkLastFMDo(r *http.Request) *Response { func (c *Controller) ServeLinkLastFMDo(r *http.Request) *Response {
token := r.URL.Query().Get("token") token := r.URL.Query().Get("token")
if token == "" { if token == "" {
return &Response{ return &Response{code: 400, err: "please provide a token"}
err: "please provide a token",
code: 400,
}
} }
apiKey, err := c.DB.GetSetting("lastfm_api_key") apiKey, err := c.DB.GetSetting("lastfm_api_key")
if err != nil { if err != nil {
@@ -199,17 +196,11 @@ func (c *Controller) ServeUnlinkListenBrainzDo(r *http.Request) *Response {
func (c *Controller) ServeChangeUsername(r *http.Request) *Response { func (c *Controller) ServeChangeUsername(r *http.Request) *Response {
username := r.URL.Query().Get("user") username := r.URL.Query().Get("user")
if username == "" { if username == "" {
return &Response{ return &Response{code: 400, err: "please provide a username"}
err: "please provide a username",
code: 400,
}
} }
user := c.DB.GetUserByName(username) user := c.DB.GetUserByName(username)
if user == nil { if user == nil {
return &Response{ return &Response{code: 400, err: "couldn't find a user with that name"}
err: "couldn't find a user with that name",
code: 400,
}
} }
data := &templateData{} data := &templateData{}
data.SelectedUser = user data.SelectedUser = user
@@ -237,17 +228,11 @@ func (c *Controller) ServeChangeUsernameDo(r *http.Request) *Response {
func (c *Controller) ServeChangePassword(r *http.Request) *Response { func (c *Controller) ServeChangePassword(r *http.Request) *Response {
username := r.URL.Query().Get("user") username := r.URL.Query().Get("user")
if username == "" { if username == "" {
return &Response{ return &Response{code: 400, err: "please provide a username"}
err: "please provide a username",
code: 400,
}
} }
user := c.DB.GetUserByName(username) user := c.DB.GetUserByName(username)
if user == nil { if user == nil {
return &Response{ return &Response{code: 400, err: "couldn't find a user with that name"}
err: "couldn't find a user with that name",
code: 400,
}
} }
data := &templateData{} data := &templateData{}
data.SelectedUser = user data.SelectedUser = user
@@ -276,17 +261,11 @@ func (c *Controller) ServeChangePasswordDo(r *http.Request) *Response {
func (c *Controller) ServeDeleteUser(r *http.Request) *Response { func (c *Controller) ServeDeleteUser(r *http.Request) *Response {
username := r.URL.Query().Get("user") username := r.URL.Query().Get("user")
if username == "" { if username == "" {
return &Response{ return &Response{code: 400, err: "please provide a username"}
err: "please provide a username",
code: 400,
}
} }
user := c.DB.GetUserByName(username) user := c.DB.GetUserByName(username)
if user == nil { if user == nil {
return &Response{ return &Response{code: 400, err: "couldn't find a user with that name"}
err: "couldn't find a user with that name",
code: 400,
}
} }
data := &templateData{} data := &templateData{}
data.SelectedUser = user data.SelectedUser = user
@@ -421,10 +400,7 @@ func (c *Controller) ServeDeleteTranscodePrefDo(r *http.Request) *Response {
user := r.Context().Value(CtxUser).(*db.User) user := r.Context().Value(CtxUser).(*db.User)
client := r.URL.Query().Get("client") client := r.URL.Query().Get("client")
if client == "" { if client == "" {
return &Response{ return &Response{code: 400, err: "please provide a client"}
err: "please provide a client",
code: 400,
}
} }
c.DB. c.DB.
Where("user_id=? AND client=?", user.ID, client). Where("user_id=? AND client=?", user.ID, client).
@@ -459,16 +435,10 @@ func (c *Controller) ServePodcastAddDo(r *http.Request) *Response {
func (c *Controller) ServePodcastDownloadDo(r *http.Request) *Response { func (c *Controller) ServePodcastDownloadDo(r *http.Request) *Response {
id, err := strconv.Atoi(r.URL.Query().Get("id")) id, err := strconv.Atoi(r.URL.Query().Get("id"))
if err != nil { if err != nil {
return &Response{ return &Response{code: 400, err: "please provide a valid podcast id"}
err: "please provide a valid podcast id",
code: 400,
}
} }
if err := c.Podcasts.DownloadPodcastAll(id); err != nil { if err := c.Podcasts.DownloadPodcastAll(id); err != nil {
return &Response{ return &Response{code: 400, err: "please provide a valid podcast id"}
err: "please provide a valid podcast id",
code: 400,
}
} }
return &Response{ return &Response{
redirect: "/admin/home", redirect: "/admin/home",
@@ -479,10 +449,7 @@ func (c *Controller) ServePodcastDownloadDo(r *http.Request) *Response {
func (c *Controller) ServePodcastUpdateDo(r *http.Request) *Response { func (c *Controller) ServePodcastUpdateDo(r *http.Request) *Response {
id, err := strconv.Atoi(r.URL.Query().Get("id")) id, err := strconv.Atoi(r.URL.Query().Get("id"))
if err != nil { if err != nil {
return &Response{ return &Response{code: 400, err: "please provide a valid podcast id"}
err: "please provide a valid podcast id",
code: 400,
}
} }
setting := db.PodcastAutoDownload(r.FormValue("setting")) setting := db.PodcastAutoDownload(r.FormValue("setting"))
var message string var message string
@@ -492,10 +459,7 @@ func (c *Controller) ServePodcastUpdateDo(r *http.Request) *Response {
case db.PodcastAutoDownloadNone: case db.PodcastAutoDownloadNone:
message = "future podcast episodes will not be downloaded" message = "future podcast episodes will not be downloaded"
default: default:
return &Response{ return &Response{code: 400, err: "please provide a valid podcast download type"}
err: "please provide a valid podcast download type",
code: 400,
}
} }
if err := c.Podcasts.SetAutoDownload(id, setting); err != nil { if err := c.Podcasts.SetAutoDownload(id, setting); err != nil {
return &Response{ return &Response{
@@ -513,16 +477,10 @@ func (c *Controller) ServePodcastDeleteDo(r *http.Request) *Response {
user := r.Context().Value(CtxUser).(*db.User) user := r.Context().Value(CtxUser).(*db.User)
id, err := strconv.Atoi(r.URL.Query().Get("id")) id, err := strconv.Atoi(r.URL.Query().Get("id"))
if err != nil { if err != nil {
return &Response{ return &Response{code: 400, err: "please provide a valid podcast id"}
err: "please provide a valid podcast id",
code: 400,
}
} }
if err := c.Podcasts.DeletePodcast(user.ID, id); err != nil { if err := c.Podcasts.DeletePodcast(user.ID, id); err != nil {
return &Response{ return &Response{code: 400, err: "please provide a valid podcast id"}
err: "please provide a valid podcast id",
code: 400,
}
} }
return &Response{ return &Response{
redirect: "/admin/home", redirect: "/admin/home",

View File

@@ -18,16 +18,16 @@ var (
errPlaylistNoMatch = errors.New("couldn't match track") errPlaylistNoMatch = errors.New("couldn't match track")
) )
func playlistParseLine(c *Controller, path string) (int, error) { func playlistParseLine(c *Controller, absPath string) (int, error) {
if strings.HasPrefix(path, "#") || strings.TrimSpace(path) == "" { if strings.HasPrefix(absPath, "#") || strings.TrimSpace(absPath) == "" {
return 0, nil return 0, nil
} }
var track db.Track var track db.Track
query := c.DB.Raw(` query := c.DB.Raw(`
SELECT tracks.id FROM TRACKS SELECT tracks.id FROM TRACKS
JOIN albums ON tracks.album_id=albums.id JOIN albums ON tracks.album_id=albums.id
WHERE (? || '/' || albums.left_path || albums.right_path || '/' || tracks.filename)=?`, WHERE (albums.root_dir || '/' || albums.left_path || albums.right_path || '/' || tracks.filename)=?`,
c.MusicPath, path) absPath)
err := query.First(&track).Error err := query.First(&track).Error
switch { switch {
case errors.Is(err, gorm.ErrRecordNotFound): case errors.Is(err, gorm.ErrRecordNotFound):
@@ -95,10 +95,7 @@ func (c *Controller) ServeUploadPlaylist(r *http.Request) *Response {
func (c *Controller) ServeUploadPlaylistDo(r *http.Request) *Response { func (c *Controller) ServeUploadPlaylistDo(r *http.Request) *Response {
if err := r.ParseMultipartForm((1 << 10) * 24); err != nil { if err := r.ParseMultipartForm((1 << 10) * 24); err != nil {
return &Response{ return &Response{code: 500, err: "couldn't parse mutlipart"}
err: "couldn't parse mutlipart",
code: 500,
}
} }
user := r.Context().Value(CtxUser).(*db.User) user := r.Context().Value(CtxUser).(*db.User)
var playlistCount int var playlistCount int
@@ -123,10 +120,7 @@ func (c *Controller) ServeDeletePlaylistDo(r *http.Request) *Response {
user := r.Context().Value(CtxUser).(*db.User) user := r.Context().Value(CtxUser).(*db.User)
id, err := strconv.Atoi(r.URL.Query().Get("id")) id, err := strconv.Atoi(r.URL.Query().Get("id"))
if err != nil { if err != nil {
return &Response{ return &Response{code: 400, err: "please provide a valid id"}
err: "please provide a valid id",
code: 400,
}
} }
c.DB. c.DB.
Where("user_id=? AND id=?", user.ID, id). Where("user_id=? AND id=?", user.ID, id).

View File

@@ -46,7 +46,6 @@ func statusToBlock(code int) string {
type Controller struct { type Controller struct {
DB *db.DB DB *db.DB
MusicPath string
Scanner *scanner.Scanner Scanner *scanner.Scanner
ProxyPrefix string ProxyPrefix string
} }

View File

@@ -55,15 +55,14 @@ func makeHTTPMock(query url.Values) (*httptest.ResponseRecorder, *http.Request)
func runQueryCases(t *testing.T, contr *Controller, h handlerSubsonic, cases []*queryCase) { func runQueryCases(t *testing.T, contr *Controller, h handlerSubsonic, cases []*queryCase) {
t.Helper() t.Helper()
for _, qc := range cases { for _, qc := range cases {
qc := qc // pin
t.Run(qc.expectPath, func(t *testing.T) { t.Run(qc.expectPath, func(t *testing.T) {
t.Parallel()
rr, req := makeHTTPMock(qc.params) rr, req := makeHTTPMock(qc.params)
contr.H(h).ServeHTTP(rr, req) contr.H(h).ServeHTTP(rr, req)
body := rr.Body.String() body := rr.Body.String()
if status := rr.Code; status != http.StatusOK { if status := rr.Code; status != http.StatusOK {
t.Fatalf("didn't give a 200\n%s", body) t.Fatalf("didn't give a 200\n%s", body)
} }
goldenPath := makeGoldenPath(t.Name()) goldenPath := makeGoldenPath(t.Name())
goldenRegen := os.Getenv("GONIC_REGEN") goldenRegen := os.Getenv("GONIC_REGEN")
if goldenRegen == "*" || (goldenRegen != "" && strings.HasPrefix(t.Name(), goldenRegen)) { if goldenRegen == "*" || (goldenRegen != "" && strings.HasPrefix(t.Name(), goldenRegen)) {
@@ -86,11 +85,10 @@ func runQueryCases(t *testing.T, contr *Controller, h handlerSubsonic, cases []*
diffOpts = append(diffOpts, jd.SET) diffOpts = append(diffOpts, jd.SET)
} }
diff := expected.Diff(actual, diffOpts...) diff := expected.Diff(actual, diffOpts...)
// pass or fail
if len(diff) == 0 { if len(diff) > 0 {
return t.Errorf("\u001b[31;1mdiffering json\u001b[0m\n%s", diff.Render())
} }
t.Errorf("\u001b[31;1mdiffering json\u001b[0m\n%s", diff.Render())
}) })
} }
} }

View File

@@ -12,18 +12,27 @@ import (
"go.senan.xyz/gonic/server/db" "go.senan.xyz/gonic/server/db"
) )
// the subsonic spec metions "artist" a lot when talking about the // the subsonic spec mentions "artist" a lot when talking about the
// browse by folder endpoints. but since we're not browsing by tag // browse by folder endpoints. but since we're not browsing by tag
// we can't access artists. so instead we'll consider the artist of // we can't access artists. so instead we'll consider the artist of
// an track to be the it's respective folder that comes directly // an track to be the it's respective folder that comes directly
// under the root directory // under the root directory
func (c *Controller) ServeGetIndexes(r *http.Request) *spec.Response { func (c *Controller) ServeGetIndexes(r *http.Request) *spec.Response {
params := r.Context().Value(CtxParams).(params.Params)
rootQ := c.DB.
Select("id").
Model(&db.Album{}).
Where("parent_id IS NULL")
if m, _ := params.Get("musicFolderId"); m != "" {
rootQ = rootQ.
Where("root_dir=?", m)
}
var folders []*db.Album var folders []*db.Album
c.DB. c.DB.
Select("*, count(sub.id) child_count"). Select("*, count(sub.id) child_count").
Joins("LEFT JOIN albums sub ON albums.id=sub.parent_id"). Joins("LEFT JOIN albums sub ON albums.id=sub.parent_id").
Where("albums.parent_id=1"). Where("albums.parent_id IN ?", rootQ.SubQuery()).
Group("albums.id"). Group("albums.id").
Order("albums.right_path COLLATE NOCASE"). Order("albums.right_path COLLATE NOCASE").
Find(&folders) Find(&folders)
@@ -31,17 +40,15 @@ func (c *Controller) ServeGetIndexes(r *http.Request) *spec.Response {
indexMap := make(map[string]*spec.Index, 27) indexMap := make(map[string]*spec.Index, 27)
resp := make([]*spec.Index, 0, 27) resp := make([]*spec.Index, 0, 27)
for _, folder := range folders { for _, folder := range folders {
i := lowerUDecOrHash(folder.IndexRightPath()) key := lowerUDecOrHash(folder.IndexRightPath())
index, ok := indexMap[i] if _, ok := indexMap[key]; !ok {
if !ok { indexMap[key] = &spec.Index{
index = &spec.Index{ Name: key,
Name: i,
Artists: []*spec.Artist{}, Artists: []*spec.Artist{},
} }
indexMap[i] = index resp = append(resp, indexMap[key])
resp = append(resp, index)
} }
index.Artists = append(index.Artists, indexMap[key].Artists = append(indexMap[key].Artists,
spec.NewArtistByFolder(folder)) spec.NewArtistByFolder(folder))
} }
sub := spec.NewResponse() sub := spec.NewResponse()
@@ -137,6 +144,10 @@ func (c *Controller) ServeGetAlbumList(r *http.Request) *spec.Response {
default: default:
return spec.NewError(10, "unknown value `%s` for parameter 'type'", v) return spec.NewError(10, "unknown value `%s` for parameter 'type'", v)
} }
if m, _ := params.Get("musicFolderId"); m != "" {
q = q.Where("root_dir=?", m)
}
var folders []*db.Album var folders []*db.Album
// TODO: think about removing this extra join to count number // TODO: think about removing this extra join to count number
// of children. it might make sense to store that in the db // of children. it might make sense to store that in the db
@@ -166,50 +177,65 @@ func (c *Controller) ServeSearchTwo(r *http.Request) *spec.Response {
return spec.NewError(10, "please provide a `query` parameter") return spec.NewError(10, "please provide a `query` parameter")
} }
query = fmt.Sprintf("%%%s%%", strings.TrimSuffix(query, "*")) query = fmt.Sprintf("%%%s%%", strings.TrimSuffix(query, "*"))
results := &spec.SearchResultTwo{} results := &spec.SearchResultTwo{}
// search "artists" // search "artists"
var artists []*db.Album rootQ := c.DB.
c.DB. Select("id").
Where(` Model(&db.Album{}).
parent_id=1 Where("parent_id IS NULL")
AND ( right_path LIKE ? OR if m, _ := params.Get("musicFolderId"); m != "" {
right_path_u_dec LIKE ? )`, rootQ = rootQ.Where("root_dir=?", m)
query, query).
Offset(params.GetOrInt("artistOffset", 0)).
Limit(params.GetOrInt("artistCount", 20)).
Find(&artists)
for _, a := range artists {
results.Artists = append(results.Artists,
spec.NewDirectoryByFolder(a, nil))
} }
var artists []*db.Album
q := c.DB.
Where(`parent_id IN ? AND (right_path LIKE ? OR right_path_u_dec LIKE ?)`, rootQ.SubQuery(), query, query).
Offset(params.GetOrInt("artistOffset", 0)).
Limit(params.GetOrInt("artistCount", 20))
if err := q.Find(&artists).Error; err != nil {
return spec.NewError(0, "find artists: %v", err)
}
for _, a := range artists {
results.Artists = append(results.Artists, spec.NewDirectoryByFolder(a, nil))
}
// search "albums" // search "albums"
var albums []*db.Album var albums []*db.Album
c.DB. q = c.DB.
Where(` Where(`tag_artist_id IS NOT NULL AND (right_path LIKE ? OR right_path_u_dec LIKE ?)`, query, query).
tag_artist_id IS NOT NULL
AND ( right_path LIKE ? OR
right_path_u_dec LIKE ? )`,
query, query).
Offset(params.GetOrInt("albumOffset", 0)). Offset(params.GetOrInt("albumOffset", 0)).
Limit(params.GetOrInt("albumCount", 20)). Limit(params.GetOrInt("albumCount", 20))
Find(&albums) if m, _ := params.Get("musicFolderId"); m != "" {
q = q.Where("root_dir=?", m)
}
if err := q.Find(&albums).Error; err != nil {
return spec.NewError(0, "find albums: %v", err)
}
for _, a := range albums { for _, a := range albums {
results.Albums = append(results.Albums, spec.NewTCAlbumByFolder(a)) results.Albums = append(results.Albums, spec.NewTCAlbumByFolder(a))
} }
// search tracks // search tracks
var tracks []*db.Track var tracks []*db.Track
c.DB. q = c.DB.
Preload("Album"). Preload("Album").
Where("filename LIKE ? OR filename_u_dec LIKE ?", Where("filename LIKE ? OR filename_u_dec LIKE ?", query, query).
query, query).
Offset(params.GetOrInt("songOffset", 0)). Offset(params.GetOrInt("songOffset", 0)).
Limit(params.GetOrInt("songCount", 20)). Limit(params.GetOrInt("songCount", 20))
Find(&tracks) if m, _ := params.Get("musicFolderId"); m != "" {
for _, t := range tracks { q = q.
results.Tracks = append(results.Tracks, Joins("JOIN albums ON albums.id=tracks.album_id").
spec.NewTCTrackByFolder(t, t.Album)) Where("albums.root_dir=?", m)
} }
// if err := q.Find(&tracks).Error; err != nil {
return spec.NewError(0, "find tracks: %v", err)
}
for _, t := range tracks {
results.Tracks = append(results.Tracks, spec.NewTCTrackByFolder(t, t.Album))
}
sub := spec.NewResponse() sub := spec.NewResponse()
sub.SearchResultTwo = results sub.SearchResultTwo = results
return sub return sub

View File

@@ -2,6 +2,7 @@ package ctrlsubsonic
import ( import (
"net/url" "net/url"
"path/filepath"
"testing" "testing"
_ "github.com/jinzhu/gorm/dialects/sqlite" _ "github.com/jinzhu/gorm/dialects/sqlite"
@@ -13,6 +14,8 @@ func TestGetIndexes(t *testing.T) {
runQueryCases(t, contr, contr.ServeGetIndexes, []*queryCase{ runQueryCases(t, contr, contr.ServeGetIndexes, []*queryCase{
{url.Values{}, "no_args", false}, {url.Values{}, "no_args", false},
{url.Values{"musicFolderId": {filepath.Join(m.TmpDir(), "m-0")}}, "with_music_folder_1", false},
{url.Values{"musicFolderId": {filepath.Join(m.TmpDir(), "m-1")}}, "with_music_folder_2", false},
}) })
} }

View File

@@ -16,28 +16,32 @@ import (
) )
func (c *Controller) ServeGetArtists(r *http.Request) *spec.Response { func (c *Controller) ServeGetArtists(r *http.Request) *spec.Response {
params := r.Context().Value(CtxParams).(params.Params)
var artists []*db.Artist var artists []*db.Artist
c.DB. q := c.DB.
Select("*, count(sub.id) album_count"). Select("*, count(sub.id) album_count").
Joins("LEFT JOIN albums sub ON artists.id=sub.tag_artist_id"). Joins("LEFT JOIN albums sub ON artists.id=sub.tag_artist_id").
Group("artists.id"). Group("artists.id").
Order("artists.name COLLATE NOCASE"). Order("artists.name COLLATE NOCASE")
Find(&artists) if m, _ := params.Get("musicFolderId"); m != "" {
q = q.Where("sub.root_dir=?", m)
}
if err := q.Find(&artists).Error; err != nil {
return spec.NewError(10, "error finding artists: %v", err)
}
// [a-z#] -> 27 // [a-z#] -> 27
indexMap := make(map[string]*spec.Index, 27) indexMap := make(map[string]*spec.Index, 27)
resp := make([]*spec.Index, 0, 27) resp := make([]*spec.Index, 0, 27)
for _, artist := range artists { for _, artist := range artists {
i := lowerUDecOrHash(artist.IndexName()) key := lowerUDecOrHash(artist.IndexName())
index, ok := indexMap[i] if _, ok := indexMap[key]; !ok {
if !ok { indexMap[key] = &spec.Index{
index = &spec.Index{ Name: key,
Name: i,
Artists: []*spec.Artist{}, Artists: []*spec.Artist{},
} }
indexMap[i] = index resp = append(resp, indexMap[key])
resp = append(resp, index)
} }
index.Artists = append(index.Artists, indexMap[key].Artists = append(indexMap[key].Artists,
spec.NewArtistByTags(artist)) spec.NewArtistByTags(artist))
} }
sub := spec.NewResponse() sub := spec.NewResponse()
@@ -144,6 +148,9 @@ func (c *Controller) ServeGetAlbumListTwo(r *http.Request) *spec.Response {
default: default:
return spec.NewError(10, "unknown value `%s` for parameter 'type'", listType) return spec.NewError(10, "unknown value `%s` for parameter 'type'", listType)
} }
if m, _ := params.Get("musicFolderId"); m != "" {
q = q.Where("root_dir=?", m)
}
var albums []*db.Album var albums []*db.Album
// TODO: think about removing this extra join to count number // TODO: think about removing this extra join to count number
// of children. it might make sense to store that in the db // of children. it might make sense to store that in the db
@@ -172,47 +179,63 @@ func (c *Controller) ServeSearchThree(r *http.Request) *spec.Response {
if err != nil { if err != nil {
return spec.NewError(10, "please provide a `query` parameter") return spec.NewError(10, "please provide a `query` parameter")
} }
query = fmt.Sprintf("%%%s%%", query = fmt.Sprintf("%%%s%%", strings.TrimSuffix(query, "*"))
strings.TrimSuffix(query, "*"))
results := &spec.SearchResultThree{} results := &spec.SearchResultThree{}
// search "artists" // search "artists"
var artists []*db.Artist var artists []*db.Artist
c.DB. q := c.DB.
Where("name LIKE ? OR name_u_dec LIKE ?", Where("name LIKE ? OR name_u_dec LIKE ?", query, query).
query, query).
Offset(params.GetOrInt("artistOffset", 0)). Offset(params.GetOrInt("artistOffset", 0)).
Limit(params.GetOrInt("artistCount", 20)). Limit(params.GetOrInt("artistCount", 20))
Find(&artists) if m, _ := params.Get("musicFolderId"); m != "" {
for _, a := range artists { q = q.
results.Artists = append(results.Artists, Joins("JOIN albums ON albums.tag_artist_id=artists.id").
spec.NewArtistByTags(a)) Where("albums.root_dir=?", m)
} }
if err := q.Find(&artists).Error; err != nil {
return spec.NewError(0, "find artists: %v", err)
}
for _, a := range artists {
results.Artists = append(results.Artists, spec.NewArtistByTags(a))
}
// search "albums" // search "albums"
var albums []*db.Album var albums []*db.Album
c.DB. q = c.DB.
Preload("TagArtist"). Preload("TagArtist").
Where("tag_title LIKE ? OR tag_title_u_dec LIKE ?", Where("tag_title LIKE ? OR tag_title_u_dec LIKE ?", query, query).
query, query).
Offset(params.GetOrInt("albumOffset", 0)). Offset(params.GetOrInt("albumOffset", 0)).
Limit(params.GetOrInt("albumCount", 20)). Limit(params.GetOrInt("albumCount", 20))
Find(&albums) if m, _ := params.Get("musicFolderId"); m != "" {
for _, a := range albums { q = q.Where("root_dir=?", m)
results.Albums = append(results.Albums,
spec.NewAlbumByTags(a, a.TagArtist))
} }
if err := q.Find(&albums).Error; err != nil {
return spec.NewError(0, "find albums: %v", err)
}
for _, a := range albums {
results.Albums = append(results.Albums, spec.NewAlbumByTags(a, a.TagArtist))
}
// search tracks // search tracks
var tracks []*db.Track var tracks []*db.Track
c.DB. q = c.DB.
Preload("Album"). Preload("Album").
Where("tag_title LIKE ? OR tag_title_u_dec LIKE ?", Where("tag_title LIKE ? OR tag_title_u_dec LIKE ?", query, query).
query, query).
Offset(params.GetOrInt("songOffset", 0)). Offset(params.GetOrInt("songOffset", 0)).
Limit(params.GetOrInt("songCount", 20)). Limit(params.GetOrInt("songCount", 20))
Find(&tracks) if m, _ := params.Get("musicFolderId"); m != "" {
for _, t := range tracks { q = q.
results.Tracks = append(results.Tracks, Joins("JOIN albums ON albums.id=tracks.album_id").
spec.NewTrackByTags(t, t.Album)) Where("albums.root_dir=?", m)
} }
if err := q.Find(&tracks).Error; err != nil {
return spec.NewError(0, "find tracks: %v", err)
}
for _, t := range tracks {
results.Tracks = append(results.Tracks, spec.NewTrackByTags(t, t.Album))
}
sub := spec.NewResponse() sub := spec.NewResponse()
sub.SearchResultThree = results sub.SearchResultThree = results
return sub return sub
@@ -313,17 +336,20 @@ func (c *Controller) ServeGetSongsByGenre(r *http.Request) *spec.Response {
if err != nil { if err != nil {
return spec.NewError(10, "please provide an `genre` parameter") return spec.NewError(10, "please provide an `genre` parameter")
} }
// TODO: add musicFolderId parameter
// (since 1.12.0) only return albums in the music folder with the given id
var tracks []*db.Track var tracks []*db.Track
c.DB. q := c.DB.
Joins("JOIN albums ON tracks.album_id=albums.id"). Joins("JOIN albums ON tracks.album_id=albums.id").
Joins("JOIN track_genres ON track_genres.track_id=tracks.id"). Joins("JOIN track_genres ON track_genres.track_id=tracks.id").
Joins("JOIN genres ON track_genres.genre_id=genres.id AND genres.name=?", genre). Joins("JOIN genres ON track_genres.genre_id=genres.id AND genres.name=?", genre).
Preload("Album"). Preload("Album").
Offset(params.GetOrInt("offset", 0)). Offset(params.GetOrInt("offset", 0)).
Limit(params.GetOrInt("count", 10)). Limit(params.GetOrInt("count", 10))
Find(&tracks) if m, _ := params.Get("musicFolderId"); m != "" {
q = q.Where("albums.root_dir=?", m)
}
if err := q.Find(&tracks).Error; err != nil {
return spec.NewError(0, "error finding tracks: %v", err)
}
sub := spec.NewResponse() sub := spec.NewResponse()
sub.TracksByGenre = &spec.TracksByGenre{ sub.TracksByGenre = &spec.TracksByGenre{
List: make([]*spec.TrackChild, len(tracks)), List: make([]*spec.TrackChild, len(tracks)),

View File

@@ -2,16 +2,19 @@ package ctrlsubsonic
import ( import (
"net/url" "net/url"
"path/filepath"
"testing" "testing"
) )
func TestGetArtists(t *testing.T) { func TestGetArtists(t *testing.T) {
t.Parallel() t.Parallel()
contr, m := makeController(t) contr, m := makeControllerRoots(t, []string{"m-0", "m-1"})
defer m.CleanUp() defer m.CleanUp()
runQueryCases(t, contr, contr.ServeGetArtists, []*queryCase{ runQueryCases(t, contr, contr.ServeGetArtists, []*queryCase{
{url.Values{}, "no_args", false}, {url.Values{}, "no_args", false},
{url.Values{"musicFolderId": {filepath.Join(m.TmpDir(), "m-0")}}, "with_music_folder_1", false},
{url.Values{"musicFolderId": {filepath.Join(m.TmpDir(), "m-1")}}, "with_music_folder_2", false},
}) })
} }

View File

@@ -4,6 +4,7 @@ import (
"errors" "errors"
"log" "log"
"net/http" "net/http"
"path/filepath"
"time" "time"
"unicode" "unicode"
@@ -70,12 +71,22 @@ func (c *Controller) ServeScrobble(r *http.Request) *spec.Response {
} }
func (c *Controller) ServeGetMusicFolders(r *http.Request) *spec.Response { func (c *Controller) ServeGetMusicFolders(r *http.Request) *spec.Response {
folders := &spec.MusicFolders{} var roots []string
folders.List = []*spec.MusicFolder{ err := c.DB.
{ID: 1, Name: "music"}, Model(&db.Album{}).
Pluck("DISTINCT(root_dir)", &roots).
Where("parent_id IS NULL").
Error
if err != nil {
return spec.NewError(0, "error getting roots: %v", err)
} }
sub := spec.NewResponse() sub := spec.NewResponse()
sub.MusicFolders = folders sub.MusicFolders = &spec.MusicFolders{}
sub.MusicFolders.List = make([]*spec.MusicFolder, len(roots))
for i, root := range roots {
sub.MusicFolders.List[i] = &spec.MusicFolder{ID: root, Name: filepath.Base(root)}
}
return sub return sub
} }
@@ -215,6 +226,9 @@ func (c *Controller) ServeGetRandomSongs(r *http.Request) *spec.Response {
q = q.Joins("JOIN track_genres ON track_genres.track_id=tracks.id") q = q.Joins("JOIN track_genres ON track_genres.track_id=tracks.id")
q = q.Joins("JOIN genres ON genres.id=track_genres.genre_id AND genres.name=?", genre) q = q.Joins("JOIN genres ON genres.id=track_genres.genre_id AND genres.name=?", genre)
} }
if m, _ := params.Get("musicFolderId"); m != "" {
q = q.Where("albums.root_dir=?", m)
}
q.Find(&tracks) q.Find(&tracks)
sub := spec.NewResponse() sub := spec.NewResponse()
sub.RandomTracks = &spec.RandomTracks{} sub.RandomTracks = &spec.RandomTracks{}

View File

@@ -74,10 +74,10 @@ var (
errCoverEmpty = errors.New("no cover found for that folder") errCoverEmpty = errors.New("no cover found for that folder")
) )
func coverGetPath(dbc *db.DB, musicPath, podcastPath string, id specid.ID) (string, error) { func coverGetPath(dbc *db.DB, podcastPath string, id specid.ID) (string, error) {
switch id.Type { switch id.Type {
case specid.Album: case specid.Album:
return coverGetPathAlbum(dbc, musicPath, id.Value) return coverGetPathAlbum(dbc, id.Value)
case specid.Podcast: case specid.Podcast:
return coverGetPathPodcast(dbc, podcastPath, id.Value) return coverGetPathPodcast(dbc, podcastPath, id.Value)
case specid.PodcastEpisode: case specid.PodcastEpisode:
@@ -87,10 +87,11 @@ func coverGetPath(dbc *db.DB, musicPath, podcastPath string, id specid.ID) (stri
} }
} }
func coverGetPathAlbum(dbc *db.DB, musicPath string, id int) (string, error) { func coverGetPathAlbum(dbc *db.DB, id int) (string, error) {
folder := &db.Album{} folder := &db.Album{}
err := dbc.DB. err := dbc.DB.
Select("id, left_path, right_path, cover"). Preload("Parent").
Select("id, root_dir, left_path, right_path, cover").
First(folder, id). First(folder, id).
Error Error
if err != nil { if err != nil {
@@ -100,7 +101,7 @@ func coverGetPathAlbum(dbc *db.DB, musicPath string, id int) (string, error) {
return "", errCoverEmpty return "", errCoverEmpty
} }
return path.Join( return path.Join(
musicPath, folder.RootDir,
folder.LeftPath, folder.LeftPath,
folder.RightPath, folder.RightPath,
folder.Cover, folder.Cover,
@@ -201,7 +202,7 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R
case specid.Track: case specid.Track:
track, _ := streamGetTrack(c.DB, id.Value) track, _ := streamGetTrack(c.DB, id.Value)
audioFile = track audioFile = track
audioPath = path.Join(c.MusicPath, track.RelPath()) audioPath = path.Join(track.AbsPath())
if err != nil { if err != nil {
return spec.NewError(70, "track with id `%s` was not found", id) return spec.NewError(70, "track with id `%s` was not found", id)
} }
@@ -278,7 +279,7 @@ func (c *Controller) ServeDownload(w http.ResponseWriter, r *http.Request) *spec
case specid.Track: case specid.Track:
track, _ := streamGetTrack(c.DB, id.Value) track, _ := streamGetTrack(c.DB, id.Value)
audioFile = track audioFile = track
filePath = path.Join(c.MusicPath, track.RelPath()) filePath = track.AbsPath()
if err != nil { if err != nil {
return spec.NewError(70, "track with id `%s` was not found", id) return spec.NewError(70, "track with id `%s` was not found", id)
} }

View File

@@ -84,14 +84,10 @@ func NewArtistByFolder(f *db.Album) *Artist {
} }
func NewDirectoryByFolder(f *db.Album, children []*TrackChild) *Directory { func NewDirectoryByFolder(f *db.Album, children []*TrackChild) *Directory {
dir := &Directory{ return &Directory{
ID: f.SID(), ID: f.SID(),
Name: f.RightPath, Name: f.RightPath,
Children: children, Children: children,
ParentID: f.ParentSID(),
} }
// don't show the root dir as a parent
if f.ParentID != 1 {
dir.ParentID = f.ParentSID()
}
return dir
} }

View File

@@ -179,7 +179,7 @@ type MusicFolders struct {
} }
type MusicFolder struct { type MusicFolder struct {
ID int `xml:"id,attr,omitempty" json:"id,omitempty"` ID string `xml:"id,attr,omitempty" json:"id,omitempty"`
Name string `xml:"name,attr,omitempty" json:"name,omitempty"` Name string `xml:"name,attr,omitempty" json:"name,omitempty"`
} }

View File

@@ -5,32 +5,6 @@
"type": "gonic", "type": "gonic",
"albumList": { "albumList": {
"album": [ "album": [
{
"id": "al-2",
"coverArt": "al-2",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "album-0",
"album": "",
"parent": "al-1",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{
"id": "al-7",
"coverArt": "al-7",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "album-0",
"album": "",
"parent": "al-6",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{ {
"id": "al-13", "id": "al-13",
"coverArt": "al-13", "coverArt": "al-13",
@@ -44,45 +18,6 @@
"songCount": 3, "songCount": 3,
"duration": 300 "duration": 300
}, },
{
"id": "al-11",
"coverArt": "al-11",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "album-0",
"album": "",
"parent": "al-10",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{
"id": "al-3",
"coverArt": "al-3",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "album-1",
"album": "",
"parent": "al-1",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{
"id": "al-12",
"coverArt": "al-12",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "album-1",
"album": "",
"parent": "al-10",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{ {
"id": "al-8", "id": "al-8",
"coverArt": "al-8", "coverArt": "al-8",
@@ -97,11 +32,11 @@
"duration": 300 "duration": 300
}, },
{ {
"id": "al-4", "id": "al-2",
"coverArt": "al-4", "coverArt": "al-2",
"artist": "artist-0", "artist": "artist-0",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"title": "album-2", "title": "album-0",
"album": "", "album": "",
"parent": "al-1", "parent": "al-1",
"isDir": true, "isDir": true,
@@ -109,6 +44,32 @@
"songCount": 3, "songCount": 3,
"duration": 300 "duration": 300
}, },
{
"id": "al-3",
"coverArt": "al-3",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "album-1",
"album": "",
"parent": "al-1",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{
"id": "al-11",
"coverArt": "al-11",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "album-0",
"album": "",
"parent": "al-10",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{ {
"id": "al-9", "id": "al-9",
"coverArt": "al-9", "coverArt": "al-9",
@@ -121,6 +82,45 @@
"name": "", "name": "",
"songCount": 3, "songCount": 3,
"duration": 300 "duration": 300
},
{
"id": "al-7",
"coverArt": "al-7",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "album-0",
"album": "",
"parent": "al-6",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{
"id": "al-12",
"coverArt": "al-12",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "album-1",
"album": "",
"parent": "al-10",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{
"id": "al-4",
"coverArt": "al-4",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "album-2",
"album": "",
"parent": "al-1",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
} }
] ]
} }

View File

@@ -5,32 +5,6 @@
"type": "gonic", "type": "gonic",
"albumList2": { "albumList2": {
"album": [ "album": [
{
"id": "al-3",
"coverArt": "al-3",
"artistId": "ar-1",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-11",
"coverArt": "al-11",
"artistId": "ar-3",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-0",
"songCount": 3,
"duration": 300,
"year": 2021
},
{ {
"id": "al-4", "id": "al-4",
"coverArt": "al-4", "coverArt": "al-4",
@@ -44,19 +18,6 @@
"duration": 300, "duration": 300,
"year": 2021 "year": 2021
}, },
{
"id": "al-9",
"coverArt": "al-9",
"artistId": "ar-2",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-2",
"songCount": 3,
"duration": 300,
"year": 2021
},
{ {
"id": "al-2", "id": "al-2",
"coverArt": "al-2", "coverArt": "al-2",
@@ -70,6 +31,32 @@
"duration": 300, "duration": 300,
"year": 2021 "year": 2021
}, },
{
"id": "al-12",
"coverArt": "al-12",
"artistId": "ar-3",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-3",
"coverArt": "al-3",
"artistId": "ar-1",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
},
{ {
"id": "al-13", "id": "al-13",
"coverArt": "al-13", "coverArt": "al-13",
@@ -83,19 +70,6 @@
"duration": 300, "duration": 300,
"year": 2021 "year": 2021
}, },
{
"id": "al-8",
"coverArt": "al-8",
"artistId": "ar-2",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
},
{ {
"id": "al-7", "id": "al-7",
"coverArt": "al-7", "coverArt": "al-7",
@@ -110,14 +84,40 @@
"year": 2021 "year": 2021
}, },
{ {
"id": "al-12", "id": "al-8",
"coverArt": "al-12", "coverArt": "al-8",
"artistId": "ar-2",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-11",
"coverArt": "al-11",
"artistId": "ar-3", "artistId": "ar-3",
"artist": "artist-2", "artist": "artist-2",
"created": "2019-11-30T00:00:00Z", "created": "2019-11-30T00:00:00Z",
"title": "", "title": "",
"album": "", "album": "",
"name": "album-1", "name": "album-0",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-9",
"coverArt": "al-9",
"artistId": "ar-2",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-2",
"songCount": 3, "songCount": 3,
"duration": 300, "duration": 300,
"year": 2021 "year": 2021

View File

@@ -9,9 +9,9 @@
{ {
"name": "a", "name": "a",
"artist": [ "artist": [
{ "id": "ar-1", "name": "artist-0", "albumCount": 3 }, { "id": "ar-1", "name": "artist-0", "albumCount": 6 },
{ "id": "ar-2", "name": "artist-1", "albumCount": 3 }, { "id": "ar-2", "name": "artist-1", "albumCount": 6 },
{ "id": "ar-3", "name": "artist-2", "albumCount": 3 } { "id": "ar-3", "name": "artist-2", "albumCount": 6 }
] ]
} }
] ]

View File

@@ -0,0 +1,20 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"type": "gonic",
"artists": {
"ignoredArticles": "",
"index": [
{
"name": "a",
"artist": [
{ "id": "ar-1", "name": "artist-0", "albumCount": 3 },
{ "id": "ar-2", "name": "artist-1", "albumCount": 3 },
{ "id": "ar-3", "name": "artist-2", "albumCount": 3 }
]
}
]
}
}
}

View File

@@ -0,0 +1,20 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"type": "gonic",
"artists": {
"ignoredArticles": "",
"index": [
{
"name": "a",
"artist": [
{ "id": "ar-1", "name": "artist-0", "albumCount": 3 },
{ "id": "ar-2", "name": "artist-1", "albumCount": 3 },
{ "id": "ar-3", "name": "artist-2", "albumCount": 3 }
]
}
]
}
}
}

View File

@@ -10,9 +10,12 @@
{ {
"name": "a", "name": "a",
"artist": [ "artist": [
{ "id": "al-2", "name": "album-0", "albumCount": 0 }, { "id": "al-1", "name": "artist-0", "albumCount": 3 },
{ "id": "al-3", "name": "album-1", "albumCount": 0 }, { "id": "al-14", "name": "artist-0", "albumCount": 3 },
{ "id": "al-4", "name": "album-2", "albumCount": 0 } { "id": "al-6", "name": "artist-1", "albumCount": 3 },
{ "id": "al-19", "name": "artist-1", "albumCount": 3 },
{ "id": "al-10", "name": "artist-2", "albumCount": 3 },
{ "id": "al-23", "name": "artist-2", "albumCount": 3 }
] ]
} }
] ]

View File

@@ -0,0 +1,21 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"type": "gonic",
"indexes": {
"lastModified": 0,
"ignoredArticles": "",
"index": [
{
"name": "a",
"artist": [
{ "id": "al-1", "name": "artist-0", "albumCount": 3 },
{ "id": "al-6", "name": "artist-1", "albumCount": 3 },
{ "id": "al-10", "name": "artist-2", "albumCount": 3 }
]
}
]
}
}
}

View File

@@ -0,0 +1,21 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"type": "gonic",
"indexes": {
"lastModified": 0,
"ignoredArticles": "",
"index": [
{
"name": "a",
"artist": [
{ "id": "al-14", "name": "artist-0", "albumCount": 3 },
{ "id": "al-19", "name": "artist-1", "albumCount": 3 },
{ "id": "al-23", "name": "artist-2", "albumCount": 3 }
]
}
]
}
}
}

View File

@@ -5,6 +5,7 @@
"type": "gonic", "type": "gonic",
"directory": { "directory": {
"id": "al-3", "id": "al-3",
"parent": "al-1",
"name": "album-1", "name": "album-1",
"child": [ "child": [
{ {

View File

@@ -5,6 +5,7 @@
"type": "gonic", "type": "gonic",
"directory": { "directory": {
"id": "al-2", "id": "al-2",
"parent": "al-1",
"name": "album-0", "name": "album-0",
"child": [ "child": [
{ {

View File

@@ -4,11 +4,6 @@
"version": "1.15.0", "version": "1.15.0",
"type": "gonic", "type": "gonic",
"searchResult2": { "searchResult2": {
"artist": [
{ "id": "al-2", "name": "album-0" },
{ "id": "al-3", "name": "album-1" },
{ "id": "al-4", "name": "album-2" }
],
"album": [ "album": [
{ {
"id": "al-2", "id": "al-2",

View File

@@ -3,6 +3,12 @@
"status": "ok", "status": "ok",
"version": "1.15.0", "version": "1.15.0",
"type": "gonic", "type": "gonic",
"searchResult2": {} "searchResult2": {
"artist": [
{ "id": "al-1", "parent": "al-5", "name": "artist-0" },
{ "id": "al-6", "parent": "al-5", "name": "artist-1" },
{ "id": "al-10", "parent": "al-5", "name": "artist-2" }
]
}
} }
} }

View File

@@ -9,8 +9,6 @@ import (
"strings" "strings"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
"gopkg.in/gormigrate.v1"
) )
func DefaultOptions() url.Values { func DefaultOptions() url.Values {
@@ -49,30 +47,6 @@ func New(path string, options url.Values) (*DB, error) {
} }
db.SetLogger(log.New(os.Stdout, "gorm ", 0)) db.SetLogger(log.New(os.Stdout, "gorm ", 0))
db.DB().SetMaxOpenConns(1) db.DB().SetMaxOpenConns(1)
migrOptions := &gormigrate.Options{
TableName: "migrations",
IDColumnName: "id",
IDColumnSize: 255,
UseTransaction: false,
}
migr := gormigrate.New(db, migrOptions, wrapMigrations(
migrateInitSchema(),
migrateCreateInitUser(),
migrateMergePlaylist(),
migrateCreateTranscode(),
migrateAddGenre(),
migrateUpdateTranscodePrefIDX(),
migrateAddAlbumIDX(),
migrateMultiGenre(),
migrateListenBrainz(),
migratePodcast(),
migrateBookmarks(),
migratePodcastAutoDownload(),
migrateAlbumCreatedAt(),
))
if err = migr.Migrate(); err != nil {
return nil, fmt.Errorf("migrating to latest version: %w", err)
}
return &DB{DB: db}, nil return &DB{DB: db}, nil
} }

View File

@@ -3,33 +3,45 @@ package db
import ( import (
"errors" "errors"
"fmt" "fmt"
"log"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
"gopkg.in/gormigrate.v1" "gopkg.in/gormigrate.v1"
) )
// $ date '+%Y%m%d%H%M' type MigrationContext struct {
OriginalMusicPath string
}
func migrateInitSchema() gormigrate.Migration { func (db *DB) Migrate(ctx MigrationContext) error {
return gormigrate.Migration{ options := &gormigrate.Options{
ID: "202002192100", TableName: "migrations",
Migrate: func(tx *gorm.DB) error { IDColumnName: "id",
return tx.AutoMigrate( IDColumnSize: 255,
Genre{}, UseTransaction: false,
TrackGenre{},
AlbumGenre{},
Track{},
Artist{},
User{},
Setting{},
Play{},
Album{},
Playlist{},
PlayQueue{},
).
Error
},
} }
// $ date '+%Y%m%d%H%M'
migrations := []*gormigrate.Migration{
construct(ctx, "202002192100", migrateInitSchema),
construct(ctx, "202002192019", migrateCreateInitUser),
construct(ctx, "202002192222", migrateMergePlaylist),
construct(ctx, "202003111222", migrateCreateTranscode),
construct(ctx, "202003121330", migrateAddGenre),
construct(ctx, "202003241509", migrateUpdateTranscodePrefIDX),
construct(ctx, "202004302006", migrateAddAlbumIDX),
construct(ctx, "202012151806", migrateMultiGenre),
construct(ctx, "202101081149", migrateListenBrainz),
construct(ctx, "202101111537", migratePodcast),
construct(ctx, "202102032210", migrateBookmarks),
construct(ctx, "202102191448", migratePodcastAutoDownload),
construct(ctx, "202110041330", migrateAlbumCreatedAt),
construct(ctx, "202111021951", migrateAlbumRootDir),
}
return gormigrate.
New(db.DB, options, migrations).
Migrate()
} }
func construct(ctx MigrationContext, id string, f func(*gorm.DB, MigrationContext) error) *gormigrate.Migration { func construct(ctx MigrationContext, id string, f func(*gorm.DB, MigrationContext) error) *gormigrate.Migration {
@@ -41,240 +53,256 @@ func construct(ctx MigrationContext, id string, f func(*gorm.DB, MigrationContex
if err := f(tx, ctx); err != nil { if err := f(tx, ctx); err != nil {
return fmt.Errorf("%q: %w", id, err) return fmt.Errorf("%q: %w", id, err)
} }
log.Printf("migration '%s' finished", id)
return tx.Create(&User{ return nil
Name: initUsername,
Password: initPassword,
IsAdmin: true,
}).
Error
}, },
} Rollback: func(*gorm.DB) error {
}
func migrateMergePlaylist() gormigrate.Migration {
return gormigrate.Migration{
ID: "202002192222",
Migrate: func(tx *gorm.DB) error {
if !tx.HasTable("playlist_items") {
return nil
}
return tx.Exec(`
UPDATE playlists
SET items=( SELECT group_concat(track_id) FROM (
SELECT track_id
FROM playlist_items
WHERE playlist_items.playlist_id=playlists.id
ORDER BY created_at
) );
DROP TABLE playlist_items;`,
).
Error
},
}
}
func migrateCreateTranscode() gormigrate.Migration {
return gormigrate.Migration{
ID: "202003111222",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(
TranscodePreference{},
).
Error
},
}
}
func migrateAddGenre() gormigrate.Migration {
return gormigrate.Migration{
ID: "202003121330",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(
Genre{},
Album{},
Track{},
).
Error
},
}
}
func migrateUpdateTranscodePrefIDX() gormigrate.Migration {
return gormigrate.Migration{
ID: "202003241509",
Migrate: func(tx *gorm.DB) error {
var hasIDX int
tx.
Select("1").
Table("sqlite_master").
Where("type = ?", "index").
Where("name = ?", "idx_user_id_client").
Count(&hasIDX)
if hasIDX == 1 {
// index already exists
return nil
}
step := tx.Exec(`
ALTER TABLE transcode_preferences RENAME TO transcode_preferences_orig;
`)
if err := step.Error; err != nil {
return fmt.Errorf("step rename: %w", err)
}
step = tx.AutoMigrate(
TranscodePreference{},
)
if err := step.Error; err != nil {
return fmt.Errorf("step create: %w", err)
}
step = tx.Exec(`
INSERT INTO transcode_preferences (user_id, client, profile)
SELECT user_id, client, profile
FROM transcode_preferences_orig;
DROP TABLE transcode_preferences_orig;
`)
if err := step.Error; err != nil {
return fmt.Errorf("step copy: %w", err)
}
return nil return nil
}, },
} }
} }
func migrateAddAlbumIDX() gormigrate.Migration { func migrateInitSchema(tx *gorm.DB, _ MigrationContext) error {
return gormigrate.Migration{ return tx.AutoMigrate(
ID: "202004302006", Genre{},
Migrate: func(tx *gorm.DB) error { TrackGenre{},
return tx.AutoMigrate( AlbumGenre{},
Album{}, Track{},
). Artist{},
Error User{},
}, Setting{},
} Play{},
Album{},
Playlist{},
PlayQueue{},
).
Error
} }
func migrateMultiGenre() gormigrate.Migration { func migrateCreateInitUser(tx *gorm.DB, _ MigrationContext) error {
return gormigrate.Migration{ const (
ID: "202012151806", initUsername = "admin"
Migrate: func(tx *gorm.DB) error { initPassword = "admin"
step := tx.AutoMigrate( )
Genre{}, err := tx.
TrackGenre{}, Where("name=?", initUsername).
AlbumGenre{}, First(&User{}).
Track{}, Error
Album{}, if !errors.Is(err, gorm.ErrRecordNotFound) {
) return nil
if err := step.Error; err != nil {
return fmt.Errorf("step auto migrate: %w", err)
}
var genreCount int
tx.
Model(Genre{}).
Count(&genreCount)
if genreCount == 0 {
return nil
}
step = tx.Exec(`
INSERT INTO track_genres (track_id, genre_id)
SELECT id, tag_genre_id
FROM tracks
WHERE tag_genre_id IS NOT NULL;
UPDATE tracks SET tag_genre_id=NULL;
`)
if err := step.Error; err != nil {
return fmt.Errorf("step migrate track genres: %w", err)
}
step = tx.Exec(`
INSERT INTO album_genres (album_id, genre_id)
SELECT id, tag_genre_id
FROM albums
WHERE tag_genre_id IS NOT NULL;
UPDATE albums SET tag_genre_id=NULL;
`)
if err := step.Error; err != nil {
return fmt.Errorf("step migrate album genres: %w", err)
}
return nil
},
} }
return tx.Create(&User{
Name: initUsername,
Password: initPassword,
IsAdmin: true,
}).
Error
} }
func migrateListenBrainz() gormigrate.Migration { func migrateMergePlaylist(tx *gorm.DB, _ MigrationContext) error {
return gormigrate.Migration{ if !tx.HasTable("playlist_items") {
ID: "202101081149", return nil
Migrate: func(tx *gorm.DB) error {
step := tx.AutoMigrate(
User{},
)
if err := step.Error; err != nil {
return fmt.Errorf("step auto migrate: %w", err)
}
return nil
},
} }
return tx.Exec(`
UPDATE playlists
SET items=( SELECT group_concat(track_id) FROM (
SELECT track_id
FROM playlist_items
WHERE playlist_items.playlist_id=playlists.id
ORDER BY created_at
) );
DROP TABLE playlist_items;`,
).
Error
} }
func migratePodcast() gormigrate.Migration { func migrateCreateTranscode(tx *gorm.DB, _ MigrationContext) error {
return gormigrate.Migration{ return tx.AutoMigrate(
ID: "202101111537", TranscodePreference{},
Migrate: func(tx *gorm.DB) error { ).
return tx.AutoMigrate( Error
Podcast{},
PodcastEpisode{},
).
Error
},
}
} }
func migrateBookmarks() gormigrate.Migration { func migrateAddGenre(tx *gorm.DB, _ MigrationContext) error {
return gormigrate.Migration{ return tx.AutoMigrate(
ID: "202102032210", Genre{},
Migrate: func(tx *gorm.DB) error { Album{},
return tx.AutoMigrate( Track{},
Bookmark{}, ).
). Error
Error
},
}
} }
func migratePodcastAutoDownload() gormigrate.Migration { func migrateUpdateTranscodePrefIDX(tx *gorm.DB, _ MigrationContext) error {
return gormigrate.Migration{ var hasIDX int
ID: "202102191448", tx.
Migrate: func(tx *gorm.DB) error { Select("1").
return tx.AutoMigrate( Table("sqlite_master").
Podcast{}, Where("type = ?", "index").
). Where("name = ?", "idx_user_id_client").
Error Count(&hasIDX)
}, if hasIDX == 1 {
// index already exists
return nil
} }
step := tx.Exec(`
ALTER TABLE transcode_preferences RENAME TO transcode_preferences_orig;
`)
if err := step.Error; err != nil {
return fmt.Errorf("step rename: %w", err)
}
step = tx.AutoMigrate(
TranscodePreference{},
)
if err := step.Error; err != nil {
return fmt.Errorf("step create: %w", err)
}
step = tx.Exec(`
INSERT INTO transcode_preferences (user_id, client, profile)
SELECT user_id, client, profile
FROM transcode_preferences_orig;
DROP TABLE transcode_preferences_orig;
`)
if err := step.Error; err != nil {
return fmt.Errorf("step copy: %w", err)
}
return nil
} }
func migrateAlbumCreatedAt() gormigrate.Migration { func migrateAddAlbumIDX(tx *gorm.DB, _ MigrationContext) error {
return gormigrate.Migration{ return tx.AutoMigrate(
ID: "202110041330", Album{},
Migrate: func(tx *gorm.DB) error { ).
step := tx.AutoMigrate( Error
Album{}, }
)
if err := step.Error; err != nil { func migrateMultiGenre(tx *gorm.DB, _ MigrationContext) error {
return fmt.Errorf("step auto migrate: %w", err) step := tx.AutoMigrate(
} Genre{},
step = tx.Exec(` TrackGenre{},
UPDATE albums SET created_at=modified_at; AlbumGenre{},
`) Track{},
if err := step.Error; err != nil { Album{},
return fmt.Errorf("step migrate album created_at: %w", err) )
} if err := step.Error; err != nil {
return nil return fmt.Errorf("step auto migrate: %w", err)
}, }
}
var genreCount int
tx.
Model(Genre{}).
Count(&genreCount)
if genreCount == 0 {
return nil
}
step = tx.Exec(`
INSERT INTO track_genres (track_id, genre_id)
SELECT id, tag_genre_id
FROM tracks
WHERE tag_genre_id IS NOT NULL;
UPDATE tracks SET tag_genre_id=NULL;
`)
if err := step.Error; err != nil {
return fmt.Errorf("step migrate track genres: %w", err)
}
step = tx.Exec(`
INSERT INTO album_genres (album_id, genre_id)
SELECT id, tag_genre_id
FROM albums
WHERE tag_genre_id IS NOT NULL;
UPDATE albums SET tag_genre_id=NULL;
`)
if err := step.Error; err != nil {
return fmt.Errorf("step migrate album genres: %w", err)
}
return nil
}
func migrateListenBrainz(tx *gorm.DB, _ MigrationContext) error {
step := tx.AutoMigrate(
User{},
)
if err := step.Error; err != nil {
return fmt.Errorf("step auto migrate: %w", err)
}
return nil
}
func migratePodcast(tx *gorm.DB, _ MigrationContext) error {
return tx.AutoMigrate(
Podcast{},
PodcastEpisode{},
).
Error
}
func migrateBookmarks(tx *gorm.DB, _ MigrationContext) error {
return tx.AutoMigrate(
Bookmark{},
).
Error
}
func migratePodcastAutoDownload(tx *gorm.DB, _ MigrationContext) error {
return tx.AutoMigrate(
Podcast{},
).
Error
}
func migrateAlbumCreatedAt(tx *gorm.DB, _ MigrationContext) error {
step := tx.AutoMigrate(
Album{},
)
if err := step.Error; err != nil {
return fmt.Errorf("step auto migrate: %w", err)
}
step = tx.Exec(`
UPDATE albums SET created_at=modified_at;
`)
if err := step.Error; err != nil {
return fmt.Errorf("step migrate album created_at: %w", err)
}
return nil
}
func migrateAlbumRootDir(tx *gorm.DB, ctx MigrationContext) error {
var hasIDX int
tx.
Select("1").
Table("sqlite_master").
Where("type = ?", "index").
Where("name = ?", "idx_album_abs_path").
Count(&hasIDX)
if hasIDX == 1 {
// index already exists
return nil
}
step := tx.AutoMigrate(
Album{},
)
if err := step.Error; err != nil {
return fmt.Errorf("step auto migrate: %w", err)
}
step = tx.Exec(`
DROP INDEX IF EXISTS idx_left_path_right_path;
`)
if err := step.Error; err != nil {
return fmt.Errorf("step drop idx: %w", err)
}
step = tx.Exec(`
UPDATE albums SET root_dir=?
`, ctx.OriginalMusicPath)
if err := step.Error; err != nil {
return fmt.Errorf("step drop idx: %w", err)
}
return nil
} }

View File

@@ -132,6 +132,18 @@ func (t *Track) MIME() string {
return v return v
} }
func (t *Track) AbsPath() string {
if t.Album == nil {
return ""
}
return path.Join(
t.Album.RootDir,
t.Album.LeftPath,
t.Album.RightPath,
t.Filename,
)
}
func (t *Track) RelPath() string { func (t *Track) RelPath() string {
if t.Album == nil { if t.Album == nil {
return "" return ""
@@ -182,11 +194,12 @@ type Album struct {
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
ModifiedAt time.Time ModifiedAt time.Time
LeftPath string `gorm:"unique_index:idx_left_path_right_path"` LeftPath string `gorm:"unique_index:idx_album_abs_path"`
RightPath string `gorm:"not null; unique_index:idx_left_path_right_path" sql:"default: null"` RightPath string `gorm:"not null; unique_index:idx_album_abs_path" sql:"default: null"`
RightPathUDec string `sql:"default: null"` RightPathUDec string `sql:"default: null"`
Parent *Album Parent *Album
ParentID int `sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"` ParentID int `sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"`
RootDir string `gorm:"unique_index:idx_album_abs_path" sql:"default: null"`
Genres []*Genre `gorm:"many2many:album_genres"` Genres []*Genre `gorm:"many2many:album_genres"`
Cover string `sql:"default: null"` Cover string `sql:"default: null"`
TagArtist *Artist TagArtist *Artist

View File

@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"path"
"sync" "sync"
"time" "time"
@@ -26,11 +25,10 @@ type Status struct {
} }
type Jukebox struct { type Jukebox struct {
playlist []*db.Track playlist []*db.Track
musicPath string index int
index int playing bool
playing bool sr beep.SampleRate
sr beep.SampleRate
// used to notify the player to re read the members // used to notify the player to re read the members
quit chan struct{} quit chan struct{}
done chan bool done chan bool
@@ -50,13 +48,12 @@ type updateSpeaker struct {
offset int offset int
} }
func New(musicPath string) *Jukebox { func New() *Jukebox {
return &Jukebox{ return &Jukebox{
musicPath: musicPath, sr: beep.SampleRate(48000),
sr: beep.SampleRate(48000), speaker: make(chan updateSpeaker, 1),
speaker: make(chan updateSpeaker, 1), done: make(chan bool),
done: make(chan bool), quit: make(chan struct{}),
quit: make(chan struct{}),
} }
} }
@@ -89,10 +86,7 @@ func (j *Jukebox) doUpdateSpeaker(su updateSpeaker) error {
return nil return nil
} }
j.index = su.index j.index = su.index
f, err := os.Open(path.Join( f, err := os.Open(j.playlist[su.index].AbsPath())
j.musicPath,
j.playlist[su.index].RelPath(),
))
if err != nil { if err != nil {
return err return err
} }

View File

@@ -48,12 +48,6 @@ func New(musicPaths []string, sorted bool, db *db.DB, genreSplit string, tagger
} }
} }
type ScanOptions struct {
IsFull bool
// TODO https://github.com/sentriz/gonic/issues/64
Path string
}
func (s *Scanner) IsScanning() bool { func (s *Scanner) IsScanning() bool {
return atomic.LoadInt32(s.scanning) == 1 return atomic.LoadInt32(s.scanning) == 1
} }

View File

@@ -1,9 +1,11 @@
package scanner_test package scanner_test
import ( import (
"fmt"
"io/ioutil" "io/ioutil"
"log" "log"
"os" "os"
"path/filepath"
"testing" "testing"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"

View File

@@ -27,7 +27,7 @@ import (
type Options struct { type Options struct {
DB *db.DB DB *db.DB
MusicPath string MusicPaths []string
PodcastPath string PodcastPath string
CachePath string CachePath string
CoverCachePath string CoverCachePath string
@@ -46,7 +46,9 @@ type Server struct {
} }
func New(opts Options) (*Server, error) { func New(opts Options) (*Server, error) {
opts.MusicPath = filepath.Clean(opts.MusicPath) for i, musicPath := range opts.MusicPaths {
opts.MusicPaths[i] = filepath.Clean(musicPath)
}
opts.CachePath = filepath.Clean(opts.CachePath) opts.CachePath = filepath.Clean(opts.CachePath)
opts.PodcastPath = filepath.Clean(opts.PodcastPath) opts.PodcastPath = filepath.Clean(opts.PodcastPath)
@@ -55,7 +57,6 @@ func New(opts Options) (*Server, error) {
scanner := scanner.New(opts.MusicPaths, false, opts.DB, opts.GenreSplit, tagger) scanner := scanner.New(opts.MusicPaths, false, opts.DB, opts.GenreSplit, tagger)
base := &ctrlbase.Controller{ base := &ctrlbase.Controller{
DB: opts.DB, DB: opts.DB,
MusicPath: opts.MusicPath,
ProxyPrefix: opts.ProxyPrefix, ProxyPrefix: opts.ProxyPrefix,
Scanner: scanner, Scanner: scanner,
} }
@@ -109,7 +110,7 @@ func New(opts Options) (*Server, error) {
} }
if opts.JukeboxEnabled { if opts.JukeboxEnabled {
jukebox := jukebox.New(opts.MusicPath) jukebox := jukebox.New()
ctrlSubsonic.Jukebox = jukebox ctrlSubsonic.Jukebox = jukebox
server.jukebox = jukebox server.jukebox = jukebox
} }