package local import ( "bytes" "context" "os" "path/filepath" "strings" "testing" "time" "github.com/bjarneo/cliamp/history" "github.com/bjarneo/cliamp/playlist" ) func newTestProvider(t *testing.T) *Provider { t.Helper() dir := t.TempDir() return &Provider{dir: dir} } // --- safePath --- func TestSafePathValid(t *testing.T) { p := newTestProvider(t) got, err := p.safePath("rock") if err != nil { t.Fatalf("safePath(%q): %v", "rock", err) } want := filepath.Join(p.dir, "rock.toml") if got != want { t.Fatalf("safePath(%q) = %q, want %q", "rock", got, want) } } func TestSafePathRejectsTraversal(t *testing.T) { p := newTestProvider(t) bad := []string{"", " ", "foo/bar", "foo\\bar"} for _, name := range bad { if _, err := p.safePath(name); err == nil { t.Errorf("safePath(%q) should have returned error", name) } } } func TestValidateNewNameRejectsNonPortableNames(t *testing.T) { bad := []string{"..", ".", "", " ", "foo/bar", "foo\\bar", "bad:name", "bad?name"} for _, name := range bad { if err := validateNewName(name); err == nil { t.Errorf("validateNewName(%q) should have returned error", name) } } } func TestSafePathRejectsSlash(t *testing.T) { p := newTestProvider(t) if _, err := p.safePath("../escape"); err == nil { t.Fatal("safePath should reject paths with /") } } // --- Name --- func TestProviderName(t *testing.T) { p := newTestProvider(t) if got := p.Name(); got != "Local" { t.Fatalf("Name() = %q, want %q", got, "Local") } } // --- writeTrack --- func TestWriteTrackMinimal(t *testing.T) { var buf bytes.Buffer writeTrack(&buf, playlist.Track{ Path: "/music/song.mp3", Title: "Song", }) got := buf.String() if !strings.Contains(got, "[[track]]") { t.Fatal("missing [[track]] header") } if !strings.Contains(got, `path = "/music/song.mp3"`) { t.Fatal("missing path") } if !strings.Contains(got, `title = "Song"`) { t.Fatal("missing title") } // Optional fields should be absent. if strings.Contains(got, "artist") { t.Fatal("empty artist should not be written") } if strings.Contains(got, "bookmark") { t.Fatal("false bookmark should not be written") } } func TestWriteTrackAllFields(t *testing.T) { var buf bytes.Buffer writeTrack(&buf, playlist.Track{ Path: "/music/song.flac", Title: "Title", Artist: "Artist", Album: "Album", Genre: "Rock", Year: 2024, TrackNumber: 3, DurationSecs: 240, Bookmark: true, Feed: true, EmbeddedLyrics: "[00:01.00]Line", AlbumArtURL: "file:///tmp/cover.jpg", }) got := buf.String() for _, want := range []string{ `path = "/music/song.flac"`, `title = "Title"`, `artist = "Artist"`, `album = "Album"`, `genre = "Rock"`, "year = 2024", "track_number = 3", "duration_secs = 240", `embedded_lyrics = "[00:01.00]Line"`, `album_art_url = "file:///tmp/cover.jpg"`, "bookmark = true", "feed = true", } { if !strings.Contains(got, want) { t.Errorf("missing %q in output:\n%s", want, got) } } } // --- loadTOML round-trip --- func TestLoadTOMLRoundTrip(t *testing.T) { p := newTestProvider(t) os.MkdirAll(p.dir, 0o755) tracks := []playlist.Track{ {Path: "/a.mp3", Title: "A", Artist: "Art1", Album: "Alb", Year: 2020, TrackNumber: 1, DurationSecs: 180, Bookmark: true, EmbeddedLyrics: "Line 1\nLine 2", AlbumArtURL: "file:///tmp/a.jpg"}, {Path: "/b.flac", Title: "B", Genre: "Jazz", Feed: true}, } if err := p.savePlaylist("test", tracks); err != nil { t.Fatalf("savePlaylist: %v", err) } loaded, err := p.Tracks("test") if err != nil { t.Fatalf("Tracks: %v", err) } if len(loaded) != 2 { t.Fatalf("got %d tracks, want 2", len(loaded)) } if loaded[0].Path != "/a.mp3" || loaded[0].Title != "A" || loaded[0].Artist != "Art1" { t.Fatalf("track 0 mismatch: %+v", loaded[0]) } if !loaded[0].Bookmark { t.Fatal("track 0 should be bookmarked") } if loaded[0].Year != 2020 || loaded[0].TrackNumber != 1 || loaded[0].DurationSecs != 180 { t.Fatalf("track 0 numeric fields mismatch: %+v", loaded[0]) } if loaded[0].EmbeddedLyrics != "Line 1\nLine 2" || loaded[0].AlbumArtURL != "file:///tmp/a.jpg" { t.Fatalf("track 0 embedded fields mismatch: %+v", loaded[0]) } if loaded[1].Path != "/b.flac" || loaded[1].Title != "B" || loaded[1].Genre != "Jazz" { t.Fatalf("track 1 mismatch: %+v", loaded[1]) } if !loaded[1].Feed { t.Fatal("track 1 should have feed=true") } } func TestLoadTOMLComments(t *testing.T) { p := newTestProvider(t) os.MkdirAll(p.dir, 0o755) content := `# This is a comment [[track]] path = "/a.mp3" title = "A" # inline comment ` path := filepath.Join(p.dir, "commented.toml") os.WriteFile(path, []byte(content), 0o644) tracks, err := p.loadTOML(path) if err != nil { t.Fatalf("loadTOML: %v", err) } if len(tracks) != 1 { t.Fatalf("got %d tracks, want 1", len(tracks)) } if tracks[0].Title != "A" { t.Fatalf("Title = %q, want %q", tracks[0].Title, "A") } } // --- Playlists --- func TestPlaylistsEmpty(t *testing.T) { p := newTestProvider(t) lists, err := p.Playlists() if err != nil { t.Fatalf("Playlists: %v", err) } if len(lists) != 0 { t.Fatalf("got %d playlists, want 0", len(lists)) } } func TestPlaylistsLists(t *testing.T) { p := newTestProvider(t) os.MkdirAll(p.dir, 0o755) p.savePlaylist("rock", []playlist.Track{{Path: "/a.mp3", Title: "A"}}) p.savePlaylist("jazz", []playlist.Track{{Path: "/b.mp3", Title: "B"}, {Path: "/c.mp3", Title: "C"}}) lists, err := p.Playlists() if err != nil { t.Fatalf("Playlists: %v", err) } if len(lists) != 2 { t.Fatalf("got %d playlists, want 2", len(lists)) } counts := map[string]int{} for _, l := range lists { counts[l.Name] = l.TrackCount } if counts["rock"] != 1 { t.Fatalf("rock has %d tracks, want 1", counts["rock"]) } if counts["jazz"] != 2 { t.Fatalf("jazz has %d tracks, want 2", counts["jazz"]) } } // --- AddTrack --- func TestAddTrack(t *testing.T) { p := newTestProvider(t) if err := p.AddTrack("new", playlist.Track{Path: "/x.mp3", Title: "X"}); err != nil { t.Fatalf("AddTrack: %v", err) } tracks, err := p.Tracks("new") if err != nil { t.Fatalf("Tracks: %v", err) } if len(tracks) != 1 || tracks[0].Title != "X" { t.Fatalf("unexpected tracks: %+v", tracks) } // Append another. if err := p.AddTrack("new", playlist.Track{Path: "/y.mp3", Title: "Y"}); err != nil { t.Fatalf("AddTrack: %v", err) } tracks, _ = p.Tracks("new") if len(tracks) != 2 { t.Fatalf("got %d tracks, want 2", len(tracks)) } } func TestCreatePlaylistCreatesEmptyFile(t *testing.T) { p := newTestProvider(t) id, err := p.CreatePlaylist(context.Background(), "empty") if err != nil { t.Fatalf("CreatePlaylist: %v", err) } if id != "empty" { t.Fatalf("CreatePlaylist id = %q, want empty", id) } if !p.Exists("empty") { t.Fatal("created playlist should exist") } tracks, err := p.Tracks("empty") if err != nil { t.Fatalf("Tracks: %v", err) } if len(tracks) != 0 { t.Fatalf("got %d tracks, want 0", len(tracks)) } if _, err := p.CreatePlaylist(context.Background(), "empty"); err == nil { t.Fatal("creating an existing playlist should fail") } } func TestExistingPlaylistWithLegacyNameRemainsWritable(t *testing.T) { p := newTestProvider(t) if err := os.MkdirAll(p.dir, 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } path := filepath.Join(p.dir, "bad:name.toml") data := []byte("[[track]]\npath = \"/a.mp3\"\ntitle = \"A\"\n") if err := os.WriteFile(path, data, 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } tracks, err := p.Tracks("bad:name") if err != nil { t.Fatalf("Tracks: %v", err) } if len(tracks) != 1 || tracks[0].Path != "/a.mp3" { t.Fatalf("Tracks = %+v, want legacy playlist contents", tracks) } if err := p.AddTrack("bad:name", playlist.Track{Path: "/b.mp3", Title: "B"}); err != nil { t.Fatalf("AddTrack legacy name: %v", err) } tracks, err = p.Tracks("bad:name") if err != nil { t.Fatalf("Tracks after AddTrack: %v", err) } if len(tracks) != 2 || tracks[1].Path != "/b.mp3" { t.Fatalf("Tracks after AddTrack = %+v, want appended track", tracks) } if _, err := p.CreatePlaylist(context.Background(), "new:name"); err == nil { t.Fatal("CreatePlaylist should reject new non-portable names") } } func TestAddTracksSkipsDuplicatePaths(t *testing.T) { p := newTestProvider(t) if err := p.AddTrack("dupes", playlist.Track{Path: "/a.mp3", Title: "A"}); err != nil { t.Fatalf("AddTrack: %v", err) } added, skipped, err := p.AddTracks("dupes", []playlist.Track{ {Path: "/a.mp3", Title: "A again"}, {Path: "/b.mp3", Title: "B"}, {Path: "/b.mp3", Title: "B again"}, }) if err != nil { t.Fatalf("AddTracks: %v", err) } if added != 1 || skipped != 2 { t.Fatalf("AddTracks added=%d skipped=%d, want 1/2", added, skipped) } tracks, err := p.Tracks("dupes") if err != nil { t.Fatalf("Tracks: %v", err) } if len(tracks) != 2 || tracks[0].Path != "/a.mp3" || tracks[1].Path != "/b.mp3" { t.Fatalf("unexpected tracks: %+v", tracks) } } // --- Exists --- func TestExists(t *testing.T) { p := newTestProvider(t) if p.Exists("nope") { t.Fatal("should not exist") } p.AddTrack("yes", playlist.Track{Path: "/a.mp3", Title: "A"}) if !p.Exists("yes") { t.Fatal("should exist after AddTrack") } } // --- SetBookmark --- func TestSetBookmark(t *testing.T) { p := newTestProvider(t) p.AddTrack("marks", playlist.Track{Path: "/a.mp3", Title: "A"}) if err := p.SetBookmark("marks", 0); err != nil { t.Fatalf("SetBookmark: %v", err) } tracks, _ := p.Tracks("marks") if !tracks[0].Bookmark { t.Fatal("track should be bookmarked after toggle") } // Toggle off. p.SetBookmark("marks", 0) tracks, _ = p.Tracks("marks") if tracks[0].Bookmark { t.Fatal("track should not be bookmarked after second toggle") } } func TestSetBookmarkOutOfRange(t *testing.T) { p := newTestProvider(t) p.AddTrack("one", playlist.Track{Path: "/a.mp3", Title: "A"}) if err := p.SetBookmark("one", 5); err == nil { t.Fatal("expected error for out-of-range index") } if err := p.SetBookmark("one", -1); err == nil { t.Fatal("expected error for negative index") } } func TestSetBookmarkByPath(t *testing.T) { p := newTestProvider(t) if _, _, err := p.AddTracks("marks", []playlist.Track{ {Path: "/a.mp3", Title: "A"}, {Path: "/b.mp3", Title: "B"}, }); err != nil { t.Fatalf("AddTracks: %v", err) } if err := p.SetBookmarkByPath("marks", "/b.mp3"); err != nil { t.Fatalf("SetBookmarkByPath: %v", err) } tracks, err := p.Tracks("marks") if err != nil { t.Fatalf("Tracks: %v", err) } if tracks[0].Bookmark || !tracks[1].Bookmark { t.Fatalf("wrong bookmark row toggled: %+v", tracks) } if err := p.SetBookmarkByPath("marks", "/missing.mp3"); err == nil { t.Fatal("missing path should return an error") } } // --- RemoveTrack --- func TestRemoveTrack(t *testing.T) { p := newTestProvider(t) if _, _, err := p.AddTracks("rem", []playlist.Track{ {Path: "/a.mp3", Title: "A"}, {Path: "/b.mp3", Title: "B"}, {Path: "/c.mp3", Title: "C"}, }); err != nil { t.Fatalf("AddTracks: %v", err) } if err := p.RemoveTrack("rem", 1); err != nil { t.Fatalf("RemoveTrack: %v", err) } tracks, _ := p.Tracks("rem") if len(tracks) != 2 { t.Fatalf("got %d tracks, want 2", len(tracks)) } if tracks[0].Title != "A" || tracks[1].Title != "C" { t.Fatalf("wrong tracks after remove: %+v", tracks) } } func TestRemoveTrackKeepsEmptyPlaylist(t *testing.T) { p := newTestProvider(t) p.AddTrack("solo", playlist.Track{Path: "/a.mp3", Title: "A"}) if err := p.RemoveTrack("solo", 0); err != nil { t.Fatalf("RemoveTrack: %v", err) } if !p.Exists("solo") { t.Fatal("playlist should remain after last track is removed") } tracks, err := p.Tracks("solo") if err != nil { t.Fatalf("Tracks: %v", err) } if len(tracks) != 0 { t.Fatalf("got %d tracks, want 0", len(tracks)) } } // --- DeletePlaylist --- func TestDeletePlaylist(t *testing.T) { p := newTestProvider(t) p.AddTrack("del", playlist.Track{Path: "/a.mp3", Title: "A"}) if err := p.DeletePlaylist("del"); err != nil { t.Fatalf("DeletePlaylist: %v", err) } if p.Exists("del") { t.Fatal("playlist should be deleted") } } // --- SavePlaylist --- func TestSavePlaylistOverwrites(t *testing.T) { p := newTestProvider(t) if _, _, err := p.AddTracks("over", []playlist.Track{ {Path: "/a.mp3", Title: "A"}, {Path: "/b.mp3", Title: "B"}, }); err != nil { t.Fatalf("AddTracks: %v", err) } // Overwrite with single track. if err := p.SavePlaylist("over", []playlist.Track{{Path: "/c.mp3", Title: "C"}}); err != nil { t.Fatalf("SavePlaylist: %v", err) } tracks, _ := p.Tracks("over") if len(tracks) != 1 || tracks[0].Title != "C" { t.Fatalf("expected single track C, got: %+v", tracks) } } // --- loadTOML edge cases --- func TestLoadTOMLMissingFile(t *testing.T) { p := newTestProvider(t) _, err := p.Tracks("nonexistent") if err == nil { t.Fatal("expected error for missing playlist") } } func TestLoadTOMLStreamField(t *testing.T) { p := newTestProvider(t) os.MkdirAll(p.dir, 0o755) content := `[[track]] path = "https://stream.example.com/live" title = "Live Radio" ` path := filepath.Join(p.dir, "radio.toml") os.WriteFile(path, []byte(content), 0o644) tracks, err := p.loadTOML(path) if err != nil { t.Fatalf("loadTOML: %v", err) } if !tracks[0].Stream { t.Fatal("URL path should set Stream=true") } } // --- Virtual "Recently Played" history playlist --- func newTestProviderWithHistory(t *testing.T) *Provider { t.Helper() dir := t.TempDir() historyPath := filepath.Join(dir, "history.toml") return &Provider{dir: filepath.Join(dir, "playlists"), history: history.NewAt(historyPath)} } func TestPlaylistsIncludesHistoryWhenNonEmpty(t *testing.T) { p := newTestProviderWithHistory(t) if err := p.history.Record(playlist.Track{Path: "/a.mp3", Title: "A"}, time.Now()); err != nil { t.Fatalf("Record: %v", err) } lists, err := p.Playlists() if err != nil { t.Fatalf("Playlists: %v", err) } if len(lists) == 0 || lists[0].Name != history.PlaylistName { t.Fatalf("expected first playlist to be %q, got %+v", history.PlaylistName, lists) } if lists[0].TrackCount != 1 { t.Errorf("history TrackCount = %d, want 1", lists[0].TrackCount) } } func TestPlaylistsOmitsHistoryWhenEmpty(t *testing.T) { p := newTestProviderWithHistory(t) lists, err := p.Playlists() if err != nil { t.Fatalf("Playlists: %v", err) } for _, pl := range lists { if pl.Name == history.PlaylistName { t.Fatalf("history entry should not appear when empty") } } } func TestTracksReadsFromHistory(t *testing.T) { p := newTestProviderWithHistory(t) base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) p.history.Record(playlist.Track{Path: "/a.mp3", Title: "A"}, base) p.history.Record(playlist.Track{Path: "/b.mp3", Title: "B"}, base.Add(time.Hour)) tracks, err := p.Tracks(history.PlaylistName) if err != nil { t.Fatalf("Tracks: %v", err) } if len(tracks) != 2 || tracks[0].Title != "B" || tracks[1].Title != "A" { t.Fatalf("history tracks order wrong: %+v", tracks) } } func TestWritesRejectedForHistoryName(t *testing.T) { p := newTestProviderWithHistory(t) track := playlist.Track{Path: "/a.mp3", Title: "A"} tests := []struct { name string call func() error }{ {"AddTrack", func() error { return p.AddTrack(history.PlaylistName, track) }}, {"AddTracks", func() error { _, _, err := p.AddTracks(history.PlaylistName, []playlist.Track{track}); return err }}, {"SavePlaylist", func() error { return p.SavePlaylist(history.PlaylistName, []playlist.Track{track}) }}, {"DeletePlaylist", func() error { return p.DeletePlaylist(history.PlaylistName) }}, {"RemoveTrack", func() error { return p.RemoveTrack(history.PlaylistName, 0) }}, {"SetBookmark", func() error { return p.SetBookmark(history.PlaylistName, 0) }}, {"SetBookmarkByPath", func() error { return p.SetBookmarkByPath(history.PlaylistName, track.Path) }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := tt.call(); err == nil { t.Errorf("%s should reject history name", tt.name) } }) } } func TestExistsForHistoryName(t *testing.T) { p := newTestProviderWithHistory(t) if p.Exists(history.PlaylistName) { t.Error("Exists should be false when history is empty") } p.history.Record(playlist.Track{Path: "/a.mp3"}, time.Now()) if !p.Exists(history.PlaylistName) { t.Error("Exists should be true once a play is recorded") } } func TestClearHistoryRemovesEntries(t *testing.T) { p := newTestProviderWithHistory(t) p.history.Record(playlist.Track{Path: "/a.mp3"}, time.Now()) if err := p.ClearHistory(); err != nil { t.Fatalf("ClearHistory: %v", err) } if got, _ := p.history.Recent(0); len(got) != 0 { t.Errorf("history not cleared: %d entries remain", len(got)) } } // --- SearchTracks (fuzzy) --- func TestTrackMatchScore(t *testing.T) { tests := []struct { name string track playlist.Track query string want bool }{ {"title subsequence", playlist.Track{Title: "Sakura"}, "skr", true}, {"artist subsequence", playlist.Track{Artist: "Radiohead"}, "rdhd", true}, {"album substring", playlist.Track{Album: "In Rainbows"}, "rainbow", true}, {"no match", playlist.Track{Title: "Sakura"}, "zzz", false}, {"all fields empty", playlist.Track{}, "x", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if _, ok := trackMatchScore(tt.track, tt.query); ok != tt.want { t.Fatalf("trackMatchScore(%+v, %q) ok = %v, want %v", tt.track, tt.query, ok, tt.want) } }) } } func TestSearchTracksFuzzyRanksAndMatchesSubsequence(t *testing.T) { p := newTestProvider(t) if err := p.savePlaylist("lib", []playlist.Track{ {Path: "/1.mp3", Title: "Cherry Blossom (Sakura Mix)"}, {Path: "/2.mp3", Title: "Sakura"}, {Path: "/3.mp3", Title: "Thunderstruck"}, }); err != nil { t.Fatalf("savePlaylist: %v", err) } // "skr" is a non-contiguous subsequence a substring search would miss. got, err := p.SearchTracks(context.Background(), "skr", 0) if err != nil { t.Fatalf("SearchTracks: %v", err) } if len(got) != 2 { t.Fatalf("got %d results, want 2: %+v", len(got), got) } // "Sakura" (prefix match) outranks "Cherry Blossom (Sakura Mix)". if got[0].Title != "Sakura" { t.Fatalf("top result = %q, want %q", got[0].Title, "Sakura") } } func TestSearchTracksEmptyQuery(t *testing.T) { p := newTestProvider(t) if err := p.savePlaylist("lib", []playlist.Track{{Path: "/1.mp3", Title: "Sakura"}}); err != nil { t.Fatalf("savePlaylist: %v", err) } got, err := p.SearchTracks(context.Background(), " ", 0) if err != nil { t.Fatalf("SearchTracks: %v", err) } if got != nil { t.Fatalf("blank query should return nil, got %+v", got) } } func TestSearchTracksLimit(t *testing.T) { p := newTestProvider(t) if err := p.savePlaylist("lib", []playlist.Track{ {Path: "/1.mp3", Title: "Sakura One"}, {Path: "/2.mp3", Title: "Sakura Two"}, {Path: "/3.mp3", Title: "Sakura Three"}, }); err != nil { t.Fatalf("savePlaylist: %v", err) } got, err := p.SearchTracks(context.Background(), "sakura", 2) if err != nil { t.Fatalf("SearchTracks: %v", err) } if len(got) != 2 { t.Fatalf("got %d results, want 2 (limit)", len(got)) } }