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
+186
View File
@@ -0,0 +1,186 @@
package core
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
type Playlists interface {
ImportFile(ctx context.Context, dir string, fname string) (*model.Playlist, error)
}
type playlists struct {
ds model.DataStore
}
func NewPlaylists(ds model.DataStore) Playlists {
return &playlists{ds: ds}
}
func IsPlaylist(filePath string) bool {
extension := strings.ToLower(filepath.Ext(filePath))
return extension == ".m3u" || extension == ".m3u8" || extension == ".nsp"
}
func (s *playlists) ImportFile(ctx context.Context, dir string, fname string) (*model.Playlist, error) {
pls, err := s.parsePlaylist(ctx, fname, dir)
if err != nil {
log.Error(ctx, "Error parsing playlist", "playlist", fname, err)
return nil, err
}
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", fname, err)
}
return pls, err
}
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, baseDir string) (*model.Playlist, error) {
pls, err := s.newSyncedPlaylist(baseDir, playlistFile)
if err != nil {
return nil, err
}
file, err := os.Open(pls.Path)
if err != nil {
return nil, err
}
defer file.Close()
extension := strings.ToLower(filepath.Ext(playlistFile))
switch extension {
case ".nsp":
return s.parseNSP(ctx, pls, file)
default:
return s.parseM3U(ctx, pls, baseDir, file)
}
}
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
playlistPath := filepath.Join(baseDir, playlistFile)
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(),
}
return pls, nil
}
func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.Reader) (*model.Playlist, error) {
content, err := io.ReadAll(file)
if err != nil {
return nil, err
}
pls.Rules = &model.SmartPlaylist{}
err = json.Unmarshal(content, pls.Rules)
if err != nil {
return nil, err
}
return pls, nil
}
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, file io.Reader) (*model.Playlist, error) {
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", pls.Name, "path", path, err)
continue
}
mfs = append(mfs, *mf)
}
pls.Tracks = nil
pls.AddMediaFiles(mfs)
return pls, scanner.Err()
}
func (s *playlists) 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)
}
// 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
}
+90
View File
@@ -0,0 +1,90 @@
package core
import (
"context"
"path/filepath"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("IsPlaylist", func() {
It("returns true for a M3U file", func() {
Expect(IsPlaylist(filepath.Join("path", "to", "test.m3u"))).To(BeTrue())
})
It("returns true for a M3U8 file", func() {
Expect(IsPlaylist(filepath.Join("path", "to", "test.m3u8"))).To(BeTrue())
})
It("returns false for a non-playlist file", func() {
Expect(IsPlaylist("testm3u")).To(BeFalse())
})
})
var _ = Describe("Playlists", func() {
var ds model.DataStore
var ps Playlists
ctx := context.Background()
BeforeEach(func() {
ds = &tests.MockDataStore{
MockedMediaFile: &mockedMediaFile{},
MockedPlaylist: &mockedPlaylist{},
}
})
Describe("ImportFile", func() {
BeforeEach(func() {
ps = NewPlaylists(ds)
})
It("parses well-formed playlists", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/pls1.m3u")
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.ImportFile(ctx, "tests/fixtures/playlists", "lf-ended.m3u")
Expect(err).To(BeNil())
Expect(pls.Tracks).To(HaveLen(2))
})
It("parses playlists using CR ending (old Mac format)", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "cr-ended.m3u")
Expect(err).To(BeNil())
Expect(pls.Tracks).To(HaveLen(2))
})
})
})
type mockedMediaFile struct {
model.MediaFileRepository
}
func (r *mockedMediaFile) FindByPath(s string) (*model.MediaFile, error) {
return &model.MediaFile{
ID: "123",
Path: s,
}, nil
}
type mockedPlaylist struct {
model.PlaylistRepository
}
func (r *mockedPlaylist) FindByPath(path string) (*model.Playlist, error) {
return nil, model.ErrNotFound
}
func (r *mockedPlaylist) Put(pls *model.Playlist) error {
return nil
}
+1
View File
@@ -20,4 +20,5 @@ var Set = wire.NewSet(
transcoder.New,
scrobbler.GetPlayTracker,
NewShare,
NewPlaylists,
)