diff --git a/cbd-tui/src/app/now_playing.rs b/cbd-tui/src/app/now_playing.rs index d1670f7..128ae7d 100644 --- a/cbd-tui/src/app/now_playing.rs +++ b/cbd-tui/src/app/now_playing.rs @@ -630,19 +630,19 @@ fn notify_now_playing(track: &Track) { } } -/// The notification body: the track, and on its own line the album with the -/// release date the provider gave. Most providers leave `release_date` unset -/// (it is an optional proto field, so absent reads as ""), which is why the -/// parentheses belong to the date and not to the album line — otherwise a -/// dateless album shows up as "Album ()". +/// 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 album.release_date() { - "" => String::new(), - date => format!(" ({date})"), + let released = match release_year(album.release_date()) { + Some(year) => format!(" ({year})"), + None => String::new(), }; format!( "{} by {}\n\n{}{released}", @@ -650,6 +650,21 @@ fn notification_body(track: &Track) -> String { ) } +/// 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 /// (architecture/build-features.md D1). #[cfg(not(feature = "notifications"))] @@ -700,13 +715,15 @@ mod tests { 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_date_when_there_is_one() { + fn notification_shows_the_release_year_when_there_is_one() { assert_eq!( body_for(Some("1977-10-28")), - "title by artist\n\nalbum (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 @@ -718,6 +735,31 @@ mod tests { 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() {