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:
@@ -0,0 +1 @@
|
||||
I use AI, and if this file is not deleted, I haven't reviewed my own code.
|
||||
@@ -156,6 +156,7 @@ func PlaylistShow(name string, jsonOutput bool) error {
|
||||
Year int `json:"year,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
DurationSecs int `json:"duration_secs,omitempty"`
|
||||
AlbumArtURL string `json:"album_art_url,omitempty"`
|
||||
Bookmark bool `json:"bookmark,omitempty"`
|
||||
}
|
||||
out := make([]jsonTrack, len(tracks))
|
||||
@@ -169,6 +170,7 @@ func PlaylistShow(name string, jsonOutput bool) error {
|
||||
Year: t.Year,
|
||||
TrackNumber: t.TrackNumber,
|
||||
DurationSecs: t.DurationSecs,
|
||||
AlbumArtURL: t.AlbumArtURL,
|
||||
Bookmark: t.Bookmark,
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -1,12 +1,14 @@
|
||||
# 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
|
||||
|
||||
- **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.
|
||||
|
||||
Embedded LRC lyrics keep their timestamps. Embedded plain text lyrics are shown in scroll mode.
|
||||
|
||||
## Streams
|
||||
|
||||
Lyrics auto update when the ICY metadata changes (e.g., internet radio station transitions).
|
||||
|
||||
@@ -69,6 +69,7 @@ Track metadata is published under the standard MPRIS keys:
|
||||
| `xesam:artist` | Artist name (as a list with one entry) |
|
||||
| `xesam:album` | Album name, when available |
|
||||
| `xesam:url` | File path or stream URL |
|
||||
| `mpris:artUrl` | Embedded album artwork from local files, when available |
|
||||
| `mpris:length` | Duration in microseconds |
|
||||
|
||||
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)
|
||||
- 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.
|
||||
|
||||
## Architecture
|
||||
|
||||
+8
-1
@@ -82,6 +82,14 @@ Each `[[track]]` section supports:
|
||||
| `path` | Yes | File path or HTTP URL |
|
||||
| `title` | Yes | Display title |
|
||||
| `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.
|
||||
|
||||
@@ -191,4 +199,3 @@ title = "My Radio"
|
||||
| `a` | Add currently playing track |
|
||||
| `d` | Delete playlist (confirms) / Remove track |
|
||||
| `←` / `Backspace` | Go back from tracks screen to list |
|
||||
|
||||
|
||||
Vendored
+8
@@ -458,6 +458,12 @@ func writeTrack(w io.Writer, t playlist.Track) {
|
||||
if t.DurationSecs != 0 {
|
||||
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 {
|
||||
fmt.Fprintln(w, "bookmark = true")
|
||||
}
|
||||
@@ -481,6 +487,8 @@ func (p *Provider) loadTOML(path string) ([]playlist.Track, error) {
|
||||
Genre: f["genre"],
|
||||
Feed: f["feed"] == "true",
|
||||
}
|
||||
t.EmbeddedLyrics = f["embedded_lyrics"]
|
||||
t.AlbumArtURL = f["album_art_url"]
|
||||
t.Stream = playlist.IsURL(t.Path)
|
||||
// "favorite" is the pre-rename alias for "bookmark"; prefer bookmark.
|
||||
bookmark, ok := f["bookmark"]
|
||||
|
||||
Vendored
+8
-1
@@ -100,6 +100,8 @@ func TestWriteTrackAllFields(t *testing.T) {
|
||||
DurationSecs: 240,
|
||||
Bookmark: true,
|
||||
Feed: true,
|
||||
EmbeddedLyrics: "[00:01.00]Line",
|
||||
AlbumArtURL: "file:///tmp/cover.jpg",
|
||||
})
|
||||
got := buf.String()
|
||||
|
||||
@@ -112,6 +114,8 @@ func TestWriteTrackAllFields(t *testing.T) {
|
||||
"year = 2024",
|
||||
"track_number = 3",
|
||||
"duration_secs = 240",
|
||||
`embedded_lyrics = "[00:01.00]Line"`,
|
||||
`album_art_url = "file:///tmp/cover.jpg"`,
|
||||
"bookmark = true",
|
||||
"feed = true",
|
||||
} {
|
||||
@@ -128,7 +132,7 @@ func TestLoadTOMLRoundTrip(t *testing.T) {
|
||||
os.MkdirAll(p.dir, 0o755)
|
||||
|
||||
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},
|
||||
}
|
||||
|
||||
@@ -153,6 +157,9 @@ func TestLoadTOMLRoundTrip(t *testing.T) {
|
||||
if loaded[0].Year != 2020 || loaded[0].TrackNumber != 1 || loaded[0].DurationSecs != 180 {
|
||||
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" {
|
||||
t.Fatalf("track 1 mismatch: %+v", loaded[1])
|
||||
|
||||
@@ -32,6 +32,7 @@ type Track struct {
|
||||
Genre string
|
||||
TrackNumber int
|
||||
URL string
|
||||
ArtURL string
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ func TestStateFields(t *testing.T) {
|
||||
Genre: "Rock",
|
||||
TrackNumber: 3,
|
||||
URL: "file:///song.mp3",
|
||||
ArtURL: "file:///cover.jpg",
|
||||
Duration: 3 * time.Minute,
|
||||
},
|
||||
VolumeDB: -3.0,
|
||||
|
||||
@@ -112,6 +112,24 @@ func Fetch(artist, title string) ([]Line, error) {
|
||||
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) {
|
||||
searchURL := fmt.Sprintf("https://lrclib.net/api/search?q=%s", url.QueryEscape(query))
|
||||
|
||||
|
||||
@@ -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) {
|
||||
tests := []struct {
|
||||
input, want string
|
||||
|
||||
@@ -37,6 +37,9 @@ func makeMetadata(t playback.Track, trackID dbus.ObjectPath) map[string]dbus.Var
|
||||
if t.URL != "" {
|
||||
m["xesam:url"] = dbus.MakeVariant(t.URL)
|
||||
}
|
||||
if t.ArtURL != "" {
|
||||
m["mpris:artUrl"] = dbus.MakeVariant(t.ArtURL)
|
||||
}
|
||||
if t.Duration > 0 {
|
||||
m["mpris:length"] = dbus.MakeVariant(t.Duration.Microseconds())
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ func TestMakeMetadataMapsPlaybackTrackToMPRISFields(t *testing.T) {
|
||||
Genre: "Ambient",
|
||||
TrackNumber: 7,
|
||||
URL: "file:///tmp/song.mp3",
|
||||
ArtURL: "file:///tmp/cover.jpg",
|
||||
Duration: 3*time.Minute + 15*time.Second,
|
||||
}
|
||||
|
||||
@@ -31,6 +32,7 @@ func TestMakeMetadataMapsPlaybackTrackToMPRISFields(t *testing.T) {
|
||||
"xesam:genre": dbus.MakeVariant([]string{"Ambient"}),
|
||||
"xesam:trackNumber": dbus.MakeVariant(7),
|
||||
"xesam:url": dbus.MakeVariant("file:///tmp/song.mp3"),
|
||||
"mpris:artUrl": dbus.MakeVariant("file:///tmp/cover.jpg"),
|
||||
"mpris:length": dbus.MakeVariant(track.Duration.Microseconds()),
|
||||
}
|
||||
|
||||
@@ -64,6 +66,7 @@ func TestMakeMetadataOmitsEmptyOptionalFields(t *testing.T) {
|
||||
"xesam:genre",
|
||||
"xesam:trackNumber",
|
||||
"xesam:url",
|
||||
"mpris:artUrl",
|
||||
"mpris:length",
|
||||
} {
|
||||
if _, ok := got[key]; ok {
|
||||
|
||||
@@ -125,7 +125,7 @@ static void bridgeDestroy(MediaCtlBridgeRef ref) {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@autoreleasepool {
|
||||
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 (artist) info[MPMediaItemPropertyArtist] = @(artist);
|
||||
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 (elapsedSecs >= 0) info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = @(elapsedSecs);
|
||||
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 {
|
||||
title, artist, album string
|
||||
artURL string
|
||||
durationSecs, elapsedSecs float64
|
||||
status playback.Status
|
||||
canSeek bool
|
||||
@@ -486,7 +500,7 @@ func (s *Service) beginRelease(allowRunLoop bool) (cgo.Handle, C.MediaCtlBridgeR
|
||||
}
|
||||
|
||||
func applyUpdate(req updateReq) {
|
||||
var cTitle, cArtist, cAlbum *C.char
|
||||
var cTitle, cArtist, cAlbum, cArtURL *C.char
|
||||
if req.title != "" {
|
||||
cTitle = C.CString(req.title)
|
||||
defer C.free(unsafe.Pointer(cTitle))
|
||||
@@ -499,11 +513,15 @@ func applyUpdate(req updateReq) {
|
||||
cAlbum = C.CString(req.album)
|
||||
defer C.free(unsafe.Pointer(cAlbum))
|
||||
}
|
||||
if req.artURL != "" {
|
||||
cArtURL = C.CString(req.artURL)
|
||||
defer C.free(unsafe.Pointer(cArtURL))
|
||||
}
|
||||
canSeek := C.int(0)
|
||||
if req.canSeek {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -526,6 +544,7 @@ func (s *Service) Update(state playback.State) {
|
||||
title: state.Track.Title,
|
||||
artist: state.Track.Artist,
|
||||
album: state.Track.Album,
|
||||
artURL: state.Track.ArtURL,
|
||||
durationSecs: state.Track.Duration.Seconds(),
|
||||
elapsedSecs: state.Position.Seconds(),
|
||||
status: state.Status,
|
||||
|
||||
@@ -90,7 +90,7 @@ func TestDarwinUpdateCoalescesPendingState(t *testing.T) {
|
||||
})
|
||||
svc.Update(playback.State{
|
||||
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,
|
||||
Seekable: false,
|
||||
})
|
||||
@@ -100,6 +100,7 @@ func TestDarwinUpdateCoalescesPendingState(t *testing.T) {
|
||||
want := updateReq{
|
||||
title: "second",
|
||||
artist: "artist",
|
||||
artURL: "file:///tmp/cover.jpg",
|
||||
durationSecs: 0,
|
||||
elapsedSecs: 2.25,
|
||||
status: playback.StatusPlaying,
|
||||
|
||||
@@ -48,6 +48,9 @@ type Track struct {
|
||||
|
||||
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.
|
||||
// Keys are namespaced by provider, e.g. "navidrome.id", "jellyfin.id".
|
||||
ProviderMeta map[string]string
|
||||
|
||||
+171
-6
@@ -1,17 +1,64 @@
|
||||
package playlist
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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
|
||||
// a local audio file and returns a Track. Falls back to filename parsing if
|
||||
// tag reading fails or the tags contain no title.
|
||||
func readTags(path string) Track {
|
||||
return readTagsWithOptions(path, false)
|
||||
}
|
||||
|
||||
func readTagsWithOptions(path string, cacheArt bool) Track {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return TrackFromFilename(path)
|
||||
@@ -19,23 +66,141 @@ func readTags(path string) Track {
|
||||
defer f.Close()
|
||||
|
||||
m, err := tag.ReadFrom(f)
|
||||
if err != nil || m == nil || strings.TrimSpace(m.Title()) == "" {
|
||||
if err != nil || m == nil {
|
||||
return TrackFromFilename(path)
|
||||
}
|
||||
|
||||
t := Track{
|
||||
Path: path,
|
||||
Title: sanitizeTag(strings.TrimSpace(m.Title())),
|
||||
Artist: sanitizeTag(strings.TrimSpace(m.Artist())),
|
||||
Album: sanitizeTag(strings.TrimSpace(m.Album())),
|
||||
Genre: sanitizeTag(strings.TrimSpace(m.Genre())),
|
||||
Year: m.Year(),
|
||||
EmbeddedLyrics: sanitizeTag(strings.TrimSpace(m.Lyrics())),
|
||||
}
|
||||
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()
|
||||
t.TrackNumber = trackNum
|
||||
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
|
||||
// filename, or using the bare filename as the title.
|
||||
func TrackFromFilename(path string) Track {
|
||||
|
||||
+63
-1
@@ -1,6 +1,15 @@
|
||||
package playlist
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
)
|
||||
|
||||
func TestTrackMeta(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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
+3
-3
@@ -1628,7 +1628,7 @@ user_id = "your-account-user-id"</code></pre>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">♪</div>
|
||||
<div class="feature-name">Synced Lyrics</div>
|
||||
<p>Auto-scrolling lyrics for local & Navidrome tracks. Press <kbd>y</kbd>.</p>
|
||||
<p>Embedded local lyrics first, then LRCLIB/NetEase fallback. Auto-scrolling for timestamped lyrics.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">⇄</div>
|
||||
@@ -1638,7 +1638,7 @@ user_id = "your-account-user-id"</code></pre>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">♬</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 class="feature">
|
||||
<div class="feature-icon">▮</div>
|
||||
@@ -1658,7 +1658,7 @@ user_id = "your-account-user-id"</code></pre>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">✎</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 class="feature">
|
||||
<div class="feature-icon">▶</div>
|
||||
|
||||
@@ -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 {
|
||||
return func() tea.Msg {
|
||||
tracks, err := resolve.Remote([]string{query})
|
||||
|
||||
+2
-1
@@ -711,6 +711,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
|
||||
case "y":
|
||||
m.lyrics.visible = !m.lyrics.visible
|
||||
if m.lyrics.visible && !m.lyrics.loading {
|
||||
track, _ := m.currentPlaybackTrack()
|
||||
artist, title := m.lyricsArtistTitle()
|
||||
if artist != "" && title != "" {
|
||||
q := artist + "\n" + title
|
||||
@@ -719,7 +720,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
|
||||
m.lyrics.loading = true
|
||||
m.lyrics.lines = nil
|
||||
m.lyrics.err = nil
|
||||
return fetchLyricsCmd(artist, title)
|
||||
return fetchTrackLyricsCmd(track, artist, title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ func (m *Model) notifyPlayback() {
|
||||
Genre: track.Genre,
|
||||
TrackNumber: track.TrackNumber,
|
||||
URL: track.Path,
|
||||
ArtURL: track.AlbumArtURL,
|
||||
Duration: m.player.Duration(),
|
||||
},
|
||||
VolumeDB: m.player.Volume(),
|
||||
|
||||
+28
-20
@@ -190,26 +190,7 @@ func (m *Model) playTrack(track playlist.Track) tea.Cmd {
|
||||
m.status.Show("Loading feed...", statusTTLLong)
|
||||
return resolveFeedTrackCmd(track.Path)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
track, fetchCmd := m.beginPlaybackTrack(track)
|
||||
|
||||
// Stream yt-dlp URLs (YouTube, SoundCloud, Bandcamp, etc.) via pipe chain.
|
||||
if playlist.IsYTDL(track.Path) {
|
||||
@@ -252,6 +233,33 @@ func (m *Model) playTrack(track playlist.Track) tea.Cmd {
|
||||
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.
|
||||
// For live streams, unpausing reconnects to get current audio instead of
|
||||
// playing stale data sitting in OS/decoder buffers from before the pause.
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
type playbackFakeEngine struct {
|
||||
playing bool
|
||||
gaplessAdvanced bool
|
||||
playCalls []string
|
||||
preloadCalls []string
|
||||
clearPreloadCalls int
|
||||
@@ -42,7 +43,13 @@ func (f *playbackFakeEngine) HasPreload() bool { return f
|
||||
func (f *playbackFakeEngine) Seekable() bool { return false }
|
||||
func (f *playbackFakeEngine) IsStreamSeek() bool { return false }
|
||||
func (f *playbackFakeEngine) IsYTDLSeek() bool { return false }
|
||||
func (f *playbackFakeEngine) GaplessAdvanced() bool { return false }
|
||||
func (f *playbackFakeEngine) GaplessAdvanced() bool {
|
||||
if !f.gaplessAdvanced {
|
||||
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) {
|
||||
@@ -280,3 +287,45 @@ func TestPreloadAfterProviderPlaylistLoadUsesFirstNewTrack(t *testing.T) {
|
||||
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
@@ -265,7 +265,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.plCursor = m.playlist.Index()
|
||||
m.adjustScroll()
|
||||
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
|
||||
// so the next track can be preloaded.
|
||||
m.preloading = false
|
||||
|
||||
Reference in New Issue
Block a user