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 TestParseM3U8EmptyFile(t *testing.T) { t.Parallel() dir := t.TempDir() emptyFile := filepath.Join(dir, "empty.m3u8") err := os.WriteFile( emptyFile, []byte(""), 0o644, ) if err != nil { t.Fatalf("could not write test file: %v", err) } _, err = parseM3U8(emptyFile) if err == nil { t.Fatal("expected error for empty 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, ) } }) } } func TestFindPlaylistFileOverlappingIDs(t *testing.T) { t.Parallel() dir := t.TempDir() // Create playlists with IDs 1 and 10 — the glob // pattern "1-*.m3u8" must not match "10-longer.m3u8". if err := writeM3U8(dir, 1, "Short", nil); err != nil { t.Fatalf("writeM3U8(1) error = %v", err) } if err := writeM3U8(dir, 10, "Longer", nil); err != nil { t.Fatalf("writeM3U8(10) error = %v", err) } found, err := findPlaylistFile(dir, 1) if err != nil { t.Fatalf("findPlaylistFile(1) error = %v", err) } if got := extractPlaylistID(found); got != 1 { t.Errorf( "findPlaylistFile(1) returned ID %d, want 1", got, ) } found, err = findPlaylistFile(dir, 10) if err != nil { t.Fatalf("findPlaylistFile(10) error = %v", err) } if got := extractPlaylistID(found); got != 10 { t.Errorf( "findPlaylistFile(10) returned ID %d, want 10", got, ) } } func TestParseM3U8SimpleFormat(t *testing.T) { t.Parallel() dir := t.TempDir() simpleFile := filepath.Join(dir, "simple.m3u") // Write a simple M3U with no #EXTM3U header — just paths. content := "Artist/Album/01 - Song.flac\nOther/Track.mp3\n" if err := os.WriteFile( simpleFile, []byte(content), 0o644, ); err != nil { t.Fatalf("could not write test file: %v", err) } parsed, err := parseM3U8(simpleFile) if err != nil { t.Fatalf("parseM3U8() error = %v", err) } if len(parsed.Entries) != 2 { t.Fatalf( "parsed %d entries, want 2", len(parsed.Entries), ) } if parsed.Entries[0].RelativePath != "Artist/Album/01 - Song.flac" { t.Errorf( "entry[0].RelativePath = %q, want %q", parsed.Entries[0].RelativePath, "Artist/Album/01 - Song.flac", ) } if parsed.Entries[1].RelativePath != "Other/Track.mp3" { t.Errorf( "entry[1].RelativePath = %q, want %q", parsed.Entries[1].RelativePath, "Other/Track.mp3", ) } } func TestParseM3U8SimpleFormatWithComments(t *testing.T) { t.Parallel() dir := t.TempDir() simpleFile := filepath.Join(dir, "commented.m3u") // Simple M3U with comment lines (no #EXTM3U header). content := "# Generated by SomeApp\n" + "Artist/Song.flac\n" + "# Another comment\n" + "Other/Track.mp3\n" if err := os.WriteFile( simpleFile, []byte(content), 0o644, ); err != nil { t.Fatalf("could not write test file: %v", err) } parsed, err := parseM3U8(simpleFile) if err != nil { t.Fatalf("parseM3U8() error = %v", err) } if len(parsed.Entries) != 2 { t.Fatalf( "parsed %d entries, want 2", len(parsed.Entries), ) } if parsed.Entries[0].RelativePath != "Artist/Song.flac" { t.Errorf( "entry[0].RelativePath = %q, want %q", parsed.Entries[0].RelativePath, "Artist/Song.flac", ) } } func TestRemoveM3UEntries(t *testing.T) { t.Parallel() entries := []m3uEntry{ {RelativePath: "Artist/Song1.flac"}, {RelativePath: "Artist/Song2.flac"}, {RelativePath: "Artist/Song3.flac"}, } targets := map[string]struct{}{ "/music/Artist/Song2.flac": {}, } result := removeM3UEntries( entries, targets, []string{"/music"}, ) if len(result) != 2 { t.Fatalf("expected 2 entries, got %d", len(result)) } if result[0].RelativePath != "Artist/Song1.flac" { t.Errorf( "entry[0] = %q, want %q", result[0].RelativePath, "Artist/Song1.flac", ) } if result[1].RelativePath != "Artist/Song3.flac" { t.Errorf( "entry[1] = %q, want %q", result[1].RelativePath, "Artist/Song3.flac", ) } } func TestRemoveM3UEntriesAll(t *testing.T) { t.Parallel() entries := []m3uEntry{ {RelativePath: "Song.flac"}, } targets := map[string]struct{}{ "/music/Song.flac": {}, } result := removeM3UEntries( entries, targets, []string{"/music"}, ) if len(result) != 0 { t.Errorf("expected 0 entries, got %d", len(result)) } } func TestReplaceM3UEntryPaths(t *testing.T) { t.Parallel() entries := []m3uEntry{ { RelativePath: "old/path/song.flac", DurationSec: 180, DisplayTitle: "Song", }, { RelativePath: "other/track.mp3", DurationSec: 240, DisplayTitle: "Track", }, } replacements := map[string]string{ "/music/old/path/song.flac": "new/path/song.flac", } result := replaceM3UEntryPaths( entries, replacements, []string{"/music"}, ) if len(result) != 2 { t.Fatalf("expected 2 entries, got %d", len(result)) } if result[0].RelativePath != "new/path/song.flac" { t.Errorf( "entry[0].RelativePath = %q, want %q", result[0].RelativePath, "new/path/song.flac", ) } // Duration and title should be preserved. if result[0].DurationSec != 180 { t.Errorf( "entry[0].DurationSec = %d, want 180", result[0].DurationSec, ) } // Unchanged entry should remain the same. if result[1].RelativePath != "other/track.mp3" { t.Errorf( "entry[1].RelativePath = %q, want %q", result[1].RelativePath, "other/track.mp3", ) } } func TestFindM3UEntry(t *testing.T) { t.Parallel() entries := []m3uEntry{ {RelativePath: "Artist/Song1.flac"}, {RelativePath: "Artist/Song2.flac"}, {RelativePath: "Artist/Song3.flac"}, } entry, idx := findM3UEntry( entries, "/music/Artist/Song2.flac", []string{"/music"}, ) if idx != 1 { t.Errorf("expected index 1, got %d", idx) } if entry.RelativePath != "Artist/Song2.flac" { t.Errorf( "entry.RelativePath = %q, want %q", entry.RelativePath, "Artist/Song2.flac", ) } // Not found. _, idx = findM3UEntry( entries, "/music/Artist/Missing.flac", []string{"/music"}, ) if idx != -1 { t.Errorf("expected index -1, got %d", idx) } } func TestResolveM3UPath(t *testing.T) { t.Parallel() tests := []struct { name string relativePath string libraryRoots []string knownPaths map[string]struct{} expected string }{ { name: "absolute path returned as-is", relativePath: "/music/song.flac", libraryRoots: []string{"/other"}, knownPaths: nil, expected: "/music/song.flac", }, { name: "relative path resolves against first root", relativePath: "Artist/song.flac", libraryRoots: []string{"/music", "/archive"}, knownPaths: map[string]struct{}{ "/music/Artist/song.flac": {}, }, expected: "/music/Artist/song.flac", }, { name: "relative path resolves against second root", relativePath: "Artist/song.flac", libraryRoots: []string{"/music", "/archive"}, knownPaths: map[string]struct{}{ "/archive/Artist/song.flac": {}, }, expected: "/archive/Artist/song.flac", }, { name: "no match falls back to first root", relativePath: "Artist/song.flac", libraryRoots: []string{"/music", "/archive"}, knownPaths: map[string]struct{}{}, expected: "/music/Artist/song.flac", }, { name: "empty roots returns path unchanged", relativePath: "Artist/song.flac", libraryRoots: nil, knownPaths: nil, expected: "Artist/song.flac", }, { name: "nil knownPaths falls back to first root", relativePath: "Artist/song.flac", libraryRoots: []string{"/music"}, knownPaths: nil, expected: "/music/Artist/song.flac", }, { name: "absolute path in knownPaths returned", relativePath: "/music/Artist/song.flac", libraryRoots: []string{"/other"}, knownPaths: map[string]struct{}{ "/music/Artist/song.flac": {}, }, expected: "/music/Artist/song.flac", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() result := resolveM3UPath( tt.relativePath, tt.libraryRoots, tt.knownPaths, ) if result != tt.expected { t.Errorf( "resolveM3UPath(%q, %v, ...) = %q, want %q", tt.relativePath, tt.libraryRoots, result, tt.expected, ) } }) } } func TestToRelativePathMultiRoot(t *testing.T) { t.Parallel() tests := []struct { name string absolutePath string libraryRoots []string expected string }{ { name: "path under first root", absolutePath: "/music/Artist/song.flac", libraryRoots: []string{"/music", "/archive"}, expected: "Artist/song.flac", }, { name: "path under second root", absolutePath: "/archive/Artist/song.flac", libraryRoots: []string{"/music", "/archive"}, expected: "Artist/song.flac", }, { name: "path under no root returns absolute", absolutePath: "/other/song.flac", libraryRoots: []string{"/music", "/archive"}, expected: "/other/song.flac", }, { name: "empty roots returns absolute", absolutePath: "/music/song.flac", libraryRoots: nil, expected: "/music/song.flac", }, { name: "empty root string skipped", absolutePath: "/music/song.flac", libraryRoots: []string{"", "/music"}, expected: "song.flac", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() result := toRelativePathMultiRoot( tt.absolutePath, tt.libraryRoots, ) if result != tt.expected { t.Errorf( "toRelativePathMultiRoot(%q, %v) = %q, want %q", tt.absolutePath, tt.libraryRoots, result, tt.expected, ) } }) } } func TestRemoveM3UEntriesMultiRoot(t *testing.T) { t.Parallel() entries := []m3uEntry{ {RelativePath: "Artist/Song1.flac"}, {RelativePath: "Band/Song2.flac"}, {RelativePath: "Artist/Song3.flac"}, } // Song1 is under /music, Song2 is under /archive. // Both should be found and removed. targets := map[string]struct{}{ "/music/Artist/Song1.flac": {}, "/archive/Band/Song2.flac": {}, } result := removeM3UEntries( entries, targets, []string{"/music", "/archive"}, ) if len(result) != 1 { t.Fatalf("expected 1 entry, got %d", len(result)) } if result[0].RelativePath != "Artist/Song3.flac" { t.Errorf( "entry[0] = %q, want %q", result[0].RelativePath, "Artist/Song3.flac", ) } } func TestFindM3UEntryMultiRoot(t *testing.T) { t.Parallel() entries := []m3uEntry{ {RelativePath: "Artist/Song1.flac"}, {RelativePath: "Band/Song2.flac"}, } // Song2 is under the second root /archive. entry, idx := findM3UEntry( entries, "/archive/Band/Song2.flac", []string{"/music", "/archive"}, ) if idx != 1 { t.Errorf("expected index 1, got %d", idx) } if entry.RelativePath != "Band/Song2.flac" { t.Errorf( "entry.RelativePath = %q, want %q", entry.RelativePath, "Band/Song2.flac", ) } // Not found in any root. _, idx = findM3UEntry( entries, "/other/Missing.flac", []string{"/music", "/archive"}, ) if idx != -1 { t.Errorf("expected index -1, got %d", idx) } } func TestReplaceM3UEntryPathsMultiRoot(t *testing.T) { t.Parallel() entries := []m3uEntry{ { RelativePath: "old/path/song.flac", DurationSec: 180, DisplayTitle: "Song", }, { RelativePath: "other/track.mp3", DurationSec: 240, DisplayTitle: "Track", }, } // The old path resolves under /archive, not /music. replacements := map[string]string{ "/archive/old/path/song.flac": "new/path/song.flac", } result := replaceM3UEntryPaths( entries, replacements, []string{"/music", "/archive"}, ) if len(result) != 2 { t.Fatalf("expected 2 entries, got %d", len(result)) } if result[0].RelativePath != "new/path/song.flac" { t.Errorf( "entry[0].RelativePath = %q, want %q", result[0].RelativePath, "new/path/song.flac", ) } if result[0].DurationSec != 180 { t.Errorf( "entry[0].DurationSec = %d, want 180", result[0].DurationSec, ) } if result[1].RelativePath != "other/track.mp3" { t.Errorf( "entry[1].RelativePath = %q, want %q", result[1].RelativePath, "other/track.mp3", ) } }