Auto-Import playlists found in the Music Folder

This commit is contained in:
Deluan 2020-07-11 14:38:17 -04:00 committed by Deluan Quintão
parent 35114be5f7
commit b9b6ce066b
9 changed files with 160 additions and 12 deletions

View File

@ -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)

View File

@ -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
}

View File

@ -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

View File

@ -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))

View File

@ -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"))

View File

@ -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{}

View File

@ -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})

View File

@ -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
}

View File

@ -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
}