chore: import upstream snapshot with attribution
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:13 +08:00
commit ead81af521
414 changed files with 73946 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"time"
"github.com/bjarneo/cliamp/history"
)
// HistoryShow prints recently played tracks, newest first. limit <= 0 prints all.
// When jsonOutput is true, output is a JSON array suitable for scripting.
func HistoryShow(limit int, jsonOutput bool) error {
store := history.New()
if store == nil {
return fmt.Errorf("could not resolve config directory")
}
entries, err := store.Recent(limit)
if err != nil {
return fmt.Errorf("read history: %w", err)
}
if jsonOutput {
type jsonEntry struct {
PlayedAt string `json:"played_at"`
Path string `json:"path"`
Title string `json:"title"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
Genre string `json:"genre,omitempty"`
Year int `json:"year,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DurationSecs int `json:"duration_secs,omitempty"`
}
out := make([]jsonEntry, len(entries))
for i, e := range entries {
out[i] = jsonEntry{
PlayedAt: e.PlayedAt.UTC().Format(time.RFC3339),
Path: e.Track.Path,
Title: e.Track.Title,
Artist: e.Track.Artist,
Album: e.Track.Album,
Genre: e.Track.Genre,
Year: e.Track.Year,
TrackNumber: e.Track.TrackNumber,
DurationSecs: e.Track.DurationSecs,
}
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
if len(entries) == 0 {
fmt.Println("No history yet — listen to a track for at least 50% of its duration to record it.")
return nil
}
fmt.Printf("Recently Played (%d tracks)\n\n", len(entries))
now := time.Now()
for i, e := range entries {
fmt.Printf(" %3d. %s (%s)\n", i+1, e.Track.DisplayName(), formatRelative(now, e.PlayedAt))
}
return nil
}
// HistoryClear wipes the history file.
func HistoryClear() error {
store := history.New()
if store == nil {
return fmt.Errorf("could not resolve config directory")
}
if err := store.Clear(); err != nil {
return fmt.Errorf("clear history: %w", err)
}
fmt.Println("History cleared.")
return nil
}
// formatRelative renders a short human-friendly duration like "3m ago" or
// "yesterday". Falls back to the date when older than a week.
func formatRelative(now, then time.Time) string {
d := now.Sub(then)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
case d < 7*24*time.Hour:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
return then.Local().Format("2006-01-02")
}
+793
View File
@@ -0,0 +1,793 @@
// Package cmd implements CLI subcommands for cliamp.
package cmd
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"github.com/bjarneo/cliamp/external/local"
"github.com/bjarneo/cliamp/internal/sshurl"
"github.com/bjarneo/cliamp/player"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/resolve"
)
// PlaylistList prints all playlists with their track counts.
func PlaylistList() error {
prov, err := newProvider()
if err != nil {
return err
}
lists, err := prov.Playlists()
if err != nil {
return fmt.Errorf("listing playlists: %w", err)
}
if len(lists) == 0 {
fmt.Println("No playlists found.")
return nil
}
maxName := 0
for _, pl := range lists {
if len(pl.Name) > maxName {
maxName = len(pl.Name)
}
}
for _, pl := range lists {
fmt.Printf(" %-*s %d tracks\n", maxName, pl.Name, pl.TrackCount)
}
return nil
}
// PlaylistCreate creates a new playlist from the given file and directory paths.
// If sshHost is non-empty, remote paths are walked via SSH.
func PlaylistCreate(name string, paths []string, sshHost string) error {
prov, err := newProvider()
if err != nil {
return err
}
if prov.Exists(name) {
return fmt.Errorf("playlist %q already exists (use `add` to append)", name)
}
if len(paths) == 0 && sshHost == "" {
if _, err := prov.CreatePlaylist(context.Background(), name); err != nil {
return fmt.Errorf("creating playlist: %w", err)
}
fmt.Printf("Created empty playlist %q.\n", name)
return nil
}
var audioPaths []string
if sshHost != "" {
remotePaths, err := sshFindAudio(sshHost, paths)
if err != nil {
return err
}
audioPaths = remotePaths
} else {
collected, err := collectLocalAudio(paths)
if err != nil {
return err
}
audioPaths = collected
}
if len(audioPaths) == 0 {
return fmt.Errorf("no audio files found in %s", strings.Join(paths, ", "))
}
tracks := make([]playlist.Track, len(audioPaths))
for i, ap := range audioPaths {
if sshHost != "" {
tracks[i] = playlist.TrackFromFilename(ap)
tracks[i].Path = "ssh://" + sshHost + ap
} else {
tracks[i] = playlist.TrackFromPath(ap)
}
}
albumAwareSort(tracks)
added, skipped, err := prov.AddTracks(name, tracks)
if err != nil {
return fmt.Errorf("writing playlist: %w", err)
}
if skipped > 0 {
fmt.Printf("Created playlist %q with %d tracks (%d duplicate skipped).\n", name, added, skipped)
} else {
fmt.Printf("Created playlist %q with %d tracks.\n", name, added)
}
return nil
}
// PlaylistAdd appends tracks from the given paths to an existing playlist.
func PlaylistAdd(name string, paths []string) error {
prov, err := newProvider()
if err != nil {
return err
}
if !prov.Exists(name) {
return fmt.Errorf("playlist %q not found", name)
}
audioPaths, err := collectLocalAudio(paths)
if err != nil {
return err
}
if len(audioPaths) == 0 {
return fmt.Errorf("no audio files found in %s", strings.Join(paths, ", "))
}
tracks := make([]playlist.Track, len(audioPaths))
for i, ap := range audioPaths {
tracks[i] = playlist.TrackFromPath(ap)
}
albumAwareSort(tracks)
added, skipped, err := prov.AddTracks(name, tracks)
if err != nil {
return fmt.Errorf("adding tracks: %w", err)
}
if skipped > 0 {
fmt.Printf("Added %d tracks to %q (%d duplicate skipped).\n", added, name, skipped)
} else {
fmt.Printf("Added %d tracks to %q.\n", added, name)
}
return nil
}
// PlaylistShow displays the tracks in a playlist. If jsonOutput is true,
// the track list is printed as a JSON array to stdout.
func PlaylistShow(name string, jsonOutput bool) error {
prov, err := newProvider()
if err != nil {
return err
}
tracks, err := prov.Tracks(name)
if err != nil {
return fmt.Errorf("playlist %q not found", name)
}
if len(tracks) == 0 {
if jsonOutput {
fmt.Println("[]")
} else {
fmt.Printf("Playlist %q is empty.\n", name)
}
return nil
}
if jsonOutput {
type jsonTrack struct {
Path string `json:"path"`
Title string `json:"title"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
Genre string `json:"genre,omitempty"`
Year int `json:"year,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DurationSecs int `json:"duration_secs,omitempty"`
AlbumArtURL string `json:"album_art_url,omitempty"`
Bookmark bool `json:"bookmark,omitempty"`
}
out := make([]jsonTrack, len(tracks))
for i, t := range tracks {
out[i] = jsonTrack{
Path: t.Path,
Title: t.Title,
Artist: t.Artist,
Album: t.Album,
Genre: t.Genre,
Year: t.Year,
TrackNumber: t.TrackNumber,
DurationSecs: t.DurationSecs,
AlbumArtURL: t.AlbumArtURL,
Bookmark: t.Bookmark,
}
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
fmt.Printf("Playlist: %s (%d tracks)\n\n", name, len(tracks))
for i, t := range tracks {
display := t.Title
if t.Artist != "" {
display = t.Artist + " - " + t.Title
}
fmt.Printf(" %3d. %s\n", i+1, display)
}
return nil
}
// PlaylistRemove removes a track by index from the named playlist.
// The index is 1-based for the user, converted to 0-based internally.
func PlaylistRemove(name string, index int) error {
prov, err := newProvider()
if err != nil {
return err
}
if err := prov.RemoveTrack(name, index-1); err != nil {
return fmt.Errorf("removing track %d from %q: %w", index, name, err)
}
fmt.Printf("Removed track %d from %q.\n", index, name)
return nil
}
// PlaylistDelete deletes an entire playlist.
func PlaylistDelete(name string) error {
prov, err := newProvider()
if err != nil {
return err
}
if err := prov.DeletePlaylist(name); err != nil {
return fmt.Errorf("deleting playlist %q: %w", name, err)
}
fmt.Printf("Deleted playlist %q.\n", name)
return nil
}
// PlaylistRename renames a local playlist.
func PlaylistRename(oldName, newName string) error {
prov, err := newProvider()
if err != nil {
return err
}
if err := prov.RenamePlaylist(oldName, newName); err != nil {
return fmt.Errorf("renaming playlist %q to %q: %w", oldName, newName, err)
}
fmt.Printf("Renamed playlist %q to %q.\n", oldName, newName)
return nil
}
// PlaylistDedupe removes duplicate tracks by exact path, keeping first wins.
func PlaylistDedupe(name string) error {
prov, err := newProvider()
if err != nil {
return err
}
tracks, err := prov.Tracks(name)
if err != nil {
return fmt.Errorf("loading playlist %q: %w", name, err)
}
seen := make(map[string]struct{}, len(tracks))
kept := tracks[:0]
removed := 0
for _, t := range tracks {
if _, ok := seen[t.Path]; ok {
removed++
fmt.Printf(" removed duplicate: %s\n", t.Path)
continue
}
seen[t.Path] = struct{}{}
kept = append(kept, t)
}
if removed == 0 {
fmt.Printf("No duplicates found in %q.\n", name)
return nil
}
if err := prov.SavePlaylist(name, kept); err != nil {
return fmt.Errorf("saving playlist %q: %w", name, err)
}
fmt.Printf("Removed %d duplicate tracks from %q.\n", removed, name)
return nil
}
// PlaylistSort sorts a playlist in place by one of the supported metadata keys.
func PlaylistSort(name, by string) error {
prov, err := newProvider()
if err != nil {
return err
}
tracks, err := prov.Tracks(name)
if err != nil {
return fmt.Errorf("loading playlist %q: %w", name, err)
}
if err := sortTracks(tracks, by); err != nil {
return err
}
if err := prov.SavePlaylist(name, tracks); err != nil {
return fmt.Errorf("saving playlist %q: %w", name, err)
}
fmt.Printf("Sorted %q by %s.\n", name, normalizeSortKey(by))
return nil
}
// PlaylistDoctor reports missing local files and optionally prunes them.
func PlaylistDoctor(name string, fix bool) error {
prov, err := newProvider()
if err != nil {
return err
}
names := []string{name}
if name == "" {
lists, err := prov.Playlists()
if err != nil {
return fmt.Errorf("listing playlists: %w", err)
}
names = names[:0]
for _, pl := range lists {
if pl.Name != "Recently Played" {
names = append(names, pl.Name)
}
}
}
totalMissing := 0
for _, plName := range names {
tracks, err := prov.Tracks(plName)
if err != nil {
return fmt.Errorf("loading playlist %q: %w", plName, err)
}
kept := tracks[:0]
missing := 0
for _, t := range tracks {
if missingLocalFile(t) {
missing++
totalMissing++
fmt.Printf(" [%s] missing: %s\n", plName, t.Path)
if fix {
continue
}
}
kept = append(kept, t)
}
if fix && missing > 0 {
if err := prov.SavePlaylist(plName, kept); err != nil {
return fmt.Errorf("saving playlist %q: %w", plName, err)
}
fmt.Printf("Pruned %d missing tracks from %q.\n", missing, plName)
}
}
if totalMissing == 0 {
fmt.Println("No missing local files found.")
}
return nil
}
// PlaylistExport writes a playlist as M3U or PLS.
func PlaylistExport(name, format, output string) error {
prov, err := newProvider()
if err != nil {
return err
}
tracks, err := prov.Tracks(name)
if err != nil {
return fmt.Errorf("loading playlist %q: %w", name, err)
}
format = strings.ToLower(strings.TrimSpace(format))
if format == "" {
format = "m3u"
}
switch format {
case "m3u", "m3u8", "pls":
default:
return fmt.Errorf("unsupported export format %q (use m3u or pls)", format)
}
var w io.Writer = os.Stdout
var f *os.File
if output != "" {
f, err = os.Create(output)
if err != nil {
return fmt.Errorf("creating %q: %w", output, err)
}
defer f.Close()
w = f
}
switch format {
case "m3u", "m3u8":
writeM3U(w, tracks)
case "pls":
writePLS(w, tracks)
}
if output != "" {
fmt.Printf("Exported %q to %s.\n", name, output)
}
return nil
}
// PlaylistImport converts a local M3U/PLS file into a TOML playlist.
func PlaylistImport(path, name string) error {
prov, err := newProvider()
if err != nil {
return err
}
if name == "" {
base := filepath.Base(path)
name = strings.TrimSuffix(base, filepath.Ext(base))
}
if prov.Exists(name) {
return fmt.Errorf("playlist %q already exists", name)
}
tracks, err := resolve.LocalPlaylist(path)
if err != nil {
return fmt.Errorf("importing %q: %w", path, err)
}
if err := prov.SavePlaylist(name, tracks); err != nil {
return fmt.Errorf("saving playlist %q: %w", name, err)
}
fmt.Printf("Imported %d tracks into %q.\n", len(tracks), name)
return nil
}
// PlaylistBookmark toggles the bookmark flag on a track by index.
func PlaylistBookmark(name string, index int) error {
prov, err := newProvider()
if err != nil {
return err
}
if err := prov.SetBookmark(name, index-1); err != nil {
return fmt.Errorf("toggling bookmark: %w", err)
}
tracks, err := prov.Tracks(name)
if err != nil {
return err
}
if index-1 < 0 || index-1 >= len(tracks) {
return fmt.Errorf("track %d no longer exists in playlist (now has %d tracks)", index, len(tracks))
}
t := tracks[index-1]
if t.Bookmark {
fmt.Printf("★ %s\n", t.DisplayName())
} else {
fmt.Printf("☆ %s\n", t.DisplayName())
}
return nil
}
// PlaylistBookmarks lists all bookmarked tracks across all playlists.
func PlaylistBookmarks() error {
prov, err := newProvider()
if err != nil {
return err
}
lists, err := prov.Playlists()
if err != nil {
return fmt.Errorf("listing playlists: %w", err)
}
total := 0
for _, pl := range lists {
tracks, err := prov.Tracks(pl.Name)
if err != nil {
continue
}
for i, t := range tracks {
if t.Bookmark {
fmt.Printf(" ★ [%s] %d. %s\n", pl.Name, i+1, t.DisplayName())
total++
}
}
}
if total == 0 {
fmt.Println("No bookmarks yet. Press f on a track to bookmark it.")
} else {
fmt.Printf("\n %d bookmarks across %d playlists.\n", total, len(lists))
}
return nil
}
// PlaylistEnrich probes duration and derives album metadata for SSH tracks.
func PlaylistEnrich(name string) error {
prov, err := newProvider()
if err != nil {
return err
}
tracks, err := prov.Tracks(name)
if err != nil {
return fmt.Errorf("loading playlist %q: %w", name, err)
}
updated := 0
for i, t := range tracks {
changed := false
if t.DurationSecs == 0 {
dur := probeDuration(t.Path)
if dur > 0 {
tracks[i].DurationSecs = dur
changed = true
fmt.Fprintf(os.Stderr, " %s: %ds\n", t.DisplayName(), dur)
}
}
if t.Album == "" {
if dir := albumFromPath(t.Path); dir != "" {
tracks[i].Album = dir
changed = true
}
}
if changed {
updated++
}
}
if updated == 0 {
fmt.Println("All tracks already enriched.")
return nil
}
if err := prov.SavePlaylist(name, tracks); err != nil {
return fmt.Errorf("saving playlist %q: %w", name, err)
}
fmt.Printf("Enriched %d tracks in %q.\n", updated, name)
return nil
}
func probeRemoteDuration(host, remotePath string) int {
// Use ffprobe over SSH for cross-platform compatibility (works on Linux and macOS remotes).
probeCmd := fmt.Sprintf("ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 %s 2>/dev/null", shellQuote(remotePath))
cmd := exec.Command("ssh",
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=yes",
"-o", "ConnectTimeout=5",
host, probeCmd,
)
out, err := cmd.Output()
if err != nil {
return 0
}
return parseProbeDuration(out)
}
func probeDuration(path string) int {
if strings.HasPrefix(path, "ssh://") {
parsed, err := sshurl.Parse(path)
if err != nil {
return 0
}
return probeRemoteDuration(parsed.Host, parsed.Path)
}
if playlist.IsURL(path) || path == "" {
return 0
}
cmd := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path)
out, err := cmd.Output()
if err != nil {
return 0
}
return parseProbeDuration(out)
}
func parseProbeDuration(out []byte) int {
s := strings.TrimSpace(string(out))
if s == "" {
return 0
}
var dur float64
fmt.Sscanf(s, "%f", &dur)
if dur <= 0 {
return 0
}
return int(dur)
}
func albumFromPath(path string) string {
if path == "" || playlist.IsURL(path) {
return ""
}
if strings.HasPrefix(path, "ssh://") {
parsed, err := sshurl.Parse(path)
if err != nil {
return ""
}
path = parsed.Path
}
dir := filepath.Base(filepath.Dir(path))
if dir == "." || dir == string(filepath.Separator) {
return ""
}
return dir
}
// collectLocalAudio resolves file/directory paths into audio file paths
// using the canonical supported extensions from the player package.
func collectLocalAudio(paths []string) ([]string, error) {
var all []string
for _, p := range paths {
files, err := resolve.CollectAudioFiles(p)
if err != nil {
return nil, fmt.Errorf("scanning %q: %w", p, err)
}
all = append(all, files...)
}
return all, nil
}
func albumAwareSort(tracks []playlist.Track) {
if len(tracks) < 2 {
return
}
for _, t := range tracks {
if t.Album == "" || t.TrackNumber == 0 {
return
}
}
sort.SliceStable(tracks, func(i, j int) bool {
a, b := tracks[i], tracks[j]
if c := strings.Compare(strings.ToLower(a.Artist), strings.ToLower(b.Artist)); c != 0 {
return c < 0
}
if c := strings.Compare(strings.ToLower(a.Album), strings.ToLower(b.Album)); c != 0 {
return c < 0
}
if a.TrackNumber != b.TrackNumber {
return a.TrackNumber < b.TrackNumber
}
return strings.ToLower(a.Path) < strings.ToLower(b.Path)
})
}
func normalizeSortKey(by string) string {
switch strings.ToLower(strings.TrimSpace(by)) {
case "", "title":
return "title"
case "track", "track#", "track_number", "track-number":
return "track"
case "artist":
return "artist"
case "album":
return "album"
case "artist+album", "artist_album", "artist-album":
return "artist+album"
case "path":
return "path"
default:
return by
}
}
func sortTracks(tracks []playlist.Track, by string) error {
key := normalizeSortKey(by)
switch key {
case "title", "track", "artist", "album", "artist+album", "path":
default:
return fmt.Errorf("unsupported sort key %q (use track, title, artist, album, artist+album, or path)", by)
}
sort.SliceStable(tracks, func(i, j int) bool {
return compareTracks(tracks[i], tracks[j], key) < 0
})
return nil
}
func compareTracks(a, b playlist.Track, key string) int {
cmpString := func(x, y string) int {
return strings.Compare(strings.ToLower(x), strings.ToLower(y))
}
firstNonZero := func(values ...int) int {
for _, v := range values {
if v != 0 {
return v
}
}
return 0
}
switch key {
case "track":
return firstNonZero(a.TrackNumber-b.TrackNumber, cmpString(a.Title, b.Title), cmpString(a.Path, b.Path))
case "artist":
return firstNonZero(cmpString(a.Artist, b.Artist), cmpString(a.Album, b.Album), a.TrackNumber-b.TrackNumber, cmpString(a.Title, b.Title), cmpString(a.Path, b.Path))
case "album":
return firstNonZero(cmpString(a.Album, b.Album), a.TrackNumber-b.TrackNumber, cmpString(a.Title, b.Title), cmpString(a.Path, b.Path))
case "artist+album":
return firstNonZero(cmpString(a.Artist, b.Artist), cmpString(a.Album, b.Album), a.TrackNumber-b.TrackNumber, cmpString(a.Title, b.Title), cmpString(a.Path, b.Path))
case "path":
return cmpString(a.Path, b.Path)
default:
return firstNonZero(cmpString(a.Title, b.Title), cmpString(a.Artist, b.Artist), cmpString(a.Path, b.Path))
}
}
func missingLocalFile(t playlist.Track) bool {
if t.Path == "" || t.Stream || playlist.IsURL(t.Path) || strings.HasPrefix(t.Path, "ssh://") {
return false
}
_, err := os.Stat(t.Path)
return errors.Is(err, os.ErrNotExist)
}
func writeM3U(w io.Writer, tracks []playlist.Track) {
fmt.Fprintln(w, "#EXTM3U")
for _, t := range tracks {
title := t.DisplayName()
if title == "" {
title = t.Path
}
duration := t.DurationSecs
if duration <= 0 {
duration = -1
}
fmt.Fprintf(w, "#EXTINF:%d,%s\n", duration, title)
fmt.Fprintln(w, t.Path)
}
}
func writePLS(w io.Writer, tracks []playlist.Track) {
fmt.Fprintln(w, "[playlist]")
for i, t := range tracks {
n := i + 1
fmt.Fprintf(w, "File%d=%s\n", n, t.Path)
if title := t.DisplayName(); title != "" {
fmt.Fprintf(w, "Title%d=%s\n", n, title)
}
length := t.DurationSecs
if length <= 0 {
length = -1
}
fmt.Fprintf(w, "Length%d=%d\n", n, length)
}
fmt.Fprintf(w, "NumberOfEntries=%d\nVersion=2\n", len(tracks))
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
func sshFindAudio(host string, paths []string) ([]string, error) {
var nameArgs []string
first := true
for ext := range player.SupportedExts {
if !first {
nameArgs = append(nameArgs, "-o")
}
nameArgs = append(nameArgs, "-name", "'*"+ext+"'")
first = false
}
var allFiles []string
for _, p := range paths {
findCmd := fmt.Sprintf("find %s -type f \\( %s \\) | sort",
shellQuote(p), strings.Join(nameArgs, " "))
sshArgs := []string{"-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=5", host, findCmd}
cmd := exec.Command("ssh", sshArgs...)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("ssh find on %s:%s: %w", host, p, err)
}
lines := strings.SplitSeq(strings.TrimSpace(string(out)), "\n")
for line := range lines {
line = strings.TrimSpace(line)
if line != "" {
allFiles = append(allFiles, line)
}
}
}
return allFiles, nil
}
func newProvider() (*local.Provider, error) {
p := local.New()
if p == nil {
return nil, fmt.Errorf("failed to initialize local playlist provider")
}
return p, nil
}
+501
View File
@@ -0,0 +1,501 @@
package cmd
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/bjarneo/cliamp/playlist"
)
// captureStdout runs fn with os.Stdout redirected to a buffer and returns what
// was written.
func captureStdout(t *testing.T, fn func() error) (string, error) {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
old := os.Stdout
os.Stdout = w
done := make(chan struct{})
var buf bytes.Buffer
go func() {
_, _ = io.Copy(&buf, r)
close(done)
}()
runErr := fn()
w.Close()
<-done
os.Stdout = old
return buf.String(), runErr
}
func writeAudioFile(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(path, []byte{}, 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
}
func setupTestEnv(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
return home
}
func TestPlaylistListEmpty(t *testing.T) {
setupTestEnv(t)
out, err := captureStdout(t, PlaylistList)
if err != nil {
t.Fatalf("PlaylistList: %v", err)
}
if !strings.Contains(out, "No playlists") {
t.Errorf("output = %q, want 'No playlists...'", out)
}
}
func TestPlaylistCreateAndList(t *testing.T) {
home := setupTestEnv(t)
audioDir := filepath.Join(home, "music")
writeAudioFile(t, filepath.Join(audioDir, "song1.mp3"))
writeAudioFile(t, filepath.Join(audioDir, "song2.flac"))
// Create
out, err := captureStdout(t, func() error {
return PlaylistCreate("mymix", []string{audioDir}, "")
})
if err != nil {
t.Fatalf("PlaylistCreate: %v", err)
}
if !strings.Contains(out, "Created playlist") {
t.Errorf("create output = %q, want 'Created playlist'", out)
}
// List
out, err = captureStdout(t, PlaylistList)
if err != nil {
t.Fatalf("PlaylistList: %v", err)
}
if !strings.Contains(out, "mymix") {
t.Errorf("list output = %q, want to mention 'mymix'", out)
}
if !strings.Contains(out, "2 tracks") {
t.Errorf("list output = %q, want '2 tracks'", out)
}
}
func TestPlaylistCreateNoAudio(t *testing.T) {
home := setupTestEnv(t)
emptyDir := filepath.Join(home, "empty")
if err := os.MkdirAll(emptyDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
err := PlaylistCreate("nothing", []string{emptyDir}, "")
if err == nil {
t.Fatal("PlaylistCreate with no audio should error")
}
if !strings.Contains(err.Error(), "no audio") {
t.Errorf("error = %q, want to mention 'no audio'", err.Error())
}
}
func TestPlaylistCreateEmpty(t *testing.T) {
setupTestEnv(t)
out, err := captureStdout(t, func() error {
return PlaylistCreate("empty", nil, "")
})
if err != nil {
t.Fatalf("PlaylistCreate empty: %v", err)
}
if !strings.Contains(out, "Created empty playlist") {
t.Fatalf("output = %q, want empty playlist confirmation", out)
}
out, err = captureStdout(t, func() error { return PlaylistShow("empty", false) })
if err != nil {
t.Fatalf("PlaylistShow empty: %v", err)
}
if !strings.Contains(out, "is empty") {
t.Fatalf("show output = %q, want empty message", out)
}
}
func TestPlaylistCreateDuplicate(t *testing.T) {
home := setupTestEnv(t)
audio := filepath.Join(home, "a.mp3")
writeAudioFile(t, audio)
if err := PlaylistCreate("dup", []string{audio}, ""); err != nil {
t.Fatalf("first PlaylistCreate: %v", err)
}
err := PlaylistCreate("dup", []string{audio}, "")
if err == nil {
t.Error("duplicate create should error")
}
if !strings.Contains(err.Error(), "already exists") {
t.Errorf("error = %q, want to mention 'already exists'", err.Error())
}
}
func TestPlaylistAddAppends(t *testing.T) {
home := setupTestEnv(t)
a := filepath.Join(home, "a.mp3")
b := filepath.Join(home, "b.mp3")
writeAudioFile(t, a)
writeAudioFile(t, b)
if err := PlaylistCreate("mix", []string{a}, ""); err != nil {
t.Fatalf("PlaylistCreate: %v", err)
}
if err := PlaylistAdd("mix", []string{b}); err != nil {
t.Fatalf("PlaylistAdd: %v", err)
}
out, _ := captureStdout(t, func() error { return PlaylistShow("mix", false) })
if !strings.Contains(out, "2 tracks") {
t.Errorf("Show output = %q, want '2 tracks' after add", out)
}
}
func TestPlaylistAddSkipsDuplicates(t *testing.T) {
home := setupTestEnv(t)
a := filepath.Join(home, "a.mp3")
writeAudioFile(t, a)
if err := PlaylistCreate("mix", []string{a}, ""); err != nil {
t.Fatalf("PlaylistCreate: %v", err)
}
out, err := captureStdout(t, func() error { return PlaylistAdd("mix", []string{a}) })
if err != nil {
t.Fatalf("PlaylistAdd duplicate: %v", err)
}
if !strings.Contains(out, "0 tracks") || !strings.Contains(out, "1 duplicate skipped") {
t.Fatalf("output = %q, want duplicate skip count", out)
}
}
func TestPlaylistAddNonExistent(t *testing.T) {
home := setupTestEnv(t)
a := filepath.Join(home, "a.mp3")
writeAudioFile(t, a)
err := PlaylistAdd("ghost", []string{a})
if err == nil {
t.Error("PlaylistAdd on non-existent playlist should error")
}
}
func TestPlaylistShowEmpty(t *testing.T) {
setupTestEnv(t)
err := PlaylistShow("ghost", false)
if err == nil {
t.Error("PlaylistShow of missing playlist should error")
}
}
func TestPlaylistShowJSON(t *testing.T) {
home := setupTestEnv(t)
audio := filepath.Join(home, "a.mp3")
writeAudioFile(t, audio)
if err := PlaylistCreate("mix", []string{audio}, ""); err != nil {
t.Fatalf("PlaylistCreate: %v", err)
}
out, err := captureStdout(t, func() error { return PlaylistShow("mix", true) })
if err != nil {
t.Fatalf("PlaylistShow JSON: %v", err)
}
if !strings.HasPrefix(strings.TrimSpace(out), "[") {
t.Errorf("JSON output should start with '[': %s", out)
}
if !strings.Contains(out, "\"path\"") {
t.Errorf("JSON output should contain 'path' key: %s", out)
}
}
func TestPlaylistRemove(t *testing.T) {
home := setupTestEnv(t)
a := filepath.Join(home, "a.mp3")
b := filepath.Join(home, "b.mp3")
writeAudioFile(t, a)
writeAudioFile(t, b)
if err := PlaylistCreate("mix", []string{a, b}, ""); err != nil {
t.Fatalf("PlaylistCreate: %v", err)
}
if err := PlaylistRemove("mix", 1); err != nil {
t.Fatalf("PlaylistRemove: %v", err)
}
out, _ := captureStdout(t, func() error { return PlaylistShow("mix", false) })
if !strings.Contains(out, "1 tracks") {
t.Errorf("output = %q, want '1 tracks' after remove", out)
}
}
func TestPlaylistRemoveOutOfRange(t *testing.T) {
home := setupTestEnv(t)
a := filepath.Join(home, "a.mp3")
writeAudioFile(t, a)
if err := PlaylistCreate("mix", []string{a}, ""); err != nil {
t.Fatalf("PlaylistCreate: %v", err)
}
err := PlaylistRemove("mix", 999)
if err == nil {
t.Error("PlaylistRemove with out-of-range index should error")
}
}
func TestPlaylistDelete(t *testing.T) {
home := setupTestEnv(t)
audio := filepath.Join(home, "a.mp3")
writeAudioFile(t, audio)
if err := PlaylistCreate("todelete", []string{audio}, ""); err != nil {
t.Fatalf("PlaylistCreate: %v", err)
}
if err := PlaylistDelete("todelete"); err != nil {
t.Fatalf("PlaylistDelete: %v", err)
}
out, _ := captureStdout(t, PlaylistList)
if strings.Contains(out, "todelete") {
t.Errorf("after delete, List output still contains 'todelete': %s", out)
}
}
func TestPlaylistDeleteMissing(t *testing.T) {
setupTestEnv(t)
err := PlaylistDelete("ghost")
if err == nil {
t.Error("PlaylistDelete on missing playlist should error")
}
}
func TestPlaylistRename(t *testing.T) {
home := setupTestEnv(t)
a := filepath.Join(home, "a.mp3")
writeAudioFile(t, a)
if err := PlaylistCreate("old", []string{a}, ""); err != nil {
t.Fatalf("PlaylistCreate: %v", err)
}
if err := PlaylistRename("old", "new"); err != nil {
t.Fatalf("PlaylistRename: %v", err)
}
if err := PlaylistShow("new", false); err != nil {
t.Fatalf("renamed playlist missing: %v", err)
}
if err := PlaylistShow("old", false); err == nil {
t.Fatal("old playlist should no longer exist")
}
}
func TestPlaylistDedupeAndSort(t *testing.T) {
home := setupTestEnv(t)
a := filepath.Join(home, "a.mp3")
b := filepath.Join(home, "b.mp3")
writeAudioFile(t, a)
writeAudioFile(t, b)
if err := PlaylistCreate("mix", nil, ""); err != nil {
t.Fatalf("PlaylistCreate empty: %v", err)
}
p, err := newProvider()
if err != nil {
t.Fatal(err)
}
if err := p.SavePlaylist("mix", []playlist.Track{{Path: b, Title: "B"}, {Path: a, Title: "A"}, {Path: a, Title: "A dup"}}); err != nil {
t.Fatalf("SavePlaylist: %v", err)
}
if err := PlaylistDedupe("mix"); err != nil {
t.Fatalf("PlaylistDedupe: %v", err)
}
if err := PlaylistSort("mix", "title"); err != nil {
t.Fatalf("PlaylistSort: %v", err)
}
tracks, err := p.Tracks("mix")
if err != nil {
t.Fatalf("Tracks: %v", err)
}
if len(tracks) != 2 || tracks[0].Title != "A" || tracks[1].Title != "B" {
t.Fatalf("tracks after dedupe/sort = %+v", tracks)
}
}
func TestPlaylistDoctorFixPrunesMissing(t *testing.T) {
home := setupTestEnv(t)
a := filepath.Join(home, "a.mp3")
missing := filepath.Join(home, "missing.mp3")
writeAudioFile(t, a)
if err := PlaylistCreate("mix", nil, ""); err != nil {
t.Fatalf("PlaylistCreate empty: %v", err)
}
p, err := newProvider()
if err != nil {
t.Fatal(err)
}
if err := p.SavePlaylist("mix", []playlist.Track{{Path: a, Title: "A"}, {Path: missing, Title: "Missing"}}); err != nil {
t.Fatalf("SavePlaylist: %v", err)
}
if err := PlaylistDoctor("mix", true); err != nil {
t.Fatalf("PlaylistDoctor: %v", err)
}
tracks, err := p.Tracks("mix")
if err != nil {
t.Fatalf("Tracks: %v", err)
}
if len(tracks) != 1 || tracks[0].Path != a {
t.Fatalf("tracks after doctor = %+v", tracks)
}
}
func TestPlaylistImportExportM3U(t *testing.T) {
home := setupTestEnv(t)
a := filepath.Join(home, "a.mp3")
writeAudioFile(t, a)
m3u := filepath.Join(home, "mix.m3u")
if err := os.WriteFile(m3u, []byte("#EXTM3U\n#EXTINF:12,Song\na.mp3\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := PlaylistImport(m3u, "imported"); err != nil {
t.Fatalf("PlaylistImport: %v", err)
}
out, err := captureStdout(t, func() error { return PlaylistExport("imported", "m3u", "") })
if err != nil {
t.Fatalf("PlaylistExport: %v", err)
}
if !strings.Contains(out, "#EXTM3U") || !strings.Contains(out, a) {
t.Fatalf("export output = %q, want M3U with resolved path", out)
}
}
func TestPlaylistExportInvalidFormatDoesNotTruncateOutput(t *testing.T) {
home := setupTestEnv(t)
p, err := newProvider()
if err != nil {
t.Fatal(err)
}
if err := p.SavePlaylist("mix", []playlist.Track{{Path: "/a.mp3", Title: "A"}}); err != nil {
t.Fatalf("SavePlaylist: %v", err)
}
out := filepath.Join(home, "keep.txt")
if err := os.WriteFile(out, []byte("keep"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := PlaylistExport("mix", "bad", out); err == nil {
t.Fatal("PlaylistExport should reject unsupported format")
}
data, err := os.ReadFile(out)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(data) != "keep" {
t.Fatalf("output file = %q, want keep", string(data))
}
}
func TestPlaylistBookmarkToggle(t *testing.T) {
home := setupTestEnv(t)
audio := filepath.Join(home, "a.mp3")
writeAudioFile(t, audio)
if err := PlaylistCreate("mix", []string{audio}, ""); err != nil {
t.Fatalf("PlaylistCreate: %v", err)
}
// Bookmark on.
out, err := captureStdout(t, func() error { return PlaylistBookmark("mix", 1) })
if err != nil {
t.Fatalf("PlaylistBookmark: %v", err)
}
if !strings.Contains(out, "★") {
t.Errorf("first bookmark output = %q, want '★'", out)
}
// Bookmark off.
out, err = captureStdout(t, func() error { return PlaylistBookmark("mix", 1) })
if err != nil {
t.Fatalf("PlaylistBookmark toggle: %v", err)
}
if !strings.Contains(out, "☆") {
t.Errorf("second bookmark output = %q, want '☆'", out)
}
}
func TestPlaylistBookmarksEmpty(t *testing.T) {
setupTestEnv(t)
out, err := captureStdout(t, PlaylistBookmarks)
if err != nil {
t.Fatalf("PlaylistBookmarks: %v", err)
}
if !strings.Contains(out, "No bookmarks") {
t.Errorf("output = %q, want 'No bookmarks...'", out)
}
}
func TestPlaylistBookmarksShowsStars(t *testing.T) {
home := setupTestEnv(t)
audio := filepath.Join(home, "a.mp3")
writeAudioFile(t, audio)
if err := PlaylistCreate("mix", []string{audio}, ""); err != nil {
t.Fatalf("PlaylistCreate: %v", err)
}
// Bookmark (captureStdout silences the toggle output).
_, _ = captureStdout(t, func() error { return PlaylistBookmark("mix", 1) })
out, err := captureStdout(t, PlaylistBookmarks)
if err != nil {
t.Fatalf("PlaylistBookmarks: %v", err)
}
if !strings.Contains(out, "★") {
t.Errorf("bookmarks output = %q, want '★'", out)
}
}
func TestCollectLocalAudioMultiplePaths(t *testing.T) {
dir := t.TempDir()
a := filepath.Join(dir, "a.mp3")
b := filepath.Join(dir, "sub", "b.flac")
writeAudioFile(t, a)
writeAudioFile(t, b)
got, err := collectLocalAudio([]string{a, b})
if err != nil {
t.Fatalf("collectLocalAudio: %v", err)
}
if len(got) != 2 {
t.Errorf("got %d paths, want 2 — %v", len(got), got)
}
}
func TestCollectLocalAudioMissingPath(t *testing.T) {
_, err := collectLocalAudio([]string{filepath.Join(t.TempDir(), "nope")})
if err == nil {
t.Error("collectLocalAudio with missing path should error")
}
}
func TestNewProvider(t *testing.T) {
setupTestEnv(t)
p, err := newProvider()
if err != nil {
t.Fatalf("newProvider: %v", err)
}
if p == nil {
t.Error("newProvider returned nil provider")
}
}
+23
View File
@@ -0,0 +1,23 @@
package cmd
import "testing"
func TestShellQuote(t *testing.T) {
tests := []struct {
input, want string
}{
{"simple", "'simple'"},
{"with space", "'with space'"},
{"it's", `'it'\''s'`},
{"", "''"},
{"a'b'c", `'a'\''b'\''c'`},
{`back\slash`, `'back\slash'`},
{`"double"`, `'"double"'`},
}
for _, tt := range tests {
got := shellQuote(tt.input)
if got != tt.want {
t.Errorf("shellQuote(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
+1178
View File
File diff suppressed because it is too large Load Diff
+367
View File
@@ -0,0 +1,367 @@
package cmd
import (
"os"
"path/filepath"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
)
// keyPress builds a synthetic key event matching what the runtime sends.
func keyPress(code rune, text string) tea.KeyPressMsg {
return tea.KeyPressMsg(tea.Key{Code: code, Text: text})
}
func TestMenuNavigation(t *testing.T) {
m := newSetupModel()
// Down twice from index 0.
m.handleKey(keyPress(tea.KeyDown, ""))
m.handleKey(keyPress(tea.KeyDown, ""))
if m.menuCursor != 2 {
t.Fatalf("menuCursor = %d, want 2", m.menuCursor)
}
// Up once.
m.handleKey(keyPress(tea.KeyUp, ""))
if m.menuCursor != 1 {
t.Fatalf("after up: menuCursor = %d, want 1", m.menuCursor)
}
// Down past the end clamps.
for i := 0; i < 99; i++ {
m.handleKey(keyPress(tea.KeyDown, ""))
}
if want := len(m.provs) - 1; m.menuCursor != want {
t.Fatalf("clamped menuCursor = %d, want %d", m.menuCursor, want)
}
}
// TestPickerSelectionFiltersFields verifies that picking the Jellyfin
// "API token" option hides the user/password fields and vice versa.
func TestPickerSelectionFiltersFields(t *testing.T) {
m := newSetupModel()
// Find Jellyfin's index.
jfIdx := -1
for i, p := range m.provs {
if p.section == "jellyfin" {
jfIdx = i
break
}
}
if jfIdx < 0 {
t.Fatal("jellyfin spec missing")
}
m.menuCursor = jfIdx
m.handleKey(keyPress(tea.KeyEnter, "")) // open picker
if m.stage != stagePicker {
t.Fatalf("stage = %v, want stagePicker", m.stage)
}
// Pick "API token" (option 0).
m.handleKey(keyPress(tea.KeyEnter, ""))
if m.stage != stageForm {
t.Fatalf("stage = %v, want stageForm", m.stage)
}
// Visible fields should be url + token, not user + password.
visibleKeys := map[string]bool{}
for _, idx := range m.visible {
visibleKeys[m.provs[jfIdx].fields[idx].key] = true
}
if !visibleKeys["url"] || !visibleKeys["token"] {
t.Fatalf("token mode missing url/token; got %v", visibleKeys)
}
if visibleKeys["user"] || visibleKeys["password"] {
t.Fatalf("token mode should hide user/password; got %v", visibleKeys)
}
// Switch back, pick password mode, verify the inverse.
m.stage = stagePicker
m.values = map[string]string{}
m.pickerCursor = 1
m.handleKey(keyPress(tea.KeyEnter, ""))
visibleKeys = map[string]bool{}
for _, idx := range m.visible {
visibleKeys[m.provs[jfIdx].fields[idx].key] = true
}
if !visibleKeys["user"] || !visibleKeys["password"] {
t.Fatalf("password mode missing user/password; got %v", visibleKeys)
}
if visibleKeys["token"] {
t.Fatalf("password mode should hide token; got %v", visibleKeys)
}
}
// TestEmbyPickerSelectionFiltersFields mirrors TestPickerSelectionFiltersFields
// for the Emby provider, which uses the same token/password picker shape.
func TestEmbyPickerSelectionFiltersFields(t *testing.T) {
m := newSetupModel()
embyIdx := -1
for i, p := range m.provs {
if p.section == "emby" {
embyIdx = i
break
}
}
if embyIdx < 0 {
t.Fatal("emby spec missing")
}
tests := []struct {
name string
pickerCursor int
wantVisible []string
wantHidden []string
}{
{"API key", 0, []string{"url", "token", "user"}, []string{"password"}},
{"password", 1, []string{"url", "user", "password"}, []string{"token"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
m.menuCursor = embyIdx
m.stage = stageMenu
m.values = map[string]string{}
m.handleKey(keyPress(tea.KeyEnter, "")) // open picker
if m.stage != stagePicker {
t.Fatalf("stage = %v, want stagePicker", m.stage)
}
m.pickerCursor = tc.pickerCursor
m.handleKey(keyPress(tea.KeyEnter, "")) // select picker option
if m.stage != stageForm {
t.Fatalf("stage = %v, want stageForm", m.stage)
}
visible := map[string]bool{}
for _, idx := range m.visible {
visible[m.provs[embyIdx].fields[idx].key] = true
}
for _, k := range tc.wantVisible {
if !visible[k] {
t.Errorf("field %q not visible; got %v", k, visible)
}
}
for _, k := range tc.wantHidden {
if visible[k] {
t.Errorf("field %q should be hidden; got %v", k, visible)
}
}
})
}
}
// TestRequiredFieldBlocksSubmit ensures pressing Enter on the last field
// without filling required values produces an error result rather than
// silently saving.
func TestRequiredFieldBlocksSubmit(t *testing.T) {
m := newSetupModel()
// Pick Navidrome.
for i, p := range m.provs {
if p.section == "navidrome" {
m.menuCursor = i
break
}
}
m.handleKey(keyPress(tea.KeyEnter, "")) // open form (no picker)
if m.stage != stageForm {
t.Fatalf("stage = %v, want stageForm", m.stage)
}
// Submit immediately with all fields blank.
m.submitForm()
if m.stage != stageResult {
t.Fatalf("stage = %v, want stageResult", m.stage)
}
if m.resultErr == nil || !strings.Contains(m.resultErr.Error(), "required") {
t.Fatalf("resultErr = %v, want a 'required' error", m.resultErr)
}
}
// TestPasteIntoActiveField checks that bracketed-paste content lands in
// the focused field, with newlines stripped (Spotify Client IDs sometimes
// arrive with a trailing newline from the source app).
func TestPasteIntoActiveField(t *testing.T) {
m := newSetupModel()
for i, p := range m.provs {
if p.section == "spotify" {
m.menuCursor = i
break
}
}
m.handleKey(keyPress(tea.KeyEnter, "")) // opens picker (custom is first, default cursor)
if m.stage != stagePicker {
t.Fatalf("stage = %v, want stagePicker", m.stage)
}
m.handleKey(keyPress(tea.KeyEnter, "")) // confirm "custom" → opens form
if m.stage != stageForm {
t.Fatalf("stage = %v, want stageForm after picker", m.stage)
}
m.handlePaste("abc123def\n")
if got := m.values["client_id"]; got != "abc123def" {
t.Fatalf("after paste: client_id = %q, want %q", got, "abc123def")
}
// A second paste appends.
m.handlePaste("XYZ")
if got := m.values["client_id"]; got != "abc123defXYZ" {
t.Fatalf("after second paste: client_id = %q", got)
}
// Pasting outside the form (e.g. on the menu) is a no-op.
m.stage = stageMenu
before := m.values["client_id"]
m.handlePaste("should not land")
if m.values["client_id"] != before {
t.Fatalf("paste leaked across stages: %q", m.values["client_id"])
}
}
func TestNetEaseSetupBody(t *testing.T) {
spec := providerSpec{}
for _, p := range providers() {
if p.section == "netease" {
spec = p
break
}
}
if spec.section == "" {
t.Fatal("netease spec missing")
}
body := spec.body(map[string]string{
keyNetEaseBrowser: "chrome",
"user_id": "42",
})
for _, want := range []string{
"enabled = true",
`cookies_from = "chrome"`,
`user_id = "42"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("body missing %q: %q", want, body)
}
}
}
func TestQobuzSetupBody(t *testing.T) {
spec := providerSpec{}
for _, p := range providers() {
if p.section == "qobuz" {
spec = p
break
}
}
if spec.section == "" {
t.Fatal("qobuz spec missing")
}
// Explicit quality selection.
body := spec.body(map[string]string{keyQobuzQuality: "27"})
for _, want := range []string{"enabled = true", "quality = 27"} {
if !strings.Contains(body, want) {
t.Fatalf("body missing %q: %q", want, body)
}
}
// Default quality when none picked.
if got := spec.body(map[string]string{}); !strings.Contains(got, "quality = 6") {
t.Fatalf("default quality not 6: %q", got)
}
// No live probe (auth happens interactively in the TUI).
if spec.validate != nil {
t.Fatal("qobuz spec should not define a validate probe")
}
}
func TestNetEasePickerSelectionFiltersFields(t *testing.T) {
base := newSetupModel()
neteaseIdx := -1
for i, p := range base.provs {
if p.section == "netease" {
neteaseIdx = i
break
}
}
if neteaseIdx < 0 {
t.Fatal("netease spec missing")
}
tests := []struct {
name string
browser string
wantVisible int
wantKey string
}{
{"chrome hides cookies_from", "chrome", 0, ""},
{"custom shows cookies_from", "custom", 1, "cookies_from"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
m := newSetupModel()
m.pidx = neteaseIdx
m.values = map[string]string{keyNetEaseBrowser: tc.browser}
m.refreshVisibleFields()
if len(m.visible) != tc.wantVisible {
t.Fatalf("visible fields = %d, want %d", len(m.visible), tc.wantVisible)
}
if tc.wantVisible == 1 {
field := m.provs[neteaseIdx].fields[m.visible[0]]
if field.key != tc.wantKey {
t.Fatalf("field = %q, want %q", field.key, tc.wantKey)
}
}
})
}
}
// TestSaveSection covers the three write paths: new file, append, replace.
func TestSaveSection(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
cfg := filepath.Join(dir, ".config", "cliamp", "config.toml")
// 1. New file.
if err := saveSection("plex", "url = \"http://x\"\ntoken = \"t\""); err != nil {
t.Fatalf("first save: %v", err)
}
got, err := os.ReadFile(cfg)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(string(got), "[plex]\n") {
t.Fatalf("new file: %q", got)
}
// 2. Append a new section.
if err := saveSection("ytmusic", "enabled = true"); err != nil {
t.Fatalf("append: %v", err)
}
got, _ = os.ReadFile(cfg)
if !strings.Contains(string(got), "[plex]") || !strings.Contains(string(got), "[ytmusic]") {
t.Fatalf("append: missing one of the sections: %q", got)
}
// 3. Replace the plex section in place.
if err := saveSection("plex", "url = \"http://NEW\"\ntoken = \"t2\""); err != nil {
t.Fatalf("replace: %v", err)
}
got, _ = os.ReadFile(cfg)
s := string(got)
if !strings.Contains(s, "http://NEW") {
t.Fatalf("replace did not write new value: %q", s)
}
if strings.Contains(s, "http://x") {
t.Fatalf("replace left old value: %q", s)
}
// Ytmusic must still be present.
if !strings.Contains(s, "[ytmusic]") {
t.Fatalf("replace clobbered ytmusic: %q", s)
}
}
+12
View File
@@ -0,0 +1,12 @@
package cmd
import (
"os"
"testing"
)
func TestMain(m *testing.M) {
os.Unsetenv("CLIAMP_CONFIG_DIR")
os.Unsetenv("XDG_CONFIG_HOME")
os.Exit(m.Run())
}