diff --git a/config.toml.example b/config.toml.example index e8a161d0..2bfffb20 100644 --- a/config.toml.example +++ b/config.toml.example @@ -13,6 +13,9 @@ shuffle = false # Start with mono output (L+R downmix) mono = false +# Restore last playlist/track/position when reopening with no CLI args +resume_session = true + # Shift+Left/Right seek jump in seconds (6-600) seek_large_step_sec = 30 diff --git a/config/config.go b/config/config.go index bea7a9c1..f63db7ad 100644 --- a/config/config.go +++ b/config/config.go @@ -108,10 +108,10 @@ func (y YouTubeMusicConfig) ResolveCredentials(fallbackFn func() (string, string // Config holds user preferences loaded from the config file. type Config struct { - Volume float64 // dB, range [-30, +6] - EQ [10]float64 // per-band gain in dB, range [-12, +12] - EQPreset string // preset name, or "" for custom - Repeat string // "off", "all", or "one" + Volume float64 // dB, range [-30, +6] + EQ [10]float64 // per-band gain in dB, range [-12, +12] + EQPreset string // preset name, or "" for custom + Repeat string // "off", "all", or "one" Shuffle bool Mono bool SeekStepLarge int // seconds for Shift+Left/Right seek jumps @@ -123,6 +123,7 @@ type Config struct { ResampleQuality int // beep resample quality factor (1–4) BitDepth int // PCM bit depth for FFmpeg output: 16 or 32 Compact bool // compact mode: cap frame width at 80 columns + ResumeSession bool // restore last playlist/index/position on startup when no args Navidrome NavidromeConfig // optional Navidrome/Subsonic server credentials Spotify SpotifyConfig // optional Spotify provider (requires Premium) YouTubeMusic YouTubeMusicConfig // optional YouTube Music provider @@ -136,6 +137,7 @@ func Default() Config { return Config{ Repeat: "off", SeekStepLarge: 30, + ResumeSession: true, SampleRate: 0, BufferMs: 100, ResampleQuality: 4, @@ -271,6 +273,8 @@ func Load() (Config, error) { } case "compact": cfg.Compact = val == "true" + case "resume_session": + cfg.ResumeSession = val == "true" } } } diff --git a/config/flags.go b/config/flags.go index aabdf720..0553db44 100644 --- a/config/flags.go +++ b/config/flags.go @@ -22,6 +22,7 @@ type Overrides struct { BitDepth *int Play *bool Compact *bool + Takeover *bool } // Apply merges non-nil overrides into cfg and clamps the result. @@ -105,6 +106,8 @@ func ParseFlags(args []string) (action string, ov Overrides, positional []string ov.Play = ptrBool(true) case "--compact": ov.Compact = ptrBool(true) + case "--takeover": + ov.Takeover = ptrBool(true) // Key-value flags. case "--provider": diff --git a/docs/configuration.md b/docs/configuration.md index cb3eb17c..bb96d582 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,6 +22,9 @@ shuffle = false # Start with mono output (L+R downmix) mono = false +# Restore last playlist/track/position when reopening with no CLI args +resume_session = true + # Shift+Left/Right seek jump in seconds seek_large_step_sec = 30 diff --git a/internal/instance/lock_nonunix.go b/internal/instance/lock_nonunix.go new file mode 100644 index 00000000..39bbc01f --- /dev/null +++ b/internal/instance/lock_nonunix.go @@ -0,0 +1,11 @@ +//go:build !unix + +package instance + +// Lock is a no-op on non-unix platforms. +type Lock struct{} + +// Acquire is a no-op on non-unix platforms. +func Acquire(takeover bool) (*Lock, error) { return &Lock{}, nil } + +func (l *Lock) Close() {} diff --git a/internal/instance/lock_unix.go b/internal/instance/lock_unix.go new file mode 100644 index 00000000..171429e7 --- /dev/null +++ b/internal/instance/lock_unix.go @@ -0,0 +1,143 @@ +//go:build unix + +package instance + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "cliamp/internal/appdir" +) + +const ( + takeoverPollInterval = 100 * time.Millisecond + takeoverTimeout = 8 * time.Second + takeoverKillAfter = 3 * time.Second +) + +type Lock struct { + f *os.File +} + +// LockedError reports that another cliamp instance currently holds the lock. +type LockedError struct { + PID int +} + +func (e LockedError) Error() string { + if e.PID > 0 { + return fmt.Sprintf("another cliamp instance is running (pid %d). Re-run with --takeover to stop it.", e.PID) + } + return "another cliamp instance is running. Re-run with --takeover to stop it." +} + +func lockFile() (string, error) { + dir, err := appdir.Dir() + if err != nil { + return "", err + } + return filepath.Join(dir, "session.lock"), nil +} + +func readPID(f *os.File) int { + if _, err := f.Seek(0, 0); err != nil { + return 0 + } + data, err := os.ReadFile(f.Name()) + if err != nil { + return 0 + } + pid, _ := strconv.Atoi(strings.TrimSpace(string(data))) + return pid +} + +func writePID(f *os.File, pid int) { + if err := f.Truncate(0); err != nil { + return + } + if _, err := f.Seek(0, 0); err != nil { + return + } + _, _ = fmt.Fprintf(f, "%d\n", pid) +} + +func tryLock(f *os.File) error { + return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) +} + +// Acquire obtains a process lock for cliamp. +// If takeover is true, it sends SIGTERM to the lock holder and retries. +func Acquire(takeover bool) (*Lock, error) { + path, err := lockFile() + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return nil, err + } + lockNow := func() error { + err := tryLock(f) + if err == nil { + writePID(f, os.Getpid()) + return nil + } + if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { + return err + } + return err + } + + if err := lockNow(); err == nil { + return &Lock{f: f}, nil + } else if !errors.Is(err, syscall.EWOULDBLOCK) && !errors.Is(err, syscall.EAGAIN) { + _ = f.Close() + return nil, err + } + + pid := readPID(f) + if !takeover { + _ = f.Close() + return nil, LockedError{PID: pid} + } + + if pid > 0 && pid != os.Getpid() { + if p, err := os.FindProcess(pid); err == nil { + _ = p.Signal(syscall.SIGTERM) + } + } + start := time.Now() + deadline := start.Add(takeoverTimeout) + sentKill := false + for time.Now().Before(deadline) { + time.Sleep(takeoverPollInterval) + if err := lockNow(); err == nil { + return &Lock{f: f}, nil + } + if !sentKill && pid > 0 && time.Since(start) >= takeoverKillAfter { + if p, err := os.FindProcess(pid); err == nil { + _ = p.Signal(syscall.SIGKILL) + } + sentKill = true + } + } + _ = f.Close() + return nil, fmt.Errorf("failed to take over running instance (pid %d)", pid) +} + +func (l *Lock) Close() { + if l == nil || l.f == nil { + return + } + _ = syscall.Flock(int(l.f.Fd()), syscall.LOCK_UN) + _ = l.f.Close() +} diff --git a/internal/instance/lock_unix_test.go b/internal/instance/lock_unix_test.go new file mode 100644 index 00000000..6ab8bdea --- /dev/null +++ b/internal/instance/lock_unix_test.go @@ -0,0 +1,121 @@ +//go:build unix + +package instance + +import ( + "bufio" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" +) + +func TestLockHelperProcess(t *testing.T) { + if os.Getenv("CLIAMP_LOCK_HELPER") != "1" { + return + } + l, err := Acquire(false) + if err != nil { + fmt.Printf("acquire error: %v\n", err) + os.Exit(2) + } + defer l.Close() + fmt.Println("ready") + for { + time.Sleep(5 * time.Second) + } +} + +func TestAcquireAndTakeover(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + cmd := exec.Command(os.Args[0], "-test.run=TestLockHelperProcess") + cmd.Env = append(os.Environ(), + "CLIAMP_LOCK_HELPER=1", + "HOME="+home, + ) + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("StdoutPipe: %v", err) + } + if err := cmd.Start(); err != nil { + t.Fatalf("Start helper: %v", err) + } + defer func() { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + }() + + sc := bufio.NewScanner(stdout) + deadline := time.Now().Add(5 * time.Second) + ready := false + for time.Now().Before(deadline) { + if sc.Scan() && sc.Text() == "ready" { + ready = true + break + } + time.Sleep(20 * time.Millisecond) + } + if !ready { + t.Fatalf("helper did not become ready") + } + + _, err = Acquire(false) + var le LockedError + if err == nil || !errors.As(err, &le) || le.PID <= 0 { + t.Fatalf("Acquire(false) err = %v, want LockedError with pid", err) + } + + l, err := Acquire(true) + if err != nil { + t.Fatalf("Acquire(true): %v", err) + } + defer l.Close() + + waitCh := make(chan error, 1) + go func() { waitCh <- cmd.Wait() }() + select { + case <-time.After(5 * time.Second): + t.Fatalf("helper process still alive after takeover") + case <-waitCh: + } + + // Lock file should contain our own pid now. + data, err := os.ReadFile(filepath.Join(home, ".config", "cliamp", "session.lock")) + if err != nil { + t.Fatalf("Read lock file: %v", err) + } + if string(data) == "" { + t.Fatalf("lock file is empty after takeover") + } +} + +func TestAcquireLockedErrorIncludesPIDFromFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + lockPath := filepath.Join(home, ".config", "cliamp", "session.lock") + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + t.Fatalf("OpenFile: %v", err) + } + defer f.Close() + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + t.Fatalf("Flock: %v", err) + } + _, _ = f.WriteString("12345\n") + + _, err = Acquire(false) + var le LockedError + if !errors.As(err, &le) || le.PID != 12345 { + t.Fatalf("Acquire(false) err = %v, want LockedError{PID:12345}", err) + } +} diff --git a/internal/resume/resume.go b/internal/resume/resume.go index 41678d01..b06e0d98 100644 --- a/internal/resume/resume.go +++ b/internal/resume/resume.go @@ -1,19 +1,33 @@ -// Package resume persists the last-played track and position so playback -// can be resumed on the next launch. +// Package resume persists session playlist + playback metadata so playback +// can be restored on the next launch. package resume import ( "encoding/json" + "fmt" "os" "path/filepath" + "strconv" + "strings" "cliamp/internal/appdir" + "cliamp/internal/tomlutil" + "cliamp/playlist" ) -// State holds enough information to resume a previous playback session. +const ( + resumeStateFileName = "resume.json" + sessionQueueFileName = "session_queue.toml" +) + +// State stores lightweight playback metadata. type State struct { - Path string `json:"path"` - PositionSec int `json:"position_sec"` + CurrentIndex int `json:"current_index"` + PositionSec int `json:"position_sec"` + + // Legacy fields kept for migration from older builds. + Path string `json:"path,omitempty"` + Tracks []playlist.Track `json:"tracks,omitempty"` } func stateFile() (string, error) { @@ -21,21 +35,30 @@ func stateFile() (string, error) { if err != nil { return "", err } - return filepath.Join(dir, "resume.json"), nil + return filepath.Join(dir, resumeStateFileName), nil } -// Save writes the resume state to disk. No-ops for empty path or zero/negative -// position to avoid overwriting a valid resume file with useless data. -// Errors are silently ignored so a failed write never disrupts normal exit. -func Save(path string, positionSec int) { - if path == "" || positionSec <= 0 { - return +func queueFilePath() (string, error) { + dir, err := appdir.Dir() + if err != nil { + return "", err } + return filepath.Join(dir, sessionQueueFileName), nil +} + +// Save writes lightweight playback metadata. +func Save(s State) { f, err := stateFile() if err != nil { return } - data, err := json.Marshal(State{Path: path, PositionSec: positionSec}) + if s.CurrentIndex < 0 { + s.CurrentIndex = 0 + } + if s.PositionSec < 0 { + s.PositionSec = 0 + } + data, err := json.Marshal(s) if err != nil { return } @@ -43,8 +66,54 @@ func Save(path string, positionSec int) { _ = os.WriteFile(f, data, 0o600) } -// Load reads the resume state from disk. Returns a zero State if the file -// does not exist or cannot be parsed. +// SaveQueue writes the current in-memory session queue to disk. +func SaveQueue(tracks []playlist.Track) { + f, err := queueFilePath() + if err != nil { + return + } + if len(tracks) == 0 { + _ = os.Remove(f) + return + } + _ = os.MkdirAll(filepath.Dir(f), 0o755) + out, err := os.Create(f) + if err != nil { + return + } + defer out.Close() + for i, t := range tracks { + if i > 0 { + _, _ = fmt.Fprintln(out) + } + _, _ = fmt.Fprintln(out, "[[track]]") + _, _ = fmt.Fprintf(out, "path = %q\n", t.Path) + _, _ = fmt.Fprintf(out, "title = %q\n", t.Title) + if t.Artist != "" { + _, _ = fmt.Fprintf(out, "artist = %q\n", t.Artist) + } + if t.Album != "" { + _, _ = fmt.Fprintf(out, "album = %q\n", t.Album) + } + if t.Genre != "" { + _, _ = fmt.Fprintf(out, "genre = %q\n", t.Genre) + } + if t.Year != 0 { + _, _ = fmt.Fprintf(out, "year = %d\n", t.Year) + } + if t.TrackNumber != 0 { + _, _ = fmt.Fprintf(out, "track_number = %d\n", t.TrackNumber) + } + } +} + +// SaveSession writes both queue playlist + metadata in one call. +func SaveSession(tracks []playlist.Track, s State) { + SaveQueue(tracks) + Save(s) +} + +// Load reads lightweight playback metadata. func Load() State { f, err := stateFile() if err != nil { @@ -58,5 +127,95 @@ func Load() State { if err := json.Unmarshal(data, &s); err != nil { return State{} } + if s.CurrentIndex < 0 { + s.CurrentIndex = 0 + } + if s.PositionSec < 0 { + s.PositionSec = 0 + } + + // Migrate legacy embedded track list into the session queue file once. + if len(s.Tracks) > 0 { + if tracks := LoadQueue(); len(tracks) == 0 { + SaveQueue(s.Tracks) + } + // If old file had path+position but no index, derive best effort index. + if s.Path != "" && s.CurrentIndex == 0 { + for i, t := range s.Tracks { + if t.Path == s.Path { + s.CurrentIndex = i + break + } + } + } + s.Tracks = nil + s.Path = "" + Save(s) + } return s } + +// LoadQueue reads the current session queue from disk. +func LoadQueue() []playlist.Track { + f, err := queueFilePath() + if err != nil { + return nil + } + data, err := os.ReadFile(f) + if err != nil { + return nil + } + return parseQueueTOML(data) +} + +func parseQueueTOML(data []byte) []playlist.Track { + var tracks []playlist.Track + var current *playlist.Track + for _, rawLine := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if line == "[[track]]" { + if current != nil { + tracks = append(tracks, *current) + } + current = &playlist.Track{} + continue + } + if current == nil { + continue + } + key, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + val = tomlutil.Unquote(strings.TrimSpace(val)) + switch key { + case "path": + current.Path = val + current.Stream = playlist.IsURL(val) + case "title": + current.Title = val + case "artist": + current.Artist = val + case "album": + current.Album = val + case "genre": + current.Genre = val + case "year": + if n, err := strconv.Atoi(val); err == nil { + current.Year = n + } + case "track_number": + if n, err := strconv.Atoi(val); err == nil { + current.TrackNumber = n + } + } + } + if current != nil { + tracks = append(tracks, *current) + } + return tracks +} diff --git a/internal/resume/resume_test.go b/internal/resume/resume_test.go new file mode 100644 index 00000000..b10e5b7e --- /dev/null +++ b/internal/resume/resume_test.go @@ -0,0 +1,120 @@ +package resume + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "cliamp/playlist" +) + +func TestSaveLoadQueueRoundTrip(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + in := []playlist.Track{ + {Path: "/tmp/a.mp3", Title: "A", Artist: "AA", Album: "Alb", Genre: "Pop", Year: 2025, TrackNumber: 1}, + {Path: "https://example.com/stream", Title: "Live"}, + } + SaveQueue(in) + out := LoadQueue() + if len(out) != len(in) { + t.Fatalf("LoadQueue len = %d, want %d", len(out), len(in)) + } + for i := range in { + if out[i].Path != in[i].Path || out[i].Title != in[i].Title || out[i].Artist != in[i].Artist { + t.Fatalf("LoadQueue[%d] = %+v, want %+v", i, out[i], in[i]) + } + } +} + +func TestSaveLoadStateClamp(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + Save(State{CurrentIndex: -5, PositionSec: -9}) + got := Load() + if got.CurrentIndex != 0 || got.PositionSec != 0 { + t.Fatalf("Load() = %+v, want index=0 position=0", got) + } +} + +func TestLoadMigratesLegacyEmbeddedTracks(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, ".config", "cliamp") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + legacy := State{ + Path: "/tmp/second.mp3", + PositionSec: 42, + Tracks: []playlist.Track{ + {Path: "/tmp/first.mp3", Title: "First"}, + {Path: "/tmp/second.mp3", Title: "Second"}, + }, + } + data, err := json.Marshal(legacy) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, resumeStateFileName), data, 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got := Load() + if got.CurrentIndex != 1 || got.PositionSec != 42 { + t.Fatalf("Load() = %+v, want index=1 position=42", got) + } + q := LoadQueue() + if len(q) != 2 || q[1].Path != "/tmp/second.mp3" { + t.Fatalf("LoadQueue() = %+v, want migrated legacy tracks", q) + } + + // Legacy fields should be removed from resume.json after migration. + raw, err := os.ReadFile(filepath.Join(dir, resumeStateFileName)) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + s := string(raw) + if strings.Contains(s, `"tracks"`) || strings.Contains(s, `"path"`) { + t.Fatalf("resume.json still contains legacy fields: %s", s) + } +} + +func TestLoadMigrationDoesNotOverwriteExistingQueue(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, ".config", "cliamp") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + // Existing queue should win. + SaveQueue([]playlist.Track{{Path: "/tmp/existing.mp3", Title: "Existing"}}) + + legacy := State{ + Path: "/tmp/legacy-second.mp3", + PositionSec: 50, + Tracks: []playlist.Track{ + {Path: "/tmp/legacy-first.mp3", Title: "Legacy First"}, + {Path: "/tmp/legacy-second.mp3", Title: "Legacy Second"}, + }, + } + data, err := json.Marshal(legacy) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, resumeStateFileName), data, 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got := Load() + if got.CurrentIndex != 1 || got.PositionSec != 50 { + t.Fatalf("Load() = %+v, want index=1 position=50", got) + } + + q := LoadQueue() + if len(q) != 1 || q[0].Path != "/tmp/existing.mp3" { + t.Fatalf("LoadQueue() = %+v, want existing queue preserved", q) + } +} diff --git a/main.go b/main.go index 1d09e867..1cc87f18 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( "cliamp/external/radio" "cliamp/external/spotify" "cliamp/external/ytmusic" + "cliamp/internal/instance" "cliamp/internal/resume" "cliamp/mpris" "cliamp/player" @@ -29,6 +30,12 @@ import ( var version string func run(overrides config.Overrides, positional []string) error { + lock, err := instance.Acquire(overrides.Takeover != nil && *overrides.Takeover) + if err != nil { + return err + } + defer lock.Close() + cfg, err := config.Load() if err != nil { return fmt.Errorf("config: %w", err) @@ -128,6 +135,17 @@ func run(overrides config.Overrides, positional []string) error { if err != nil { return err } + resumeState := resume.State{} + var sessionTracks []playlist.Track + if cfg.ResumeSession { + resumeState = resume.Load() + sessionTracks = resume.LoadQueue() + } + restoredSession := false + if cfg.ResumeSession && len(positional) == 0 && len(resolved.Tracks) == 0 && len(resolved.Pending) == 0 && len(sessionTracks) > 0 { + resolved.Tracks = append(resolved.Tracks, sessionTracks...) + restoredSession = true + } // Determine default provider key. defaultProvider := cfg.Provider @@ -136,7 +154,7 @@ func run(overrides config.Overrides, positional []string) error { } // No args + radio provider: stream the built-in radio directly. - if len(positional) == 0 && defaultProvider == "radio" { + if len(positional) == 0 && defaultProvider == "radio" && !restoredSession { resolved.Pending = append(resolved.Pending, "https://radio.cliamp.stream/streams.m3u") } @@ -174,12 +192,19 @@ func run(overrides config.Overrides, positional []string) error { cfg.ApplyPlayer(p) cfg.ApplyPlaylist(pl) + if restoredSession { + pl.SetIndex(resumeState.CurrentIndex) + } themes := theme.LoadAll() m := ui.NewModel(p, pl, providers, defaultProvider, localProv, themes, cfg.Navidrome, navClient) + m.SetResumeSessionEnabled(cfg.ResumeSession) m.SetSeekStepLarge(cfg.SeekStepLargeDuration()) m.SetPendingURLs(resolved.Pending) + if restoredSession { + m.SyncPlaylistCursor() + } if len(resolved.Tracks) == 0 && len(resolved.Pending) == 0 { m.StartInProvider() } @@ -200,8 +225,10 @@ func run(overrides config.Overrides, positional []string) error { } // PositionSec == 0 is indistinguishable from "never played"; skip resume. - if rs := resume.Load(); rs.Path != "" && rs.PositionSec > 0 { - m.SetResume(rs.Path, rs.PositionSec) + if cfg.ResumeSession && restoredSession && resumeState.PositionSec > 0 { + if track, idx := pl.Current(); idx >= 0 && track.Path != "" { + m.SetResume(track.Path, resumeState.PositionSec) + } } prog := tea.NewProgram(m, tea.WithAltScreen()) @@ -224,8 +251,16 @@ func run(overrides config.Overrides, positional []string) error { } _ = config.Save("theme", fmt.Sprintf("%q", themeName)) - if path, secs := fm.ResumeState(); path != "" && secs > 0 { - resume.Save(path, secs) + if cfg.ResumeSession { + tracks, idx := fm.SessionState() + if len(tracks) == 0 { + tracks, idx = fm.CurrentSessionState() + } + _, secs := fm.ResumeState() + resume.SaveSession(tracks, resume.State{ + PositionSec: secs, + CurrentIndex: idx, + }) } } @@ -261,6 +296,7 @@ Appearance: General: -h, --help Show this help message -v, --version Show the current version + --takeover Stop an existing cliamp instance and take over the session lock --upgrade Upgrade cliamp to the latest release Examples: diff --git a/ui/keys.go b/ui/keys.go index 9a3a82e0..41eff92f 100644 --- a/ui/keys.go +++ b/ui/keys.go @@ -17,18 +17,26 @@ import ( // quit shuts down the player and signals the TUI to exit. func (m *Model) quit() tea.Cmd { - // Only save resume for seekable tracks: - // - local files (not stream) - // - HTTP streams with known duration (podcast MP3s, seek-by-reconnect) - // Exclude YTDL (position unreliable) and real-time live streams. + // Snapshot session state for startup restore. + tracks := m.playlist.Tracks() + m.exitSession.tracks = append([]playlist.Track(nil), tracks...) + m.exitSession.index = m.playlist.Index() + + // Only save resume for tracks we can seek back into: + // - local files + // - seekable HTTP streams (known duration) + // - yt-dlp tracks (seek-by-restart) + // Exclude real-time live streams. if track, _ := m.playlist.Current(); track.Path != "" && - !playlist.IsYTDL(track.Path) && !track.IsLive() && + !track.IsLive() && + (m.player.Seekable() || m.player.IsYTDLSeek()) && m.player.IsPlaying() { if secs := int(m.player.Position().Seconds()); secs > 0 { m.exitResume.path = track.Path m.exitResume.secs = secs } } + m.saveSession(true) m.player.Close() m.quitting = true @@ -290,6 +298,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { } m.scrobbleCurrent() m.playlist.SetIndex(m.plCursor) + m.markSessionDirty() cmd := m.playCurrentTrack() m.notifyMPRIS() return cmd @@ -652,6 +661,7 @@ func (m *Model) handleSearchKey(msg tea.KeyMsg) tea.Cmd { if len(m.search.results) > 0 { idx := m.search.results[m.search.cursor] m.playlist.SetIndex(idx) + m.markSessionDirty() m.plCursor = idx m.adjustScroll() cmd = m.playCurrentTrack() @@ -862,8 +872,10 @@ func (m *Model) handlePlMgrTracksKey(msg tea.KeyMsg) tea.Cmd { m.player.ClearPreload() m.resetYTDLBatch() m.playlist.Replace(m.plManager.tracks) + m.markSessionDirty() m.plCursor = 0 m.playlist.SetIndex(0) + m.markSessionDirty() m.adjustScroll() m.plManager.visible = false m.focus = focusPlaylist diff --git a/ui/keys_nav.go b/ui/keys_nav.go index a9a9d057..1e914781 100644 --- a/ui/keys_nav.go +++ b/ui/keys_nav.go @@ -312,8 +312,10 @@ func (m *Model) handleNavTrackListKey(msg tea.KeyMsg) tea.Cmd { } m.playlist.Add(toAdd...) + m.markSessionDirty() newIdx := m.playlist.Len() - len(toAdd) m.playlist.SetIndex(newIdx) + m.markSessionDirty() m.plCursor = newIdx m.adjustScroll() if len(toAdd) > 1 { @@ -342,9 +344,11 @@ func (m *Model) handleNavTrackListKey(msg tea.KeyMsg) tea.Cmd { m.player.ClearPreload() m.resetYTDLBatch() m.playlist.Replace(tracks) + m.markSessionDirty() m.plCursor = 0 m.plScroll = 0 m.playlist.SetIndex(0) + m.markSessionDirty() m.focus = focusPlaylist m.navBrowser.visible = false cmd := m.playCurrentTrack() @@ -364,10 +368,12 @@ func (m *Model) handleNavTrackListKey(msg tea.KeyMsg) tea.Cmd { if len(tracks) > 0 { wasEmpty := m.playlist.Len() == 0 m.playlist.Add(tracks...) + m.markSessionDirty() m.status.text = fmt.Sprintf("Added %d tracks", len(tracks)) m.status.ttl = 80 if wasEmpty || !m.player.IsPlaying() { m.playlist.SetIndex(0) + m.markSessionDirty() cmd := m.playCurrentTrack() m.notifyMPRIS() return cmd @@ -385,12 +391,14 @@ func (m *Model) handleNavTrackListKey(msg tea.KeyMsg) tea.Cmd { if rawIdx < len(m.navBrowser.tracks) { t := m.navBrowser.tracks[rawIdx] m.playlist.Add(t) + m.markSessionDirty() newIdx := m.playlist.Len() - 1 m.playlist.Queue(newIdx) m.status.text = fmt.Sprintf("Queued: %s", t.DisplayName()) m.status.ttl = 80 if !m.player.IsPlaying() { m.playlist.Next() + m.markSessionDirty() cmd := m.playCurrentTrack() m.notifyMPRIS() return cmd diff --git a/ui/model.go b/ui/model.go index f607c583..7abe3868 100644 --- a/ui/model.go +++ b/ui/model.go @@ -13,6 +13,7 @@ import ( "cliamp/config" "cliamp/external/local" "cliamp/external/navidrome" + "cliamp/internal/resume" "cliamp/mpris" "cliamp/player" "cliamp/playlist" @@ -172,10 +173,21 @@ type Model struct { path string secs int } + exitSession struct { + tracks []playlist.Track + index int + } // preloading is true while a preloadStreamCmd goroutine is in-flight. preloading bool + sessionSave struct { + enabled bool + dirty bool + dirtyAt time.Time + lastPosSave time.Time + } + // Live stream title from ICY metadata (e.g., "Artist - Song") streamTitle string @@ -253,6 +265,9 @@ func (m *Model) SetAutoPlay(v bool) { m.autoPlay = v } // SetCompact enables compact mode which caps the frame width at 80 columns. func (m *Model) SetCompact(v bool) { m.compact = v } +// SetResumeSessionEnabled enables periodic session autosave. +func (m *Model) SetResumeSessionEnabled(v bool) { m.sessionSave.enabled = v } + // SetSeekStepLarge configures the Shift+Left/Right seek jump amount. func (m *Model) SetSeekStepLarge(d time.Duration) { switch { @@ -307,6 +322,74 @@ func (m Model) ResumeState() (path string, secs int) { return m.exitResume.path, m.exitResume.secs } +// SessionState returns the playlist/session state captured at exit. +func (m Model) SessionState() (tracks []playlist.Track, index int) { + return m.exitSession.tracks, m.exitSession.index +} + +// CurrentSessionState returns session state from the active playlist model. +// This is used as a fallback when quit() wasn't the shutdown path. +func (m Model) CurrentSessionState() (tracks []playlist.Track, index int) { + src := m.playlist.Tracks() + tracks = append([]playlist.Track(nil), src...) + return tracks, m.playlist.Index() +} + +func (m *Model) markSessionDirty() { + if !m.sessionSave.enabled { + return + } + m.sessionSave.dirty = true + m.sessionSave.dirtyAt = time.Now() +} + +func (m Model) resumePositionSec() int { + track, idx := m.playlist.Current() + if idx < 0 || track.Path == "" || track.IsLive() { + return 0 + } + if !m.player.IsPlaying() || (!m.player.Seekable() && !m.player.IsYTDLSeek()) { + return 0 + } + if secs := int(m.player.Position().Seconds()); secs > 0 { + return secs + } + return 0 +} + +func (m Model) saveSession(includePlaylist bool) { + if !m.sessionSave.enabled { + return + } + tracks, idx := m.CurrentSessionState() + state := resume.State{ + CurrentIndex: idx, + PositionSec: m.resumePositionSec(), + } + if includePlaylist { + resume.SaveSession(tracks, state) + return + } + resume.Save(state) +} + +func (m *Model) autosaveSessionTick() { + if !m.sessionSave.enabled { + return + } + now := time.Now() + if m.sessionSave.dirty && now.Sub(m.sessionSave.dirtyAt) >= 800*time.Millisecond { + m.saveSession(true) + m.sessionSave.dirty = false + m.sessionSave.lastPosSave = now + return + } + if now.Sub(m.sessionSave.lastPosSave) >= 15*time.Second { + m.saveSession(false) + m.sessionSave.lastPosSave = now + } +} + // ThemeName returns the current theme name. func (m Model) ThemeName() string { if m.themeIdx < 0 || m.themeIdx >= len(m.themes) { @@ -439,6 +522,20 @@ func (m *Model) SetPendingURLs(urls []string) { m.feedLoading = len(urls) > 0 } +// SyncPlaylistCursor aligns the UI cursor with the current playlist index. +func (m *Model) SyncPlaylistCursor() { + if m.playlist.Len() == 0 { + m.plCursor = 0 + m.plScroll = 0 + return + } + m.plCursor = m.playlist.Index() + if m.plCursor < 0 { + m.plCursor = 0 + } + m.adjustScroll() +} + // SetEQPreset sets the preset index by name. Returns true if found. func (m *Model) SetEQPreset(name string) bool { for i, p := range eqPresets { @@ -646,6 +743,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tickMsg: + m.autosaveSessionTick() // Cache expensive player state once per tick so View() render // functions don't re-acquire speaker.Lock() multiple times. if !m.buffering { @@ -753,6 +851,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.maybeScrobble(finishedTrack, fullDur, fullDur) m.playlist.Next() + m.markSessionDirty() m.plCursor = m.playlist.Index() m.adjustScroll() m.titleOff = 0 @@ -826,6 +925,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.resetYTDLBatch() m.playlist.Replace(msg) + m.markSessionDirty() m.plCursor = 0 m.plScroll = 0 m.focus = focusPlaylist @@ -891,6 +991,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.playlist.Add(msg.tracks...) + m.markSessionDirty() m.ytdlBatch.offset += len(msg.tracks) if len(msg.tracks) < ytdlBatchSize { m.ytdlBatch.done = true @@ -904,6 +1005,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.feedLoading = false if len(msg.tracks) > 0 { m.playlist.Add(msg.tracks...) + m.markSessionDirty() m.status.text = fmt.Sprintf("Loaded %d track(s)", len(msg.tracks)) m.status.ttl = 60 // Set up incremental loading for YouTube Radio playlists. @@ -931,6 +1033,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(msg) > 0 { startIdx := m.playlist.Len() m.playlist.Add(msg...) + m.markSessionDirty() for i := startIdx; i < m.playlist.Len(); i++ { m.playlist.Queue(i) } @@ -968,10 +1071,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.player.ClearPreload() m.resetYTDLBatch() m.playlist.Replace(msg.tracks) + m.markSessionDirty() m.plCursor = 0 m.plScroll = 0 } else { m.playlist.Add(msg.tracks...) + m.markSessionDirty() } m.focus = focusPlaylist m.status.text = fmt.Sprintf("Added %d track(s)", len(msg.tracks)) @@ -979,6 +1084,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.player.IsPlaying() && m.playlist.Len() > 0 { if msg.replace { m.playlist.SetIndex(0) + m.markSessionDirty() } cmd := m.playCurrentTrack() m.notifyMPRIS() @@ -1020,6 +1126,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Update the track with the downloaded local file and metadata. m.playlist.SetTrack(msg.index, msg.track) + m.markSessionDirty() // Play the local file (seekable). cmd := m.playTrack(msg.track) m.notifyMPRIS() @@ -1115,6 +1222,7 @@ func (m *Model) nextTrack() tea.Cmd { m.player.Stop() return nil } + m.markSessionDirty() m.plCursor = m.playlist.Index() m.adjustScroll() return m.playTrack(track) @@ -1139,6 +1247,7 @@ func (m *Model) prevTrack() tea.Cmd { if !ok { return nil } + m.markSessionDirty() m.plCursor = m.playlist.Index() m.adjustScroll() return m.playTrack(track) @@ -1217,9 +1326,10 @@ func (m *Model) applyResume() { if track.Path != m.resume.path { return } - // Only seek if the player reports the stream is seekable; otherwise the - // seek is a no-op that returns nil, which we must not mistake for success. - if !m.player.Seekable() { + // Allow resume when: + // - the current source is seekable, or + // - yt-dlp seek-by-restart is available. + if !m.player.Seekable() && !m.player.IsYTDLSeek() { return } target := time.Duration(m.resume.secs) * time.Second