diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 8fc24198..1e4f8cbb 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -40,7 +40,8 @@ func CreateNativeAPIRouter() *nativeapi.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) share := core.NewShare(dataStore) - router := nativeapi.New(dataStore, share) + playlists := core.NewPlaylists(dataStore) + router := nativeapi.New(dataStore, share, playlists) return router } diff --git a/core/playlists.go b/core/playlists.go index fd943389..0e19177c 100644 --- a/core/playlists.go +++ b/core/playlists.go @@ -23,6 +23,7 @@ import ( type Playlists interface { ImportFile(ctx context.Context, dir string, fname string) (*model.Playlist, error) Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error + ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) } type playlists struct { @@ -47,6 +48,26 @@ func (s *playlists) ImportFile(ctx context.Context, dir string, fname string) (* return pls, err } +func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) { + owner, _ := request.UserFrom(ctx) + pls := &model.Playlist{ + OwnerID: owner.ID, + Public: false, + Sync: true, + } + pls, err := s.parseM3U(ctx, pls, "", reader) + if err != nil { + log.Error(ctx, "Error parsing playlist", err) + return nil, err + } + err = s.ds.Playlist(ctx).Put(pls) + if err != nil { + log.Error(ctx, "Error saving playlist", err) + return nil, err + } + return pls, nil +} + func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, baseDir string) (*model.Playlist, error) { pls, err := s.newSyncedPlaylist(baseDir, playlistFile) if err != nil { @@ -107,31 +128,40 @@ func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.R return pls, nil } -func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, file io.Reader) (*model.Playlist, error) { +func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, reader io.Reader) (*model.Playlist, error) { mediaFileRepository := s.ds.MediaFile(ctx) - scanner := bufio.NewScanner(file) + scanner := bufio.NewScanner(reader) scanner.Split(scanLines) var mfs model.MediaFiles for scanner.Scan() { - path := strings.TrimSpace(scanner.Text()) - // Skip empty lines and extended info - if path == "" || strings.HasPrefix(path, "#") { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "#PLAYLIST:") { + if split := strings.Split(line, ":"); len(split) >= 2 { + pls.Name = split[1] + } continue } - if strings.HasPrefix(path, "file://") { - path = strings.TrimPrefix(path, "file://") - path, _ = url.QueryUnescape(path) + // Skip empty lines and extended info + if line == "" || strings.HasPrefix(line, "#") { + continue } - if !filepath.IsAbs(path) { - path = filepath.Join(baseDir, path) + if strings.HasPrefix(line, "file://") { + line = strings.TrimPrefix(line, "file://") + line, _ = url.QueryUnescape(line) } - mf, err := mediaFileRepository.FindByPath(path) + if baseDir != "" && !filepath.IsAbs(line) { + line = filepath.Join(baseDir, line) + } + mf, err := mediaFileRepository.FindByPath(line) if err != nil { - log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path, err) + log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", line, err) continue } mfs = append(mfs, *mf) } + if pls.Name == "" { + pls.Name = time.Now().Format(time.RFC3339) + } pls.Tracks = nil pls.AddMediaFiles(mfs) diff --git a/core/playlists_test.go b/core/playlists_test.go index 0c828e6a..c06b2ca2 100644 --- a/core/playlists_test.go +++ b/core/playlists_test.go @@ -2,6 +2,10 @@ package core import ( "context" + "os" + "time" + + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -12,13 +16,16 @@ import ( var _ = Describe("Playlists", func() { var ds model.DataStore var ps Playlists + var mp mockedPlaylist ctx := context.Background() BeforeEach(func() { + mp = mockedPlaylist{} ds = &tests.MockDataStore{ MockedMediaFile: &mockedMediaFile{}, - MockedPlaylist: &mockedPlaylist{}, + MockedPlaylist: &mp, } + ctx = request.WithUser(ctx, model.User{ID: "123"}) }) Describe("ImportFile", func() { @@ -29,10 +36,12 @@ var _ = Describe("Playlists", func() { It("parses well-formed playlists", func() { pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/pls1.m3u") Expect(err).To(BeNil()) + Expect(pls.OwnerID).To(Equal("123")) 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")) + Expect(mp.last).To(Equal(pls)) }) It("parses playlists using LF ending", func() { @@ -48,6 +57,37 @@ var _ = Describe("Playlists", func() { }) }) + + Describe("ImportM3U", func() { + BeforeEach(func() { + ps = NewPlaylists(ds) + ctx = request.WithUser(ctx, model.User{ID: "123"}) + }) + + It("parses well-formed playlists", func() { + f, _ := os.Open("tests/fixtures/playlists/pls-post-with-name.m3u") + defer f.Close() + pls, err := ps.ImportM3U(ctx, f) + Expect(pls.OwnerID).To(Equal("123")) + Expect(pls.Name).To(Equal("playlist 1")) + Expect(err).To(BeNil()) + 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")) + Expect(mp.last).To(Equal(pls)) + f.Close() + + }) + + It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() { + f, _ := os.Open("tests/fixtures/playlists/pls-post.m3u") + defer f.Close() + pls, err := ps.ImportM3U(ctx, f) + Expect(err).To(BeNil()) + _, err = time.Parse(time.RFC3339, pls.Name) + Expect(err).To(BeNil()) + }) + }) }) type mockedMediaFile struct { @@ -62,6 +102,7 @@ func (r *mockedMediaFile) FindByPath(s string) (*model.MediaFile, error) { } type mockedPlaylist struct { + last *model.Playlist model.PlaylistRepository } @@ -69,6 +110,7 @@ func (r *mockedPlaylist) FindByPath(string) (*model.Playlist, error) { return nil, model.ErrNotFound } -func (r *mockedPlaylist) Put(*model.Playlist) error { +func (r *mockedPlaylist) Put(pls *model.Playlist) error { + r.last = pls return nil } diff --git a/scanner/playlist_importer_test.go b/scanner/playlist_importer_test.go index 1b60f5c0..410bb1ee 100644 --- a/scanner/playlist_importer_test.go +++ b/scanner/playlist_importer_test.go @@ -59,7 +59,7 @@ var _ = Describe("playlistImporter", func() { conf.Server.PlaylistsPath = "." ps = newPlaylistImporter(ds, pls, cw, "tests/fixtures/playlists") - Expect(ps.processPlaylists(ctx, "tests/fixtures/playlists")).To(Equal(int64(3))) + Expect(ps.processPlaylists(ctx, "tests/fixtures/playlists")).To(Equal(int64(5))) Expect(ps.processPlaylists(ctx, "tests/fixtures/playlists/subfolder1")).To(Equal(int64(0))) }) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 76ddbdf5..4a2ed7db 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -14,12 +14,13 @@ import ( type Router struct { http.Handler - ds model.DataStore - share core.Share + ds model.DataStore + share core.Share + playlists core.Playlists } -func New(ds model.DataStore, share core.Share) *Router { - r := &Router{ds: ds, share: share} +func New(ds model.DataStore, share core.Share, playlists core.Playlists) *Router { + r := &Router{ds: ds, share: share, playlists: playlists} r.Handler = r.routes() return r } @@ -40,13 +41,13 @@ func (n *Router) routes() http.Handler { n.R(r, "/artist", model.Artist{}, false) n.R(r, "/genre", model.Genre{}, false) n.R(r, "/player", model.Player{}, true) - n.R(r, "/playlist", model.Playlist{}, true) n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) n.R(r, "/radio", model.Radio{}, true) if conf.Server.EnableSharing { n.RX(r, "/share", n.share.NewRepository, true) } + n.addPlaylistRoute(r) n.addPlaylistTrackRoute(r) // Keepalive endpoint to be used to keep the session valid (ex: while playing songs) @@ -82,6 +83,30 @@ func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.Repository }) } +func (n *Router) addPlaylistRoute(r chi.Router) { + constructor := func(ctx context.Context) rest.Repository { + return n.ds.Resource(ctx, model.Playlist{}) + } + + r.Route("/playlist", func(r chi.Router) { + r.Get("/", rest.GetAll(constructor)) + r.Post("/", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-type") == "application/json" { + rest.Post(constructor)(w, r) + return + } + createPlaylistFromM3U(n.playlists)(w, r) + }) + + r.Route("/{id}", func(r chi.Router) { + r.Use(server.URLParamsMiddleware) + r.Get("/", rest.Get(constructor)) + r.Put("/", rest.Put(constructor)) + r.Delete("/", rest.Delete(constructor)) + }) + }) +} + func (n *Router) addPlaylistTrackRoute(r chi.Router) { r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) { r.Get("/", func(w http.ResponseWriter, r *http.Request) { diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index 494e4dcf..9abf80e4 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -11,6 +11,7 @@ import ( "github.com/deluan/rest" "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" @@ -42,6 +43,26 @@ func getPlaylist(ds model.DataStore) http.HandlerFunc { } } +func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + pls, err := playlists.ImportM3U(ctx, r.Body) + if err != nil { + log.Error(r.Context(), "Error parsing playlist", err) + // TODO: consider returning StatusBadRequest for playlists that are malformed + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + _, err = w.Write([]byte(pls.ToM3U8())) + if err != nil { + log.Error(ctx, "Error sending m3u contents", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } +} + func handleExportPlaylist(ds model.DataStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index c4681221..3945aa67 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -625,7 +625,8 @@ var _ = Describe("Responses", func() { Context("with data", func() { BeforeEach(func() { - t, _ := time.Parse(time.RFC822, time.RFC822) + timeFmt := "2006-01-02 15:04:00" + t, _ := time.Parse(timeFmt, timeFmt) response.ScanStatus = &ScanStatus{ Scanning: true, FolderCount: 123, diff --git a/tests/fixtures/playlists/pls-post-with-name.m3u b/tests/fixtures/playlists/pls-post-with-name.m3u new file mode 100644 index 00000000..a214b703 --- /dev/null +++ b/tests/fixtures/playlists/pls-post-with-name.m3u @@ -0,0 +1,4 @@ +#PLAYLIST:playlist 1 +tests/fixtures/test.mp3 +tests/fixtures/test.ogg +file:///tests/fixtures/01%20Invisible%20(RED)%20Edit%20Version.mp3 \ No newline at end of file diff --git a/tests/fixtures/playlists/pls-post.m3u b/tests/fixtures/playlists/pls-post.m3u new file mode 100644 index 00000000..d665c89a --- /dev/null +++ b/tests/fixtures/playlists/pls-post.m3u @@ -0,0 +1,3 @@ +tests/fixtures/test.mp3 +tests/fixtures/test.ogg +file:///tests/fixtures/01%20Invisible%20(RED)%20Edit%20Version.mp3 \ No newline at end of file