feat(lyrics,mediactl): embed local lyrics and album art in MPRIS/NowPlaying

Read embedded lyrics (LRC or plain text) and cover art from local file
tags at play time. Lyrics are preferred over network fetch when present;
album art is cached by content hash under ~/.local/share/cliamp/album-art/
and published via mpris:artUrl (Linux) and MPNowPlayingInfoCenter (macOS).
This commit is contained in:
lentikr
2026-06-29 20:17:41 +08:00
parent 854911e9cd
commit 184a67e519
25 changed files with 452 additions and 53 deletions
+1
View File
@@ -0,0 +1 @@
I use AI, and if this file is not deleted, I haven't reviewed my own code.
+2
View File
@@ -156,6 +156,7 @@ func PlaylistShow(name string, jsonOutput bool) error {
Year int `json:"year,omitempty"` Year int `json:"year,omitempty"`
TrackNumber int `json:"track_number,omitempty"` TrackNumber int `json:"track_number,omitempty"`
DurationSecs int `json:"duration_secs,omitempty"` DurationSecs int `json:"duration_secs,omitempty"`
AlbumArtURL string `json:"album_art_url,omitempty"`
Bookmark bool `json:"bookmark,omitempty"` Bookmark bool `json:"bookmark,omitempty"`
} }
out := make([]jsonTrack, len(tracks)) out := make([]jsonTrack, len(tracks))
@@ -169,6 +170,7 @@ func PlaylistShow(name string, jsonOutput bool) error {
Year: t.Year, Year: t.Year,
TrackNumber: t.TrackNumber, TrackNumber: t.TrackNumber,
DurationSecs: t.DurationSecs, DurationSecs: t.DurationSecs,
AlbumArtURL: t.AlbumArtURL,
Bookmark: t.Bookmark, Bookmark: t.Bookmark,
} }
} }
+3 -1
View File
@@ -1,12 +1,14 @@
# Lyrics # Lyrics
Press `y` to show lyrics for the current track. Lyrics are fetched from LRCLIB and NetEase Cloud Music. Press `y` to show lyrics for the current track. For local files, cliamp uses embedded lyrics from the file tags first. If no embedded lyrics are present, lyrics are fetched from LRCLIB and NetEase Cloud Music.
## Modes ## Modes
- **Synced lyrics**: for local files and Navidrome tracks, lyrics auto scroll and highlight the active line in time with playback. - **Synced lyrics**: for local files and Navidrome tracks, lyrics auto scroll and highlight the active line in time with playback.
- **Scroll mode**: for streams and plain lyrics without timestamps, use `j`/`k` or arrow keys to scroll manually. - **Scroll mode**: for streams and plain lyrics without timestamps, use `j`/`k` or arrow keys to scroll manually.
Embedded LRC lyrics keep their timestamps. Embedded plain text lyrics are shown in scroll mode.
## Streams ## Streams
Lyrics auto update when the ICY metadata changes (e.g., internet radio station transitions). Lyrics auto update when the ICY metadata changes (e.g., internet radio station transitions).
+3
View File
@@ -69,6 +69,7 @@ Track metadata is published under the standard MPRIS keys:
| `xesam:artist` | Artist name (as a list with one entry) | | `xesam:artist` | Artist name (as a list with one entry) |
| `xesam:album` | Album name, when available | | `xesam:album` | Album name, when available |
| `xesam:url` | File path or stream URL | | `xesam:url` | File path or stream URL |
| `mpris:artUrl` | Embedded album artwork from local files, when available |
| `mpris:length` | Duration in microseconds | | `mpris:length` | Duration in microseconds |
Query metadata with: Query metadata with:
@@ -114,6 +115,8 @@ On macOS, Cliamp publishes now-playing information to the system's MPNowPlayingI
- Hardware media keys (play/pause, next, previous) - Hardware media keys (play/pause, next, previous)
- Bluetooth headphone buttons - Bluetooth headphone buttons
Local files with embedded cover art publish that artwork to Control Centre and Lock Screen media controls. Artwork is cached by content under `~/.local/share/cliamp/album-art/` and pruned opportunistically to stay around 100 MB.
The macOS implementation requires the media-control runtime to pin the main goroutine to thread 0 (via `runtime.LockOSThread`) so that the Cocoa run loop can pump events. Bubbletea runs on a background goroutine instead. The macOS implementation requires the media-control runtime to pin the main goroutine to thread 0 (via `runtime.LockOSThread`) so that the Cocoa run loop can pump events. Bubbletea runs on a background goroutine instead.
## Architecture ## Architecture
+8 -1
View File
@@ -82,6 +82,14 @@ Each `[[track]]` section supports:
| `path` | Yes | File path or HTTP URL | | `path` | Yes | File path or HTTP URL |
| `title` | Yes | Display title | | `title` | Yes | Display title |
| `artist` | No | Artist name | | `artist` | No | Artist name |
| `album` | No | Album name |
| `genre` | No | Genre name |
| `year` | No | Release year |
| `track_number` | No | Track number |
| `duration_secs` | No | Duration in seconds |
| `embedded_lyrics` | No | Lyrics copied from local file tags |
| `album_art_url` | No | Cached file URL for embedded album art |
| `bookmark` | No | Bookmark flag |
HTTP/HTTPS paths are automatically treated as streams. HTTP/HTTPS paths are automatically treated as streams.
@@ -191,4 +199,3 @@ title = "My Radio"
| `a` | Add currently playing track | | `a` | Add currently playing track |
| `d` | Delete playlist (confirms) / Remove track | | `d` | Delete playlist (confirms) / Remove track |
| `←` / `Backspace` | Go back from tracks screen to list | | `←` / `Backspace` | Go back from tracks screen to list |
+8
View File
@@ -458,6 +458,12 @@ func writeTrack(w io.Writer, t playlist.Track) {
if t.DurationSecs != 0 { if t.DurationSecs != 0 {
fmt.Fprintf(w, "duration_secs = %d\n", t.DurationSecs) fmt.Fprintf(w, "duration_secs = %d\n", t.DurationSecs)
} }
if t.EmbeddedLyrics != "" {
fmt.Fprintf(w, "embedded_lyrics = %q\n", t.EmbeddedLyrics)
}
if t.AlbumArtURL != "" {
fmt.Fprintf(w, "album_art_url = %q\n", t.AlbumArtURL)
}
if t.Bookmark { if t.Bookmark {
fmt.Fprintln(w, "bookmark = true") fmt.Fprintln(w, "bookmark = true")
} }
@@ -481,6 +487,8 @@ func (p *Provider) loadTOML(path string) ([]playlist.Track, error) {
Genre: f["genre"], Genre: f["genre"],
Feed: f["feed"] == "true", Feed: f["feed"] == "true",
} }
t.EmbeddedLyrics = f["embedded_lyrics"]
t.AlbumArtURL = f["album_art_url"]
t.Stream = playlist.IsURL(t.Path) t.Stream = playlist.IsURL(t.Path)
// "favorite" is the pre-rename alias for "bookmark"; prefer bookmark. // "favorite" is the pre-rename alias for "bookmark"; prefer bookmark.
bookmark, ok := f["bookmark"] bookmark, ok := f["bookmark"]
+18 -11
View File
@@ -90,16 +90,18 @@ func TestWriteTrackMinimal(t *testing.T) {
func TestWriteTrackAllFields(t *testing.T) { func TestWriteTrackAllFields(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
writeTrack(&buf, playlist.Track{ writeTrack(&buf, playlist.Track{
Path: "/music/song.flac", Path: "/music/song.flac",
Title: "Title", Title: "Title",
Artist: "Artist", Artist: "Artist",
Album: "Album", Album: "Album",
Genre: "Rock", Genre: "Rock",
Year: 2024, Year: 2024,
TrackNumber: 3, TrackNumber: 3,
DurationSecs: 240, DurationSecs: 240,
Bookmark: true, Bookmark: true,
Feed: true, Feed: true,
EmbeddedLyrics: "[00:01.00]Line",
AlbumArtURL: "file:///tmp/cover.jpg",
}) })
got := buf.String() got := buf.String()
@@ -112,6 +114,8 @@ func TestWriteTrackAllFields(t *testing.T) {
"year = 2024", "year = 2024",
"track_number = 3", "track_number = 3",
"duration_secs = 240", "duration_secs = 240",
`embedded_lyrics = "[00:01.00]Line"`,
`album_art_url = "file:///tmp/cover.jpg"`,
"bookmark = true", "bookmark = true",
"feed = true", "feed = true",
} { } {
@@ -128,7 +132,7 @@ func TestLoadTOMLRoundTrip(t *testing.T) {
os.MkdirAll(p.dir, 0o755) os.MkdirAll(p.dir, 0o755)
tracks := []playlist.Track{ tracks := []playlist.Track{
{Path: "/a.mp3", Title: "A", Artist: "Art1", Album: "Alb", Year: 2020, TrackNumber: 1, DurationSecs: 180, Bookmark: true}, {Path: "/a.mp3", Title: "A", Artist: "Art1", Album: "Alb", Year: 2020, TrackNumber: 1, DurationSecs: 180, Bookmark: true, EmbeddedLyrics: "Line 1\nLine 2", AlbumArtURL: "file:///tmp/a.jpg"},
{Path: "/b.flac", Title: "B", Genre: "Jazz", Feed: true}, {Path: "/b.flac", Title: "B", Genre: "Jazz", Feed: true},
} }
@@ -153,6 +157,9 @@ func TestLoadTOMLRoundTrip(t *testing.T) {
if loaded[0].Year != 2020 || loaded[0].TrackNumber != 1 || loaded[0].DurationSecs != 180 { if loaded[0].Year != 2020 || loaded[0].TrackNumber != 1 || loaded[0].DurationSecs != 180 {
t.Fatalf("track 0 numeric fields mismatch: %+v", loaded[0]) t.Fatalf("track 0 numeric fields mismatch: %+v", loaded[0])
} }
if loaded[0].EmbeddedLyrics != "Line 1\nLine 2" || loaded[0].AlbumArtURL != "file:///tmp/a.jpg" {
t.Fatalf("track 0 embedded fields mismatch: %+v", loaded[0])
}
if loaded[1].Path != "/b.flac" || loaded[1].Title != "B" || loaded[1].Genre != "Jazz" { if loaded[1].Path != "/b.flac" || loaded[1].Title != "B" || loaded[1].Genre != "Jazz" {
t.Fatalf("track 1 mismatch: %+v", loaded[1]) t.Fatalf("track 1 mismatch: %+v", loaded[1])
+1
View File
@@ -32,6 +32,7 @@ type Track struct {
Genre string Genre string
TrackNumber int TrackNumber int
URL string URL string
ArtURL string
Duration time.Duration Duration time.Duration
} }
+1
View File
@@ -52,6 +52,7 @@ func TestStateFields(t *testing.T) {
Genre: "Rock", Genre: "Rock",
TrackNumber: 3, TrackNumber: 3,
URL: "file:///song.mp3", URL: "file:///song.mp3",
ArtURL: "file:///cover.jpg",
Duration: 3 * time.Minute, Duration: 3 * time.Minute,
}, },
VolumeDB: -3.0, VolumeDB: -3.0,
+18
View File
@@ -112,6 +112,24 @@ func Fetch(artist, title string) ([]Line, error) {
return nil, ErrNotFound return nil, ErrNotFound
} }
// ParseEmbedded converts lyrics read from local file tags into display lines.
// Timestamped LRC data remains synced; plain text is returned as scrollable
// lines at timestamp 0.
func ParseEmbedded(data string) []Line {
data = strings.TrimSpace(data)
if data == "" {
return nil
}
lines := parseLRC(data)
if len(lines) > 0 {
return lines
}
for raw := range strings.SplitSeq(data, "\n") {
lines = append(lines, Line{Start: 0, Text: strings.TrimSpace(raw)})
}
return lines
}
func fetchLRCLIB(query string) ([]Line, error) { func fetchLRCLIB(query string) ([]Line, error) {
searchURL := fmt.Sprintf("https://lrclib.net/api/search?q=%s", url.QueryEscape(query)) searchURL := fmt.Sprintf("https://lrclib.net/api/search?q=%s", url.QueryEscape(query))
+20
View File
@@ -80,6 +80,26 @@ func TestParseLRCEmptyText(t *testing.T) {
} }
} }
func TestParseEmbeddedPrefersSyncedLRC(t *testing.T) {
lines := ParseEmbedded("[00:01.50]Hello\nplain fallback")
if len(lines) != 1 {
t.Fatalf("got %d lines, want 1 synced line", len(lines))
}
if lines[0].Start != 1500*time.Millisecond || lines[0].Text != "Hello" {
t.Fatalf("line = %+v, want synced Hello at 1.5s", lines[0])
}
}
func TestParseEmbeddedPlainText(t *testing.T) {
lines := ParseEmbedded("Line one\nLine two")
if len(lines) != 2 {
t.Fatalf("got %d lines, want 2", len(lines))
}
if lines[0].Start != 0 || lines[0].Text != "Line one" || lines[1].Text != "Line two" {
t.Fatalf("lines = %+v, want plain zero-timestamp lines", lines)
}
}
func TestCleanQuery(t *testing.T) { func TestCleanQuery(t *testing.T) {
tests := []struct { tests := []struct {
input, want string input, want string
+3
View File
@@ -37,6 +37,9 @@ func makeMetadata(t playback.Track, trackID dbus.ObjectPath) map[string]dbus.Var
if t.URL != "" { if t.URL != "" {
m["xesam:url"] = dbus.MakeVariant(t.URL) m["xesam:url"] = dbus.MakeVariant(t.URL)
} }
if t.ArtURL != "" {
m["mpris:artUrl"] = dbus.MakeVariant(t.ArtURL)
}
if t.Duration > 0 { if t.Duration > 0 {
m["mpris:length"] = dbus.MakeVariant(t.Duration.Microseconds()) m["mpris:length"] = dbus.MakeVariant(t.Duration.Microseconds())
} }
+3
View File
@@ -18,6 +18,7 @@ func TestMakeMetadataMapsPlaybackTrackToMPRISFields(t *testing.T) {
Genre: "Ambient", Genre: "Ambient",
TrackNumber: 7, TrackNumber: 7,
URL: "file:///tmp/song.mp3", URL: "file:///tmp/song.mp3",
ArtURL: "file:///tmp/cover.jpg",
Duration: 3*time.Minute + 15*time.Second, Duration: 3*time.Minute + 15*time.Second,
} }
@@ -31,6 +32,7 @@ func TestMakeMetadataMapsPlaybackTrackToMPRISFields(t *testing.T) {
"xesam:genre": dbus.MakeVariant([]string{"Ambient"}), "xesam:genre": dbus.MakeVariant([]string{"Ambient"}),
"xesam:trackNumber": dbus.MakeVariant(7), "xesam:trackNumber": dbus.MakeVariant(7),
"xesam:url": dbus.MakeVariant("file:///tmp/song.mp3"), "xesam:url": dbus.MakeVariant("file:///tmp/song.mp3"),
"mpris:artUrl": dbus.MakeVariant("file:///tmp/cover.jpg"),
"mpris:length": dbus.MakeVariant(track.Duration.Microseconds()), "mpris:length": dbus.MakeVariant(track.Duration.Microseconds()),
} }
@@ -64,6 +66,7 @@ func TestMakeMetadataOmitsEmptyOptionalFields(t *testing.T) {
"xesam:genre", "xesam:genre",
"xesam:trackNumber", "xesam:trackNumber",
"xesam:url", "xesam:url",
"mpris:artUrl",
"mpris:length", "mpris:length",
} { } {
if _, ok := got[key]; ok { if _, ok := got[key]; ok {
+22 -3
View File
@@ -125,7 +125,7 @@ static void bridgeDestroy(MediaCtlBridgeRef ref) {
} }
// playbackState: 0 = stopped, 1 = playing, 2 = paused // playbackState: 0 = stopped, 1 = playing, 2 = paused
static void updateNowPlaying(const char *title, const char *artist, const char *album, static void updateNowPlaying(const char *title, const char *artist, const char *album, const char *artURL,
double durationSecs, double elapsedSecs, int playbackState, int canSeek) { double durationSecs, double elapsedSecs, int playbackState, int canSeek) {
@autoreleasepool { @autoreleasepool {
MPRemoteCommandCenter *cc = [MPRemoteCommandCenter sharedCommandCenter]; MPRemoteCommandCenter *cc = [MPRemoteCommandCenter sharedCommandCenter];
@@ -141,6 +141,19 @@ static void updateNowPlaying(const char *title, const char *artist, const char *
if (title) info[MPMediaItemPropertyTitle] = @(title); if (title) info[MPMediaItemPropertyTitle] = @(title);
if (artist) info[MPMediaItemPropertyArtist] = @(artist); if (artist) info[MPMediaItemPropertyArtist] = @(artist);
if (album) info[MPMediaItemPropertyAlbumTitle] = @(album); if (album) info[MPMediaItemPropertyAlbumTitle] = @(album);
if (artURL) {
// cliamp only passes local file:// artwork URLs here. Avoid extending
// this path to remote artwork without moving image loading off-thread.
NSURL *url = [NSURL URLWithString:@(artURL)];
NSImage *image = url ? [[[NSImage alloc] initWithContentsOfURL:url] autorelease] : nil;
if (image) {
MPMediaItemArtwork *artwork = [[[MPMediaItemArtwork alloc] initWithBoundsSize:image.size
requestHandler:^NSImage * _Nonnull(CGSize size) {
return image;
}] autorelease];
info[MPMediaItemPropertyArtwork] = artwork;
}
}
if (durationSecs > 0) info[MPMediaItemPropertyPlaybackDuration] = @(durationSecs); if (durationSecs > 0) info[MPMediaItemPropertyPlaybackDuration] = @(durationSecs);
if (elapsedSecs >= 0) info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = @(elapsedSecs); if (elapsedSecs >= 0) info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = @(elapsedSecs);
info[MPNowPlayingInfoPropertyPlaybackRate] = @(playbackState == 1 ? 1.0 : 0.0); info[MPNowPlayingInfoPropertyPlaybackRate] = @(playbackState == 1 ? 1.0 : 0.0);
@@ -198,6 +211,7 @@ func Run(prog *tea.Program, svc *Service) (tea.Model, error) {
type updateReq struct { type updateReq struct {
title, artist, album string title, artist, album string
artURL string
durationSecs, elapsedSecs float64 durationSecs, elapsedSecs float64
status playback.Status status playback.Status
canSeek bool canSeek bool
@@ -486,7 +500,7 @@ func (s *Service) beginRelease(allowRunLoop bool) (cgo.Handle, C.MediaCtlBridgeR
} }
func applyUpdate(req updateReq) { func applyUpdate(req updateReq) {
var cTitle, cArtist, cAlbum *C.char var cTitle, cArtist, cAlbum, cArtURL *C.char
if req.title != "" { if req.title != "" {
cTitle = C.CString(req.title) cTitle = C.CString(req.title)
defer C.free(unsafe.Pointer(cTitle)) defer C.free(unsafe.Pointer(cTitle))
@@ -499,11 +513,15 @@ func applyUpdate(req updateReq) {
cAlbum = C.CString(req.album) cAlbum = C.CString(req.album)
defer C.free(unsafe.Pointer(cAlbum)) defer C.free(unsafe.Pointer(cAlbum))
} }
if req.artURL != "" {
cArtURL = C.CString(req.artURL)
defer C.free(unsafe.Pointer(cArtURL))
}
canSeek := C.int(0) canSeek := C.int(0)
if req.canSeek { if req.canSeek {
canSeek = 1 canSeek = 1
} }
C.updateNowPlaying(cTitle, cArtist, cAlbum, C.updateNowPlaying(cTitle, cArtist, cAlbum, cArtURL,
C.double(req.durationSecs), C.double(req.elapsedSecs), nowPlayingState(req.status), canSeek) C.double(req.durationSecs), C.double(req.elapsedSecs), nowPlayingState(req.status), canSeek)
} }
@@ -526,6 +544,7 @@ func (s *Service) Update(state playback.State) {
title: state.Track.Title, title: state.Track.Title,
artist: state.Track.Artist, artist: state.Track.Artist,
album: state.Track.Album, album: state.Track.Album,
artURL: state.Track.ArtURL,
durationSecs: state.Track.Duration.Seconds(), durationSecs: state.Track.Duration.Seconds(),
elapsedSecs: state.Position.Seconds(), elapsedSecs: state.Position.Seconds(),
status: state.Status, status: state.Status,
+2 -1
View File
@@ -90,7 +90,7 @@ func TestDarwinUpdateCoalescesPendingState(t *testing.T) {
}) })
svc.Update(playback.State{ svc.Update(playback.State{
Status: playback.StatusPlaying, Status: playback.StatusPlaying,
Track: playback.Track{Title: "second", Artist: "artist"}, Track: playback.Track{Title: "second", Artist: "artist", ArtURL: "file:///tmp/cover.jpg"},
Position: 2250 * time.Millisecond, Position: 2250 * time.Millisecond,
Seekable: false, Seekable: false,
}) })
@@ -100,6 +100,7 @@ func TestDarwinUpdateCoalescesPendingState(t *testing.T) {
want := updateReq{ want := updateReq{
title: "second", title: "second",
artist: "artist", artist: "artist",
artURL: "file:///tmp/cover.jpg",
durationSecs: 0, durationSecs: 0,
elapsedSecs: 2.25, elapsedSecs: 2.25,
status: playback.StatusPlaying, status: playback.StatusPlaying,
+3
View File
@@ -48,6 +48,9 @@ type Track struct {
Unplayable bool // true when the track is known not playable in the current playback context Unplayable bool // true when the track is known not playable in the current playback context
EmbeddedLyrics string // embedded lyrics from local file tags, when present
AlbumArtURL string // file:// URL for cached embedded album art, when present
// ProviderMeta holds provider-specific key-value pairs. // ProviderMeta holds provider-specific key-value pairs.
// Keys are namespaced by provider, e.g. "navidrome.id", "jellyfin.id". // Keys are namespaced by provider, e.g. "navidrome.id", "jellyfin.id".
ProviderMeta map[string]string ProviderMeta map[string]string
+172 -7
View File
@@ -1,17 +1,64 @@
package playlist package playlist
import ( import (
"crypto/sha256"
"encoding/hex"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time"
"github.com/dhowden/tag" "github.com/dhowden/tag"
"cliamp/internal/appdir"
) )
const (
albumArtCacheDir = "album-art"
albumArtCacheMaxBytes = 100 << 20
)
var supportedPictureExts = map[string]bool{
"jpg": true,
"jpeg": true,
"png": true,
"gif": true,
"webp": true,
"bmp": true,
"tiff": true,
}
// RefreshEmbeddedMetadata returns track with embedded local-file lyrics and
// album art populated from its current Path. Existing non-embedded fields are
// preserved so saved playlist metadata and provider fields remain stable.
func RefreshEmbeddedMetadata(track Track) Track {
if track.Path == "" || track.Stream || IsURL(track.Path) || strings.HasPrefix(track.Path, "ssh://") {
return track
}
if track.EmbeddedLyrics != "" && track.AlbumArtURL != "" {
return track
}
fresh := readTagsWithOptions(track.Path, track.AlbumArtURL == "")
if track.EmbeddedLyrics == "" {
track.EmbeddedLyrics = fresh.EmbeddedLyrics
}
if track.AlbumArtURL == "" {
track.AlbumArtURL = fresh.AlbumArtURL
}
return track
}
// readTags reads embedded metadata (ID3v2, Vorbis comments, MP4 atoms) from // readTags reads embedded metadata (ID3v2, Vorbis comments, MP4 atoms) from
// a local audio file and returns a Track. Falls back to filename parsing if // a local audio file and returns a Track. Falls back to filename parsing if
// tag reading fails or the tags contain no title. // tag reading fails or the tags contain no title.
func readTags(path string) Track { func readTags(path string) Track {
return readTagsWithOptions(path, false)
}
func readTagsWithOptions(path string, cacheArt bool) Track {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
return TrackFromFilename(path) return TrackFromFilename(path)
@@ -19,23 +66,141 @@ func readTags(path string) Track {
defer f.Close() defer f.Close()
m, err := tag.ReadFrom(f) m, err := tag.ReadFrom(f)
if err != nil || m == nil || strings.TrimSpace(m.Title()) == "" { if err != nil || m == nil {
return TrackFromFilename(path) return TrackFromFilename(path)
} }
t := Track{ t := Track{
Path: path, Path: path,
Title: sanitizeTag(strings.TrimSpace(m.Title())), EmbeddedLyrics: sanitizeTag(strings.TrimSpace(m.Lyrics())),
Artist: sanitizeTag(strings.TrimSpace(m.Artist())),
Album: sanitizeTag(strings.TrimSpace(m.Album())),
Genre: sanitizeTag(strings.TrimSpace(m.Genre())),
Year: m.Year(),
} }
if cacheArt {
t.AlbumArtURL = cacheAlbumArt(m.Picture())
}
if strings.TrimSpace(m.Title()) == "" {
fallback := TrackFromFilename(path)
fallback.EmbeddedLyrics = t.EmbeddedLyrics
fallback.AlbumArtURL = t.AlbumArtURL
return fallback
}
t.Title = sanitizeTag(strings.TrimSpace(m.Title()))
t.Artist = sanitizeTag(strings.TrimSpace(m.Artist()))
t.Album = sanitizeTag(strings.TrimSpace(m.Album()))
t.Genre = sanitizeTag(strings.TrimSpace(m.Genre()))
t.Year = m.Year()
trackNum, _ := m.Track() trackNum, _ := m.Track()
t.TrackNumber = trackNum t.TrackNumber = trackNum
return t return t
} }
func cacheAlbumArt(picture *tag.Picture) string {
if picture == nil || len(picture.Data) == 0 {
return ""
}
dir, err := appdir.DataDir()
if err != nil {
return ""
}
artDir := filepath.Join(dir, albumArtCacheDir)
if err := os.MkdirAll(artDir, 0o755); err != nil {
return ""
}
sum := sha256.Sum256(picture.Data)
ext := normalizedPictureExt(picture)
path := filepath.Join(artDir, hex.EncodeToString(sum[:])+"."+ext)
if _, err := os.Stat(path); err == nil {
now := time.Now()
_ = os.Chtimes(path, now, now)
cleanupAlbumArtCache(artDir, albumArtCacheMaxBytes, path)
return fileURL(path)
}
if err := os.WriteFile(path, picture.Data, 0o644); err != nil {
return ""
}
cleanupAlbumArtCache(artDir, albumArtCacheMaxBytes, path)
return fileURL(path)
}
func normalizedPictureExt(picture *tag.Picture) string {
ext := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(picture.Ext), "."))
if ext == "jpeg" {
return "jpg"
}
if supportedPictureExts[ext] {
return ext
}
switch strings.ToLower(strings.TrimSpace(picture.MIMEType)) {
case "image/jpeg", "image/jpg":
return "jpg"
case "image/png":
return "png"
case "image/gif":
return "gif"
case "image/webp":
return "webp"
case "image/bmp":
return "bmp"
case "image/tiff":
return "tiff"
default:
return "jpg"
}
}
func cleanupAlbumArtCache(dir string, maxBytes int64, keepPath string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
type cachedFile struct {
path string
size int64
modTime time.Time
}
files := make([]cachedFile, 0, len(entries))
var total int64
for _, entry := range entries {
info, err := entry.Info()
if err != nil || !info.Mode().IsRegular() {
continue
}
path := filepath.Join(dir, entry.Name())
size := info.Size()
total += size
files = append(files, cachedFile{path: path, size: size, modTime: info.ModTime()})
}
if total <= maxBytes {
return
}
sort.Slice(files, func(i, j int) bool {
return files[i].modTime.Before(files[j].modTime)
})
for _, file := range files {
if total <= maxBytes {
return
}
if file.path == keepPath {
continue
}
if err := os.Remove(file.path); err == nil {
total -= file.size
}
}
}
func fileURL(path string) string {
abs, err := filepath.Abs(path)
if err != nil {
abs = path
}
u := url.URL{Scheme: "file", Path: filepath.ToSlash(abs)}
return u.String()
}
// TrackFromFilename creates a Track by parsing "Artist - Title" from the // TrackFromFilename creates a Track by parsing "Artist - Title" from the
// filename, or using the bare filename as the title. // filename, or using the bare filename as the title.
func TrackFromFilename(path string) Track { func TrackFromFilename(path string) Track {
+63 -1
View File
@@ -1,6 +1,15 @@
package playlist package playlist
import "testing" import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/dhowden/tag"
)
func TestTrackMeta(t *testing.T) { func TestTrackMeta(t *testing.T) {
t.Run("nil map returns empty", func(t *testing.T) { t.Run("nil map returns empty", func(t *testing.T) {
@@ -78,6 +87,59 @@ func TestTrackIsLive(t *testing.T) {
}) })
} }
func TestFileURL(t *testing.T) {
got := fileURL(filepath.Join("tmp", "cover art.jpg"))
if !strings.HasPrefix(got, "file:///") {
t.Fatalf("fileURL = %q, want file URL", got)
}
if runtime.GOOS != "windows" && !strings.Contains(got, "cover%20art.jpg") {
t.Fatalf("fileURL = %q, want escaped spaces", got)
}
}
func TestCacheAlbumArtUsesContentHash(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
first := cacheAlbumArt(&tag.Picture{Ext: "jpeg", Data: []byte("same cover")})
second := cacheAlbumArt(&tag.Picture{Ext: "jpg", Data: []byte("same cover")})
if first == "" || first != second {
t.Fatalf("cacheAlbumArt URLs = %q and %q, want same non-empty URL", first, second)
}
matches, err := filepath.Glob(filepath.Join(home, ".local", "share", "cliamp", albumArtCacheDir, "*"))
if err != nil {
t.Fatalf("Glob: %v", err)
}
if len(matches) != 1 {
t.Fatalf("cached files = %d, want 1: %v", len(matches), matches)
}
}
func TestCleanupAlbumArtCacheKeepsCurrentFile(t *testing.T) {
dir := t.TempDir()
oldPath := filepath.Join(dir, "old.jpg")
keepPath := filepath.Join(dir, "keep.jpg")
if err := os.WriteFile(oldPath, []byte(strings.Repeat("o", 80)), 0o644); err != nil {
t.Fatalf("WriteFile old: %v", err)
}
if err := os.WriteFile(keepPath, []byte(strings.Repeat("k", 80)), 0o644); err != nil {
t.Fatalf("WriteFile keep: %v", err)
}
oldTime := time.Now().Add(-time.Hour)
if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
t.Fatalf("Chtimes old: %v", err)
}
cleanupAlbumArtCache(dir, 100, keepPath)
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
t.Fatalf("old cache file still exists or stat failed: %v", err)
}
if _, err := os.Stat(keepPath); err != nil {
t.Fatalf("current cache file was removed: %v", err)
}
}
func TestTrackFromURL(t *testing.T) { func TestTrackFromURL(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+3 -3
View File
@@ -1628,7 +1628,7 @@ user_id = "your-account-user-id"</code></pre>
<div class="feature"> <div class="feature">
<div class="feature-icon"></div> <div class="feature-icon"></div>
<div class="feature-name">Synced Lyrics</div> <div class="feature-name">Synced Lyrics</div>
<p>Auto-scrolling lyrics for local &amp; Navidrome tracks. Press <kbd>y</kbd>.</p> <p>Embedded local lyrics first, then LRCLIB/NetEase fallback. Auto-scrolling for timestamped lyrics.</p>
</div> </div>
<div class="feature"> <div class="feature">
<div class="feature-icon"></div> <div class="feature-icon"></div>
@@ -1638,7 +1638,7 @@ user_id = "your-account-user-id"</code></pre>
<div class="feature"> <div class="feature">
<div class="feature-icon"></div> <div class="feature-icon"></div>
<div class="feature-name">MPRIS / Media Keys</div> <div class="feature-name">MPRIS / Media Keys</div>
<p>Desktop integration. Hardware media keys and <code>playerctl</code>.</p> <p>Desktop integration. Hardware media keys, <code>playerctl</code>, and embedded local cover art.</p>
</div> </div>
<div class="feature"> <div class="feature">
<div class="feature-icon"></div> <div class="feature-icon"></div>
@@ -1658,7 +1658,7 @@ user_id = "your-account-user-id"</code></pre>
<div class="feature"> <div class="feature">
<div class="feature-icon"></div> <div class="feature-icon"></div>
<div class="feature-name">Embedded Tag Reading</div> <div class="feature-name">Embedded Tag Reading</div>
<p>ID3v2, Vorbis comments, MP4 atoms — artist, album, genre, year.</p> <p>ID3v2, Vorbis comments, MP4 atoms — artist, album, genre, year, lyrics, cover art.</p>
</div> </div>
<div class="feature"> <div class="feature">
<div class="feature-icon"></div> <div class="feature-icon"></div>
+10
View File
@@ -195,6 +195,16 @@ func fetchLyricsCmd(artist, title string) tea.Cmd {
} }
} }
func fetchTrackLyricsCmd(track playlist.Track, artist, title string) tea.Cmd {
return func() tea.Msg {
if lines := lyrics.ParseEmbedded(track.EmbeddedLyrics); len(lines) > 0 {
return lyricsLoadedMsg{lines: lines}
}
lines, err := lyrics.Fetch(artist, title)
return lyricsLoadedMsg{lines: lines, err: err}
}
}
func fetchNetSearchCmd(query string) tea.Cmd { func fetchNetSearchCmd(query string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
tracks, err := resolve.Remote([]string{query}) tracks, err := resolve.Remote([]string{query})
+2 -1
View File
@@ -711,6 +711,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
case "y": case "y":
m.lyrics.visible = !m.lyrics.visible m.lyrics.visible = !m.lyrics.visible
if m.lyrics.visible && !m.lyrics.loading { if m.lyrics.visible && !m.lyrics.loading {
track, _ := m.currentPlaybackTrack()
artist, title := m.lyricsArtistTitle() artist, title := m.lyricsArtistTitle()
if artist != "" && title != "" { if artist != "" && title != "" {
q := artist + "\n" + title q := artist + "\n" + title
@@ -719,7 +720,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
m.lyrics.loading = true m.lyrics.loading = true
m.lyrics.lines = nil m.lyrics.lines = nil
m.lyrics.err = nil m.lyrics.err = nil
return fetchLyricsCmd(artist, title) return fetchTrackLyricsCmd(track, artist, title)
} }
} }
} }
+1
View File
@@ -95,6 +95,7 @@ func (m *Model) notifyPlayback() {
Genre: track.Genre, Genre: track.Genre,
TrackNumber: track.TrackNumber, TrackNumber: track.TrackNumber,
URL: track.Path, URL: track.Path,
ArtURL: track.AlbumArtURL,
Duration: m.player.Duration(), Duration: m.player.Duration(),
}, },
VolumeDB: m.player.Volume(), VolumeDB: m.player.Volume(),
+28 -20
View File
@@ -190,26 +190,7 @@ func (m *Model) playTrack(track playlist.Track) tea.Cmd {
m.status.Show("Loading feed...", statusTTLLong) m.status.Show("Loading feed...", statusTTLLong)
return resolveFeedTrackCmd(track.Path) return resolveFeedTrackCmd(track.Path)
} }
track, fetchCmd := m.beginPlaybackTrack(track)
m.setPlaybackTrack(track)
m.reconnect.attempts = 0
m.reconnect.at = time.Time{}
m.streamTitle = ""
m.lyrics.lines = nil
m.lyrics.err = nil
m.lyrics.query = ""
m.lyrics.scroll = 0
m.seek.active = false
m.seek.timer = 0
m.seek.timerFor = 0
m.seek.grace = 0
m.seek.graceFor = 0
var fetchCmd tea.Cmd
if m.lyrics.visible && track.Artist != "" && track.Title != "" {
m.lyrics.loading = true
m.lyrics.query = track.Artist + "\n" + track.Title
fetchCmd = fetchLyricsCmd(track.Artist, track.Title)
}
// Stream yt-dlp URLs (YouTube, SoundCloud, Bandcamp, etc.) via pipe chain. // Stream yt-dlp URLs (YouTube, SoundCloud, Bandcamp, etc.) via pipe chain.
if playlist.IsYTDL(track.Path) { if playlist.IsYTDL(track.Path) {
@@ -252,6 +233,33 @@ func (m *Model) playTrack(track playlist.Track) tea.Cmd {
return m.preloadNext() return m.preloadNext()
} }
// beginPlaybackTrack centralizes metadata refresh and model state reset for a
// new active track. It is used both by explicit playback and by gapless
// transitions, which advance audio without calling playTrack.
func (m *Model) beginPlaybackTrack(track playlist.Track) (playlist.Track, tea.Cmd) {
track = playlist.RefreshEmbeddedMetadata(track)
m.setPlaybackTrack(track)
m.reconnect.attempts = 0
m.reconnect.at = time.Time{}
m.streamTitle = ""
m.lyrics.lines = nil
m.lyrics.err = nil
m.lyrics.query = ""
m.lyrics.scroll = 0
m.seek.active = false
m.seek.timer = 0
m.seek.timerFor = 0
m.seek.grace = 0
m.seek.graceFor = 0
if m.lyrics.visible && track.Artist != "" && track.Title != "" {
m.lyrics.loading = true
m.lyrics.query = track.Artist + "\n" + track.Title
return track, fetchTrackLyricsCmd(track, track.Artist, track.Title)
}
m.lyrics.loading = false
return track, nil
}
// togglePlayPause starts playback if stopped, or toggles pause if playing. // togglePlayPause starts playback if stopped, or toggles pause if playing.
// For live streams, unpausing reconnects to get current audio instead of // For live streams, unpausing reconnects to get current audio instead of
// playing stale data sitting in OS/decoder buffers from before the pause. // playing stale data sitting in OS/decoder buffers from before the pause.
+52 -3
View File
@@ -12,6 +12,7 @@ import (
type playbackFakeEngine struct { type playbackFakeEngine struct {
playing bool playing bool
gaplessAdvanced bool
playCalls []string playCalls []string
preloadCalls []string preloadCalls []string
clearPreloadCalls int clearPreloadCalls int
@@ -42,9 +43,15 @@ func (f *playbackFakeEngine) HasPreload() bool { return f
func (f *playbackFakeEngine) Seekable() bool { return false } func (f *playbackFakeEngine) Seekable() bool { return false }
func (f *playbackFakeEngine) IsStreamSeek() bool { return false } func (f *playbackFakeEngine) IsStreamSeek() bool { return false }
func (f *playbackFakeEngine) IsYTDLSeek() bool { return false } func (f *playbackFakeEngine) IsYTDLSeek() bool { return false }
func (f *playbackFakeEngine) GaplessAdvanced() bool { return false } func (f *playbackFakeEngine) GaplessAdvanced() bool {
func (f *playbackFakeEngine) Position() time.Duration { return 0 } if !f.gaplessAdvanced {
func (f *playbackFakeEngine) Duration() time.Duration { return 0 } return false
}
f.gaplessAdvanced = false
return true
}
func (f *playbackFakeEngine) Position() time.Duration { return 0 }
func (f *playbackFakeEngine) Duration() time.Duration { return 0 }
func (f *playbackFakeEngine) PositionAndDuration() (time.Duration, time.Duration) { func (f *playbackFakeEngine) PositionAndDuration() (time.Duration, time.Duration) {
return 0, 0 return 0, 0
} }
@@ -280,3 +287,45 @@ func TestPreloadAfterProviderPlaylistLoadUsesFirstNewTrack(t *testing.T) {
t.Fatalf("preloadCalls = %v, want first new track", player.preloadCalls) t.Fatalf("preloadCalls = %v, want first new track", player.preloadCalls)
} }
} }
func TestGaplessAdvanceRefreshesLyricsAndArtwork(t *testing.T) {
player := &playbackFakeEngine{playing: true, gaplessAdvanced: true}
p := playlist.New()
p.Replace([]playlist.Track{
{Title: "Old", Artist: "Artist", Path: "old.mp3", DurationSecs: 180, EmbeddedLyrics: "old lyric", AlbumArtURL: "file:///old.jpg"},
{Title: "New", Artist: "Artist", Path: "new.mp3", DurationSecs: 180, EmbeddedLyrics: "[00:01.00]new lyric", AlbumArtURL: "file:///new.jpg"},
})
p.SetIndex(0)
m := Model{
player: player,
playlist: p,
vis: ui.NewVisualizer(float64(player.SampleRate())),
lyrics: lyricsState{
visible: true,
query: "Artist\nOld",
},
}
m.setPlaybackTrack(p.Tracks()[0])
m.lyrics.lines = nil
next, cmd := m.Update(tickMsg(time.Now()))
m2 := next.(Model)
if cmd == nil {
t.Fatal("Update() command = nil, want lyric/preload/tick batch")
}
track, _ := m2.currentPlaybackTrack()
if track.Title != "New" {
t.Fatalf("current track = %q, want New", track.Title)
}
if track.AlbumArtURL != "file:///new.jpg" {
t.Fatalf("AlbumArtURL = %q, want new artwork", track.AlbumArtURL)
}
if m2.lyrics.query != "Artist\nNew" {
t.Fatalf("lyrics.query = %q, want new track query", m2.lyrics.query)
}
if !m2.lyrics.loading {
t.Fatal("lyrics.loading = false, want true for new track fetch")
}
}
+5 -1
View File
@@ -265,7 +265,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.plCursor = m.playlist.Index() m.plCursor = m.playlist.Index()
m.adjustScroll() m.adjustScroll()
m.titleOff = 0 m.titleOff = 0
m.setPlaybackTrack(newTrack) var gaplessLyricCmd tea.Cmd
newTrack, gaplessLyricCmd = m.beginPlaybackTrack(newTrack)
if gaplessLyricCmd != nil {
cmds = append(cmds, gaplessLyricCmd)
}
// The preload that just fired is consumed — clear the in-flight flag // The preload that just fired is consumed — clear the in-flight flag
// so the next track can be preloaded. // so the next track can be preloaded.
m.preloading = false m.preloading = false