refactor: unify logic to export to M3U8

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-05-27 12:37:57 -04:00
parent 85a7268192
commit 71851b076c
5 changed files with 96 additions and 27 deletions

View File

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

View File

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

View File

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

View File

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

View File

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