Add Recently Played history backed by a virtual local playlist
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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).
|
||||
Vendored
+84
-7
@@ -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
|
||||
|
||||
Vendored
+103
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 <secs></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>
|
||||
|
||||
@@ -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,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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user