Add Recently Played history backed by a virtual local playlist

This commit is contained in:
Bjarne Øverli
2026-05-06 22:29:37 +02:00
parent 2d74cde95c
commit 587bf37576
12 changed files with 946 additions and 18 deletions
+97
View File
@@ -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")
}
+27
View File
@@ -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{
+13
View File
@@ -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
+55
View File
@@ -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).
+84 -7
View File
@@ -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
+103
View File
@@ -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))
}
}
+310
View File
@@ -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
}
+223
View File
@@ -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)
}
}
+7
View File
@@ -1566,6 +1566,11 @@
<div class="feature-name">Playlists</div>
<p>TOML playlists, M3U/M3U8/PLS support, playlist manager with album grouping.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">Recently Played</div>
<p>Auto-recorded listening history. Browse it as a virtual playlist or run <code>cliamp history</code> from the shell.</p>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-name">HTTP Streaming</div>
@@ -2049,6 +2054,8 @@
<div class="key-row"><kbd>seek &lt;secs&gt;</kbd><span>Relative seek</span></div>
<div class="key-row"><kbd>load "Name"</kbd><span>Load a playlist</span></div>
<div class="key-row"><kbd>queue /path/file</kbd><span>Queue a track</span></div>
<div class="key-row"><kbd>history [--limit N]</kbd><span>Show recently played</span></div>
<div class="key-row"><kbd>history clear</kbd><span>Wipe recently-played file</span></div>
<div class="key-row"><kbd>shuffle [on|off]</kbd><span>Toggle or set shuffle</span></div>
<div class="key-row"><kbd>repeat [off|all|one]</kbd><span>Set or cycle repeat</span></div>
<div class="key-row"><kbd>mono [on|off]</kbd><span>Mono output</span></div>
+2
View File
@@ -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()
+4
View File
@@ -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
+21 -11
View File
@@ -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)