Skip to content

Commit 1f0b019

Browse files
committed
3.0 feature update with searching match + finetuned matching
1 parent d95f903 commit 1f0b019

File tree

3 files changed

+148
-23
lines changed

3 files changed

+148
-23
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "lrcsync"
3-
version = "0.2.0"
3+
version = "0.3.0"
44
edition = "2021"
55
description = "a simple tool to sync lrc files from lrclib.net"
66
license = "GPL-3.0"

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,29 @@
11
# lrcsync
22
lrclib.net client, basically looks in current directory for audio files and pulls the lrc for the song if it exists from lrclib.net. Quick poc atm but I have this because I can't run lrcget on a remote server lol because it has no gui.
3+
4+
## Usage
5+
Simply run:
6+
```
7+
lrcsync
8+
```
9+
in a directory with audio files and for audio files with metadata it'll look them up on lrclib.net and if there is a specific match it'll pull them.
10+
11+
For a more "loose" search you can use the `--search` flag, this will use the search endpoint which can account for different punctuation, misspellings, and case. You can also use `--ignore` to ignore certain properties when searching, for example to ignore the artist name you can do:
12+
```bash
13+
lrcsync --search --ignore artist --tolerance 3.0
14+
```
15+
When search is used as a fallback it will try to match by closest duration. The `--tolerance` flag can be used to set a tolerance in seconds, any results exceeding this threshold will be ignored.
16+
17+
```
18+
Usage: lrcsync [OPTIONS]
19+
20+
Options:
21+
-u, --lrclib-url <LRCLIB_URL> [default: https://lrclib.net]
22+
-a, --hidden
23+
-f, --force overwrite existing lrc files
24+
-i, --ignore <IGNORE> ignore the follow properties when searching lrclib by not sending them, comma seperated
25+
-s, --search use searching on lrclib as a fallback
26+
-t, --tolerance <TOLERANCE> tolerance in seconds for searching lrclib [default: 5]
27+
-h, --help Print help
28+
-V, --version Print version
29+
```

src/main.rs

Lines changed: 120 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11

2+
use anyhow::bail;
23
use audiotags::Tag;
34
use clap::Parser;
4-
use ignore::WalkBuilder;
5+
use ignore::{DirEntry, WalkBuilder};
56
use serde::{Serialize, Deserialize};
67
use tokio::{fs::File, io::AsyncWriteExt};
78

@@ -15,7 +16,11 @@ pub struct CliConfig {
1516
#[arg(short = 'f', long = "force", default_value_t = false, help = "overwrite existing lrc files")]
1617
pub force: bool,
1718
#[arg(short = 'i', long = "ignore", value_parser, num_args = 1, help = "ignore the follow properties when searching lrclib by not sending them, comma seperated")]
18-
pub ignore: Vec<String>
19+
pub ignore: Vec<String>,
20+
#[arg(short = 's', long = "search", default_value_t = false, help = "use searching on lrclib as a fallback")]
21+
pub search: bool,
22+
#[arg(short = 't', long = "tolerance", default_value_t = 5.0, help = "tolerance in seconds for searching lrclib")]
23+
pub tolerance: f32,
1924
}
2025

