diff --git a/Cargo.lock b/Cargo.lock index a922b47..202b579 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 7751732..1faafa0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", diff --git a/src/main.rs b/src/main.rs index cea1426..5b3a520 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { - lang: "XXX".to_string(), - description: format!("Fetched from {}", source.name()), - text: lyrics.to_string(), - }); + if ext == "mp3" { + let mut mp3_tag = + id3::Tag::read_from_path(&file_path).unwrap_or_else(|_| id3::Tag::new()); - if let Err(e) = tag.write_to_path(file_path, Version::Id3v24) { - eprintln!( - "[ERROR] Failed to write tags into {}: {}", - file_path.display(), - e - ); + mp3_tag.add_frame(id3::frame::Lyrics { + lang: "XXX".to_string(), + description: String::new(), + text: lyrics.to_string(), + }); + + if let Err(e) = mp3_tag.write_to_path(&file_path, Version::Id3v24) { + eprintln!( + "[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 ); } diff --git a/src/sources/genius.rs b/src/sources/genius.rs index e22d6d1..82f7f72 100644 --- a/src/sources/genius.rs +++ b/src/sources/genius.rs @@ -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 { + async fn fetch_lyrics( + &self, + tag: &Tag, + _properties: &FileProperties, + allow_inaccurate: bool, + ) -> Result { 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); diff --git a/src/sources/lrclib.rs b/src/sources/lrclib.rs index 92970a2..4eb82ff 100644 --- a/src/sources/lrclib.rs +++ b/src/sources/lrclib.rs @@ -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 { + async fn fetch_lyrics( + &self, + tag: &Tag, + properties: &FileProperties, + allow_inaccurate: bool, + ) -> Result { 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?; diff --git a/src/sources/mod.rs b/src/sources/mod.rs index 4e53bb1..3ed3ba9 100644 --- a/src/sources/mod.rs +++ b/src/sources/mod.rs @@ -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; + async fn fetch_lyrics( + &self, + tag: &Tag, + properties: &FileProperties, + allow_inaccurate: bool, + ) -> Result; } pub async fn create_source(name: &str) -> Result> {