Files
wehub-resource-sync ead81af521
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
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

794 lines
19 KiB
Go

// 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
}