2126
static DEFAULT_USER_AGENT: &str = concat!(
@@ -42,7 +47,7 @@ pub struct LrclibQuery {
4247

4348
impl LrclibQuery {
4449
// old method
45-
pub fn to_query_string(&self) -> String {
50+
pub fn to_get_query_string(&self) -> String {
4651
let mut query = String::new();
4752
query.push_str("track_name=");
4853
query.push_str(&self.track_name);
@@ -59,16 +64,23 @@ impl LrclibQuery {
5964
query
6065
}
6166

62-
pub fn to_query(&self) -> Vec<(String, String)> {
67+
pub fn to_get_query(&self) -> Vec<(String, String)> {
68+
let mut query = self.to_search_query();
69+
if let Some(duration) = &self.duration {
70+
query.push(("duration".to_string(), duration.to_string()));
71+
}
72+
query
73+
}
74+
75+
pub fn to_search_query(&self) -> Vec<(String, String)> {
6376
let mut query = Vec::new();
6477
query.push(("track_name".to_string(), self.track_name.clone()));
65-
query.push(("artist_name".to_string(), self.artist_name.clone()));
78+
if self.artist_name.len() > 0 {
79+
query.push(("artist_name".to_string(), self.artist_name.clone()));
80+
}
6681
if let Some(album_name) = &self.album_name {
6782
query.push(("album_name".to_string(), album_name.clone()));
6883
}
69-
if let Some(duration) = &self.duration {
70-
query.push(("duration".to_string(), duration.to_string()));
71-
}
7284
query
7385
}
7486

@@ -109,7 +121,7 @@ impl LrcLibClient {
109121

110122
pub async fn get(&self, query: &LrclibQuery) -> anyhow::Result<Option<LrclibItem>> {
111123
let url = format!("{}/api/get" ,self.url);
112-
let request_builder = self.client.get(url).query(&query.to_query());
124+
let request_builder = self.client.get(url).query(&query.to_get_query());
113125
let response = request_builder.send().await?;
114126
if response.status().is_success() {
115127
let body = response.text().await?;
@@ -127,8 +139,50 @@ impl LrcLibClient {
127139
}
128140
}
129141
}
142+
143+
pub async fn search(&self, query: &LrclibQuery) -> anyhow::Result<Option<Vec<LrclibItem>>> {
144+
let url = format!("{}/api/search" ,self.url);
145+
let request_builder = self.client.get(url).query(&query.to_search_query());
146+
let response = request_builder.send().await?;
147+
if response.status().is_success() {
148+
let body = response.text().await?;
149+
match serde_json::from_str::<Vec<LrclibItem>>(&body) {
150+
Ok(items) => Ok(Some(items)),
151+
Err(err) => {
152+
anyhow::bail!("Error parsing lrclib response (did the api schema change?): {}", err);
153+
},
154+
}
155+
} else {
156+
if response.status() == reqwest::StatusCode::NOT_FOUND {
157+
Ok(None)
158+
} else {
159+
Err(anyhow::anyhow!("Error getting lrclib item: {}", response.status()))
160+
}
161+
}
162+
}
130163
}
131164

165+
pub async fn write_lrc_for_file(entry: &DirEntry, synced_lyrics: &str, config: &CliConfig) -> anyhow::Result<()> {
166+
let lrc_path = entry.path().with_extension("lrc");
167+
168+
match File::create(lrc_path).await {
169+
Ok(mut file) => {
170+
match file.write_all(synced_lyrics.as_bytes()).await {
171+
Ok(_) => {
172+
println!("Wrote synced lrc to {}", entry.path().display());
173+
},
174+
Err(err) => {
175+
bail!("Writing file failed: {}", err);
176+
}
177+
}
178+
},
179+
Err(err) => {
180+
bail!("Creating file failed: {}", err);
181+
}
182+
}
183+
184+
Ok(())
185+
}
132186

133187
#[tokio::main]
134188
async fn main() {
@@ -181,33 +235,77 @@ async fn main() {
181235
if config.ignore.contains(&"duration".to_string()) {
182236
lrc_query.remove_duration();
183237
}
184-
if config.ignore.contains(&"album_name".to_string()) {
238+
if config.ignore.contains(&"album_name".to_string()) || config.ignore.contains(&"album".to_string()) {
185239
lrc_query.remove_album_name();
186240
}
187241
match client.get(&lrc_query).await {
188242
Ok(Some(lrc_item)) => {
189243
if let Some(synced_lyrics) = &lrc_item.syncedLyrics {
190244
println!("Found synced lrc for {}", entry.path().display());
191245
// write to file with extension changed to .lrc
192-
match File::create(entry.path().with_extension("lrc")).await {
193-
Ok(mut file) => {
194-
match file.write_all(synced_lyrics.as_bytes()).await {
195-
Ok(_) => {
196-
println!("Wrote synced lrc to {}", entry.path().display());
197-
},
198-
Err(err) => {
199-
println!("Error writing file {}: {}",entry.path().display(), err);
246+
match write_lrc_for_file(&entry, synced_lyrics, &config).await {
247+
Ok(_) => {},
248+
Err(err) => {
249+
println!("Error in saving lrc {}: {}",entry.path().display(), err);
250+
}
251+
}
252+
}
253+
},
254+
Ok(None) => {
255+
if config.search {
256+
// search fallback
257+
println!("Searching lrc for {}", entry.path().display());
258+
// hide artist hack
259+
if config.ignore.contains(&"artist_name".to_string()) || config.ignore.contains(&"artist".to_string()) {
260+
lrc_query.artist_name = "".to_string();
261+
}
262+
match client.search(&lrc_query).await {
263+
Ok(Some(lrc_items)) => {
264+
// weird order but it works and avoids too much nesting
265+
if lrc_items.len() == 0 {
266+
println!("Did not find lrc for (no results) {}",entry.path().display());
267+
} else {
268+
let mut canidates = lrc_items;
269+
if let Some(duration) = &lrc_query.duration {
270+
// sort by closed to target duration
271+
canidates.sort_by(|a, b| {
272+
let a_duration = a.duration as f32;
273+
let b_duration = b.duration as f32;
274+
let a_delta = a_duration - duration;
275+
let b_delta = b_duration - duration;
276+
return a_delta.abs().partial_cmp(&b_delta.abs()).unwrap();
277+
});
278+
279+
if config.tolerance > 0.0 {
280+
canidates = canidates.into_iter().filter(|item| {
281+
let item_duration = item.duration as f32;
282+
let delta = item_duration - duration;
283+
return delta.abs() < config.tolerance;
284+
}).collect();
285+
}
286+
}
287+
288+
println!("Searched lrc (found {}secs vs actual {}secs out of {} filtered results) for {}",canidates[0].duration,lrc_query.duration.unwrap_or(-1.0), canidates.len(), entry.path().display());
289+
// write to file with extension changed to .lrc
290+
// TODO: manual duration tolerance?
291+
match write_lrc_for_file(&entry, &canidates[0].syncedLyrics.as_ref().unwrap(), &config).await {
292+
Ok(_) => {},
293+
Err(err) => {
294+
println!("Error in saving lrc {}: {}",entry.path().display(), err);
295+
}
200296
}
201297
}
202298
},
299+
Ok(None) => {
300+
println!("Did not find lrc for {}",entry.path().display());
301+
},
203302
Err(err) => {
204-
println!("Error creating file {}: {}",entry.path().display(), err);
303+
println!("Error searching lrc for {}: {}",entry.path().display(), err);
205304
}
206305
}
306+
} else {
307+
println!("Did not find lrc for {}",entry.path().display());
207308
}
208-
},
209-
Ok(None) => {
210-
println!("Did not find lrc for {}",entry.path().display());
211309
}
212310
Err(err) => {
213311
println!("Error finding lrc for {}: {}",entry.path().display(), err);

0 commit comments

Comments
 (0)