diff --git a/model/mediafile.go b/model/mediafile.go index 897cfded5..2cd34b69a 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -53,7 +53,8 @@ type MediaFileRepository interface { Get(id string) (*MediaFile, error) GetAll(options ...QueryOptions) (MediaFiles, error) FindByAlbum(albumId string) (MediaFiles, error) - FindByPath(path string) (MediaFiles, error) + FindAllByPath(path string) (MediaFiles, error) + FindByPath(path string) (*MediaFile, error) FindPathsRecursively(basePath string) ([]string, error) GetStarred(options ...QueryOptions) (MediaFiles, error) GetRandom(options ...QueryOptions) (MediaFiles, error) diff --git a/model/playlist.go b/model/playlist.go index 2931eac95..6ce64bf1a 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -27,6 +27,7 @@ type PlaylistRepository interface { Put(pls *Playlist) error Get(id string) (*Playlist, error) GetAll(options ...QueryOptions) (Playlists, error) + FindByPath(path string) (*Playlist, error) Delete(id string) error Tracks(playlistId string) PlaylistTrackRepository } diff --git a/model/user.go b/model/user.go index ad21e5db3..dcb23831c 100644 --- a/model/user.go +++ b/model/user.go @@ -22,6 +22,7 @@ type UserRepository interface { CountAll(...QueryOptions) (int64, error) Get(id string) (*User, error) Put(*User) error + FindFirstAdmin() (*User, error) // FindByUsername must be case-insensitive FindByUsername(username string) (*User, error) UpdateLastLoginAt(id string) error diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 5b6ff5485..040205bb8 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -80,8 +80,20 @@ func (r mediaFileRepository) FindByAlbum(albumId string) (model.MediaFiles, erro return res, err } -// FindByPath only return mediafiles that are direct children of requested path -func (r mediaFileRepository) FindByPath(path string) (model.MediaFiles, error) { +func (r mediaFileRepository) FindByPath(path string) (*model.MediaFile, error) { + sel := r.selectMediaFile().Where(Eq{"path": path}) + var res model.MediaFiles + if err := r.queryAll(sel, &res); err != nil { + return nil, err + } + if len(res) == 0 { + return nil, model.ErrNotFound + } + return &res[0], nil +} + +// FindAllByPath only return mediafiles that are direct children of requested path +func (r mediaFileRepository) FindAllByPath(path string) (model.MediaFiles, error) { // Query by path based on https://stackoverflow.com/a/13911906/653632 sel0 := r.selectMediaFile().Columns(fmt.Sprintf("substr(path, %d) AS item", len(path)+2)). Where(pathStartsWith(path)) diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 2093943a0..d03a6df1c 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -55,7 +55,7 @@ var _ = Describe("MediaRepository", func() { Expect(mr.Put(&model.MediaFile{ID: "7001", Path: P("/Find:By'Path/_/123.mp3")})).To(BeNil()) Expect(mr.Put(&model.MediaFile{ID: "7002", Path: P("/Find:By'Path/1/123.mp3")})).To(BeNil()) - found, err := mr.FindByPath(P("/Find:By'Path/_/")) + found, err := mr.FindAllByPath(P("/Find:By'Path/_/")) Expect(err).To(BeNil()) Expect(found).To(HaveLen(1)) Expect(found[0].ID).To(Equal("7001")) @@ -65,12 +65,12 @@ var _ = Describe("MediaRepository", func() { Expect(mr.Put(&model.MediaFile{ID: "7003", Path: P("/Casesensitive/file1.mp3")})).To(BeNil()) Expect(mr.Put(&model.MediaFile{ID: "7004", Path: P("/casesensitive/file2.mp3")})).To(BeNil()) - found, err := mr.FindByPath(P("/Casesensitive")) + found, err := mr.FindAllByPath(P("/Casesensitive")) Expect(err).To(BeNil()) Expect(found).To(HaveLen(1)) Expect(found[0].ID).To(Equal("7003")) - found, err = mr.FindByPath(P("/casesensitive/")) + found, err = mr.FindAllByPath(P("/casesensitive/")) Expect(err).To(BeNil()) Expect(found).To(HaveLen(1)) Expect(found[0].ID).To(Equal("7004")) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 189139db7..650386c00 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -103,6 +103,16 @@ func (r *playlistRepository) Get(id string) (*model.Playlist, error) { return &pls, err } +func (r *playlistRepository) FindByPath(path string) (*model.Playlist, error) { + sel := r.newSelect().Columns("*").Where(Eq{"path": path}) + var pls model.Playlist + err := r.queryOne(sel, &pls) + if err != nil { + return nil, err + } + return &pls, err +} + func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) { sel := r.newSelect(options...).Columns("*").Where(r.userFilter()) res := model.Playlists{} diff --git a/persistence/user_repository.go b/persistence/user_repository.go index f183e5f8c..a8797d320 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -65,6 +65,13 @@ func (r *userRepository) Put(u *model.User) error { return err } +func (r *userRepository) FindFirstAdmin() (*model.User, error) { + sel := r.newSelect(model.QueryOptions{Sort: "updated_at", Max: 1}).Columns("*").Where(Eq{"is_admin": true}) + var usr model.User + err := r.queryOne(sel, &usr) + return &usr, err +} + func (r *userRepository) FindByUsername(username string) (*model.User, error) { username = strings.ToLower(username) sel := r.newSelect().Columns("*").Where(Eq{"user_name": username}) diff --git a/scanner/tag_scanner.go b/scanner/tag_scanner.go index 0b5ebea42..6f6770efd 100644 --- a/scanner/tag_scanner.go +++ b/scanner/tag_scanner.go @@ -93,12 +93,14 @@ func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time) erro if err != nil { return err } + // TODO Search for playlists and import (with `sync` on) } for _, c := range deleted { err := s.processDeletedDir(ctx, c, updatedArtists, updatedAlbums, cnt) if err != nil { return err } + // TODO "Un-sync" all playlists synched from a deleted folder } err = s.flushAlbums(ctx, updatedAlbums) @@ -152,7 +154,7 @@ func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedA // Load folder's current tracks from DB into a map currentTracks := map[string]model.MediaFile{} - ct, err := s.ds.MediaFile(ctx).FindByPath(dir) + ct, err := s.ds.MediaFile(ctx).FindAllByPath(dir) if err != nil { return err } @@ -282,7 +284,7 @@ func (s *TagScanner) processDeletedDir(ctx context.Context, dir string, updatedA dir = filepath.Join(s.rootFolder, dir) start := time.Now() - mfs, err := s.ds.MediaFile(ctx).FindByPath(dir) + mfs, err := s.ds.MediaFile(ctx).FindAllByPath(dir) if err != nil { return err } diff --git a/scanner/tag_scanner_2.go b/scanner/tag_scanner_2.go index fb2b5d4bd..2f7a78b25 100644 --- a/scanner/tag_scanner_2.go +++ b/scanner/tag_scanner_2.go @@ -1,7 +1,11 @@ package scanner import ( + "bufio" "context" + "fmt" + "io/ioutil" + "os" "path/filepath" "sort" "strings" @@ -9,6 +13,7 @@ import ( "github.com/deluan/navidrome/log" "github.com/deluan/navidrome/model" + "github.com/deluan/navidrome/model/request" "github.com/deluan/navidrome/utils" ) @@ -70,20 +75,24 @@ func (s *TagScanner2) Scan(ctx context.Context, lastModifiedSince time.Time) err err := s.processDeletedDir(ctx, dir) if err != nil { log.Error("Error removing deleted folder from DB", "path", dir, err) - continue } + // TODO "Un-sync" all playlists synced from a deleted folder } for _, dir := range changedDirs { err := s.processChangedDir(ctx, dir) if err != nil { log.Error("Error updating folder in the DB", "path", dir, err) - continue } } _ = s.albumMap.flush() _ = s.artistMap.flush() + // Now that all mediafiles are imported/updated, search for and import playlists + for _, dir := range changedDirs { + _ = s.processPlaylists(ctx, dir) + } + err = s.ds.GC(log.NewContext(ctx)) log.Info("Finished processing Music Folder", "folder", s.rootFolder, "elapsed", time.Since(start), "added", s.cnt.added, "updated", s.cnt.updated, "deleted", s.cnt.deleted) @@ -153,7 +162,7 @@ func (s *TagScanner2) getDeletedDirs(ctx context.Context, allDirs dirMap, change func (s *TagScanner2) processDeletedDir(ctx context.Context, dir string) error { start := time.Now() - mfs, err := s.ds.MediaFile(ctx).FindByPath(dir) + mfs, err := s.ds.MediaFile(ctx).FindAllByPath(dir) if err != nil { return err } @@ -179,7 +188,7 @@ func (s *TagScanner2) processChangedDir(ctx context.Context, dir string) error { // Load folder's current tracks from DB into a map currentTracks := map[string]model.MediaFile{} - ct, err := s.ds.MediaFile(ctx).FindByPath(dir) + ct, err := s.ds.MediaFile(ctx).FindAllByPath(dir) if err != nil { return err } @@ -295,3 +304,108 @@ func (s *TagScanner2) loadTracks(filePaths []string) (model.MediaFiles, error) { } return mfs, nil } + +func (s *TagScanner2) processPlaylists(ctx context.Context, dir string) error { + files, err := ioutil.ReadDir(dir) + if err != nil { + log.Error(ctx, "Error reading files", "dir", dir, err) + return err + } + for _, f := range files { + match, _ := filepath.Match("*.m3u", strings.ToLower(f.Name())) + if !match { + continue + } + pls, err := s.parsePlaylist(ctx, f.Name(), dir) + if err != nil { + log.Error(ctx, "Error parsing playlist", "playlist", f.Name(), err) + continue + } + log.Debug("Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks)) + err = s.updatePlaylistIfNewer(ctx, pls) + if err != nil { + log.Error(ctx, "Error updating playlist", "playlist", f.Name(), err) + } + } + return nil +} + +func (s *TagScanner2) parsePlaylist(ctx context.Context, playlistFile string, baseDir string) (*model.Playlist, error) { + playlistPath := filepath.Join(baseDir, playlistFile) + file, err := os.Open(playlistPath) + if err != nil { + return nil, err + } + defer file.Close() + info, err := os.Stat(playlistPath) + if err != nil { + return nil, err + } + + var extension = filepath.Ext(playlistFile) + var name = playlistFile[0 : len(playlistFile)-len(extension)] + + pls := &model.Playlist{ + Name: name, + Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile), + Public: false, + Path: playlistPath, + Sync: true, + UpdatedAt: info.ModTime(), + } + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + path := scanner.Text() + // Skip extended info + if strings.HasPrefix(path, "#") { + continue + } + if !filepath.IsAbs(path) { + path = filepath.Join(baseDir, path) + } + mf, err := s.ds.MediaFile(ctx).FindByPath(path) + if err != nil { + log.Warn(ctx, "Path in playlist not found", "playlist", playlistFile, "path", path, err) + continue + } + pls.Tracks = append(pls.Tracks, *mf) + } + + return pls, scanner.Err() +} + +func (s *TagScanner2) updatePlaylistIfNewer(ctx context.Context, newPls *model.Playlist) error { + owner := s.getPlaylistsOwner(ctx) + ctx = request.WithUsername(ctx, owner.UserName) + ctx = request.WithUser(ctx, *owner) + + pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path) + if err != nil && err != model.ErrNotFound { + return err + } + if err == nil && !pls.Sync { + log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path) + return nil + } + + if err == nil { + log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path) + newPls.ID = pls.ID + newPls.Name = pls.Name + newPls.Comment = pls.Comment + newPls.Owner = pls.Owner + } else { + log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName) + newPls.Owner = owner.UserName + } + return s.ds.Playlist(ctx).Put(newPls) +} + +func (s *TagScanner2) getPlaylistsOwner(ctx context.Context) *model.User { + u, err := s.ds.User(ctx).FindFirstAdmin() + if err != nil { + log.Error(ctx, "Error retrieving playlist owner", err) + } + return u +}