Import smart playlists (extension .nsp)

This commit is contained in:
Deluan 2021-10-18 22:17:29 -04:00 committed by Deluan Quintão
parent 21da1df4ea
commit 1a96e9fe65
12 changed files with 242 additions and 110 deletions

View file

@ -0,0 +1,63 @@
package scanner
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/mattn/go-zglob"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type playlistImporter struct {
ds model.DataStore
pls core.Playlists
rootFolder string
}
func newPlaylistImporter(ds model.DataStore, playlists core.Playlists, rootFolder string) *playlistImporter {
return &playlistImporter{ds: ds, pls: playlists, rootFolder: rootFolder}
}
func (s *playlistImporter) processPlaylists(ctx context.Context, dir string) int64 {
if !s.inPlaylistsPath(dir) {
return 0
}
var count int64
files, err := os.ReadDir(dir)
if err != nil {
log.Error(ctx, "Error reading files", "dir", dir, err)
return count
}
for _, f := range files {
if !core.IsPlaylist(f.Name()) {
continue
}
pls, err := s.pls.ImportFile(ctx, dir, f.Name())
if err != nil {
log.Error(ctx, "Error parsing playlist", "playlist", f.Name(), err)
continue
}
if pls.IsSmartPlaylist() {
log.Debug("Imported smart playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
} else {
log.Debug("Imported playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
}
count++
}
return count
}
func (s *playlistImporter) inPlaylistsPath(dir string) bool {
rel, _ := filepath.Rel(s.rootFolder, dir)
for _, path := range strings.Split(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
if match, _ := zglob.Match(path, rel); match {
return true
}
}
return false
}

View file

@ -3,6 +3,8 @@ package scanner
import (
"context"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
@ -11,9 +13,10 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("playlistSync", func() {
var _ = Describe("playlistImporter", func() {
var ds model.DataStore
var ps *playlistSync
var ps *playlistImporter
var pls core.Playlists
ctx := context.Background()
BeforeEach(func() {
@ -21,34 +24,7 @@ var _ = Describe("playlistSync", func() {
MockedMediaFile: &mockedMediaFile{},
MockedPlaylist: &mockedPlaylist{},
}
})
Describe("parsePlaylist", func() {
BeforeEach(func() {
ps = newPlaylistSync(ds, "tests/")
})
It("parses well-formed playlists", func() {
pls, err := ps.parsePlaylist(ctx, "playlists/pls1.m3u", "tests/fixtures")
Expect(err).To(BeNil())
Expect(pls.Tracks).To(HaveLen(3))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg"))
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
})
It("parses playlists using LF ending", func() {
pls, err := ps.parsePlaylist(ctx, "lf-ended.m3u", "tests/fixtures/playlists")
Expect(err).To(BeNil())
Expect(pls.Tracks).To(HaveLen(2))
})
It("parses playlists using CR ending (old Mac format)", func() {
pls, err := ps.parsePlaylist(ctx, "cr-ended.m3u", "tests/fixtures/playlists")
Expect(err).To(BeNil())
Expect(pls.Tracks).To(HaveLen(2))
})
pls = core.NewPlaylists(ds)
})
Describe("processPlaylists", func() {
@ -57,19 +33,19 @@ var _ = Describe("playlistSync", func() {
conf.Server.PlaylistsPath = consts.DefaultPlaylistsPath
})
It("finds and import playlists at the top level", func() {
ps = newPlaylistSync(ds, "tests/fixtures/playlists/subfolder1")
ps = newPlaylistImporter(ds, pls, "tests/fixtures/playlists/subfolder1")
Expect(ps.processPlaylists(ctx, "tests/fixtures/playlists/subfolder1")).To(Equal(int64(1)))
})
It("finds and import playlists at any subfolder level", func() {
ps = newPlaylistSync(ds, "tests")
ps = newPlaylistImporter(ds, pls, "tests")
Expect(ps.processPlaylists(ctx, "tests/fixtures/playlists/subfolder1")).To(Equal(int64(1)))
})
})
It("ignores playlists not in the PlaylistsPath", func() {
conf.Server.PlaylistsPath = "subfolder1"
ps = newPlaylistSync(ds, "tests/fixtures/playlists")
ps = newPlaylistImporter(ds, pls, "tests/fixtures/playlists")
Expect(ps.processPlaylists(ctx, "tests/fixtures/playlists/subfolder1")).To(Equal(int64(1)))
Expect(ps.processPlaylists(ctx, "tests/fixtures/playlists/subfolder2")).To(Equal(int64(0)))
@ -77,7 +53,7 @@ var _ = Describe("playlistSync", func() {
It("only imports playlists from the root of MusicFolder if PlaylistsPath is '.'", func() {
conf.Server.PlaylistsPath = "."
ps = newPlaylistSync(ds, "tests/fixtures/playlists")
ps = newPlaylistImporter(ds, pls, "tests/fixtures/playlists")
Expect(ps.processPlaylists(ctx, "tests/fixtures/playlists")).To(Equal(int64(3)))
Expect(ps.processPlaylists(ctx, "tests/fixtures/playlists/subfolder1")).To(Equal(int64(0)))

View file

@ -1,171 +0,0 @@
package scanner
import (
"bufio"
"bytes"
"context"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/mattn/go-zglob"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils"
)
type playlistSync struct {
ds model.DataStore
rootFolder string
}
func newPlaylistSync(ds model.DataStore, rootFolder string) *playlistSync {
return &playlistSync{ds: ds, rootFolder: rootFolder}
}
func (s *playlistSync) processPlaylists(ctx context.Context, dir string) int64 {
if !s.inPlaylistsPath(dir) {
return 0
}
var count int64
files, err := os.ReadDir(dir)
if err != nil {
log.Error(ctx, "Error reading files", "dir", dir, err)
return count
}
for _, f := range files {
if !utils.IsPlaylist(f.Name()) {
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.updatePlaylist(ctx, pls)
if err != nil {
log.Error(ctx, "Error updating playlist", "playlist", f.Name(), err)
}
count++
}
return count
}
func (s *playlistSync) 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(),
}
mediaFileRepository := s.ds.MediaFile(ctx)
scanner := bufio.NewScanner(file)
scanner.Split(scanLines)
var mfs model.MediaFiles
for scanner.Scan() {
path := scanner.Text()
// Skip extended info
if strings.HasPrefix(path, "#") {
continue
}
if strings.HasPrefix(path, "file://") {
path = strings.TrimPrefix(path, "file://")
path, _ = url.QueryUnescape(path)
}
if !filepath.IsAbs(path) {
path = filepath.Join(baseDir, path)
}
mf, err := mediaFileRepository.FindByPath(path)
if err != nil {
log.Warn(ctx, "Path in playlist not found", "playlist", playlistFile, "path", path, err)
continue
}
mfs = append(mfs, *mf)
}
pls.Tracks = nil
pls.AddMediaFiles(mfs)
return pls, scanner.Err()
}
func (s *playlistSync) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
owner, _ := request.UsernameFrom(ctx)
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
newPls.Public = pls.Public
} else {
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner)
newPls.Owner = owner
}
return s.ds.Playlist(ctx).Put(newPls)
}
func (s *playlistSync) inPlaylistsPath(dir string) bool {
rel, _ := filepath.Rel(s.rootFolder, dir)
for _, path := range strings.Split(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
if match, _ := zglob.Match(path, rel); match {
return true
}
}
return false
}
// From https://stackoverflow.com/a/41433698
func scanLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.IndexAny(data, "\r\n"); i >= 0 {
if data[i] == '\n' {
// We have a line terminated by single newline.
return i + 1, data[0:i], nil
}
advance = i + 1
if len(data) > i+1 && data[i+1] == '\n' {
advance += 1
}
return advance, data[0:i], nil
}
// If we're at EOF, we have a final, non-terminated line. Return it.
if atEOF {
return len(data), data, nil
}
// Request more data.
return 0, nil, nil
}

View file

@ -46,6 +46,7 @@ type scanner struct {
status map[string]*scanStatus
lock *sync.RWMutex
ds model.DataStore
pls core.Playlists
cacheWarmer core.CacheWarmer
broker events.Broker
}
@ -57,9 +58,10 @@ type scanStatus struct {
lastUpdate time.Time
}
func New(ds model.DataStore, cacheWarmer core.CacheWarmer, broker events.Broker) Scanner {
func New(ds model.DataStore, playlists core.Playlists, cacheWarmer core.CacheWarmer, broker events.Broker) Scanner {
s := &scanner{
ds: ds,
pls: playlists,
cacheWarmer: cacheWarmer,
broker: broker,
folders: map[string]FolderScanner{},
@ -250,5 +252,5 @@ func (s *scanner) loadFolders() {
}
func (s *scanner) newScanner(f model.MediaFolder) FolderScanner {
return NewTagScanner(f.Path, s.ds, s.cacheWarmer)
return NewTagScanner(f.Path, s.ds, s.pls, s.cacheWarmer)
}

View file

@ -22,15 +22,15 @@ type TagScanner struct {
rootFolder string
ds model.DataStore
cacheWarmer core.CacheWarmer
plsSync *playlistSync
plsSync *playlistImporter
cnt *counters
mapper *mediaFileMapper
}
func NewTagScanner(rootFolder string, ds model.DataStore, cacheWarmer core.CacheWarmer) *TagScanner {
func NewTagScanner(rootFolder string, ds model.DataStore, playlists core.Playlists, cacheWarmer core.CacheWarmer) *TagScanner {
return &TagScanner{
rootFolder: rootFolder,
plsSync: newPlaylistSync(ds, rootFolder),
plsSync: newPlaylistImporter(ds, playlists, rootFolder),
ds: ds,
cacheWarmer: cacheWarmer,
}

View file

@ -10,6 +10,7 @@ import (
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils"
)
@ -95,7 +96,7 @@ func loadDir(ctx context.Context, dirPath string) ([]string, *dirStats, error) {
if utils.IsAudioFile(entry.Name()) {
stats.AudioFilesCount++
} else {
stats.HasPlaylist = stats.HasPlaylist || utils.IsPlaylist(entry.Name())
stats.HasPlaylist = stats.HasPlaylist || core.IsPlaylist(entry.Name())
stats.HasImages = stats.HasImages || utils.IsImageFile(entry.Name())
}
}