Krolyxon
Tag v0.2.0

rmptuisrclyricsmod.rs

373 lines · 11.9 KB · Rust

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
6mod lrc;
7
8use std::{
9 collections::HashMap,
10 path::{Path, PathBuf},
11 time::Duration,
12};
13
14pub use lrc::{LyricLine, Lyrics};
15use serde::Deserialize;
16use tokio::{sync::mpsc, task::JoinHandle};
17use tracing::{debug, warn};
18
19use 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)]
23pub enum LyricsStatus {
24 #[default]
25 Idle,
26 Loading,
27 Found(Lyrics),
28 NotFound,
29 Failed(String),
30}
31
32#[derive(Debug, Default)]
33pub struct LyricsState {
34 /// The song the status belongs to.
35 pub file: Option<String>,
36 pub status: LyricsStatus,
37}
38
39impl 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`].
49pub type LookupResult = Result<Option<Lyrics>, String>;
50
51pub 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
61impl 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
150struct 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
163impl 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")]
253struct Record {
254 #[serde(default)]
255 instrumental: bool,
256 plain_lyrics: Option<String>,
257 synced_lyrics: Option<String>,
258}
259
260impl 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.
275fn 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 - `.
295fn 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
310async 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
316fn 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)]
332mod 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}