mirror of
https://github.com/navidrome/navidrome.git
synced 2025-06-03 17:11:08 +03:00
refactor: unify logic to export to M3U8
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
85a7268192
commit
71851b076c
@ -9,6 +9,7 @@ import (
|
|||||||
"mime"
|
"mime"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gohugoio/hashstructure"
|
"github.com/gohugoio/hashstructure"
|
||||||
@ -330,6 +331,23 @@ func firstArtPath(currentPath string, currentDisc int, m MediaFile) (string, int
|
|||||||
return currentPath, currentDisc
|
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 MediaFileCursor iter.Seq2[MediaFile, error]
|
||||||
|
|
||||||
type MediaFileRepository interface {
|
type MediaFileRepository interface {
|
||||||
|
@ -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() {
|
var _ = Describe("MediaFile", func() {
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/model/criteria"
|
"github.com/navidrome/navidrome/model/criteria"
|
||||||
@ -53,17 +51,9 @@ func (pls *Playlist) RemoveTracks(idxToRemove []int) {
|
|||||||
pls.Tracks = newTracks
|
pls.Tracks = newTracks
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
|
// ToM3U8 exports the playlist to the Extended M3U8 format
|
||||||
// https://docs.fileformat.com/audio/m3u/#extended-m3u
|
|
||||||
func (pls *Playlist) ToM3U8() string {
|
func (pls *Playlist) ToM3U8() string {
|
||||||
buf := strings.Builder{}
|
return pls.MediaFiles().ToM3U8(pls.Name, true)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pls *Playlist) AddTracks(mediaFileIds []string) {
|
func (pls *Playlist) AddTracks(mediaFileIds []string) {
|
||||||
|
@ -13,13 +13,17 @@ var _ = Describe("Playlist", func() {
|
|||||||
pls = model.Playlist{Name: "Mellow sunset"}
|
pls = model.Playlist{Name: "Mellow sunset"}
|
||||||
pls.Tracks = model.PlaylistTracks{
|
pls.Tracks = model.PlaylistTracks{
|
||||||
{MediaFile: model.MediaFile{Artist: "Morcheeba feat. Kurt Wagner", Title: "What New York Couples Fight About",
|
{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)",
|
{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",
|
{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",
|
{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() {
|
It("generates the correct M3U format", func() {
|
@ -2,7 +2,6 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -50,17 +49,9 @@ func (s Share) CoverArtID() ArtworkID {
|
|||||||
|
|
||||||
type Shares []Share
|
type Shares []Share
|
||||||
|
|
||||||
// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
|
// ToM3U8 exports the share to the Extended M3U8 format.
|
||||||
// https://docs.fileformat.com/audio/m3u/#extended-m3u
|
|
||||||
func (s Share) ToM3U8() string {
|
func (s Share) ToM3U8() string {
|
||||||
buf := strings.Builder{}
|
return s.Tracks.ToM3U8(cmp.Or(s.Description, s.ID), false)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShareRepository interface {
|
type ShareRepository interface {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user