Skip to content

Commit 967a84e

Browse files
program247365claude
andcommitted
feat(now-playing): full artwork + metadata for every provider
Populate the OS Now Playing widget (macOS Control Center / MPRIS) with artwork, real artist, and album for all music providers — previously it showed a blank cover and the service name as the artist. - Cover art: set_metadata passes thumbnail_path as a percent-encoded file:// URL (souvlaki feeds it to NSImage / MPMediaItemArtwork on macOS, the MPRIS URI on Linux). Uniform across providers. - Stream-first SoundCloud/HypeM tracks aren't downloaded and so had no art; fetch their thumbnail lazily for the current track via a new ytdlp::fetch_thumbnail (one yt-dlp call that writes the thumbnail and prints the id used to name it). Done in prepare_track_for_playback so a playlist fetches one-at-a-time as tracks play, not all upfront. - Local files have no embedded art: use a bundled fallback cover (assets/local-cover.png, embedded via include_bytes! and materialized into the cache) so the widget is never blank. - Real artist: new TrackInfo.artist, joined from Spotify Track.artists and parsed from yt-dlp artist/creator/uploader/channel. set_metadata prefers it, falling back to the service name. Album is the playlist/album collection name. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8255c45 commit 967a84e

8 files changed

Lines changed: 195 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,15 @@ There are now two major UI modes:
245245
- a directly-requested **single** Spotify track that is unavailable is caught at
246246
resolve (`ensure_track_available`) so it shows the modal; unavailable tracks
247247
inside a playlist/album are silently dropped during concurrent metadata fetch.
248+
- the OS Now Playing widget (`media_controls::set_metadata`) gets title, artist
249+
(real artist when the source provides one — Spotify `Track.artists`, yt-dlp
250+
`artist`/`uploader`; falls back to the service name), album (the playlist/album
251+
`collection`), and cover art. Cover art is passed as a percent-encoded
252+
`file://` URL of `thumbnail_path` (souvlaki hands it to `NSImage` /
253+
MPRIS). Stream-first SoundCloud/HypeM tracks fetch their thumbnail lazily at
254+
playback via `ytdlp::fetch_thumbnail`; local files (no embedded art) use a
255+
bundled fallback cover embedded from `assets/local-cover.png` via
256+
`include_bytes!` and materialized into the cache, so the widget is never blank.
248257
- `souvlaki` is wired with the `use_zbus` feature so Linux builds don't need `libdbus-1-dev`; macOS uses `MPRemoteCommandCenter` + `MPNowPlayingInfoCenter` directly. Windows is intentionally unwired (would need a hidden message-only HWND + a per-tick `pump_event_queue`).
249258

250259
## Tests

assets/local-cover.png

18.8 KB
Loading

assets/local-cover.svg

Lines changed: 46 additions & 0 deletions
Loading

