From 587bf375760b0a72f9a60b67ead24811f26a995b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bjarne=20=C3=98verli?= Date: Wed, 6 May 2026 22:29:37 +0200 Subject: [PATCH] Add Recently Played history backed by a virtual local playlist --- cmd/history.go | 97 ++++++++++ commands.go | 27 +++ docs/cli.md | 13 ++ docs/history.md | 55 ++++++ external/local/provider.go | 91 +++++++++- external/local/provider_test.go | 103 +++++++++++ history/history.go | 310 ++++++++++++++++++++++++++++++++ history/history_test.go | 223 +++++++++++++++++++++++ site/index.html | 7 + ui/model/init.go | 2 + ui/model/model.go | 4 + ui/model/notifications.go | 32 ++-- 12 files changed, 946 insertions(+), 18 deletions(-) create mode 100644 cmd/history.go create mode 100644 docs/history.md create mode 100644 history/history.go create mode 100644 history/history_test.go diff --git a/cmd/history.go b/cmd/history.go new file mode 100644 index 0000000..52fb286 --- /dev/null +++ b/cmd/history.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "time" + + "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") +} diff --git a/commands.go b/commands.go index 9759f5f..374e641 100644 --- a/commands.go +++ b/commands.go @@ -65,6 +65,7 @@ func buildApp() *cli.Command { upgradeCommand(), pluginsCommand(), playlistCommand(), + historyCommand(), setupCommand(), spotifyCommand(), ipcSimpleCommand("play", "resume playback"), @@ -453,6 +454,32 @@ func playlistCommand() *cli.Command { } } +func historyCommand() *cli.Command { + return &cli.Command{ + Name: "history", + Usage: "show recently played tracks", + Description: "Lists tracks that have been played past the scrobble threshold.\n" + + "Browse the same data inside the TUI under Local Playlists →\n" + + "\"Recently Played\".", + Flags: []cli.Flag{ + &cli.IntFlag{Name: "limit", Usage: "max entries to show (0 = all)", Value: 50}, + &cli.BoolFlag{Name: "json", Usage: "machine-readable JSON output"}, + }, + Action: func(ctx context.Context, c *cli.Command) error { + return cmd.HistoryShow(int(c.Int("limit")), c.Bool("json")) + }, + Commands: []*cli.Command{ + { + Name: "clear", + Usage: "delete the history file", + Action: func(ctx context.Context, c *cli.Command) error { + return cmd.HistoryClear() + }, + }, + }, + } +} + // ipcSimpleCommand creates a fire-and-forget IPC command (play, pause, etc.). func ipcSimpleCommand(name, usage string) *cli.Command { return &cli.Command{ diff --git a/docs/cli.md b/docs/cli.md index 42803e6..6e14402 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -123,6 +123,19 @@ cliamp playlist delete "Name" # delete entire playlist See [playlists.md](playlists.md) for the TOML format and [ssh-streaming.md](ssh-streaming.md) for remote playback. +## Recently Played + +```sh +cliamp history # show the 50 most recent plays +cliamp history --limit 200 # change the cap +cliamp history --json # machine-readable output +cliamp history clear # wipe ~/.config/cliamp/history.toml +``` + +A play is recorded once you've listened to a track for at least 50% of its +duration. Inside the TUI, the same data appears as the virtual "Recently +Played" entry in the Local Playlists provider. See [history.md](history.md). + ## Spotify ```sh diff --git a/docs/history.md b/docs/history.md new file mode 100644 index 0000000..63e5a41 --- /dev/null +++ b/docs/history.md @@ -0,0 +1,55 @@ +# Recently Played + +cliamp keeps a local listening history in `~/.config/cliamp/history.toml`. A +play is recorded once you've listened to a track for at least 50% of its +duration — the same threshold Last.fm and the Navidrome scrobbler use, so +skipped tracks never enter the list. + +## Browsing in the TUI + +Open the **Local Playlists** provider. When at least one play has been +recorded, a virtual `Recently Played` entry appears at the top of the list. +Open it like any other playlist — the tracks are listed newest-first. The list +is read-only: bookmarking, removing tracks, or deleting the playlist itself is +rejected with a clear error. + +To clear the list, run `cliamp history clear` (see below). + +## CLI + +```sh +cliamp history # show the 50 most recent plays +cliamp history --limit 200 # show the 200 most recent +cliamp history --limit 0 # show all (capped at 200 entries on disk) +cliamp history --json # machine-readable output +cliamp history clear # wipe the history file +``` + +The relative timestamp (`3m ago`, `yesterday`, …) is local time. The JSON +output uses `played_at` in RFC 3339 UTC for portability. + +## File format + +`history.toml` uses the same minimal TOML dialect as cliamp's local playlists: + +```toml +[[entry]] +played_at = "2026-05-06T22:09:11Z" +path = "/home/me/Music/AC-DC/Highway to Hell.flac" +title = "Highway to Hell" +artist = "AC/DC" +album = "Highway to Hell" +year = 1979 +duration_secs = 208 +``` + +Entries cap at 200 by default; older plays roll off FIFO. Consecutive replays +of the same track within 5 minutes update the existing top entry's timestamp +rather than duplicating it. + +## What is not recorded + +- Tracks you skipped before the 50% threshold. +- Live streams without a known duration (radio stations, ICY streams) — there + is no "halfway through" to detect. +- Tracks with empty paths (defensive guard). diff --git a/external/local/provider.go b/external/local/provider.go index f47ee03..c314914 100644 --- a/external/local/provider.go +++ b/external/local/provider.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" + "cliamp/history" "cliamp/internal/appdir" "cliamp/internal/tomlutil" "cliamp/playlist" @@ -29,7 +30,8 @@ var ( // Provider reads and writes TOML-based playlists stored on disk. type Provider struct { - dir string // e.g. ~/.config/cliamp/playlists/ + dir string // e.g. ~/.config/cliamp/playlists/ + history *history.Store } // New creates a Provider using ~/.config/cliamp/playlists/ as the base directory. @@ -38,7 +40,10 @@ func New() *Provider { if err != nil { return nil } - return &Provider{dir: filepath.Join(dir, "playlists")} + return &Provider{ + dir: filepath.Join(dir, "playlists"), + history: history.New(), + } } func (p *Provider) Name() string { return "Local Playlists" } @@ -57,18 +62,27 @@ func (p *Provider) safePath(name string) (string, error) { return resolved, nil } -// Playlists scans the directory for .toml files and returns their metadata. -// Returns an empty list (not error) when the directory doesn't exist. +func isHistoryName(name string) bool { + return name == history.PlaylistName +} + +// Playlists scans the directory for .toml files and returns their metadata, +// prepending the virtual "Recently Played" entry when the user has any +// recorded plays. Returns an empty list (not error) when neither exists. func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) { + var lists []playlist.PlaylistInfo + if info, ok := p.historyInfo(); ok { + lists = append(lists, info) + } + entries, err := os.ReadDir(p.dir) if errors.Is(err, fs.ErrNotExist) { - return nil, nil + return lists, nil } if err != nil { return nil, err } - var lists []playlist.PlaylistInfo for _, e := range entries { if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".toml") { continue @@ -88,8 +102,33 @@ func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) { return lists, nil } +// historyInfo returns the synthetic PlaylistInfo entry for "Recently Played", +// or ok=false when the history store is unavailable or empty. +func (p *Provider) historyInfo() (playlist.PlaylistInfo, bool) { + if p.history == nil { + return playlist.PlaylistInfo{}, false + } + tracks, err := p.history.Tracks(0) + if err != nil || len(tracks) == 0 { + return playlist.PlaylistInfo{}, false + } + return playlist.PlaylistInfo{ + ID: history.PlaylistName, + Name: history.PlaylistName, + TrackCount: len(tracks), + DurationSecs: playlist.TotalDurationSecs(tracks), + }, true +} + // Tracks parses the TOML file for the given playlist name and returns its tracks. +// The reserved "Recently Played" name is served from the history store. func (p *Provider) Tracks(playlistID string) ([]playlist.Track, error) { + if isHistoryName(playlistID) { + if p.history == nil { + return nil, nil + } + return p.history.Tracks(0) + } path, err := p.safePath(playlistID) if err != nil { return nil, err @@ -100,6 +139,9 @@ func (p *Provider) Tracks(playlistID string) ([]playlist.Track, error) { // AddTrack appends a track to the named playlist, creating the directory and // file if needed. func (p *Provider) AddTrack(playlistName string, track playlist.Track) error { + if isHistoryName(playlistName) { + return errReservedHistoryName + } if err := os.MkdirAll(p.dir, 0o755); err != nil { return err } @@ -125,6 +167,9 @@ func (p *Provider) AddTrack(playlistName string, track playlist.Track) error { // AddTracks appends multiple tracks in a single file open/close cycle. func (p *Provider) AddTracks(playlistName string, tracks []playlist.Track) error { + if isHistoryName(playlistName) { + return errReservedHistoryName + } if err := os.MkdirAll(p.dir, 0o755); err != nil { return err } @@ -153,8 +198,14 @@ func (p *Provider) AddTracks(playlistName string, tracks []playlist.Track) error return nil } -// Exists reports whether a playlist with the given name exists on disk. +// Exists reports whether a playlist with the given name exists on disk, or +// whether it refers to the virtual "Recently Played" history with at least +// one entry recorded. func (p *Provider) Exists(name string) bool { + if isHistoryName(name) { + _, ok := p.historyInfo() + return ok + } path, err := p.safePath(name) if err != nil { return false @@ -194,8 +245,15 @@ func (p *Provider) savePlaylist(name string, tracks []playlist.Track) error { return os.Rename(tmp, path) } +// errReservedHistoryName is returned when a caller tries to write to or +// otherwise mutate the synthetic history playlist. +var errReservedHistoryName = errors.New(`"Recently Played" is a virtual history playlist and cannot be modified`) + // SetBookmark toggles the bookmark flag on a track and rewrites the playlist. func (p *Provider) SetBookmark(playlistName string, idx int) error { + if isHistoryName(playlistName) { + return errReservedHistoryName + } tracks, err := p.loadTOMLByName(playlistName) if err != nil { return err @@ -218,6 +276,9 @@ func (p *Provider) loadTOMLByName(name string) ([]playlist.Track, error) { // SavePlaylist overwrites a playlist with the given tracks. func (p *Provider) SavePlaylist(name string, tracks []playlist.Track) error { + if isHistoryName(name) { + return errReservedHistoryName + } return p.savePlaylist(name, tracks) } @@ -285,7 +346,11 @@ func trackMatches(t playlist.Track, lowerQuery string) bool { } // DeletePlaylist removes the TOML file for the named playlist. +// "Recently Played" cannot be deleted via this method — use ClearHistory. func (p *Provider) DeletePlaylist(name string) error { + if isHistoryName(name) { + return errReservedHistoryName + } path, err := p.safePath(name) if err != nil { return err @@ -293,9 +358,21 @@ func (p *Provider) DeletePlaylist(name string) error { return os.Remove(path) } +// ClearHistory wipes the recorded play history. Returns nil if no history +// exists yet. +func (p *Provider) ClearHistory() error { + if p.history == nil { + return nil + } + return p.history.Clear() +} + // RemoveTrack removes a track by index from the named playlist. // If the playlist becomes empty after removal, the file is deleted. func (p *Provider) RemoveTrack(name string, index int) error { + if isHistoryName(name) { + return errReservedHistoryName + } tracks, err := p.Tracks(name) if err != nil { return err diff --git a/external/local/provider_test.go b/external/local/provider_test.go index 5c3536f..38fbba0 100644 --- a/external/local/provider_test.go +++ b/external/local/provider_test.go @@ -6,7 +6,9 @@ import ( "path/filepath" "strings" "testing" + "time" + "cliamp/history" "cliamp/playlist" ) @@ -402,3 +404,104 @@ title = "Live Radio" t.Fatal("URL path should set Stream=true") } } + +// --- Virtual "Recently Played" history playlist --- + +func newTestProviderWithHistory(t *testing.T) *Provider { + t.Helper() + dir := t.TempDir() + historyPath := filepath.Join(dir, "history.toml") + return &Provider{dir: filepath.Join(dir, "playlists"), history: history.NewAt(historyPath)} +} + +func TestPlaylistsIncludesHistoryWhenNonEmpty(t *testing.T) { + p := newTestProviderWithHistory(t) + if err := p.history.Record(playlist.Track{Path: "/a.mp3", Title: "A"}, time.Now()); err != nil { + t.Fatalf("Record: %v", err) + } + lists, err := p.Playlists() + if err != nil { + t.Fatalf("Playlists: %v", err) + } + if len(lists) == 0 || lists[0].Name != history.PlaylistName { + t.Fatalf("expected first playlist to be %q, got %+v", history.PlaylistName, lists) + } + if lists[0].TrackCount != 1 { + t.Errorf("history TrackCount = %d, want 1", lists[0].TrackCount) + } +} + +func TestPlaylistsOmitsHistoryWhenEmpty(t *testing.T) { + p := newTestProviderWithHistory(t) + lists, err := p.Playlists() + if err != nil { + t.Fatalf("Playlists: %v", err) + } + for _, pl := range lists { + if pl.Name == history.PlaylistName { + t.Fatalf("history entry should not appear when empty") + } + } +} + +func TestTracksReadsFromHistory(t *testing.T) { + p := newTestProviderWithHistory(t) + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + p.history.Record(playlist.Track{Path: "/a.mp3", Title: "A"}, base) + p.history.Record(playlist.Track{Path: "/b.mp3", Title: "B"}, base.Add(time.Hour)) + + tracks, err := p.Tracks(history.PlaylistName) + if err != nil { + t.Fatalf("Tracks: %v", err) + } + if len(tracks) != 2 || tracks[0].Title != "B" || tracks[1].Title != "A" { + t.Fatalf("history tracks order wrong: %+v", tracks) + } +} + +func TestWritesRejectedForHistoryName(t *testing.T) { + p := newTestProviderWithHistory(t) + track := playlist.Track{Path: "/a.mp3", Title: "A"} + + tests := []struct { + name string + call func() error + }{ + {"AddTrack", func() error { return p.AddTrack(history.PlaylistName, track) }}, + {"AddTracks", func() error { return p.AddTracks(history.PlaylistName, []playlist.Track{track}) }}, + {"SavePlaylist", func() error { return p.SavePlaylist(history.PlaylistName, []playlist.Track{track}) }}, + {"DeletePlaylist", func() error { return p.DeletePlaylist(history.PlaylistName) }}, + {"RemoveTrack", func() error { return p.RemoveTrack(history.PlaylistName, 0) }}, + {"SetBookmark", func() error { return p.SetBookmark(history.PlaylistName, 0) }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.call(); err == nil { + t.Errorf("%s should reject history name", tt.name) + } + }) + } +} + +func TestExistsForHistoryName(t *testing.T) { + p := newTestProviderWithHistory(t) + if p.Exists(history.PlaylistName) { + t.Error("Exists should be false when history is empty") + } + p.history.Record(playlist.Track{Path: "/a.mp3"}, time.Now()) + if !p.Exists(history.PlaylistName) { + t.Error("Exists should be true once a play is recorded") + } +} + +func TestClearHistoryRemovesEntries(t *testing.T) { + p := newTestProviderWithHistory(t) + p.history.Record(playlist.Track{Path: "/a.mp3"}, time.Now()) + if err := p.ClearHistory(); err != nil { + t.Fatalf("ClearHistory: %v", err) + } + if got, _ := p.history.Recent(0); len(got) != 0 { + t.Errorf("history not cleared: %d entries remain", len(got)) + } +} diff --git a/history/history.go b/history/history.go new file mode 100644 index 0000000..2ecb82d --- /dev/null +++ b/history/history.go @@ -0,0 +1,310 @@ +// Package history persists the user's recently played tracks to a TOML file +// in the cliamp config directory. Entries are recorded when a track has been +// played past the scrobble threshold (the same heuristic Last.fm and the +// Navidrome scrobbler use) so skipped tracks never enter the list. +// +// The store is safe for concurrent callers and writes atomically (temp file + +// rename) so a crash mid-write cannot leave a half-finished history.toml. +package history + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "time" + + "cliamp/internal/appdir" + "cliamp/internal/tomlutil" + "cliamp/playlist" +) + +// DefaultCap is the maximum number of entries kept on disk. Older entries are +// dropped FIFO once the cap is exceeded. +const DefaultCap = 200 + +// dedupWindow is how recently the previous entry must have been recorded for +// a same-path play to be treated as a replay (timestamp updated, no new row) +// rather than a fresh listening event. This filters out cases where a user +// scrubs back to the start of a track that's already 50% played. +const dedupWindow = 5 * time.Minute + +// PlaylistName is the virtual playlist name surfaced to the UI by the local +// provider. Browsing this name returns history entries newest-first. +const PlaylistName = "Recently Played" + +// Entry pairs a track with the wall-clock time it was played past threshold. +type Entry struct { + Track playlist.Track + PlayedAt time.Time +} + +// Store reads and writes the history TOML file. +type Store struct { + path string + cap int + + mu sync.Mutex +} + +// New returns a Store backed by ~/.config/cliamp/history.toml. Returns nil if +// the config directory cannot be resolved (rare; same failure mode as the +// local playlist provider). +func New() *Store { + dir, err := appdir.Dir() + if err != nil { + return nil + } + return &Store{path: filepath.Join(dir, "history.toml"), cap: DefaultCap} +} + +// NewAt returns a Store rooted at an explicit file path. Used by tests. +func NewAt(path string) *Store { + return &Store{path: path, cap: DefaultCap} +} + +// SetCap overrides the entry cap. Values <= 0 leave the cap unchanged. +func (s *Store) SetCap(n int) { + if n > 0 { + s.mu.Lock() + s.cap = n + s.mu.Unlock() + } +} + +// Path returns the on-disk file path. +func (s *Store) Path() string { return s.path } + +// Record appends an entry for track played at playedAt. If the most recent +// entry has the same path and was logged within dedupWindow, its timestamp is +// updated in place instead of duplicating the row. Empty paths are ignored. +func (s *Store) Record(track playlist.Track, playedAt time.Time) error { + if s == nil || strings.TrimSpace(track.Path) == "" { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + + entries, _ := s.loadLocked() + if n := len(entries); n > 0 { + top := entries[0] + if top.Track.Path == track.Path && playedAt.Sub(top.PlayedAt) < dedupWindow { + entries[0].PlayedAt = playedAt + entries[0].Track = mergeTrackMeta(top.Track, track) + return s.saveLocked(entries) + } + } + + entry := Entry{Track: track, PlayedAt: playedAt} + entries = append([]Entry{entry}, entries...) + if s.cap > 0 && len(entries) > s.cap { + entries = entries[:s.cap] + } + return s.saveLocked(entries) +} + +// Recent returns up to limit entries, newest first. limit <= 0 returns all. +func (s *Store) Recent(limit int) ([]Entry, error) { + if s == nil { + return nil, nil + } + s.mu.Lock() + defer s.mu.Unlock() + entries, err := s.loadLocked() + if err != nil { + return nil, err + } + if limit > 0 && len(entries) > limit { + entries = entries[:limit] + } + return entries, nil +} + +// Tracks returns up to limit recent tracks, newest first, suitable for handing +// to a playlist.Playlist. The PlayedAt timestamp is dropped. +func (s *Store) Tracks(limit int) ([]playlist.Track, error) { + entries, err := s.Recent(limit) + if err != nil { + return nil, err + } + out := make([]playlist.Track, len(entries)) + for i, e := range entries { + out[i] = e.Track + } + return out, nil +} + +// Clear deletes the history file. Returns nil if the file does not exist. +func (s *Store) Clear() error { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + err := os.Remove(s.path) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err +} + +func (s *Store) loadLocked() ([]Entry, error) { + data, err := os.ReadFile(s.path) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + return parse(data), nil +} + +func (s *Store) saveLocked(entries []Entry) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + tmp := s.path + ".tmp" + f, err := os.Create(tmp) + if err != nil { + return err + } + for i, e := range entries { + if i > 0 { + fmt.Fprintln(f) + } + writeEntry(f, e) + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + return os.Rename(tmp, s.path) +} + +// mergeTrackMeta keeps any non-empty metadata from the previous entry when a +// replay supplies a sparser track (e.g. an ICY title-only update arriving +// after the original tags were captured). +func mergeTrackMeta(prev, cur playlist.Track) playlist.Track { + if cur.Title == "" { + cur.Title = prev.Title + } + if cur.Artist == "" { + cur.Artist = prev.Artist + } + if cur.Album == "" { + cur.Album = prev.Album + } + if cur.Genre == "" { + cur.Genre = prev.Genre + } + if cur.Year == 0 { + cur.Year = prev.Year + } + if cur.TrackNumber == 0 { + cur.TrackNumber = prev.TrackNumber + } + if cur.DurationSecs == 0 { + cur.DurationSecs = prev.DurationSecs + } + return cur +} + +func writeEntry(w io.Writer, e Entry) { + fmt.Fprintln(w, "[[entry]]") + fmt.Fprintf(w, "played_at = %q\n", e.PlayedAt.UTC().Format(time.RFC3339)) + fmt.Fprintf(w, "path = %q\n", e.Track.Path) + fmt.Fprintf(w, "title = %q\n", e.Track.Title) + if e.Track.Artist != "" { + fmt.Fprintf(w, "artist = %q\n", e.Track.Artist) + } + if e.Track.Album != "" { + fmt.Fprintf(w, "album = %q\n", e.Track.Album) + } + if e.Track.Genre != "" { + fmt.Fprintf(w, "genre = %q\n", e.Track.Genre) + } + if e.Track.Year != 0 { + fmt.Fprintf(w, "year = %d\n", e.Track.Year) + } + if e.Track.TrackNumber != 0 { + fmt.Fprintf(w, "track_number = %d\n", e.Track.TrackNumber) + } + if e.Track.DurationSecs != 0 { + fmt.Fprintf(w, "duration_secs = %d\n", e.Track.DurationSecs) + } +} + +// parse skips unknown keys to keep the on-disk format forward-compatible. +func parse(data []byte) []Entry { + var entries []Entry + var cur *Entry + + flush := func() { + if cur != nil { + entries = append(entries, *cur) + } + } + + for rawLine := range strings.SplitSeq(string(data), "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if line == "[[entry]]" { + flush() + cur = &Entry{} + continue + } + if cur == nil { + continue + } + key, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + val = tomlutil.Unquote(strings.TrimSpace(val)) + switch key { + case "played_at": + if t, err := time.Parse(time.RFC3339, val); err == nil { + cur.PlayedAt = t + } + case "path": + cur.Track.Path = val + cur.Track.Stream = playlist.IsURL(val) + case "title": + cur.Track.Title = val + case "artist": + cur.Track.Artist = val + case "album": + cur.Track.Album = val + case "genre": + cur.Track.Genre = val + case "year": + if n, err := strconv.Atoi(val); err == nil { + cur.Track.Year = n + } + case "track_number": + if n, err := strconv.Atoi(val); err == nil { + cur.Track.TrackNumber = n + } + case "duration_secs": + if n, err := strconv.Atoi(val); err == nil { + cur.Track.DurationSecs = n + } + } + } + flush() + + // Drop entries that failed to parse a path (the only required field). + entries = slices.DeleteFunc(entries, func(e Entry) bool { + return strings.TrimSpace(e.Track.Path) == "" + }) + return entries +} diff --git a/history/history_test.go b/history/history_test.go new file mode 100644 index 0000000..fb48dd3 --- /dev/null +++ b/history/history_test.go @@ -0,0 +1,223 @@ +package history + +import ( + "os" + "path/filepath" + "testing" + "time" + + "cliamp/playlist" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + dir := t.TempDir() + return NewAt(filepath.Join(dir, "history.toml")) +} + +func mustRecord(t *testing.T, s *Store, track playlist.Track, at time.Time) { + t.Helper() + if err := s.Record(track, at); err != nil { + t.Fatalf("Record: %v", err) + } +} + +func TestRecentEmpty(t *testing.T) { + s := newTestStore(t) + got, err := s.Recent(0) + if err != nil { + t.Fatalf("Recent: %v", err) + } + if len(got) != 0 { + t.Fatalf("Recent on empty store = %d entries, want 0", len(got)) + } +} + +func TestRecordOrdering(t *testing.T) { + s := newTestStore(t) + now := time.Now().UTC().Truncate(time.Second) + + mustRecord(t, s, playlist.Track{Path: "/a.mp3", Title: "A"}, now.Add(-3*time.Hour)) + mustRecord(t, s, playlist.Track{Path: "/b.mp3", Title: "B"}, now.Add(-2*time.Hour)) + mustRecord(t, s, playlist.Track{Path: "/c.mp3", Title: "C"}, now.Add(-1*time.Hour)) + + got, err := s.Recent(0) + if err != nil { + t.Fatalf("Recent: %v", err) + } + if len(got) != 3 { + t.Fatalf("got %d entries, want 3", len(got)) + } + wantOrder := []string{"C", "B", "A"} + for i, e := range got { + if e.Track.Title != wantOrder[i] { + t.Errorf("entry %d title = %q, want %q", i, e.Track.Title, wantOrder[i]) + } + } +} + +func TestDedupConsecutiveReplay(t *testing.T) { + track := playlist.Track{Path: "/a.mp3", Title: "A"} + first := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + gap time.Duration + wantLen int + wantTime time.Time // only checked when wantLen == 1 + }{ + {"inside window updates timestamp", 2 * time.Minute, 1, first.Add(2 * time.Minute)}, + {"outside window is a new play", 10 * time.Minute, 2, time.Time{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := newTestStore(t) + mustRecord(t, s, track, first) + mustRecord(t, s, track, first.Add(tt.gap)) + + got, _ := s.Recent(0) + if len(got) != tt.wantLen { + t.Fatalf("got %d entries, want %d", len(got), tt.wantLen) + } + if tt.wantLen == 1 && !got[0].PlayedAt.Equal(tt.wantTime) { + t.Fatalf("PlayedAt = %v, want %v", got[0].PlayedAt, tt.wantTime) + } + }) + } +} + +func TestCapTruncates(t *testing.T) { + s := newTestStore(t) + s.SetCap(3) + + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + for i := 0; i < 5; i++ { + mustRecord(t, s, playlist.Track{ + Path: filepath.FromSlash("/track" + string(rune('A'+i)) + ".mp3"), + Title: string(rune('A' + i)), + }, base.Add(time.Duration(i)*time.Hour)) + } + + got, _ := s.Recent(0) + if len(got) != 3 { + t.Fatalf("got %d entries, want 3 (cap)", len(got)) + } + wantTitles := []string{"E", "D", "C"} // newest 3 + for i, e := range got { + if e.Track.Title != wantTitles[i] { + t.Errorf("entry %d = %q, want %q", i, e.Track.Title, wantTitles[i]) + } + } +} + +func TestRecentLimit(t *testing.T) { + s := newTestStore(t) + for i := 0; i < 10; i++ { + mustRecord(t, s, playlist.Track{Path: "/x" + string(rune('0'+i))}, time.Now().Add(time.Duration(i)*time.Minute)) + } + got, _ := s.Recent(4) + if len(got) != 4 { + t.Fatalf("Recent(4) returned %d, want 4", len(got)) + } +} + +func TestRecordIgnoresEmptyPath(t *testing.T) { + s := newTestStore(t) + if err := s.Record(playlist.Track{Title: "no path"}, time.Now()); err != nil { + t.Fatalf("Record: %v", err) + } + got, _ := s.Recent(0) + if len(got) != 0 { + t.Fatalf("got %d entries, want 0 (empty path skipped)", len(got)) + } +} + +func TestPersistAcrossInstances(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "history.toml") + + s1 := NewAt(path) + mustRecord(t, s1, playlist.Track{Path: "/a.mp3", Title: "A", Artist: "Artist", Album: "Album", Year: 2026, DurationSecs: 180}, time.Date(2026, 5, 6, 22, 0, 0, 0, time.UTC)) + + s2 := NewAt(path) + got, err := s2.Recent(0) + if err != nil { + t.Fatalf("Recent: %v", err) + } + if len(got) != 1 { + t.Fatalf("reloaded %d entries, want 1", len(got)) + } + e := got[0] + if e.Track.Title != "A" || e.Track.Artist != "Artist" || e.Track.Album != "Album" { + t.Errorf("track meta lost: %+v", e.Track) + } + if e.Track.Year != 2026 || e.Track.DurationSecs != 180 { + t.Errorf("numeric meta lost: year=%d dur=%d", e.Track.Year, e.Track.DurationSecs) + } + if !e.PlayedAt.Equal(time.Date(2026, 5, 6, 22, 0, 0, 0, time.UTC)) { + t.Errorf("PlayedAt round-trip wrong: %v", e.PlayedAt) + } +} + +func TestStreamFlagInferredOnReload(t *testing.T) { + s := newTestStore(t) + mustRecord(t, s, playlist.Track{Path: "https://example.com/stream", Title: "Live"}, time.Now()) + + // Force a reload by creating a fresh store at the same path. + s2 := NewAt(s.Path()) + got, _ := s2.Recent(0) + if len(got) != 1 || !got[0].Track.Stream { + t.Fatalf("Stream flag not inferred from URL on reload: %+v", got) + } +} + +func TestClearRemovesFile(t *testing.T) { + s := newTestStore(t) + mustRecord(t, s, playlist.Track{Path: "/a.mp3"}, time.Now()) + if err := s.Clear(); err != nil { + t.Fatalf("Clear: %v", err) + } + if _, err := os.Stat(s.Path()); !os.IsNotExist(err) { + t.Fatalf("file should be gone after Clear, stat err = %v", err) + } + got, _ := s.Recent(0) + if len(got) != 0 { + t.Fatalf("post-Clear Recent = %d, want 0", len(got)) + } +} + +func TestClearMissingFileNoError(t *testing.T) { + s := newTestStore(t) + if err := s.Clear(); err != nil { + t.Fatalf("Clear on missing file: %v", err) + } +} + +func TestTracksOrdered(t *testing.T) { + s := newTestStore(t) + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + mustRecord(t, s, playlist.Track{Path: "/a.mp3", Title: "A"}, base) + mustRecord(t, s, playlist.Track{Path: "/b.mp3", Title: "B"}, base.Add(1*time.Hour)) + + tracks, err := s.Tracks(0) + if err != nil { + t.Fatalf("Tracks: %v", err) + } + if len(tracks) != 2 || tracks[0].Title != "B" || tracks[1].Title != "A" { + t.Fatalf("Tracks order wrong: %+v", tracks) + } +} + +func TestNilStoreSafe(t *testing.T) { + var s *Store + if err := s.Record(playlist.Track{Path: "/a.mp3"}, time.Now()); err != nil { + t.Errorf("nil Record returned err: %v", err) + } + if got, err := s.Recent(0); err != nil || got != nil { + t.Errorf("nil Recent: got=%v err=%v", got, err) + } + if err := s.Clear(); err != nil { + t.Errorf("nil Clear returned err: %v", err) + } +} diff --git a/site/index.html b/site/index.html index 0567ee5..e920ad6 100644 --- a/site/index.html +++ b/site/index.html @@ -1566,6 +1566,11 @@
Playlists

