diff --git a/model/mediafile.go b/model/mediafile.go index cdb001c85..5068e5d04 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -9,6 +9,7 @@ import ( "mime" "path/filepath" "slices" + "strings" "time" "github.com/gohugoio/hashstructure" @@ -330,6 +331,23 @@ func firstArtPath(currentPath string, currentDisc int, m MediaFile) (string, int return currentPath, currentDisc } +// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in +// https://docs.fileformat.com/audio/m3u/#extended-m3u +func (mfs MediaFiles) ToM3U8(title string, absolutePaths bool) string { + buf := strings.Builder{} + buf.WriteString("#EXTM3U\n") + buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", title)) + for _, t := range mfs { + buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title)) + if absolutePaths { + buf.WriteString(t.AbsolutePath() + "\n") + } else { + buf.WriteString(t.Path + "\n") + } + } + return buf.String() +} + type MediaFileCursor iter.Seq2[MediaFile, error] type MediaFileRepository interface { diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 7f583cf75..635a61d30 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -402,6 +402,72 @@ var _ = Describe("MediaFiles", func() { }) }) }) + + Describe("ToM3U8", func() { + It("returns header only for empty MediaFiles", func() { + mfs = MediaFiles{} + result := mfs.ToM3U8("My Playlist", false) + Expect(result).To(Equal("#EXTM3U\n#PLAYLIST:My Playlist\n")) + }) + + DescribeTable("duration formatting", + func(duration float32, expected string) { + mfs = MediaFiles{{Title: "Song", Artist: "Artist", Duration: duration, Path: "song.mp3"}} + result := mfs.ToM3U8("Test", false) + Expect(result).To(ContainSubstring(expected)) + }, + Entry("zero duration", float32(0.0), "#EXTINF:0,"), + Entry("whole number", float32(120.0), "#EXTINF:120,"), + Entry("rounds 0.5 down", float32(180.5), "#EXTINF:180,"), + Entry("rounds 0.6 up", float32(240.6), "#EXTINF:241,"), + ) + + Context("multiple tracks", func() { + BeforeEach(func() { + mfs = MediaFiles{ + {Title: "Song One", Artist: "Artist A", Duration: 120, Path: "a/song1.mp3", LibraryPath: "/music"}, + {Title: "Song Two", Artist: "Artist B", Duration: 241, Path: "b/song2.mp3", LibraryPath: "/music"}, + {Title: "Song with \"quotes\" & ampersands", Artist: "Artist with Ümläuts", Duration: 90, Path: "special/file.mp3", LibraryPath: "/música"}, + } + }) + + DescribeTable("generates correct output", + func(absolutePaths bool, expectedContent string) { + result := mfs.ToM3U8("Multi Track", absolutePaths) + Expect(result).To(Equal(expectedContent)) + }, + Entry("relative paths", + false, + "#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n", + ), + Entry("absolute paths", + true, + "#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\n/music/a/song1.mp3\n#EXTINF:241,Artist B - Song Two\n/music/b/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\n/música/special/file.mp3\n", + ), + Entry("special characters", + false, + "#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n", + ), + ) + }) + + Context("path variations", func() { + It("handles different path structures", func() { + mfs = MediaFiles{ + {Title: "Root", Artist: "Artist", Duration: 60, Path: "song.mp3", LibraryPath: "/lib"}, + {Title: "Nested", Artist: "Artist", Duration: 60, Path: "deep/nested/song.mp3", LibraryPath: "/lib"}, + } + + relativeResult := mfs.ToM3U8("Test", false) + Expect(relativeResult).To(ContainSubstring("song.mp3\n")) + Expect(relativeResult).To(ContainSubstring("deep/nested/song.mp3\n")) + + absoluteResult := mfs.ToM3U8("Test", true) + Expect(absoluteResult).To(ContainSubstring("/lib/song.mp3\n")) + Expect(absoluteResult).To(ContainSubstring("/lib/deep/nested/song.mp3\n")) + }) + }) + }) }) var _ = Describe("MediaFile", func() { diff --git a/model/playlist.go b/model/playlist.go index 521adfcd0..96a431b45 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -1,10 +1,8 @@ package model import ( - "fmt" "slices" "strconv" - "strings" "time" "github.com/navidrome/navidrome/model/criteria" @@ -53,17 +51,9 @@ func (pls *Playlist) RemoveTracks(idxToRemove []int) { pls.Tracks = newTracks } -// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in -// https://docs.fileformat.com/audio/m3u/#extended-m3u +// ToM3U8 exports the playlist to the Extended M3U8 format func (pls *Playlist) ToM3U8() string { - buf := strings.Builder{} - buf.WriteString("#EXTM3U\n") - buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", pls.Name)) - for _, t := range pls.Tracks { - buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title)) - buf.WriteString(t.AbsolutePath() + "\n") - } - return buf.String() + return pls.MediaFiles().ToM3U8(pls.Name, true) } func (pls *Playlist) AddTracks(mediaFileIds []string) { diff --git a/model/playlists_test.go b/model/playlist_test.go similarity index 71% rename from model/playlists_test.go rename to model/playlist_test.go index 600e116cc..a54cecd53 100644 --- a/model/playlists_test.go +++ b/model/playlist_test.go @@ -13,13 +13,17 @@ var _ = Describe("Playlist", func() { pls = model.Playlist{Name: "Mellow sunset"} pls.Tracks = model.PlaylistTracks{ {MediaFile: model.MediaFile{Artist: "Morcheeba feat. Kurt Wagner", Title: "What New York Couples Fight About", - Duration: 377.84, Path: "/music/library/Morcheeba/Charango/01-06 What New York Couples Fight About.mp3"}}, + Duration: 377.84, + LibraryPath: "/music/library", Path: "Morcheeba/Charango/01-06 What New York Couples Fight About.mp3"}}, {MediaFile: model.MediaFile{Artist: "A Tribe Called Quest", Title: "Description of a Fool (Groove Armada's Acoustic mix)", - Duration: 374.49, Path: "/music/library/Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3"}}, + Duration: 374.49, + LibraryPath: "/music/library", Path: "Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3"}}, {MediaFile: model.MediaFile{Artist: "Lou Reed", Title: "Walk on the Wild Side", - Duration: 253.1, Path: "/music/library/Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a"}}, + Duration: 253.1, + LibraryPath: "/music/library", Path: "Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a"}}, {MediaFile: model.MediaFile{Artist: "Legião Urbana", Title: "On the Way Home", - Duration: 163.89, Path: "/music/library/Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3"}}, + Duration: 163.89, + LibraryPath: "/music/library", Path: "Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3"}}, } }) It("generates the correct M3U format", func() { diff --git a/model/share.go b/model/share.go index 0f52f5323..acb5fb428 100644 --- a/model/share.go +++ b/model/share.go @@ -2,7 +2,6 @@ package model import ( "cmp" - "fmt" "strings" "time" @@ -50,17 +49,9 @@ func (s Share) CoverArtID() ArtworkID { type Shares []Share -// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in -// https://docs.fileformat.com/audio/m3u/#extended-m3u +// ToM3U8 exports the share to the Extended M3U8 format. func (s Share) ToM3U8() string { - buf := strings.Builder{} - buf.WriteString("#EXTM3U\n") - buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", cmp.Or(s.Description, s.ID))) - for _, t := range s.Tracks { - buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title)) - buf.WriteString(t.Path + "\n") - } - return buf.String() + return s.Tracks.ToM3U8(cmp.Or(s.Description, s.ID), false) } type ShareRepository interface {