src/media_controls.rs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use color_eyre::eyre::{eyre, Result};
22
use souvlaki::{
33
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig,
44
};
5+
use std::path::Path;
56
use std::sync::{
67
mpsc::{Receiver, Sender},
78
Arc, Mutex,
@@ -70,10 +71,18 @@ pub struct MediaSessionHandle {
7071

7172
impl MediaSessionHandle {
7273
pub fn set_metadata(&self, track: &TrackInfo) {
74+
// souvlaki loads the cover via NSURL (macOS) / MPRIS URI (Linux), so the
75+
// downloaded album art / thumbnail has to be handed over as a `file://`
76+
// URL. `cover_url` borrows the string, so it must outlive the call.
77+
let cover_url = track.thumbnail_path.as_deref().map(file_url);
7378
let mut controls = self.controls.lock().unwrap();
7479
let _ = controls.set_metadata(MediaMetadata {
7580
title: Some(&track.title),
76-
artist: track.service.as_deref(),
81+
// Real artist when the source gave us one; otherwise the service
82+
// name so the widget subtitle is never empty.
83+
artist: track.artist.as_deref().or(track.service.as_deref()),
84+
album: track.collection.as_deref(),
85+
cover_url: cover_url.as_deref(),
7786
duration: track.duration_secs.map(Duration::from_secs_f64),
7887
..Default::default()
7988
});
@@ -94,3 +103,43 @@ impl MediaSessionHandle {
94103
let _ = controls.set_playback(state);
95104
}
96105
}
106+
107+
/// Build a `file://` URL for a local path. souvlaki passes `cover_url` straight
108+
/// to `NSURL URLWithString:` (macOS) / a MPRIS URI (Linux), both of which need a
109+
/// percent-encoded URL — so a raw path with spaces would silently fail to load.
110+
/// Keeps RFC 3986 unreserved characters and `/`, percent-encodes everything else
111+
/// per UTF-8 byte.
112+
fn file_url(path: &Path) -> String {
113+
let mut url = String::from("file://");
114+
for byte in path.to_string_lossy().bytes() {
115+
match byte {
116+
b'/' | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
117+
url.push(byte as char)
118+
}
119+
_ => url.push_str(&format!("%{byte:02X}")),
120+
}
121+
}
122+
url
123+
}
124+
125+
#[cfg(test)]
126+
mod tests {
127+
use super::file_url;
128+
use std::path::Path;
129+
130+
#[test]
131+
fn encodes_spaces_and_keeps_slashes() {
132+
assert_eq!(
133+
file_url(Path::new("/Users/First Last/art/a b.jpg")),
134+
"file:///Users/First%20Last/art/a%20b.jpg"
135+
);
136+
}
137+
138+
#[test]
139+
fn leaves_plain_paths_untouched() {
140+
assert_eq!(
141+
file_url(Path::new("/Users/kevin/Library/Caches/sh.kbr.looper/spotify/art/ab12.jpg")),
142+
"file:///Users/kevin/Library/Caches/sh.kbr.looper/spotify/art/ab12.jpg"
143+
);
144+
}
145+
}

src/play_loop.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,19 @@ fn browse_history_session(
179179
}
180180
}
181181

182+
/// Path to the bundled fallback album cover for local files, which have no
183+
/// embedded art. The PNG is embedded in the binary and materialized into the
184+
/// cache on first use, so a Homebrew-installed binary needs no external asset.
185+
/// Best-effort: `None` if it can't be written (then the cover is simply blank).
186+
fn local_file_cover() -> Option<std::path::PathBuf> {
187+
const COVER: &[u8] = include_bytes!("../assets/local-cover.png");
188+
let path = plugin::cache_dir_path().ok()?.join("local-cover.png");
189+
if !path.exists() {
190+
std::fs::write(&path, COVER).ok()?;
191+
}
192+
Some(path)
193+
}
194+
182195
fn play_file_session(
183196
terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
184197
title_state: &mut TitleState,
@@ -231,9 +244,12 @@ fn play_file_session(
231244
source_url: None,
232245
pending_download: None,
233246
service: None,
234-
thumbnail_path: None,
247+
// Local files have no embedded art; use the bundled fallback
248+
// cover so the macOS Now Playing widget is never blank.
249+
thumbnail_path: local_file_cover(),
235250
is_live: false,
236251
collection: None,
252+
artist: None,
237253
}],
238254
false,
239255
ctx,
@@ -1355,6 +1371,23 @@ fn prepare_track_for_playback(
13551371
}
13561372
}
13571373

1374+
// Stream-first tracks (SoundCloud/HypeM) are never downloaded, so the
1375+
// cached-file backfill above never runs and they'd have no artwork. Fetch
1376+
// the thumbnail lazily for the current track only (not every track in a
1377+
// playlist upfront). Spotify and YouTube-live already carry their art.
1378+
if track.thumbnail_path.is_none()
1379+
&& matches!(
1380+
track.playback,
1381+
PlaybackInput::HttpStream { .. } | PlaybackInput::ProcessStdout { .. }
1382+
)
1383+
{
1384+
if let Some(source_url) = track.source_url.clone() {
1385+
if let Ok(cache_dir) = plugin::cache_dir_path() {
1386+
track.thumbnail_path = ytdlp::fetch_thumbnail(&source_url, &cache_dir);
1387+
}
1388+
}
1389+
}
1390+
13581391
let Some(pending) = track.pending_download.clone() else {
13591392
return Ok(false);
13601393
};

src/plugin/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ pub struct TrackInfo {
2727
/// Name of the playlist or album this track was resolved from, if any.
2828
/// Shown in the playback header so you know what collection is playing.
2929
pub collection: Option<String>,
30+
/// Performing artist(s), when the source provides them. Surfaced in the OS
31+
/// Now Playing widget; falls back to the service name when absent.
32+
pub artist: Option<String>,
3033
}
3134