TOML playlists, M3U/M3U8/PLS support, playlist manager with album grouping.

+
+
+
Recently Played
+

Auto-recorded listening history. Browse it as a virtual playlist or run cliamp history from the shell.

+
HTTP Streaming
@@ -2049,6 +2054,8 @@
seek <secs>Relative seek
load "Name"Load a playlist
queue /path/fileQueue a track
+
history [--limit N]Show recently played
+
history clearWipe recently-played file
shuffle [on|off]Toggle or set shuffle
repeat [off|all|one]Set or cycle repeat
mono [on|off]Mono output
diff --git a/ui/model/init.go b/ui/model/init.go index bdc4c27..6b1a56c 100644 --- a/ui/model/init.go +++ b/ui/model/init.go @@ -6,6 +6,7 @@ import ( tea "charm.land/bubbletea/v2" + "cliamp/history" "cliamp/luaplugin" "cliamp/player" "cliamp/playlist" @@ -38,6 +39,7 @@ func New(p player.Engine, pl *playlist.Playlist, providers []ProviderEntry, defa providers: providers, navBrowser: navBrowserState{}, luaMgr: luaMgr, + historyStore: history.New(), showAlbumHeaders: true, } m.termTitle = initialTerminalTitleState() diff --git a/ui/model/model.go b/ui/model/model.go index dc6d3d0..eb0287d 100644 --- a/ui/model/model.go +++ b/ui/model/model.go @@ -4,6 +4,7 @@ package model import ( "time" + "cliamp/history" "cliamp/internal/playback" "cliamp/luaplugin" "cliamp/player" @@ -238,6 +239,9 @@ type Model struct { // Lua plugin manager (nil if no plugins loaded) luaMgr *luaplugin.Manager + // History recorder (nil if config dir unavailable; safe to call when nil) + historyStore *history.Store + // Theme state: -1 = Default (ANSI), 0+ = index into themes themes []theme.Theme themeIdx int diff --git a/ui/model/notifications.go b/ui/model/notifications.go index 58d2cda..c9b93f6 100644 --- a/ui/model/notifications.go +++ b/ui/model/notifications.go @@ -122,19 +122,29 @@ func (m *Model) nowPlaying(track playlist.Track) { // - a provider claims the track via provider metadata // - the track reached at least 50% of its known duration // -// The call is dispatched in a goroutine so it never blocks the UI. +// The call is dispatched in a goroutine so it never blocks the UI. The same +// 50% threshold gates a local history entry so skipped tracks never land in +// "Recently Played". func (m *Model) maybeScrobble(track playlist.Track, elapsed, duration time.Duration) { + dur := duration + if dur <= 0 { + dur = time.Duration(track.DurationSecs) * time.Second + } + pastThreshold := dur > 0 && elapsed >= dur/2 + // Emit scrobble event to Lua plugins for all tracks (not just Navidrome). - if m.luaMgr != nil && m.luaMgr.HasHooks() { - dur := duration - if dur <= 0 { - dur = time.Duration(track.DurationSecs) * time.Second - } - if dur > 0 && elapsed >= dur/2 { - data := trackToMap(track) - data["played_secs"] = elapsed.Seconds() - m.luaMgr.Emit(luaplugin.EventTrackScrobble, data) - } + if m.luaMgr != nil && m.luaMgr.HasHooks() && pastThreshold { + data := trackToMap(track) + data["played_secs"] = elapsed.Seconds() + m.luaMgr.Emit(luaplugin.EventTrackScrobble, data) + } + + // Record into local history regardless of provider. Live streams without + // duration are filtered by pastThreshold. The write is synchronous so + // successive scrobbles preserve their ordering on disk; the file is small + // (~30 KB at the 200-entry cap) so the latency is sub-millisecond. + if pastThreshold && m.historyStore != nil { + _ = m.historyStore.Record(track, time.Now()) } reporter := m.findPlaybackReporter(track)