Compare commits

..

2 Commits

Author SHA1 Message Date
Test User 1228c7cb70 cbd-tui: show the album year, not the full release date
A notification has one line for the album, and "album (1977-10-28)"
spends it on a day nobody asked about.

`release_date` is whatever the provider's API (or fsdy's sidecar TOML)
said and is not always ISO 8601, so `release_year` reads the leading four
digits of a year-first date and returns None for anything else — an
unparseable date is dropped rather than shown raw or half-parsed, which
also keeps the "no date, no parentheses" path doing the work. `get(..4)`
is the safe form: a short string or a multi-byte boundary yields None
instead of panicking, and the tests pin both down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 10:57:36 +02:00
Test User bbebee0b4f cbd-tui: no empty parentheses in the now-playing notification
`Album.release_date` is an optional proto field and most providers never
set it, so the notification body read "album ()" for nearly every track:
the parentheses were part of the format string rather than of the date.

Move the body into `notification_body`, where the parentheses belong to
the date and an absent one (unset or empty, since prost's accessor
returns "" for both) simply drops them, and cover the three shapes with
tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 08:15:17 +02:00
1 changed files with 100 additions and 12 deletions

View File

@ -619,18 +619,7 @@ impl NowPlaying {
/// if the binary is renamed or wrapped. /// if the binary is renamed or wrapped.
#[cfg(feature = "notifications")] #[cfg(feature = "notifications")]
fn notify_now_playing(track: &Track) { fn notify_now_playing(track: &Track) {
let body = if let Some(ref album) = track.album { let body = notification_body(track);
format!(
"{} by {}\n\n{} ({})",
track.title,
track.artist,
album.title,
// FIXME: get out year and format differently if it's missing
album.release_date()
)
} else {
format!("{} by {}", track.title, track.artist)
};
if let Err(err) = Notification::new() if let Err(err) = Notification::new()
.appname("crabidy") .appname("crabidy")
.summary("Now playing") .summary("Now playing")
@ -641,6 +630,41 @@ fn notify_now_playing(track: &Track) {
} }
} }
/// The notification body: the track, and on its own line the album with its
/// release year. Most providers leave `release_date` unset (it is an optional
/// proto field, so absent reads as ""), which is why the parentheses belong to
/// the year and not to the album line — otherwise a dateless album shows up as
/// "Album ()".
#[cfg(feature = "notifications")]
fn notification_body(track: &Track) -> String {
let Some(album) = &track.album else {
return format!("{} by {}", track.title, track.artist);
};
let released = match release_year(album.release_date()) {
Some(year) => format!(" ({year})"),
None => String::new(),
};
format!(
"{} by {}\n\n{}{released}",
track.title, track.artist, album.title
)
}
/// The year in a provider's `release_date`, for a notification that has one
/// line to spend on the album.
///
/// The strings are not all ISO 8601 — each provider passes through whatever
/// its API or (for `fsdy`) the sidecar TOML said — so this reads the leading
/// four digits of a `YYYY`/`YYYY-MM-DD`/`YYYY/MM/DD` date and gives up on
/// anything else rather than guessing. An unparseable date is dropped, not
/// shown raw: it is metadata noise, and a notification is not the place to
/// debug it.
#[cfg(feature = "notifications")]
fn release_year(release_date: &str) -> Option<&str> {
let year = release_date.trim().get(..4)?;
year.chars().all(|c| c.is_ascii_digit()).then_some(year)
}
/// Built without the `notifications` feature: nothing to show /// Built without the `notifications` feature: nothing to show
/// (architecture/build-features.md D1). /// (architecture/build-features.md D1).
#[cfg(not(feature = "notifications"))] #[cfg(not(feature = "notifications"))]
@ -679,6 +703,70 @@ mod tests {
} }
} }
/// The notification body for a track on `album`, which carries
/// `release_date`.
#[cfg(feature = "notifications")]
fn body_for(release_date: Option<&str>) -> String {
let mut track = now_playing(0, 0).track.expect("track");
track.album = Some(crabidy_core::proto::crabidy::Album {
title: "album".to_string(),
release_date: release_date.map(str::to_string),
});
notification_body(&track)
}
/// A full date is reduced to its year; a bare year is already one.
#[cfg(feature = "notifications")]
#[test]
fn notification_shows_the_release_year_when_there_is_one() {
assert_eq!(
body_for(Some("1977-10-28")),
"title by artist\n\nalbum (1977)"
);
assert_eq!(body_for(Some("1977")), "title by artist\n\nalbum (1977)");
}
/// No date must mean no parentheses, whether the provider left the field
/// unset or sent it empty.
#[cfg(feature = "notifications")]
#[test]
fn notification_omits_empty_parentheses() {
assert_eq!(body_for(None), "title by artist\n\nalbum");
assert_eq!(body_for(Some("")), "title by artist\n\nalbum");
}
/// Providers hand through whatever their API said, so a date that is not
/// year-first is dropped rather than shown raw or half-parsed. Nothing
/// here may panic — `get(..4)` has to survive a short string and a
/// multi-byte boundary.
#[cfg(feature = "notifications")]
#[test]
fn an_unparseable_release_date_is_left_out() {
for date in ["Oct 1977", "77", "-", "жизнь", "12/05/1977", "197"] {
assert_eq!(
body_for(Some(date)),
"title by artist\n\nalbum",
"date {date:?} should not reach the notification"
);
}
}
/// The year is taken from the front, whatever separator follows it.
#[cfg(feature = "notifications")]
#[test]
fn the_year_is_read_from_the_front_of_the_date() {
assert_eq!(release_year(" 1977-10-28 "), Some("1977"));
assert_eq!(release_year("1977/10/28"), Some("1977"));
assert_eq!(release_year("19771028"), Some("1977"));
}
#[cfg(feature = "notifications")]
#[test]
fn notification_without_an_album_is_just_the_track() {
let track = now_playing(0, 0).track.expect("track");
assert_eq!(notification_body(&track), "title by artist");
}
/// One flat color, no markers: the glyph tests are about the bars' /// One flat color, no markers: the glyph tests are about the bars'
/// geometry, so they pin the paint down and let the color tests own it. /// geometry, so they pin the paint down and let the color tests own it.
fn flat() -> SpectrumStyle { fn flat() -> SpectrumStyle {