3235
pub trait ServicePlugin {

src/plugin/ytdlp.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use super::TrackInfo;
1515
pub struct MetadataEntry {
1616
pub id: String,
1717
pub title: String,
18+
pub artist: Option<String>,
1819
pub duration_secs: Option<f64>,
1920
pub webpage_url: String,
2021
pub live_status: LiveStatus,
@@ -92,6 +93,7 @@ pub fn cached_track_from_entry(entry: MetadataEntry, cache_dir: &Path, service:
9293
thumbnail_path,
9394
is_live: false,
9495
collection: None,
96+
artist: entry.artist,
9597
}
9698
}
9799

@@ -107,6 +109,7 @@ pub fn streaming_track_from_entry(entry: MetadataEntry, service: &str) -> Result
107109
thumbnail_path: None,
108110
is_live: matches!(entry.live_status, LiveStatus::IsLive),
109111
collection: None,
112+
artist: entry.artist,
110113
})
111114
}
112115

@@ -132,6 +135,7 @@ pub fn resolve_streaming_tracks(url: &str) -> Result<Vec<TrackInfo>> {
132135
thumbnail_path: None,
133136
is_live,
134137
collection: None,
138+
artist: entry.artist,
135139
})
136140
})
137141
.collect()
@@ -232,6 +236,38 @@ pub fn download_thumbnail_only(url: &str, cache_dir: &Path) -> Result<()> {
232236
Ok(())
233237
}
234238

239+
/// Fetch a track's thumbnail and return its cached path. One yt-dlp call both
240+
/// writes the thumbnail (named `<id>.jpg`) and prints the id used to name it, so
241+
/// callers don't need to know the id ahead of time. Best-effort: `None` on any
242+
/// failure. Used to lazily backfill artwork for stream-first tracks
243+
/// (SoundCloud/HypeM), which are never downloaded.
244+
pub fn fetch_thumbnail(url: &str, cache_dir: &Path) -> Option<PathBuf> {
245+
let output_template = cache_dir.join("%(id)s.%(ext)s");
246+
let output = Command::new("yt-dlp")
247+
.arg("--skip-download")
248+
.arg("--write-thumbnail")
249+
.arg("--convert-thumbnails")
250+
.arg("jpg")
251+
.arg("--no-playlist")
252+
.arg("--no-warnings")
253+
.arg("--print")
254+
.arg("id")
255+
.arg("-o")
256+
.arg(&output_template)
257+
.arg(url)
258+
.stderr(Stdio::null())
259+
.output()
260+
.ok()?;
261+
if !output.status.success() {
262+
return None;
263+
}
264+
let id = String::from_utf8_lossy(&output.stdout).trim().to_string();
265+
if id.is_empty() {
266+
return None;
267+
}
268+
thumbnail_for(cache_dir, &id)
269+
}
270+
235271
pub fn download_track_with_progress(
236272
url: &str,
237273
cache_dir: &Path,
@@ -365,6 +401,9 @@ fn parse_entry(value: Value, fallback_url: &str) -> Result<MetadataEntry> {
365401
.map(sanitize_id)
366402
.unwrap_or_else(|| sanitize_id(&title));
367403

404+
let artist = first_str(&value, &["artist", "creator", "uploader", "channel"])
405+
.map(str::to_string)
406+
.filter(|s| !s.is_empty());
368407
let duration_secs = value.get("duration").and_then(Value::as_f64);
369408
let webpage_url = youtube_watch_url(&value)
370409
.or_else(|| {
@@ -382,6 +421,7 @@ fn parse_entry(value: Value, fallback_url: &str) -> Result<MetadataEntry> {
382421
Ok(MetadataEntry {
383422
id,
384423
title,
424+
artist,
385425
duration_secs,
386426
webpage_url,
387427
live_status,

src/spotify/mod.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,18 @@ fn track_info(
141141
collection: Option<String>,
142142
thumbnail_path: Option<PathBuf>,
143143
) -> TrackInfo {
144+
let artist = if track.artists.is_empty() {
145+
None
146+
} else {
147+
Some(
148+
track
149+
.artists
150+
.iter()
151+
.map(|a| a.name.clone())
152+
.collect::<Vec<_>>()
153+
.join(", "),
154+
)
155+
};
144156
TrackInfo {
145157
title: track.name,
146158
duration_secs: Some((track.duration.max(0) as f64) / 1000.0),
@@ -151,6 +163,7 @@ fn track_info(
151163
thumbnail_path,
152164
is_live: false,
153165
collection,
166+
artist,
154167
}
155168
}
156169

0 commit comments

Comments
 (0)