diff --git a/cmd/gonic/gonic.go b/cmd/gonic/gonic.go index dafdeae0..5238bb49 100644 --- a/cmd/gonic/gonic.go +++ b/cmd/gonic/gonic.go @@ -30,7 +30,6 @@ const ( func main() { set := flag.NewFlagSet(gonic.Name, flag.ExitOnError) 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") confCachePath := set.String("cache-path", "", "path to cache") 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)") confHTTPLog := set.Bool("http-log", true, "http request logging (optional)") 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)") if err := ff.Parse(set, os.Args[1:], @@ -62,8 +65,13 @@ func main() { log.Printf(" %-15s %s\n", f.Name, value) }) - if _, err := os.Stat(*confMusicPath); os.IsNotExist(err) { - log.Fatal("please provide a valid music directory") + if len(confMusicPaths) == 0 { + 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) { log.Fatal("please provide a valid podcast directory") @@ -90,13 +98,20 @@ func main() { if err != nil { 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(`^\/*(.*?)\/*$`) *confProxyPrefix = proxyPrefixExpr.ReplaceAllString(*confProxyPrefix, `/$1`) server, err := server.New(server.Options{ - DB: db, - MusicPath: *confMusicPath, + DB: dbc, + MusicPaths: confMusicPaths, CachePath: cacheDirAudio, CoverCachePath: cacheDirCovers, ProxyPrefix: *confProxyPrefix, @@ -125,3 +140,14 @@ func main() { 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 +} diff --git a/server/ctrladmin/handlers.go b/server/ctrladmin/handlers.go index d1dcb8b0..10caada5 100644 --- a/server/ctrladmin/handlers.go +++ b/server/ctrladmin/handlers.go @@ -139,10 +139,7 @@ func (c *Controller) ServeChangeOwnPasswordDo(r *http.Request) *Response { func (c *Controller) ServeLinkLastFMDo(r *http.Request) *Response { token := r.URL.Query().Get("token") if token == "" { - return &Response{ - err: "please provide a token", - code: 400, - } + return &Response{code: 400, err: "please provide a token"} } apiKey, err := c.DB.GetSetting("lastfm_api_key") if err != nil { @@ -199,17 +196,11 @@ func (c *Controller) ServeUnlinkListenBrainzDo(r *http.Request) *Response { func (c *Controller) ServeChangeUsername(r *http.Request) *Response { username := r.URL.Query().Get("user") if username == "" { - return &Response{ - err: "please provide a username", - code: 400, - } + return &Response{code: 400, err: "please provide a username"} } user := c.DB.GetUserByName(username) if user == nil { - return &Response{ - err: "couldn't find a user with that name", - code: 400, - } + return &Response{code: 400, err: "couldn't find a user with that name"} } data := &templateData{} data.SelectedUser = user @@ -237,17 +228,11 @@ func (c *Controller) ServeChangeUsernameDo(r *http.Request) *Response { func (c *Controller) ServeChangePassword(r *http.Request) *Response { username := r.URL.Query().Get("user") if username == "" { - return &Response{ - err: "please provide a username", - code: 400, - } + return &Response{code: 400, err: "please provide a username"} } user := c.DB.GetUserByName(username) if user == nil { - return &Response{ - err: "couldn't find a user with that name", - code: 400, - } + return &Response{code: 400, err: "couldn't find a user with that name"} } data := &templateData{} data.SelectedUser = user @@ -276,17 +261,11 @@ func (c *Controller) ServeChangePasswordDo(r *http.Request) *Response { func (c *Controller) ServeDeleteUser(r *http.Request) *Response { username := r.URL.Query().Get("user") if username == "" { - return &Response{ - err: "please provide a username", - code: 400, - } + return &Response{code: 400, err: "please provide a username"} } user := c.DB.GetUserByName(username) if user == nil { - return &Response{ - err: "couldn't find a user with that name", - code: 400, - } + return &Response{code: 400, err: "couldn't find a user with that name"} } data := &templateData{} data.SelectedUser = user @@ -421,10 +400,7 @@ func (c *Controller) ServeDeleteTranscodePrefDo(r *http.Request) *Response { user := r.Context().Value(CtxUser).(*db.User) client := r.URL.Query().Get("client") if client == "" { - return &Response{ - err: "please provide a client", - code: 400, - } + return &Response{code: 400, err: "please provide a client"} } c.DB. 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 { id, err := strconv.Atoi(r.URL.Query().Get("id")) if err != nil { - return &Response{ - err: "please provide a valid podcast id", - code: 400, - } + return &Response{code: 400, err: "please provide a valid podcast id"} } if err := c.Podcasts.DownloadPodcastAll(id); err != nil { - return &Response{ - err: "please provide a valid podcast id", - code: 400, - } + return &Response{code: 400, err: "please provide a valid podcast id"} } return &Response{ redirect: "/admin/home", @@ -479,10 +449,7 @@ func (c *Controller) ServePodcastDownloadDo(r *http.Request) *Response { func (c *Controller) ServePodcastUpdateDo(r *http.Request) *Response { id, err := strconv.Atoi(r.URL.Query().Get("id")) if err != nil { - return &Response{ - err: "please provide a valid podcast id", - code: 400, - } + return &Response{code: 400, err: "please provide a valid podcast id"} } setting := db.PodcastAutoDownload(r.FormValue("setting")) var message string @@ -492,10 +459,7 @@ func (c *Controller) ServePodcastUpdateDo(r *http.Request) *Response { case db.PodcastAutoDownloadNone: message = "future podcast episodes will not be downloaded" default: - return &Response{ - err: "please provide a valid podcast download type", - code: 400, - } + return &Response{code: 400, err: "please provide a valid podcast download type"} } if err := c.Podcasts.SetAutoDownload(id, setting); err != nil { return &Response{ @@ -513,16 +477,10 @@ func (c *Controller) ServePodcastDeleteDo(r *http.Request) *Response { user := r.Context().Value(CtxUser).(*db.User) id, err := strconv.Atoi(r.URL.Query().Get("id")) if err != nil { - return &Response{ - err: "please provide a valid podcast id", - code: 400, - } + return &Response{code: 400, err: "please provide a valid podcast id"} } if err := c.Podcasts.DeletePodcast(user.ID, id); err != nil { - return &Response{ - err: "please provide a valid podcast id", - code: 400, - } + return &Response{code: 400, err: "please provide a valid podcast id"} } return &Response{ redirect: "/admin/home", diff --git a/server/ctrladmin/handlers_playlist.go b/server/ctrladmin/handlers_playlist.go index 6d7e1850..3e060e3c 100644 --- a/server/ctrladmin/handlers_playlist.go +++ b/server/ctrladmin/handlers_playlist.go @@ -18,16 +18,16 @@ var ( errPlaylistNoMatch = errors.New("couldn't match track") ) -func playlistParseLine(c *Controller, path string) (int, error) { - if strings.HasPrefix(path, "#") || strings.TrimSpace(path) == "" { +func playlistParseLine(c *Controller, absPath string) (int, error) { + if strings.HasPrefix(absPath, "#") || strings.TrimSpace(absPath) == "" { return 0, nil } var track db.Track query := c.DB.Raw(` SELECT tracks.id FROM TRACKS JOIN albums ON tracks.album_id=albums.id - WHERE (? || '/' || albums.left_path || albums.right_path || '/' || tracks.filename)=?`, - c.MusicPath, path) + WHERE (albums.root_dir || '/' || albums.left_path || albums.right_path || '/' || tracks.filename)=?`, + absPath) err := query.First(&track).Error switch { 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 { if err := r.ParseMultipartForm((1 << 10) * 24); err != nil { - return &Response{ - err: "couldn't parse mutlipart", - code: 500, - } + return &Response{code: 500, err: "couldn't parse mutlipart"} } user := r.Context().Value(CtxUser).(*db.User) var playlistCount int @@ -123,10 +120,7 @@ func (c *Controller) ServeDeletePlaylistDo(r *http.Request) *Response { user := r.Context().Value(CtxUser).(*db.User) id, err := strconv.Atoi(r.URL.Query().Get("id")) if err != nil { - return &Response{ - err: "please provide a valid id", - code: 400, - } + return &Response{code: 400, err: "please provide a valid id"} } c.DB. Where("user_id=? AND id=?", user.ID, id). diff --git a/server/ctrlbase/ctrl.go b/server/ctrlbase/ctrl.go index 39daa242..a2f91c14 100644 --- a/server/ctrlbase/ctrl.go +++ b/server/ctrlbase/ctrl.go @@ -46,7 +46,6 @@ func statusToBlock(code int) string { type Controller struct { DB *db.DB - MusicPath string Scanner *scanner.Scanner ProxyPrefix string } diff --git a/server/ctrlsubsonic/ctrl_test.go b/server/ctrlsubsonic/ctrl_test.go index 3200a2be..1083098b 100644 --- a/server/ctrlsubsonic/ctrl_test.go +++ b/server/ctrlsubsonic/ctrl_test.go @@ -55,15 +55,14 @@ func makeHTTPMock(query url.Values) (*httptest.ResponseRecorder, *http.Request) func runQueryCases(t *testing.T, contr *Controller, h handlerSubsonic, cases []*queryCase) { t.Helper() for _, qc := range cases { - qc := qc // pin t.Run(qc.expectPath, func(t *testing.T) { - t.Parallel() rr, req := makeHTTPMock(qc.params) contr.H(h).ServeHTTP(rr, req) body := rr.Body.String() if status := rr.Code; status != http.StatusOK { t.Fatalf("didn't give a 200\n%s", body) } + goldenPath := makeGoldenPath(t.Name()) goldenRegen := os.Getenv("GONIC_REGEN") 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) } diff := expected.Diff(actual, diffOpts...) - // pass or fail - if len(diff) == 0 { - return + + if len(diff) > 0 { + t.Errorf("\u001b[31;1mdiffering json\u001b[0m\n%s", diff.Render()) } - t.Errorf("\u001b[31;1mdiffering json\u001b[0m\n%s", diff.Render()) }) } } diff --git a/server/ctrlsubsonic/handlers_by_folder.go b/server/ctrlsubsonic/handlers_by_folder.go index 080e767f..04ccc2d5 100644 --- a/server/ctrlsubsonic/handlers_by_folder.go +++ b/server/ctrlsubsonic/handlers_by_folder.go @@ -12,18 +12,27 @@ import ( "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 // 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 // under the root directory 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 c.DB. Select("*, count(sub.id) child_count"). 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"). Order("albums.right_path COLLATE NOCASE"). Find(&folders) @@ -31,17 +40,15 @@ func (c *Controller) ServeGetIndexes(r *http.Request) *spec.Response { indexMap := make(map[string]*spec.Index, 27) resp := make([]*spec.Index, 0, 27) for _, folder := range folders { - i := lowerUDecOrHash(folder.IndexRightPath()) - index, ok := indexMap[i] - if !ok { - index = &spec.Index{ - Name: i, + key := lowerUDecOrHash(folder.IndexRightPath()) + if _, ok := indexMap[key]; !ok { + indexMap[key] = &spec.Index{ + Name: key, Artists: []*spec.Artist{}, } - indexMap[i] = index - resp = append(resp, index) + resp = append(resp, indexMap[key]) } - index.Artists = append(index.Artists, + indexMap[key].Artists = append(indexMap[key].Artists, spec.NewArtistByFolder(folder)) } sub := spec.NewResponse() @@ -137,6 +144,10 @@ func (c *Controller) ServeGetAlbumList(r *http.Request) *spec.Response { default: 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 // TODO: think about removing this extra join to count number // 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") } query = fmt.Sprintf("%%%s%%", strings.TrimSuffix(query, "*")) + results := &spec.SearchResultTwo{} + // search "artists" + 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 artists []*db.Album - c.DB. - Where(` - parent_id=1 - AND ( right_path LIKE ? OR - right_path_u_dec LIKE ? )`, - query, query). + 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)). - Find(&artists) + 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)) + results.Artists = append(results.Artists, spec.NewDirectoryByFolder(a, nil)) } + // search "albums" var albums []*db.Album - c.DB. - Where(` - tag_artist_id IS NOT NULL - AND ( right_path LIKE ? OR - right_path_u_dec LIKE ? )`, - query, query). + q = c.DB. + Where(`tag_artist_id IS NOT NULL AND (right_path LIKE ? OR right_path_u_dec LIKE ?)`, query, query). Offset(params.GetOrInt("albumOffset", 0)). - Limit(params.GetOrInt("albumCount", 20)). - Find(&albums) + Limit(params.GetOrInt("albumCount", 20)) + 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 { results.Albums = append(results.Albums, spec.NewTCAlbumByFolder(a)) } + // search tracks var tracks []*db.Track - c.DB. + q = c.DB. Preload("Album"). - Where("filename LIKE ? OR filename_u_dec LIKE ?", - query, query). + Where("filename LIKE ? OR filename_u_dec LIKE ?", query, query). Offset(params.GetOrInt("songOffset", 0)). - Limit(params.GetOrInt("songCount", 20)). - Find(&tracks) + Limit(params.GetOrInt("songCount", 20)) + if m, _ := params.Get("musicFolderId"); m != "" { + q = q. + Joins("JOIN albums ON albums.id=tracks.album_id"). + 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)) + results.Tracks = append(results.Tracks, spec.NewTCTrackByFolder(t, t.Album)) } - // + sub := spec.NewResponse() sub.SearchResultTwo = results return sub diff --git a/server/ctrlsubsonic/handlers_by_folder_test.go b/server/ctrlsubsonic/handlers_by_folder_test.go index 0cfe691a..8c5c6e7c 100644 --- a/server/ctrlsubsonic/handlers_by_folder_test.go +++ b/server/ctrlsubsonic/handlers_by_folder_test.go @@ -2,6 +2,7 @@ package ctrlsubsonic import ( "net/url" + "path/filepath" "testing" _ "github.com/jinzhu/gorm/dialects/sqlite" @@ -13,6 +14,8 @@ func TestGetIndexes(t *testing.T) { runQueryCases(t, contr, contr.ServeGetIndexes, []*queryCase{ {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}, }) } diff --git a/server/ctrlsubsonic/handlers_by_tags.go b/server/ctrlsubsonic/handlers_by_tags.go index 9c19bc65..bc80032f 100644 --- a/server/ctrlsubsonic/handlers_by_tags.go +++ b/server/ctrlsubsonic/handlers_by_tags.go @@ -16,28 +16,32 @@ import ( ) func (c *Controller) ServeGetArtists(r *http.Request) *spec.Response { + params := r.Context().Value(CtxParams).(params.Params) var artists []*db.Artist - c.DB. + q := c.DB. Select("*, count(sub.id) album_count"). Joins("LEFT JOIN albums sub ON artists.id=sub.tag_artist_id"). Group("artists.id"). - Order("artists.name COLLATE NOCASE"). - Find(&artists) + Order("artists.name COLLATE NOCASE") + 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 indexMap := make(map[string]*spec.Index, 27) resp := make([]*spec.Index, 0, 27) for _, artist := range artists { - i := lowerUDecOrHash(artist.IndexName()) - index, ok := indexMap[i] - if !ok { - index = &spec.Index{ - Name: i, + key := lowerUDecOrHash(artist.IndexName()) + if _, ok := indexMap[key]; !ok { + indexMap[key] = &spec.Index{ + Name: key, Artists: []*spec.Artist{}, } - indexMap[i] = index - resp = append(resp, index) + resp = append(resp, indexMap[key]) } - index.Artists = append(index.Artists, + indexMap[key].Artists = append(indexMap[key].Artists, spec.NewArtistByTags(artist)) } sub := spec.NewResponse() @@ -144,6 +148,9 @@ func (c *Controller) ServeGetAlbumListTwo(r *http.Request) *spec.Response { default: 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 // TODO: think about removing this extra join to count number // 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 { 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.SearchResultThree{} + // search "artists" var artists []*db.Artist - c.DB. - Where("name LIKE ? OR name_u_dec LIKE ?", - query, query). + q := c.DB. + Where("name LIKE ? OR name_u_dec LIKE ?", query, query). Offset(params.GetOrInt("artistOffset", 0)). - Limit(params.GetOrInt("artistCount", 20)). - Find(&artists) + Limit(params.GetOrInt("artistCount", 20)) + if m, _ := params.Get("musicFolderId"); m != "" { + q = q. + Joins("JOIN albums ON albums.tag_artist_id=artists.id"). + 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)) + results.Artists = append(results.Artists, spec.NewArtistByTags(a)) } + // search "albums" var albums []*db.Album - c.DB. + q = c.DB. Preload("TagArtist"). - Where("tag_title LIKE ? OR tag_title_u_dec LIKE ?", - query, query). + Where("tag_title LIKE ? OR tag_title_u_dec LIKE ?", query, query). Offset(params.GetOrInt("albumOffset", 0)). - Limit(params.GetOrInt("albumCount", 20)). - Find(&albums) + Limit(params.GetOrInt("albumCount", 20)) + 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 { - results.Albums = append(results.Albums, - spec.NewAlbumByTags(a, a.TagArtist)) + results.Albums = append(results.Albums, spec.NewAlbumByTags(a, a.TagArtist)) } + // search tracks var tracks []*db.Track - c.DB. + q = c.DB. Preload("Album"). - Where("tag_title LIKE ? OR tag_title_u_dec LIKE ?", - query, query). + Where("tag_title LIKE ? OR tag_title_u_dec LIKE ?", query, query). Offset(params.GetOrInt("songOffset", 0)). - Limit(params.GetOrInt("songCount", 20)). - Find(&tracks) + Limit(params.GetOrInt("songCount", 20)) + if m, _ := params.Get("musicFolderId"); m != "" { + q = q. + Joins("JOIN albums ON albums.id=tracks.album_id"). + 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)) + results.Tracks = append(results.Tracks, spec.NewTrackByTags(t, t.Album)) } + sub := spec.NewResponse() sub.SearchResultThree = results return sub @@ -313,17 +336,20 @@ func (c *Controller) ServeGetSongsByGenre(r *http.Request) *spec.Response { if err != nil { 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 - c.DB. + q := c.DB. Joins("JOIN albums ON tracks.album_id=albums.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). Preload("Album"). Offset(params.GetOrInt("offset", 0)). - Limit(params.GetOrInt("count", 10)). - Find(&tracks) + Limit(params.GetOrInt("count", 10)) + 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.TracksByGenre = &spec.TracksByGenre{ List: make([]*spec.TrackChild, len(tracks)), diff --git a/server/ctrlsubsonic/handlers_by_tags_test.go b/server/ctrlsubsonic/handlers_by_tags_test.go index 55f9ed9c..90918e24 100644 --- a/server/ctrlsubsonic/handlers_by_tags_test.go +++ b/server/ctrlsubsonic/handlers_by_tags_test.go @@ -2,16 +2,19 @@ package ctrlsubsonic import ( "net/url" + "path/filepath" "testing" ) func TestGetArtists(t *testing.T) { t.Parallel() - contr, m := makeController(t) + contr, m := makeControllerRoots(t, []string{"m-0", "m-1"}) defer m.CleanUp() runQueryCases(t, contr, contr.ServeGetArtists, []*queryCase{ {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}, }) } diff --git a/server/ctrlsubsonic/handlers_common.go b/server/ctrlsubsonic/handlers_common.go index 99c6522e..c2c9ec8e 100644 --- a/server/ctrlsubsonic/handlers_common.go +++ b/server/ctrlsubsonic/handlers_common.go @@ -4,6 +4,7 @@ import ( "errors" "log" "net/http" + "path/filepath" "time" "unicode" @@ -70,12 +71,22 @@ func (c *Controller) ServeScrobble(r *http.Request) *spec.Response { } func (c *Controller) ServeGetMusicFolders(r *http.Request) *spec.Response { - folders := &spec.MusicFolders{} - folders.List = []*spec.MusicFolder{ - {ID: 1, Name: "music"}, + var roots []string + err := c.DB. + 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.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 } @@ -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 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) sub := spec.NewResponse() sub.RandomTracks = &spec.RandomTracks{} diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index 73a1d3ee..5ef831b2 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -74,10 +74,10 @@ var ( 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 { case specid.Album: - return coverGetPathAlbum(dbc, musicPath, id.Value) + return coverGetPathAlbum(dbc, id.Value) case specid.Podcast: return coverGetPathPodcast(dbc, podcastPath, id.Value) 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{} err := dbc.DB. - Select("id, left_path, right_path, cover"). + Preload("Parent"). + Select("id, root_dir, left_path, right_path, cover"). First(folder, id). Error if err != nil { @@ -100,7 +101,7 @@ func coverGetPathAlbum(dbc *db.DB, musicPath string, id int) (string, error) { return "", errCoverEmpty } return path.Join( - musicPath, + folder.RootDir, folder.LeftPath, folder.RightPath, folder.Cover, @@ -201,7 +202,7 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R case specid.Track: track, _ := streamGetTrack(c.DB, id.Value) audioFile = track - audioPath = path.Join(c.MusicPath, track.RelPath()) + audioPath = path.Join(track.AbsPath()) if err != nil { 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: track, _ := streamGetTrack(c.DB, id.Value) audioFile = track - filePath = path.Join(c.MusicPath, track.RelPath()) + filePath = track.AbsPath() if err != nil { return spec.NewError(70, "track with id `%s` was not found", id) } diff --git a/server/ctrlsubsonic/spec/construct_by_folder.go b/server/ctrlsubsonic/spec/construct_by_folder.go index 8040f8f4..e5ce0a9d 100644 --- a/server/ctrlsubsonic/spec/construct_by_folder.go +++ b/server/ctrlsubsonic/spec/construct_by_folder.go @@ -84,14 +84,10 @@ func NewArtistByFolder(f *db.Album) *Artist { } func NewDirectoryByFolder(f *db.Album, children []*TrackChild) *Directory { - dir := &Directory{ + return &Directory{ ID: f.SID(), Name: f.RightPath, Children: children, + ParentID: f.ParentSID(), } - // don't show the root dir as a parent - if f.ParentID != 1 { - dir.ParentID = f.ParentSID() - } - return dir } diff --git a/server/ctrlsubsonic/spec/spec.go b/server/ctrlsubsonic/spec/spec.go index f30fab89..05b9ee99 100644 --- a/server/ctrlsubsonic/spec/spec.go +++ b/server/ctrlsubsonic/spec/spec.go @@ -179,7 +179,7 @@ type MusicFolders 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"` } diff --git a/server/ctrlsubsonic/testdata/test_get_album_list_random b/server/ctrlsubsonic/testdata/test_get_album_list_random index 409cb219..084698e7 100644 --- a/server/ctrlsubsonic/testdata/test_get_album_list_random +++ b/server/ctrlsubsonic/testdata/test_get_album_list_random @@ -6,24 +6,24 @@ "albumList": { "album": [ { - "id": "al-2", - "coverArt": "al-2", - "artist": "artist-0", + "id": "al-13", + "coverArt": "al-13", + "artist": "artist-2", "created": "2019-11-30T00:00:00Z", - "title": "album-0", + "title": "album-2", "album": "", - "parent": "al-1", + "parent": "al-10", "isDir": true, "name": "", "songCount": 3, "duration": 300 }, { - "id": "al-7", - "coverArt": "al-7", + "id": "al-8", + "coverArt": "al-8", "artist": "artist-1", "created": "2019-11-30T00:00:00Z", - "title": "album-0", + "title": "album-1", "album": "", "parent": "al-6", "isDir": true, @@ -32,26 +32,13 @@ "duration": 300 }, { - "id": "al-13", - "coverArt": "al-13", - "artist": "artist-2", - "created": "2019-11-30T00:00:00Z", - "title": "album-2", - "album": "", - "parent": "al-10", - "isDir": true, - "name": "", - "songCount": 3, - "duration": 300 - }, - { - "id": "al-11", - "coverArt": "al-11", - "artist": "artist-2", + "id": "al-2", + "coverArt": "al-2", + "artist": "artist-0", "created": "2019-11-30T00:00:00Z", "title": "album-0", "album": "", - "parent": "al-10", + "parent": "al-1", "isDir": true, "name": "", "songCount": 3, @@ -71,11 +58,11 @@ "duration": 300 }, { - "id": "al-12", - "coverArt": "al-12", + "id": "al-11", + "coverArt": "al-11", "artist": "artist-2", "created": "2019-11-30T00:00:00Z", - "title": "album-1", + "title": "album-0", "album": "", "parent": "al-10", "isDir": true, @@ -84,11 +71,11 @@ "duration": 300 }, { - "id": "al-8", - "coverArt": "al-8", + "id": "al-9", + "coverArt": "al-9", "artist": "artist-1", "created": "2019-11-30T00:00:00Z", - "title": "album-1", + "title": "album-2", "album": "", "parent": "al-6", "isDir": true, @@ -97,26 +84,39 @@ "duration": 300 }, { - "id": "al-4", - "coverArt": "al-4", - "artist": "artist-0", + "id": "al-7", + "coverArt": "al-7", + "artist": "artist-1", "created": "2019-11-30T00:00:00Z", - "title": "album-2", + "title": "album-0", "album": "", - "parent": "al-1", + "parent": "al-6", "isDir": true, "name": "", "songCount": 3, "duration": 300 }, { - "id": "al-9", - "coverArt": "al-9", - "artist": "artist-1", + "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-6", + "parent": "al-1", "isDir": true, "name": "", "songCount": 3, diff --git a/server/ctrlsubsonic/testdata/test_get_album_list_two_random b/server/ctrlsubsonic/testdata/test_get_album_list_two_random index 053ed35f..11f0e214 100644 --- a/server/ctrlsubsonic/testdata/test_get_album_list_two_random +++ b/server/ctrlsubsonic/testdata/test_get_album_list_two_random @@ -6,66 +6,53 @@ "albumList2": { "album": [ { - "id": "al-3", - "coverArt": "al-3", + "id": "al-4", + "coverArt": "al-4", "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", + "name": "album-2", "songCount": 3, "duration": 300, "year": 2021 }, { - "id": "al-4", - "coverArt": "al-4", + "id": "al-2", + "coverArt": "al-2", "artistId": "ar-1", "artist": "artist-0", "created": "2019-11-30T00:00:00Z", "title": "", "album": "", - "name": "album-2", + "name": "album-0", "songCount": 3, "duration": 300, "year": 2021 }, { - "id": "al-9", - "coverArt": "al-9", - "artistId": "ar-2", - "artist": "artist-1", + "id": "al-12", + "coverArt": "al-12", + "artistId": "ar-3", + "artist": "artist-2", "created": "2019-11-30T00:00:00Z", "title": "", "album": "", - "name": "album-2", + "name": "album-1", "songCount": 3, "duration": 300, "year": 2021 }, { - "id": "al-2", - "coverArt": "al-2", + "id": "al-3", + "coverArt": "al-3", "artistId": "ar-1", "artist": "artist-0", "created": "2019-11-30T00:00:00Z", "title": "", "album": "", - "name": "album-0", + "name": "album-1", "songCount": 3, "duration": 300, "year": 2021 @@ -84,40 +71,53 @@ "year": 2021 }, { - "id": "al-8", - "coverArt": "al-8", + "id": "al-7", + "coverArt": "al-7", "artistId": "ar-2", "artist": "artist-1", "created": "2019-11-30T00:00:00Z", "title": "", "album": "", - "name": "album-1", + "name": "album-0", "songCount": 3, "duration": 300, "year": 2021 }, { - "id": "al-7", - "coverArt": "al-7", + "id": "al-8", + "coverArt": "al-8", "artistId": "ar-2", "artist": "artist-1", "created": "2019-11-30T00:00:00Z", "title": "", "album": "", - "name": "album-0", + "name": "album-1", "songCount": 3, "duration": 300, "year": 2021 }, { - "id": "al-12", - "coverArt": "al-12", + "id": "al-11", + "coverArt": "al-11", "artistId": "ar-3", "artist": "artist-2", "created": "2019-11-30T00:00:00Z", "title": "", "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, "duration": 300, "year": 2021 diff --git a/server/ctrlsubsonic/testdata/test_get_artists_no_args b/server/ctrlsubsonic/testdata/test_get_artists_no_args index 5ca1d77f..5a604e01 100644 --- a/server/ctrlsubsonic/testdata/test_get_artists_no_args +++ b/server/ctrlsubsonic/testdata/test_get_artists_no_args @@ -9,9 +9,9 @@ { "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 } + { "id": "ar-1", "name": "artist-0", "albumCount": 6 }, + { "id": "ar-2", "name": "artist-1", "albumCount": 6 }, + { "id": "ar-3", "name": "artist-2", "albumCount": 6 } ] } ] diff --git a/server/ctrlsubsonic/testdata/test_get_artists_with_music_folder_1 b/server/ctrlsubsonic/testdata/test_get_artists_with_music_folder_1 new file mode 100644 index 00000000..5ca1d77f --- /dev/null +++ b/server/ctrlsubsonic/testdata/test_get_artists_with_music_folder_1 @@ -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 } + ] + } + ] + } + } +} diff --git a/server/ctrlsubsonic/testdata/test_get_artists_with_music_folder_2 b/server/ctrlsubsonic/testdata/test_get_artists_with_music_folder_2 new file mode 100644 index 00000000..5ca1d77f --- /dev/null +++ b/server/ctrlsubsonic/testdata/test_get_artists_with_music_folder_2 @@ -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 } + ] + } + ] + } + } +} diff --git a/server/ctrlsubsonic/testdata/test_get_indexes_no_args b/server/ctrlsubsonic/testdata/test_get_indexes_no_args index 04d00dfd..ddff578e 100644 --- a/server/ctrlsubsonic/testdata/test_get_indexes_no_args +++ b/server/ctrlsubsonic/testdata/test_get_indexes_no_args @@ -10,9 +10,12 @@ { "name": "a", "artist": [ - { "id": "al-2", "name": "album-0", "albumCount": 0 }, - { "id": "al-3", "name": "album-1", "albumCount": 0 }, - { "id": "al-4", "name": "album-2", "albumCount": 0 } + { "id": "al-1", "name": "artist-0", "albumCount": 3 }, + { "id": "al-14", "name": "artist-0", "albumCount": 3 }, + { "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 } ] } ] diff --git a/server/ctrlsubsonic/testdata/test_get_indexes_with_music_folder_1 b/server/ctrlsubsonic/testdata/test_get_indexes_with_music_folder_1 new file mode 100644 index 00000000..30a5952d --- /dev/null +++ b/server/ctrlsubsonic/testdata/test_get_indexes_with_music_folder_1 @@ -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 } + ] + } + ] + } + } +} diff --git a/server/ctrlsubsonic/testdata/test_get_indexes_with_music_folder_2 b/server/ctrlsubsonic/testdata/test_get_indexes_with_music_folder_2 new file mode 100644 index 00000000..78ee3c17 --- /dev/null +++ b/server/ctrlsubsonic/testdata/test_get_indexes_with_music_folder_2 @@ -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 } + ] + } + ] + } + } +} diff --git a/server/ctrlsubsonic/testdata/test_get_music_directory_with_tracks b/server/ctrlsubsonic/testdata/test_get_music_directory_with_tracks index 41f50379..e1f9b2ea 100644 --- a/server/ctrlsubsonic/testdata/test_get_music_directory_with_tracks +++ b/server/ctrlsubsonic/testdata/test_get_music_directory_with_tracks @@ -5,6 +5,7 @@ "type": "gonic", "directory": { "id": "al-3", + "parent": "al-1", "name": "album-1", "child": [ { diff --git a/server/ctrlsubsonic/testdata/test_get_music_directory_without_tracks b/server/ctrlsubsonic/testdata/test_get_music_directory_without_tracks index 08b52d9c..4bedd648 100644 --- a/server/ctrlsubsonic/testdata/test_get_music_directory_without_tracks +++ b/server/ctrlsubsonic/testdata/test_get_music_directory_without_tracks @@ -5,6 +5,7 @@ "type": "gonic", "directory": { "id": "al-2", + "parent": "al-1", "name": "album-0", "child": [ { diff --git a/server/ctrlsubsonic/testdata/test_search_two_q_alb b/server/ctrlsubsonic/testdata/test_search_two_q_alb index 00d79cda..e66412d6 100644 --- a/server/ctrlsubsonic/testdata/test_search_two_q_alb +++ b/server/ctrlsubsonic/testdata/test_search_two_q_alb @@ -4,11 +4,6 @@ "version": "1.15.0", "type": "gonic", "searchResult2": { - "artist": [ - { "id": "al-2", "name": "album-0" }, - { "id": "al-3", "name": "album-1" }, - { "id": "al-4", "name": "album-2" } - ], "album": [ { "id": "al-2", diff --git a/server/ctrlsubsonic/testdata/test_search_two_q_art b/server/ctrlsubsonic/testdata/test_search_two_q_art index ff286808..10d33d50 100644 --- a/server/ctrlsubsonic/testdata/test_search_two_q_art +++ b/server/ctrlsubsonic/testdata/test_search_two_q_art @@ -3,6 +3,12 @@ "status": "ok", "version": "1.15.0", "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" } + ] + } } } diff --git a/server/db/db.go b/server/db/db.go index 390ce3d8..3742dd42 100644 --- a/server/db/db.go +++ b/server/db/db.go @@ -9,8 +9,6 @@ import ( "strings" "github.com/jinzhu/gorm" - - "gopkg.in/gormigrate.v1" ) 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.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 } diff --git a/server/db/migrations.go b/server/db/migrations.go index 8ec12f56..7a515acc 100644 --- a/server/db/migrations.go +++ b/server/db/migrations.go @@ -3,33 +3,45 @@ package db import ( "errors" "fmt" + "log" "github.com/jinzhu/gorm" "gopkg.in/gormigrate.v1" ) -// $ date '+%Y%m%d%H%M' +type MigrationContext struct { + OriginalMusicPath string +} -func migrateInitSchema() gormigrate.Migration { - return gormigrate.Migration{ - ID: "202002192100", - Migrate: func(tx *gorm.DB) error { - return tx.AutoMigrate( - Genre{}, - TrackGenre{}, - AlbumGenre{}, - Track{}, - Artist{}, - User{}, - Setting{}, - Play{}, - Album{}, - Playlist{}, - PlayQueue{}, - ). - Error - }, +func (db *DB) Migrate(ctx MigrationContext) error { + options := &gormigrate.Options{ + TableName: "migrations", + IDColumnName: "id", + IDColumnSize: 255, + UseTransaction: false, + } + + // $ 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 { @@ -41,240 +53,256 @@ func construct(ctx MigrationContext, id string, f func(*gorm.DB, MigrationContex if err := f(tx, ctx); err != nil { return fmt.Errorf("%q: %w", id, err) } - - return tx.Create(&User{ - Name: initUsername, - Password: initPassword, - IsAdmin: true, - }). - Error + log.Printf("migration '%s' finished", id) + return nil + }, + Rollback: func(*gorm.DB) error { + return nil }, } } -func migrateMergePlaylist() gormigrate.Migration { - return gormigrate.Migration{ - ID: "202002192222", - Migrate: func(tx *gorm.DB) error { - if !tx.HasTable("playlist_items") { - return nil - } +func migrateInitSchema(tx *gorm.DB, _ MigrationContext) error { + return tx.AutoMigrate( + Genre{}, + TrackGenre{}, + AlbumGenre{}, + Track{}, + Artist{}, + User{}, + Setting{}, + Play{}, + Album{}, + Playlist{}, + PlayQueue{}, + ). + Error +} - 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 migrateCreateInitUser(tx *gorm.DB, _ MigrationContext) error { + const ( + initUsername = "admin" + initPassword = "admin" + ) + err := tx. + Where("name=?", initUsername). + First(&User{}). + Error + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil } + + return tx.Create(&User{ + Name: initUsername, + Password: initPassword, + IsAdmin: true, + }). + Error } -func migrateCreateTranscode() gormigrate.Migration { - return gormigrate.Migration{ - ID: "202003111222", - Migrate: func(tx *gorm.DB) error { - return tx.AutoMigrate( - TranscodePreference{}, - ). - Error - }, +func migrateMergePlaylist(tx *gorm.DB, _ MigrationContext) 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 migrateAddGenre() gormigrate.Migration { - return gormigrate.Migration{ - ID: "202003121330", - Migrate: func(tx *gorm.DB) error { - return tx.AutoMigrate( - Genre{}, - Album{}, - Track{}, - ). - Error - }, - } +func migrateCreateTranscode(tx *gorm.DB, _ MigrationContext) error { + return tx.AutoMigrate( + TranscodePreference{}, + ). + 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 - } +func migrateAddGenre(tx *gorm.DB, _ MigrationContext) error { + return tx.AutoMigrate( + Genre{}, + Album{}, + Track{}, + ). + Error +} - 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) - } +func migrateUpdateTranscodePrefIDX(tx *gorm.DB, _ MigrationContext) 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.AutoMigrate( - TranscodePreference{}, - ) - if err := step.Error; err != nil { - return fmt.Errorf("step create: %w", err) - } + 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.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 - }, + step = tx.AutoMigrate( + TranscodePreference{}, + ) + if err := step.Error; err != nil { + return fmt.Errorf("step create: %w", err) } -} -func migrateAddAlbumIDX() gormigrate.Migration { - return gormigrate.Migration{ - ID: "202004302006", - Migrate: func(tx *gorm.DB) error { - return tx.AutoMigrate( - Album{}, - ). - Error - }, + 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 migrateMultiGenre() gormigrate.Migration { - return gormigrate.Migration{ - ID: "202012151806", - Migrate: func(tx *gorm.DB) error { - step := tx.AutoMigrate( - Genre{}, - TrackGenre{}, - AlbumGenre{}, - Track{}, - Album{}, - ) - if err := step.Error; err != nil { - return fmt.Errorf("step auto migrate: %w", err) - } +func migrateAddAlbumIDX(tx *gorm.DB, _ MigrationContext) error { + return tx.AutoMigrate( + Album{}, + ). + Error +} - var genreCount int - tx. - Model(Genre{}). - Count(&genreCount) - if genreCount == 0 { - return nil - } +func migrateMultiGenre(tx *gorm.DB, _ MigrationContext) error { + step := tx.AutoMigrate( + Genre{}, + TrackGenre{}, + AlbumGenre{}, + Track{}, + Album{}, + ) + if err := step.Error; err != nil { + return fmt.Errorf("step auto migrate: %w", err) + } - 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) - } + var genreCount int + tx. + Model(Genre{}). + Count(&genreCount) + if genreCount == 0 { + return nil + } - 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 - }, + 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) } -} -func migrateListenBrainz() gormigrate.Migration { - return gormigrate.Migration{ - ID: "202101081149", - 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 - }, + 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 migratePodcast() gormigrate.Migration { - return gormigrate.Migration{ - ID: "202101111537", - Migrate: func(tx *gorm.DB) error { - return tx.AutoMigrate( - Podcast{}, - PodcastEpisode{}, - ). - Error - }, +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 migrateBookmarks() gormigrate.Migration { - return gormigrate.Migration{ - ID: "202102032210", - Migrate: func(tx *gorm.DB) error { - return tx.AutoMigrate( - Bookmark{}, - ). - Error - }, - } +func migratePodcast(tx *gorm.DB, _ MigrationContext) error { + return tx.AutoMigrate( + Podcast{}, + PodcastEpisode{}, + ). + Error } -func migratePodcastAutoDownload() gormigrate.Migration { - return gormigrate.Migration{ - ID: "202102191448", - Migrate: func(tx *gorm.DB) error { - return tx.AutoMigrate( - Podcast{}, - ). - 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 migrateAlbumCreatedAt() gormigrate.Migration { - return gormigrate.Migration{ - ID: "202110041330", - Migrate: func(tx *gorm.DB) 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 } diff --git a/server/db/model.go b/server/db/model.go index f6065ca2..15801923 100644 --- a/server/db/model.go +++ b/server/db/model.go @@ -132,6 +132,18 @@ func (t *Track) MIME() string { 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 { if t.Album == nil { return "" @@ -182,11 +194,12 @@ type Album struct { CreatedAt time.Time UpdatedAt time.Time ModifiedAt time.Time - LeftPath string `gorm:"unique_index:idx_left_path_right_path"` - RightPath string `gorm:"not null; unique_index:idx_left_path_right_path" sql:"default: null"` + LeftPath string `gorm:"unique_index:idx_album_abs_path"` + RightPath string `gorm:"not null; unique_index:idx_album_abs_path" sql:"default: null"` RightPathUDec string `sql:"default: null"` Parent *Album 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"` Cover string `sql:"default: null"` TagArtist *Artist diff --git a/server/jukebox/jukebox.go b/server/jukebox/jukebox.go index 7cbaf767..3552c042 100644 --- a/server/jukebox/jukebox.go +++ b/server/jukebox/jukebox.go @@ -6,7 +6,6 @@ import ( "fmt" "log" "os" - "path" "sync" "time" @@ -26,11 +25,10 @@ type Status struct { } type Jukebox struct { - playlist []*db.Track - musicPath string - index int - playing bool - sr beep.SampleRate + playlist []*db.Track + index int + playing bool + sr beep.SampleRate // used to notify the player to re read the members quit chan struct{} done chan bool @@ -50,13 +48,12 @@ type updateSpeaker struct { offset int } -func New(musicPath string) *Jukebox { +func New() *Jukebox { return &Jukebox{ - musicPath: musicPath, - sr: beep.SampleRate(48000), - speaker: make(chan updateSpeaker, 1), - done: make(chan bool), - quit: make(chan struct{}), + sr: beep.SampleRate(48000), + speaker: make(chan updateSpeaker, 1), + done: make(chan bool), + quit: make(chan struct{}), } } @@ -89,10 +86,7 @@ func (j *Jukebox) doUpdateSpeaker(su updateSpeaker) error { return nil } j.index = su.index - f, err := os.Open(path.Join( - j.musicPath, - j.playlist[su.index].RelPath(), - )) + f, err := os.Open(j.playlist[su.index].AbsPath()) if err != nil { return err } diff --git a/server/scanner/scanner.go b/server/scanner/scanner.go index 5370596c..eda3e11e 100644 --- a/server/scanner/scanner.go +++ b/server/scanner/scanner.go @@ -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 { return atomic.LoadInt32(s.scanning) == 1 } diff --git a/server/scanner/scanner_test.go b/server/scanner/scanner_test.go index 6cac4727..c03884ea 100644 --- a/server/scanner/scanner_test.go +++ b/server/scanner/scanner_test.go @@ -1,9 +1,11 @@ package scanner_test import ( + "fmt" "io/ioutil" "log" "os" + "path/filepath" "testing" "github.com/jinzhu/gorm" diff --git a/server/server.go b/server/server.go index 6e2badfa..adcff54d 100644 --- a/server/server.go +++ b/server/server.go @@ -27,7 +27,7 @@ import ( type Options struct { DB *db.DB - MusicPath string + MusicPaths []string PodcastPath string CachePath string CoverCachePath string @@ -46,7 +46,9 @@ type Server struct { } 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.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) base := &ctrlbase.Controller{ DB: opts.DB, - MusicPath: opts.MusicPath, ProxyPrefix: opts.ProxyPrefix, Scanner: scanner, } @@ -109,7 +110,7 @@ func New(opts Options) (*Server, error) { } if opts.JukeboxEnabled { - jukebox := jukebox.New(opts.MusicPath) + jukebox := jukebox.New() ctrlSubsonic.Jukebox = jukebox server.jukebox = jukebox }