794 lines
19 KiB
Go
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
|
|
}
|