use lofty to support formats different from mp3

This commit is contained in:
2026-04-05 01:41:47 +03:00
parent 02205f1cea
commit d9de6b3800
6 changed files with 174 additions and 43 deletions
Generated
+48 -1
View File
@@ -355,6 +355,12 @@ dependencies = [
"syn",
]
[[package]]
name = "data-encoding"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -836,7 +842,6 @@ dependencies = [
"bitflags",
"byteorder",
"flate2",
"tokio",
]
[[package]]
@@ -984,6 +989,32 @@ dependencies = [
"scopeguard",
]
[[package]]
name = "lofty"
version = "0.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d0c107dba5af049cf1c36b646fc1ba0cd2705f40d766d2c4c64f1b797c5fbed"
dependencies = [
"byteorder",
"data-encoding",
"flate2",
"lofty_attr",
"log",
"ogg_pager",
"paste",
]
[[package]]
name = "lofty_attr"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "log"
version = "0.4.29"
@@ -1008,6 +1039,7 @@ dependencies = [
"futures",
"id3",
"itertools",
"lofty",
"reqwest",
"scraper",
"serde",
@@ -1058,6 +1090,15 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "ogg_pager"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d6d1ca8364b84e0cf725eed06b1460c44671e6c0fb28765f5262de3ece07fdc"
dependencies = [
"byteorder",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -1105,6 +1146,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "percent-encoding"
version = "2.3.2"
+2 -1
View File
@@ -21,7 +21,8 @@ anyhow = "1.0.102"
itertools = "0.14.0"
dotenv = "0.15.0"
id3 = { version = "1.16.4", default-features = false, features = ["tokio"] }
lofty = "0.23.3"
id3 = "1.16.4"
reqwest = { version = "0.13.2", default-features = false, features = [
"rustls",
"json",
+76 -18
View File
@@ -1,5 +1,9 @@
mod sources;
use lofty::{
file::{AudioFile, TaggedFileExt},
tag::TagExt,
};
use sources::*;
use anyhow::Result;
@@ -16,7 +20,8 @@ use tokio::sync::Semaphore;
use clap::Parser;
use id3::{Tag, TagLike, Version, frame::Lyrics};
use id3::{TagLike, Version};
use lofty::{config::WriteOptions, tag::ItemKey};
#[derive(Parser)]
struct Args {
@@ -175,28 +180,44 @@ async fn process_file(
overwrite: bool,
allow_inaccurate: bool,
) {
let mut tag = match Tag::async_read_from_path(file_path).await {
Ok(tag) => tag,
let mut tagged_file = match lofty::read_from_path(&file_path) {
Ok(file) => file,
Err(e) => {
eprintln!("[ERROR] Failed to read '{}': {}", file_path.display(), e);
return;
}
};
let search_tag = match tagged_file.primary_tag().or(tagged_file.first_tag()) {
Some(t) => t.clone(),
None => {
eprintln!(
"[ERROR] Failed to read ID3 tag for file '{}': {}",
file_path.display(),
e
"[ERROR] No tags in '{}', don't know what to search for. Skipping",
file_path.display()
);
return;
}
};
if !overwrite && tag.lyrics().next().is_some() {
let properties = tagged_file.properties();
let has_lyrics = search_tag.items().any(|item| {
matches!(item.key(), ItemKey::Lyrics) || format!("{:?}", item.key()).contains("Lyric")
});
if !overwrite && has_lyrics {
println!(
"[INFO] File '{}' already has lyrics, skipping (use --overwrite to force)",
"[INFO] Lyrics already exist in '{}', skipping",
file_path.display()
);
return;
}
for source in sources.iter() {
match source.fetch_lyrics(&tag, allow_inaccurate).await {
match source
.fetch_lyrics(&search_tag, properties, allow_inaccurate)
.await
{
Ok(lyrics) => {
let lyrics = lyrics.trim();
if lyrics.is_empty() {
@@ -209,34 +230,71 @@ async fn process_file(
}
println!(
"[INFO] Successfully fetched lyrics for file '{}' from source '{}'",
"[INFO] Found lyrics for '{}' via '{}'",
file_path.display(),
source.name()
);
tag.remove_all_lyrics();
tag.remove_all_synchronised_lyrics();
let ext = file_path
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
tag.add_frame(Lyrics {
if ext == "mp3" {
let mut mp3_tag =
id3::Tag::read_from_path(&file_path).unwrap_or_else(|_| id3::Tag::new());
mp3_tag.add_frame(id3::frame::Lyrics {
lang: "XXX".to_string(),
description: format!("Fetched from {}", source.name()),
description: String::new(),
text: lyrics.to_string(),
});
if let Err(e) = tag.write_to_path(file_path, Version::Id3v24) {
if let Err(e) = mp3_tag.write_to_path(&file_path, Version::Id3v24) {
eprintln!(
"[ERROR] Failed to write tags into {}: {}",
"[ERROR] Failed to save MP3 tag in {}: {}",
file_path.display(),
e
);
} else {
println!(
"[INFO] Successfully wrote lyrics for '{}' via id3 (MP3)",
file_path.display()
);
}
} else {
if tagged_file.primary_tag().is_none() {
let tag_type = tagged_file.primary_tag_type();
tagged_file.insert_tag(lofty::tag::Tag::new(tag_type));
}
let tag_to_save = tagged_file.primary_tag_mut().unwrap();
tag_to_save.remove_key(ItemKey::Lyrics);
tag_to_save.insert_text(ItemKey::Lyrics, lyrics.to_string());
if let Err(e) = tag_to_save.save_to_path(&file_path, WriteOptions::default()) {
eprintln!(
"[ERROR] Failed to save tags in {}: {}",
file_path.display(),
e
);
} else {
println!(
"[INFO] Successfully wrote lyrics for '{}' via lofty ({})",
file_path.display(),
ext.to_uppercase()
);
}
}
break;
}
Err(e) => {
eprintln!(
"[ERROR] Failed to fetch lyrics for file '{}' from source '{}': {}",
file_path.display(),
"[ERROR] Source '{}' error for '{}': {}",
source.name(),
file_path.display(),
e
);
}
+14 -4
View File
@@ -2,7 +2,10 @@ use crate::sources::LyricsSource;
use anyhow::{Context, Ok, Result, anyhow};
use async_trait::async_trait;
use id3::{Tag, TagLike};
use lofty::{
properties::FileProperties,
tag::{Accessor, Tag},
};
use reqwest::Client;
use scraper::{ElementRef, Html, Node, Selector};
use serde::Deserialize;
@@ -66,13 +69,20 @@ impl LyricsSource for GeniusSource {
"Genius"
}
async fn fetch_lyrics(&self, tag: &Tag, allow_inaccurate: bool) -> Result<String> {
async fn fetch_lyrics(
&self,
tag: &Tag,
_properties: &FileProperties,
allow_inaccurate: bool,
) -> Result<String> {
let title = tag
.title()
.ok_or_else(|| anyhow!("Missing track title in ID3 tag"))?;
.ok_or_else(|| anyhow!("Missing track title in ID3 tag"))?
.to_string();
let artist = tag
.artist()
.ok_or_else(|| anyhow!("Missing artist name in ID3 tag"))?;
.ok_or_else(|| anyhow!("Missing artist name in ID3 tag"))?
.to_string();
let query = format!("{} {}", artist, title);
+20 -10
View File
@@ -2,7 +2,10 @@ use crate::sources::LyricsSource;
use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use id3::{Tag, TagLike};
use lofty::{
properties::FileProperties,
tag::{Accessor, Tag},
};
use reqwest::{Client, StatusCode};
use serde::Deserialize;
@@ -63,25 +66,32 @@ impl LyricsSource for LrcLibSource {
"LrcLib"
}
async fn fetch_lyrics(&self, tag: &Tag, allow_inaccurate: bool) -> Result<String> {
async fn fetch_lyrics(
&self,
tag: &Tag,
properties: &FileProperties,
allow_inaccurate: bool,
) -> Result<String> {
let base_url = "https://lrclib.net/api";
let track_name = tag
.title()
.ok_or_else(|| anyhow!("Missing track title in ID3 tag"))?;
.ok_or_else(|| anyhow!("Missing track title in ID3 tag"))?
.to_string();
let artist_name = tag
.artist()
.ok_or_else(|| anyhow!("Missing artist name in ID3 tag"))?;
let album_name = tag.album().unwrap_or_default();
let target_duration = tag.duration().unwrap_or_default() / 1000;
.ok_or_else(|| anyhow!("Missing artist name in ID3 tag"))?
.to_string();
let album_name = tag.album().unwrap_or_default().to_string();
let target_duration = properties.duration().as_secs() as u32;
let mut get_req = self
.client
.get(format!("{base_url}/get"))
.query(&[("track_name", track_name), ("artist_name", artist_name)]);
.query(&[("track_name", &track_name), ("artist_name", &artist_name)]);
if !album_name.is_empty() {
get_req = get_req.query(&[("album_name", album_name)]);
get_req = get_req.query(&[("album_name", &album_name)]);
}
if target_duration > 0 {
@@ -100,10 +110,10 @@ impl LyricsSource for LrcLibSource {
let mut search_req = self
.client
.get(format!("{base_url}/search"))
.query(&[("track_name", track_name), ("artist_name", artist_name)]);
.query(&[("track_name", &track_name), ("artist_name", &artist_name)]);
if !album_name.is_empty() {
search_req = search_req.query(&[("album_name", album_name)]);
search_req = search_req.query(&[("album_name", &album_name)]);
}
let response = search_req.send().await?;
+7 -2
View File
@@ -6,13 +6,18 @@ pub use lrclib::LrcLibSource;
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use id3::Tag;
use lofty::{properties::FileProperties, tag::Tag};
use std::sync::Arc;
#[async_trait]
pub trait LyricsSource: Send + Sync {
fn name(&self) -> &'static str;
async fn fetch_lyrics(&self, tag: &Tag, allow_inaccurate: bool) -> Result<String>;
async fn fetch_lyrics(
&self,
tag: &Tag,
properties: &FileProperties,
allow_inaccurate: bool,
) -> Result<String>;
}
pub async fn create_source(name: &str) -> Result<Arc<dyn LyricsSource>> {