| 1 | //! Finding lyrics for the current song. |
| 2 | //! |
| 3 | //! Sources, in order: an `.lrc` file next to the song, the on-disk cache, and |
| 4 | //! [LRCLIB](https://lrclib.net). |
| 5 | |
| 6 | mod lrc; |
| 7 | |
| 8 | use std::{ |
| 9 | collections::HashMap, |
| 10 | path::{Path, PathBuf}, |
| 11 | time::Duration, |
| 12 | }; |
| 13 | |
| 14 | pub use lrc::{LyricLine, Lyrics}; |
| 15 | use serde::Deserialize; |
| 16 | use tokio::{sync::mpsc, task::JoinHandle}; |
| 17 | use tracing::{debug, warn}; |
| 18 | |
| 19 | use crate::{config::LyricsConfig, event::AppEvent, mpd::Song, util}; |
| 20 | |
| 21 | /// What the UI knows about the lyrics of the current song. |
| 22 | #[derive(Clone, Debug, Default)] |
| 23 | pub enum LyricsStatus { |
| 24 | #[default] |
| 25 | Idle, |
| 26 | Loading, |
| 27 | Found(Lyrics), |
| 28 | NotFound, |
| 29 | Failed(String), |
| 30 | } |
| 31 | |
| 32 | #[derive(Debug, Default)] |
| 33 | pub struct LyricsState { |
| 34 | /// The song the status belongs to. |
| 35 | pub file: Option<String>, |
| 36 | pub status: LyricsStatus, |
| 37 | } |
| 38 | |
| 39 | impl LyricsState { |
| 40 | pub fn lyrics(&self) -> Option<&Lyrics> { |
| 41 | match &self.status { |
| 42 | LyricsStatus::Found(lyrics) => Some(lyrics), |
| 43 | _ => None, |
| 44 | } |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | /// The result of a lookup, sent back as an [`AppEvent::Lyrics`]. |
| 49 | pub type LookupResult = Result<Option<Lyrics>, String>; |
| 50 | |
| 51 | pub struct LyricsService { |
| 52 | config: LyricsConfig, |
| 53 | http: reqwest::Client, |
| 54 | events: mpsc::UnboundedSender<AppEvent>, |
| 55 | task: Option<JoinHandle<()>>, |
| 56 | /// Results of this session, including misses, to avoid repeated lookups. |
| 57 | memory: HashMap<String, Option<Lyrics>>, |
| 58 | pub state: LyricsState, |
| 59 | } |
| 60 | |
| 61 | impl LyricsService { |
| 62 | pub fn new(config: LyricsConfig, events: mpsc::UnboundedSender<AppEvent>) -> Self { |
| 63 | let http = reqwest::Client::builder() |
| 64 | .user_agent(concat!( |
| 65 | "rmptui/", |
| 66 | env!("CARGO_PKG_VERSION"), |
| 67 | " (https://github.com/krolyxon/rmptui)" |
| 68 | )) |
| 69 | .timeout(Duration::from_secs(10)) |
| 70 | .build() |
| 71 | .unwrap_or_default(); |
| 72 | Self { |
| 73 | config, |
| 74 | http, |
| 75 | events, |
| 76 | task: None, |
| 77 | memory: HashMap::new(), |
| 78 | state: LyricsState::default(), |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | /// Makes sure lyrics for `song` are loaded or being loaded. With `force`, |
| 83 | /// skips the session memory and the cache. |
| 84 | pub fn ensure(&mut self, song: Option<&Song>, music_dir: Option<&Path>, force: bool) { |
| 85 | let Some(song) = song else { |
| 86 | self.cancel(); |
| 87 | self.state = LyricsState::default(); |
| 88 | return; |
| 89 | }; |
| 90 | if !force && self.state.file.as_deref() == Some(song.file.as_str()) { |
| 91 | return; |
| 92 | } |
| 93 | self.cancel(); |
| 94 | self.state.file = Some(song.file.clone()); |
| 95 | if !force && let Some(known) = self.memory.get(&song.file) { |
| 96 | self.state.status = known |
| 97 | .clone() |
| 98 | .map_or(LyricsStatus::NotFound, LyricsStatus::Found); |
| 99 | return; |
| 100 | } |
| 101 | self.state.status = LyricsStatus::Loading; |
| 102 | let request = Lookup { |
| 103 | file: song.file.clone(), |
| 104 | artist: song.artist().map(str::to_owned), |
| 105 | title: lookup_title(song), |
| 106 | album: song.album().map(str::to_owned), |
| 107 | duration: song.duration, |
| 108 | local: music_dir.map(|dir| dir.join(&song.file).with_extension("lrc")), |
| 109 | use_cache: self.config.cache && !force, |
| 110 | write_cache: self.config.cache, |
| 111 | online: self.config.online, |
| 112 | base_url: self.config.lrclib_url.trim_end_matches('/').to_owned(), |
| 113 | }; |
| 114 | let http = self.http.clone(); |
| 115 | let events = self.events.clone(); |
| 116 | self.task = Some(tokio::spawn(async move { |
| 117 | let file = request.file.clone(); |
| 118 | let result = request.run(&http).await; |
| 119 | let _ = events.send(AppEvent::Lyrics { file, result }); |
| 120 | })); |
| 121 | } |
| 122 | |
| 123 | /// Applies a finished lookup. Returns whether it was for the current song. |
| 124 | pub fn finish(&mut self, file: String, result: LookupResult) -> bool { |
| 125 | match &result { |
| 126 | Ok(found) => { |
| 127 | self.memory.insert(file.clone(), found.clone()); |
| 128 | } |
| 129 | Err(error) => warn!("lyrics lookup for {file} failed: {error}"), |
| 130 | } |
| 131 | if self.state.file.as_deref() != Some(file.as_str()) { |
| 132 | return false; |
| 133 | } |
| 134 | self.task = None; |
| 135 | self.state.status = match result { |
| 136 | Ok(Some(lyrics)) => LyricsStatus::Found(lyrics), |
| 137 | Ok(None) => LyricsStatus::NotFound, |
| 138 | Err(error) => LyricsStatus::Failed(error), |
| 139 | }; |
| 140 | true |
| 141 | } |
| 142 | |
| 143 | fn cancel(&mut self) { |
| 144 | if let Some(task) = self.task.take() { |
| 145 | task.abort(); |
| 146 | } |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | struct Lookup { |
| 151 | file: String, |
| 152 | artist: Option<String>, |
| 153 | title: Option<String>, |
| 154 | album: Option<String>, |
| 155 | duration: Option<Duration>, |
| 156 | local: Option<PathBuf>, |
| 157 | use_cache: bool, |
| 158 | write_cache: bool, |
| 159 | online: bool, |
| 160 | base_url: String, |
| 161 | } |
| 162 | |
| 163 | impl Lookup { |
| 164 | async fn run(self, http: &reqwest::Client) -> LookupResult { |
| 165 | if let Some(path) = &self.local |
| 166 | && let Some(lyrics) = read_lyrics(path).await |
| 167 | { |
| 168 | debug!("lyrics for {} from {}", self.file, path.display()); |
| 169 | return Ok(Some(lyrics)); |
| 170 | } |
| 171 | let (Some(artist), Some(title)) = (&self.artist, &self.title) else { |
| 172 | return Ok(None); |
| 173 | }; |
| 174 | let cache_path = cache_path(artist, title); |
| 175 | if self.use_cache |
| 176 | && let Some(lyrics) = read_lyrics(&cache_path).await |
| 177 | { |
| 178 | return Ok(Some(lyrics)); |
| 179 | } |
| 180 | if !self.online { |
| 181 | return Ok(None); |
| 182 | } |
| 183 | let Some(text) = self.fetch(http, artist, title).await? else { |
| 184 | return Ok(None); |
| 185 | }; |
| 186 | if self.write_cache { |
| 187 | if let Some(parent) = cache_path.parent() { |
| 188 | let _ = tokio::fs::create_dir_all(parent).await; |
| 189 | } |
| 190 | if let Err(error) = tokio::fs::write(&cache_path, &text).await { |
| 191 | warn!( |
| 192 | "cannot write lyrics cache {}: {error}", |
| 193 | cache_path.display() |
| 194 | ); |
| 195 | } |
| 196 | } |
| 197 | let lyrics = Lyrics::parse(&text); |
| 198 | Ok((!lyrics.is_empty()).then_some(lyrics)) |
| 199 | } |
| 200 | |
| 201 | /// Asks LRCLIB for an exact match first, then falls back to a search. |
| 202 | async fn fetch( |
| 203 | &self, |
| 204 | http: &reqwest::Client, |
| 205 | artist: &str, |
| 206 | title: &str, |
| 207 | ) -> Result<Option<String>, String> { |
| 208 | let mut query = vec![ |
| 209 | ("artist_name", artist.to_owned()), |
| 210 | ("track_name", title.to_owned()), |
| 211 | ]; |
| 212 | if let Some(album) = &self.album { |
| 213 | query.push(("album_name", album.clone())); |
| 214 | } |
| 215 | if let Some(duration) = self.duration { |
| 216 | query.push(("duration", duration.as_secs().to_string())); |
| 217 | } |
| 218 | let response = http |
| 219 | .get(format!("{}/api/get", self.base_url)) |
| 220 | .query(&query) |
| 221 | .send() |
| 222 | .await |
| 223 | .map_err(|e| e.to_string())?; |
| 224 | if response.status().is_success() { |
| 225 | let record: Record = response.json().await.map_err(|e| e.to_string())?; |
| 226 | if let Some(text) = record.text() { |
| 227 | return Ok(Some(text)); |
| 228 | } |
| 229 | } else if response.status() != reqwest::StatusCode::NOT_FOUND { |
| 230 | return Err(format!("LRCLIB answered {}", response.status())); |
| 231 | } |
| 232 | |
| 233 | let records: Vec<Record> = http |
| 234 | .get(format!("{}/api/search", self.base_url)) |
| 235 | .query(&[("artist_name", artist), ("track_name", title)]) |
| 236 | .send() |
| 237 | .await |
| 238 | .and_then(reqwest::Response::error_for_status) |
| 239 | .map_err(|e| e.to_string())? |
| 240 | .json() |
| 241 | .await |
| 242 | .map_err(|e| e.to_string())?; |
| 243 | // Prefer synced lyrics, then plain ones. |
| 244 | let synced = records |
| 245 | .iter() |
| 246 | .find_map(|r| r.synced_lyrics.clone().filter(|s| !s.trim().is_empty())); |
| 247 | Ok(synced.or_else(|| records.iter().find_map(Record::text))) |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | #[derive(Debug, Deserialize)] |
| 252 | #[serde(rename_all = "camelCase")] |
| 253 | struct Record { |
| 254 | #[serde(default)] |
| 255 | instrumental: bool, |
| 256 | plain_lyrics: Option<String>, |
| 257 | synced_lyrics: Option<String>, |
| 258 | } |
| 259 | |
| 260 | impl Record { |
| 261 | fn text(&self) -> Option<String> { |
| 262 | if self.instrumental { |
| 263 | return Some("♪ Instrumental ♪".to_owned()); |
| 264 | } |
| 265 | self.synced_lyrics |
| 266 | .clone() |
| 267 | .filter(|s| !s.trim().is_empty()) |
| 268 | .or_else(|| self.plain_lyrics.clone().filter(|s| !s.trim().is_empty())) |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | /// The title to look lyrics up by: the Title tag, or a title derived from |
| 273 | /// the file name (`05. Artist - Title (Explicit).mp3` → `Title`), without |
| 274 | /// markers that lyric databases do not include. |
| 275 | fn lookup_title(song: &Song) -> Option<String> { |
| 276 | let mut title = song |
| 277 | .title() |
| 278 | .map_or_else(|| title_from_file_name(song), str::to_owned); |
| 279 | for marker in [ |
| 280 | "(Explicit)", |
| 281 | "[Explicit]", |
| 282 | "(explicit)", |
| 283 | "[explicit]", |
| 284 | "(Clean)", |
| 285 | "[Clean]", |
| 286 | ] { |
| 287 | title = title.replace(marker, ""); |
| 288 | } |
| 289 | let title = title.trim().to_owned(); |
| 290 | // A bare track number is not a title. |
| 291 | (!title.chars().all(|c| c.is_ascii_digit())).then_some(title) |
| 292 | } |
| 293 | |
| 294 | /// Strips a leading track number (`05. `, `05 - `, `05_`) and `Artist - `. |
| 295 | fn title_from_file_name(song: &Song) -> String { |
| 296 | let stem = song.display_title(); |
| 297 | let without_artist = |s: &str| -> Option<String> { |
| 298 | let rest = s.strip_prefix(song.artist()?)?; |
| 299 | Some(rest.trim_start_matches([' ', '-']).to_owned()) |
| 300 | }; |
| 301 | let digits = stem.len() - stem.trim_start_matches(|c: char| c.is_ascii_digit()).len(); |
| 302 | let without_track = (digits > 0 && stem[digits..].starts_with(['.', '-', '_', ' '])) |
| 303 | .then(|| stem[digits..].trim_start_matches(['.', '-', '_', ' '])); |
| 304 | without_artist(stem) |
| 305 | .or_else(|| without_track.and_then(without_artist)) |
| 306 | .or_else(|| without_track.map(str::to_owned)) |
| 307 | .unwrap_or_else(|| stem.to_owned()) |
| 308 | } |
| 309 | |
| 310 | async fn read_lyrics(path: &Path) -> Option<Lyrics> { |
| 311 | let text = tokio::fs::read_to_string(path).await.ok()?; |
| 312 | let lyrics = Lyrics::parse(&text); |
| 313 | (!lyrics.is_empty()).then_some(lyrics) |
| 314 | } |
| 315 | |
| 316 | fn cache_path(artist: &str, title: &str) -> PathBuf { |
| 317 | let name: String = format!("{artist} - {title}") |
| 318 | .chars() |
| 319 | .map(|c| { |
| 320 | if matches!(c, '/' | '\\' | '\0') || c.is_control() { |
| 321 | '_' |
| 322 | } else { |
| 323 | c |
| 324 | } |
| 325 | }) |
| 326 | .take(200) |
| 327 | .collect(); |
| 328 | util::cache_dir().join("lyrics").join(format!("{name}.lrc")) |
| 329 | } |
| 330 | |
| 331 | #[cfg(test)] |
| 332 | mod tests { |
| 333 | use super::*; |
| 334 | |
| 335 | #[test] |
| 336 | fn derives_lookup_titles() { |
| 337 | let song = |file: &str, tags: &[(&str, &str)]| Song { |
| 338 | file: file.into(), |
| 339 | tags: tags |
| 340 | .iter() |
| 341 | .map(|(k, v)| ((*k).into(), (*v).into())) |
| 342 | .collect(), |
| 343 | ..Song::default() |
| 344 | }; |
| 345 | let untagged = song( |
| 346 | "x/05. 50 Cent - Candy Shop (Explicit).mp3", |
| 347 | &[("Artist", "50 Cent")], |
| 348 | ); |
| 349 | assert_eq!(lookup_title(&untagged).as_deref(), Some("Candy Shop")); |
| 350 | let tagged = song("x/a.flac", &[("Title", "Song [Explicit]")]); |
| 351 | assert_eq!(lookup_title(&tagged).as_deref(), Some("Song")); |
| 352 | assert_eq!(lookup_title(&song("x/01.mp3", &[])), None); |
| 353 | } |
| 354 | |
| 355 | #[test] |
| 356 | fn sanitizes_cache_names() { |
| 357 | let path = cache_path("AC/DC", "T.N.T."); |
| 358 | assert_eq!(path.file_name().unwrap(), "AC_DC - T.N.T..lrc"); |
| 359 | } |
| 360 | |
| 361 | #[test] |
| 362 | fn prefers_synced_record_text() { |
| 363 | let record: Record = serde_json::from_str( |
| 364 | r#"{"instrumental":false,"plainLyrics":"plain","syncedLyrics":"[00:01.00]synced"}"#, |
| 365 | ) |
| 366 | .unwrap(); |
| 367 | assert_eq!(record.text().as_deref(), Some("[00:01.00]synced")); |
| 368 | let record: Record = |
| 369 | serde_json::from_str(r#"{"instrumental":true,"plainLyrics":null,"syncedLyrics":null}"#) |
| 370 | .unwrap(); |
| 371 | assert!(record.text().unwrap().contains("Instrumental")); |
| 372 | } |
| 373 | } |