chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user