playlists now save to files, can be restored from files

This commit is contained in:
2026-02-19 23:10:20 -05:00
parent 56cf92a44c
commit 560ab3a3b5
18 changed files with 2454 additions and 113 deletions
+7 -1
View File
@@ -96,7 +96,9 @@ func NewYellowJacketApp(
yjApp.assetHandler.RegisterHandler("/covers/", coverHandler)
// create playlist service
yjApp.playlist = playlist.NewService(yjApp.logger, yjApp.database)
yjApp.playlist = playlist.NewService(
yjApp.logger, yjApp.database, yjApp.appConfig,
)
yjApp.FEBindings = []any{
yjApp.FrontendUtil,
@@ -146,6 +148,10 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
// clear the queue and stop playback before wiping data.
yj.library.SetQueue(yj.queue)
// Give the library a reference to the playlist service so
// FullRescan can restore playlists from M3U8 files.
yj.library.SetPlaylistRestorer(yj.playlist)
// Register playback finished handler to drive queue auto-advance.
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
+9
View File
@@ -56,6 +56,15 @@ const (
LibraryConfigChanged = "LibraryConfigChanged"
)
// Playlist events.
const (
PlaylistCreated = "PlaylistCreated"
PlaylistDeleted = "PlaylistDeleted"
PlaylistRenamed = "PlaylistRenamed"
PlaylistTracksChanged = "PlaylistTracksChanged"
PlaylistsRestored = "PlaylistsRestored"
)
// Library events.
const (
LibraryScanStarted = "LibraryScanStarted"
+32 -1
View File
@@ -31,8 +31,39 @@ func (fe *FrontendUtil) DirectoryPicker() (string, error) {
fe.ctx,
runtime.OpenDialogOptions{})
if err != nil {
return "", fmt.Errorf("could not open directory dialog\n%w", err)
return "", fmt.Errorf(
"could not open directory dialog\n%w", err,
)
}
return dir, nil
}
// PlaylistFilePicker opens a file selection dialog filtered
// to M3U/M3U8 playlist files.
func (fe *FrontendUtil) PlaylistFilePicker() (
string,
error,
) {
runtime.LogInfo(fe.ctx, "selecting a playlist file")
file, err := runtime.OpenFileDialog(
fe.ctx,
runtime.OpenDialogOptions{
Title: "Import Playlist",
Filters: []runtime.FileFilter{
{
DisplayName: "Playlist Files (*.m3u, *.m3u8)",
Pattern: "*.m3u;*.m3u8",
},
},
},
)
if err != nil {
return "", fmt.Errorf(
"could not open file dialog: %w", err,
)
}
return file, nil
}
+19 -5
View File
@@ -63,13 +63,20 @@ type queueClearer interface {
Clear()
}
// playlistRestorer is a narrow interface for restoring playlists
// from M3U8 files after a library rescan.
type playlistRestorer interface {
RestoreAllPlaylists()
}
// Library manages scanning and querying the music collection.
type Library struct {
ctx context.Context
logger *slog.Logger
conf *Config
db *database.DB
queue queueClearer
ctx context.Context
logger *slog.Logger
conf *Config
db *database.DB
queue queueClearer
playlistRestorer playlistRestorer
}
// SetQueue provides the library with a reference to the queue so
@@ -79,6 +86,13 @@ func (l *Library) SetQueue(q queueClearer) {
l.queue = q
}
// SetPlaylistRestorer provides the library with a reference to
// the playlist service so that FullRescan can restore playlists
// from M3U8 files after wiping data.
func (l *Library) SetPlaylistRestorer(p playlistRestorer) {
l.playlistRestorer = p
}
// NewLibrary creates a new library with the given configuration.
// A nil config is permitted; the library will be inert until a valid
// configuration is supplied via the LibraryConfigChanged event.
+6
View File
@@ -68,6 +68,12 @@ func (l *Library) FullRescan() (*ScanMetrics, error) {
clearDBDur + clearFilesDur
}
// Restore playlists from M3U8 files now that the library
// has been rescanned and audio_files are populated again.
if l.playlistRestorer != nil {
l.playlistRestorer.RestoreAllPlaylists()
}
return metrics, err
}
+430
View File
@@ -0,0 +1,430 @@
package playlist
import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
const (
m3uHeader = "#EXTM3U"
m3uPlaylist = "#PLAYLIST:"
m3uExtInf = "#EXTINF:"
m3uExtension = ".m3u8"
)
var (
errInvalidM3U = errors.New("invalid M3U file: missing #EXTM3U header")
errEmptyM3UFile = errors.New("M3U file is empty")
errPlaylistDirNil = errors.New("playlists directory path is empty")
)
// unsafeChars matches characters that are not safe for filenames.
// Uses Unicode letter/digit classes so accented characters are kept.
var unsafeChars = regexp.MustCompile(`[^\p{L}\p{N}\-. ]+`)
// m3uEntry represents a single track entry parsed from an M3U8 file.
type m3uEntry struct {
// RelativePath is the path relative to the library root.
RelativePath string
// DurationSec is the track duration in seconds (from #EXTINF).
DurationSec int
// DisplayTitle is the display title (from #EXTINF).
DisplayTitle string
}
// parsedPlaylist is the result of parsing an M3U8 file.
type parsedPlaylist struct {
Name string
Entries []m3uEntry
}
// writeM3U8 writes a playlist to an M3U8 file at the given directory.
// The file is named "{id}-{sanitized-name}.m3u8".
func writeM3U8(
dirPath string,
playlistID int64,
name string,
entries []m3uEntry,
) error {
if dirPath == "" {
return errPlaylistDirNil
}
filePath := playlistFilePath(dirPath, playlistID, name)
// Remove any old file for this ID with a different name.
if err := removeOldPlaylistFile(
dirPath, playlistID, filePath,
); err != nil {
return fmt.Errorf(
"could not remove old playlist file: %w", err,
)
}
file, err := os.Create(filePath)
if err != nil {
return fmt.Errorf(
"could not create M3U8 file %q: %w",
filePath, err,
)
}
defer func() { _ = file.Close() }()
w := bufio.NewWriter(file)
// Write header.
_, _ = fmt.Fprintln(w, m3uHeader)
_, _ = fmt.Fprintf(
w, "%s%s\n", m3uPlaylist, name,
)
// Write entries.
for _, entry := range entries {
_, _ = fmt.Fprintf(
w, "%s%d,%s\n",
m3uExtInf,
entry.DurationSec,
entry.DisplayTitle,
)
_, _ = fmt.Fprintln(w, entry.RelativePath)
}
if err := w.Flush(); err != nil {
return fmt.Errorf(
"could not flush M3U8 file %q: %w",
filePath, err,
)
}
return nil
}
// parseM3U8 reads and parses an M3U8 (or M3U) file.
func parseM3U8(filePath string) (parsedPlaylist, error) {
file, err := os.Open(filePath)
if err != nil {
return parsedPlaylist{}, fmt.Errorf(
"could not open M3U file %q: %w", filePath, err,
)
}
defer func() { _ = file.Close() }()
scanner := bufio.NewScanner(file)
var result parsedPlaylist
headerSeen := false
pendingDuration := 0
pendingTitle := ""
hasPendingExtInf := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
// Check header.
if !headerSeen {
if line == m3uHeader {
headerSeen = true
continue
}
return parsedPlaylist{}, errInvalidM3U
}
// Playlist name directive.
if strings.HasPrefix(line, m3uPlaylist) {
result.Name = strings.TrimPrefix(line, m3uPlaylist)
continue
}
// EXTINF line.
if strings.HasPrefix(line, m3uExtInf) {
dur, title := parseExtInf(line)
pendingDuration = dur
pendingTitle = title
hasPendingExtInf = true
continue
}
// Skip other comment lines.
if strings.HasPrefix(line, "#") {
continue
}
// This is a track path line.
entry := m3uEntry{
RelativePath: line,
}
if hasPendingExtInf {
entry.DurationSec = pendingDuration
entry.DisplayTitle = pendingTitle
hasPendingExtInf = false
pendingDuration = 0
pendingTitle = ""
}
result.Entries = append(result.Entries, entry)
}
if err := scanner.Err(); err != nil {
return parsedPlaylist{}, fmt.Errorf(
"error reading M3U file %q: %w", filePath, err,
)
}
if !headerSeen {
return parsedPlaylist{}, errEmptyM3UFile
}
// Derive name from filename if not set via #PLAYLIST directive.
if result.Name == "" {
base := filepath.Base(filePath)
result.Name = strings.TrimSuffix(
base, filepath.Ext(base),
)
// Strip ID prefix if present (e.g., "1-my-playlist").
if idx := strings.Index(result.Name, "-"); idx > 0 {
prefix := result.Name[:idx]
if _, err := strconv.ParseInt(
prefix, 10, 64,
); err == nil {
result.Name = result.Name[idx+1:]
}
}
}
return result, nil
}
// parseExtInf parses an #EXTINF line and returns duration and title.
// Format: #EXTINF:duration,display title.
func parseExtInf(line string) (int, string) {
data := strings.TrimPrefix(line, m3uExtInf)
commaIdx := strings.Index(data, ",")
if commaIdx < 0 {
dur, _ := strconv.Atoi(strings.TrimSpace(data))
return dur, ""
}
durStr := strings.TrimSpace(data[:commaIdx])
title := strings.TrimSpace(data[commaIdx+1:])
dur, _ := strconv.Atoi(durStr)
return dur, title
}
// playlistFilePath returns the full path for a playlist M3U8 file.
func playlistFilePath(
dirPath string,
id int64,
name string,
) string {
sanitized := sanitizeFilename(name)
return filepath.Join(
dirPath,
fmt.Sprintf("%d-%s%s", id, sanitized, m3uExtension),
)
}
// sanitizeFilename converts a playlist name to a safe filename.
func sanitizeFilename(name string) string {
// Lowercase.
s := strings.ToLower(name)
// Replace spaces and underscores with hyphens.
s = strings.ReplaceAll(s, " ", "-")
s = strings.ReplaceAll(s, "_", "-")
// Remove unsafe characters.
s = unsafeChars.ReplaceAllString(s, "")
// Collapse multiple hyphens.
for strings.Contains(s, "--") {
s = strings.ReplaceAll(s, "--", "-")
}
// Trim leading/trailing hyphens and dots.
s = strings.Trim(s, "-.")
// Ensure non-empty.
if s == "" {
s = "playlist"
}
// Truncate to a reasonable length.
const maxLen = 100
if runeCount := len([]rune(s)); runeCount > maxLen {
runes := []rune(s)
s = string(runes[:maxLen])
}
return s
}
// findPlaylistFile finds the existing M3U8 file for a given playlist
// ID by globbing for "{id}-*.m3u8".
func findPlaylistFile(
dirPath string,
id int64,
) (string, error) {
pattern := filepath.Join(
dirPath,
fmt.Sprintf("%d-*%s", id, m3uExtension),
)
matches, err := filepath.Glob(pattern)
if err != nil {
return "", fmt.Errorf(
"could not glob for playlist file: %w", err,
)
}
if len(matches) == 0 {
return "", nil
}
return matches[0], nil
}
// removeOldPlaylistFile removes an old playlist file for the given
// ID if it exists and differs from the expected path.
func removeOldPlaylistFile(
dirPath string,
id int64,
expectedPath string,
) error {
existing, err := findPlaylistFile(dirPath, id)
if err != nil {
return err
}
if existing == "" || existing == expectedPath {
return nil
}
if err := os.Remove(existing); err != nil && !os.IsNotExist(err) {
return fmt.Errorf(
"could not remove old playlist file %q: %w",
existing, err,
)
}
return nil
}
// toAbsolutePath converts a relative path to an absolute path using
// the library root. If the path is already absolute, it is returned
// as-is.
func toAbsolutePath(relativePath, libraryRoot string) string {
if filepath.IsAbs(relativePath) {
return relativePath
}
return filepath.Join(libraryRoot, relativePath)
}
// toRelativePath converts an absolute path to a relative path based
// on the library root. If the path cannot be made relative, it is
// returned as-is.
func toRelativePath(absolutePath, libraryRoot string) string {
if libraryRoot == "" {
return absolutePath
}
rel, err := filepath.Rel(libraryRoot, absolutePath)
if err != nil {
return absolutePath
}
// If the relative path escapes the library root (starts with
// ".."), keep the absolute path.
if strings.HasPrefix(rel, "..") {
return absolutePath
}
return rel
}
// isValidM3UExtension checks whether a file extension is a
// recognized M3U variant.
func isValidM3UExtension(ext string) bool {
lower := strings.ToLower(ext)
return lower == ".m3u" || lower == ".m3u8"
}
// listPlaylistFiles returns all M3U8 files in the playlists
// directory.
func listPlaylistFiles(dirPath string) ([]string, error) {
pattern := filepath.Join(dirPath, "*"+m3uExtension)
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf(
"could not list playlist files: %w", err,
)
}
return matches, nil
}
// extractPlaylistID extracts the playlist DB ID from an M3U8
// filename. The expected format is "{id}-{name}.m3u8". Returns 0 if
// the ID cannot be extracted.
func extractPlaylistID(filePath string) int64 {
base := filepath.Base(filePath)
name := strings.TrimSuffix(base, filepath.Ext(base))
idx := strings.Index(name, "-")
if idx <= 0 {
return 0
}
id, err := strconv.ParseInt(name[:idx], 10, 64)
if err != nil {
return 0
}
return id
}
// displayTitle builds an EXTINF display title from artist and title.
func displayTitle(artist, title string) string {
artist = strings.TrimSpace(artist)
title = strings.TrimSpace(title)
if artist == "" && title == "" {
return "Unknown"
}
if artist == "" {
return title
}
if title == "" {
return artist
}
return artist + " - " + title
}
+594
View File
@@ -0,0 +1,594 @@
package playlist
import (
"os"
"path/filepath"
"testing"
)
func TestSanitizeFilename(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
expected string
}{
{
name: "simple name",
input: "My Playlist",
expected: "my-playlist",
},
{
name: "special characters",
input: "Rock & Roll: Best Of!",
expected: "rock-roll-best-of",
},
{
name: "unicode characters",
input: "Música Favorita",
expected: "música-favorita",
},
{
name: "empty string",
input: "",
expected: "playlist",
},
{
name: "only special characters",
input: "!!!@@@###",
expected: "playlist",
},
{
name: "underscores become hyphens",
input: "my_cool_playlist",
expected: "my-cool-playlist",
},
{
name: "multiple spaces collapse",
input: "my big playlist",
expected: "my-big-playlist",
},
{
name: "leading and trailing hyphens trimmed",
input: " --hello-- ",
expected: "hello",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := sanitizeFilename(tt.input)
if result != tt.expected {
t.Errorf(
"sanitizeFilename(%q) = %q, want %q",
tt.input, result, tt.expected,
)
}
})
}
}
func TestPlaylistFilePath(t *testing.T) {
t.Parallel()
result := playlistFilePath("/data/playlists", 42, "My Favorites")
expected := filepath.Join(
"/data/playlists", "42-my-favorites.m3u8",
)
if result != expected {
t.Errorf(
"playlistFilePath() = %q, want %q",
result, expected,
)
}
}
func TestWriteAndParseM3U8(t *testing.T) {
t.Parallel()
dir := t.TempDir()
entries := []m3uEntry{
{
RelativePath: "Artist/Album/01 - Song.flac",
DurationSec: 243,
DisplayTitle: "Artist - Song",
},
{
RelativePath: "Other/Track.mp3",
DurationSec: 180,
DisplayTitle: "Other - Track",
},
}
err := writeM3U8(dir, 1, "Test Playlist", entries)
if err != nil {
t.Fatalf("writeM3U8() error = %v", err)
}
// Verify file exists.
expectedPath := filepath.Join(dir, "1-test-playlist.m3u8")
if _, err := os.Stat(expectedPath); err != nil {
t.Fatalf("expected file %q to exist: %v", expectedPath, err)
}
// Parse it back.
parsed, err := parseM3U8(expectedPath)
if err != nil {
t.Fatalf("parseM3U8() error = %v", err)
}
if parsed.Name != "Test Playlist" {
t.Errorf(
"parsed.Name = %q, want %q",
parsed.Name, "Test Playlist",
)
}
if len(parsed.Entries) != len(entries) {
t.Fatalf(
"parsed %d entries, want %d",
len(parsed.Entries), len(entries),
)
}
for i, entry := range parsed.Entries {
if entry.RelativePath != entries[i].RelativePath {
t.Errorf(
"entry[%d].RelativePath = %q, want %q",
i, entry.RelativePath,
entries[i].RelativePath,
)
}
if entry.DurationSec != entries[i].DurationSec {
t.Errorf(
"entry[%d].DurationSec = %d, want %d",
i, entry.DurationSec,
entries[i].DurationSec,
)
}
if entry.DisplayTitle != entries[i].DisplayTitle {
t.Errorf(
"entry[%d].DisplayTitle = %q, want %q",
i, entry.DisplayTitle,
entries[i].DisplayTitle,
)
}
}
}
func TestWriteM3U8EmptyPlaylist(t *testing.T) {
t.Parallel()
dir := t.TempDir()
err := writeM3U8(dir, 5, "Empty", nil)
if err != nil {
t.Fatalf("writeM3U8() error = %v", err)
}
parsed, err := parseM3U8(
filepath.Join(dir, "5-empty.m3u8"),
)
if err != nil {
t.Fatalf("parseM3U8() error = %v", err)
}
if parsed.Name != "Empty" {
t.Errorf("parsed.Name = %q, want %q", parsed.Name, "Empty")
}
if len(parsed.Entries) != 0 {
t.Errorf(
"parsed %d entries, want 0",
len(parsed.Entries),
)
}
}
func TestWriteM3U8EmptyDir(t *testing.T) {
t.Parallel()
err := writeM3U8("", 1, "test", nil)
if err == nil {
t.Fatal("expected error for empty dir path")
}
}
func TestParseM3U8InvalidFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
badFile := filepath.Join(dir, "bad.m3u8")
// Write a file without the M3U header.
err := os.WriteFile(
badFile,
[]byte("just some text\n"),
0o644,
)
if err != nil {
t.Fatalf("could not write test file: %v", err)
}
_, err = parseM3U8(badFile)
if err == nil {
t.Fatal("expected error for invalid M3U file")
}
}
func TestParseM3U8NonExistentFile(t *testing.T) {
t.Parallel()
_, err := parseM3U8("/nonexistent/file.m3u8")
if err == nil {
t.Fatal("expected error for non-existent file")
}
}
func TestToRelativePath(t *testing.T) {
t.Parallel()
tests := []struct {
name string
absPath string
libraryRoot string
expected string
}{
{
name: "normal relative",
absPath: "/music/Artist/Album/song.flac",
libraryRoot: "/music",
expected: "Artist/Album/song.flac",
},
{
name: "path outside library root",
absPath: "/other/song.flac",
libraryRoot: "/music",
expected: "/other/song.flac",
},
{
name: "empty library root",
absPath: "/music/song.flac",
libraryRoot: "",
expected: "/music/song.flac",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := toRelativePath(
tt.absPath, tt.libraryRoot,
)
if result != tt.expected {
t.Errorf(
"toRelativePath(%q, %q) = %q, want %q",
tt.absPath, tt.libraryRoot,
result, tt.expected,
)
}
})
}
}
func TestToAbsolutePath(t *testing.T) {
t.Parallel()
tests := []struct {
name string
relPath string
libraryRoot string
expected string
}{
{
name: "relative path",
relPath: "Artist/Album/song.flac",
libraryRoot: "/music",
expected: "/music/Artist/Album/song.flac",
},
{
name: "already absolute",
relPath: "/music/song.flac",
libraryRoot: "/other",
expected: "/music/song.flac",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := toAbsolutePath(
tt.relPath, tt.libraryRoot,
)
if result != tt.expected {
t.Errorf(
"toAbsolutePath(%q, %q) = %q, want %q",
tt.relPath, tt.libraryRoot,
result, tt.expected,
)
}
})
}
}
func TestDisplayTitle(t *testing.T) {
t.Parallel()
tests := []struct {
name string
artist string
title string
expected string
}{
{
name: "both present",
artist: "Artist",
title: "Title",
expected: "Artist - Title",
},
{
name: "artist only",
artist: "Artist",
title: "",
expected: "Artist",
},
{
name: "title only",
artist: "",
title: "Title",
expected: "Title",
},
{
name: "neither present",
artist: "",
title: "",
expected: "Unknown",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := displayTitle(tt.artist, tt.title)
if result != tt.expected {
t.Errorf(
"displayTitle(%q, %q) = %q, want %q",
tt.artist, tt.title,
result, tt.expected,
)
}
})
}
}
func TestExtractPlaylistID(t *testing.T) {
t.Parallel()
tests := []struct {
name string
filePath string
expected int64
}{
{
name: "normal ID-prefixed filename",
filePath: "/data/playlists/42-my-favorites.m3u8",
expected: 42,
},
{
name: "no ID prefix",
filePath: "/data/playlists/my-favorites.m3u8",
expected: 0,
},
{
name: "ID only",
filePath: "/data/playlists/1-.m3u8",
expected: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := extractPlaylistID(tt.filePath)
if result != tt.expected {
t.Errorf(
"extractPlaylistID(%q) = %d, want %d",
tt.filePath, result, tt.expected,
)
}
})
}
}
func TestFindPlaylistFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
// Create a playlist file.
err := writeM3U8(dir, 7, "Test", nil)
if err != nil {
t.Fatalf("writeM3U8() error = %v", err)
}
// Find it.
found, err := findPlaylistFile(dir, 7)
if err != nil {
t.Fatalf("findPlaylistFile() error = %v", err)
}
if found == "" {
t.Fatal("expected to find playlist file")
}
// Try to find a non-existent ID.
found, err = findPlaylistFile(dir, 999)
if err != nil {
t.Fatalf("findPlaylistFile() error = %v", err)
}
if found != "" {
t.Errorf("expected empty string, got %q", found)
}
}
func TestIsValidM3UExtension(t *testing.T) {
t.Parallel()
tests := []struct {
ext string
expected bool
}{
{".m3u", true},
{".m3u8", true},
{".M3U", true},
{".M3U8", true},
{".mp3", false},
{".txt", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.ext, func(t *testing.T) {
t.Parallel()
result := isValidM3UExtension(tt.ext)
if result != tt.expected {
t.Errorf(
"isValidM3UExtension(%q) = %v, want %v",
tt.ext, result, tt.expected,
)
}
})
}
}
func TestRemoveOldPlaylistFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
// Create an initial playlist file.
err := writeM3U8(dir, 3, "Old Name", nil)
if err != nil {
t.Fatalf("writeM3U8() error = %v", err)
}
oldPath := filepath.Join(dir, "3-old-name.m3u8")
if _, err := os.Stat(oldPath); err != nil {
t.Fatalf("old file should exist: %v", err)
}
// Write with a new name — should remove the old file.
err = writeM3U8(dir, 3, "New Name", nil)
if err != nil {
t.Fatalf("writeM3U8() error = %v", err)
}
// Old file should be gone.
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
t.Error("old file should have been removed")
}
// New file should exist.
newPath := filepath.Join(dir, "3-new-name.m3u8")
if _, err := os.Stat(newPath); err != nil {
t.Errorf("new file should exist: %v", err)
}
}
func TestListPlaylistFiles(t *testing.T) {
t.Parallel()
dir := t.TempDir()
// Create some playlist files.
for i := int64(1); i <= 3; i++ {
if err := writeM3U8(
dir, i, "playlist", nil,
); err != nil {
t.Fatalf("writeM3U8() error = %v", err)
}
}
// Also create a non-m3u8 file that should be ignored.
err := os.WriteFile(
filepath.Join(dir, "notes.txt"),
[]byte("test"),
0o644,
)
if err != nil {
t.Fatalf("could not create decoy file: %v", err)
}
files, err := listPlaylistFiles(dir)
if err != nil {
t.Fatalf("listPlaylistFiles() error = %v", err)
}
if len(files) != 3 {
t.Errorf("found %d files, want 3", len(files))
}
}
func TestParseExtInf(t *testing.T) {
t.Parallel()
tests := []struct {
name string
line string
expectedDur int
expectedName string
}{
{
name: "standard EXTINF",
line: "#EXTINF:243,Artist - Title",
expectedDur: 243,
expectedName: "Artist - Title",
},
{
name: "duration only",
line: "#EXTINF:180",
expectedDur: 180,
expectedName: "",
},
{
name: "zero duration",
line: "#EXTINF:0,Some Title",
expectedDur: 0,
expectedName: "Some Title",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dur, title := parseExtInf(tt.line)
if dur != tt.expectedDur {
t.Errorf(
"duration = %d, want %d",
dur, tt.expectedDur,
)
}
if title != tt.expectedName {
t.Errorf(
"title = %q, want %q",
title, tt.expectedName,
)
}
})
}
}
File diff suppressed because it is too large Load Diff