diff --git a/Cargo.lock b/Cargo.lock index ba6c830..8fa47bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -513,12 +513,12 @@ dependencies = [ "eframe", "egui_extras", "font-kit", - "id3", "image 0.24.9", "itertools", "lazy_static", "libpulse-binding", "libpulse-simple-binding", + "lofty", "log", "rand", "rayon", @@ -987,6 +987,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "data-url" version = "0.3.1" @@ -1421,12 +1427,12 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.30" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", - "miniz_oxide 0.7.2", + "miniz_oxide 0.8.5", ] [[package]] @@ -1930,17 +1936,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" -[[package]] -name = "id3" -version = "1.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "472295f55960dd48e38c89442fa5d5423f5cf0ed2c665485be78e129231a39e9" -dependencies = [ - "bitflags 2.9.0", - "byteorder", - "flate2", -] - [[package]] name = "idna" version = "0.5.0" @@ -2284,11 +2279,37 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lofty" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8bc4717ff10833a623b009e9254ae8667c7a59edc3cfb01c37aeeef4b6d54a7" +dependencies = [ + "byteorder", + "data-encoding", + "flate2", + "lofty_attr", + "log", + "ogg_pager", + "paste", +] + +[[package]] +name = "lofty_attr" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "log" -version = "0.4.21" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "mach2" @@ -2386,6 +2407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -2868,6 +2890,15 @@ dependencies = [ "cc", ] +[[package]] +name = "ogg_pager" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87b0bef808533c5890ab77279538212efdbbbd9aa4ef1ccdfcfbf77a42f7e6fa" +dependencies = [ + "byteorder", +] + [[package]] name = "once_cell" version = "1.20.2" diff --git a/Cargo.toml b/Cargo.toml index 6d49327..152cb98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,6 @@ deb_depends = ["libasound2", "libpulse0", "libgtk-3-0"] cpal = "0.15" eframe = "0.31.1" egui_extras = { version = "0.31.1", features = ["all_loaders", "image"] } -id3 = "1.16.2" itertools = "0.12" lazy_static = "1.4.0" rayon = "1.10" @@ -51,6 +50,7 @@ libpulse-simple-binding = { version = "2.27.1", optional = true } urlencoding = "2.1" ureq = "2.9" uuid = { version = "1.23.0", default-features = false, features = ["v4"] } +lofty = "0.21.0" [features] default = [] diff --git a/build_mac_app.sh b/build_mac_app.sh new file mode 100755 index 0000000..423489b --- /dev/null +++ b/build_mac_app.sh @@ -0,0 +1,55 @@ +#!/bin/bash +set -e + +APP_NAME="BirdPlayer" +APP_DIR="target/release/$APP_NAME.app" +CONTENTS_DIR="$APP_DIR/Contents" +MACOS_DIR="$CONTENTS_DIR/MacOS" +RESOURCES_DIR="$CONTENTS_DIR/Resources" + +echo "Building release binary..." +cargo build --release + +echo "Creating app bundle structure..." +mkdir -p "$MACOS_DIR" +mkdir -p "$RESOURCES_DIR" + +echo "Copying binary..." +cp target/release/bird-player "$MACOS_DIR/" + +echo "Copying icon..." +if [ -f "assets/icons/icon.icns" ]; then + cp assets/icons/icon.icns "$RESOURCES_DIR/" +fi + +echo "Creating Info.plist..." +cat << PLIST > "$CONTENTS_DIR/Info.plist" + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + bird-player + CFBundleIconFile + icon.icns + CFBundleIdentifier + com.retric.birdplayer + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + BirdPlayer + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.2.0 + CFBundleVersion + 1 + NSHighResolutionCapable + + + +PLIST + +echo "App bundle created at $APP_DIR" diff --git a/src/app/components/library_component.rs b/src/app/components/library_component.rs index 12afb33..782a822 100644 --- a/src/app/components/library_component.rs +++ b/src/app/components/library_component.rs @@ -87,15 +87,21 @@ impl AppComponent for LibraryComponent { { if let Some(new_path) = rfd::FileDialog::new().pick_folder() { // Add the path to the library - ctx.library.add_path(new_path); - - // Get the last added path and import it - if let Some(newest_path) = ctx.library.paths().last() { - if newest_path.status() - == crate::app::library::LibraryPathStatus::NotImported - { - ctx.import_library_paths(newest_path); - } + let path_exists = !ctx.library.add_path(new_path.clone()); + + // If it existed, find it and rescan. If new, import the bottom-most path. + let path_to_import = if path_exists { + ctx.library + .paths() + .iter() + .find(|p| *p.path() == new_path) + .cloned() + } else { + ctx.library.paths().last().cloned() + }; + + if let Some(p) = path_to_import { + ctx.import_library_paths(&p); } } } diff --git a/src/app/components/playlist_table/services.rs b/src/app/components/playlist_table/services.rs index 5caef21..fe0c676 100644 --- a/src/app/components/playlist_table/services.rs +++ b/src/app/components/playlist_table/services.rs @@ -45,6 +45,25 @@ impl<'a> PlaylistTableService<'a> { } } + #[allow(dead_code)] + pub(crate) fn update_cover(&mut self, idx: usize, image_path: std::path::PathBuf) { + let Some(mut track) = self + .playlist() + .and_then(|playlist| playlist.tracks.get(idx).cloned()) + else { + return; + }; + + if self.ctx.update_track_cover(&mut track, &image_path) { + if let Some(slot) = self + .playlist_mut() + .and_then(|playlist| playlist.tracks.get_mut(idx)) + { + *slot = track; + } + } + } + pub(crate) fn play_track(&mut self, idx: usize) { let Some(track) = self .playlist() diff --git a/src/app/components/window_chrome.rs b/src/app/components/window_chrome.rs index 3059fda..09abd0f 100644 --- a/src/app/components/window_chrome.rs +++ b/src/app/components/window_chrome.rs @@ -19,15 +19,21 @@ impl AppComponent for WindowChrome { if ui.button(t("open")).clicked() { if let Some(new_path) = rfd::FileDialog::new().pick_folder() { // Add the path to the library - ctx.library.add_path(new_path); - - // Get the last added path and import it - if let Some(newest_path) = ctx.library.paths().last() { - if newest_path.status() - == crate::app::library::LibraryPathStatus::NotImported - { - ctx.import_library_paths(newest_path); - } + let path_exists = !ctx.library.add_path(new_path.clone()); + + // If it existed, find it and rescan. If new, import the bottom-most path. + let path_to_import = if path_exists { + ctx.library + .paths() + .iter() + .find(|p| *p.path() == new_path) + .cloned() + } else { + ctx.library.paths().last().cloned() + }; + + if let Some(p) = path_to_import { + ctx.import_library_paths(&p); } } ui.close_menu(); diff --git a/src/app/core.rs b/src/app/core.rs index 0823460..d2b07f2 100644 --- a/src/app/core.rs +++ b/src/app/core.rs @@ -309,6 +309,29 @@ impl App { success } + #[allow(dead_code)] + pub fn update_track_cover( + &mut self, + track: &mut LibraryItem, + image_path: &std::path::PathBuf, + ) -> bool { + let db_conn = self.db().connection(); + + let success = LibraryService::update_track_cover( + track, + image_path, + &mut self.library, + &mut self.playlists, + &db_conn, + ); + + if success { + self.save_state(); + } + + success + } + // Add these new methods for language handling pub fn set_language(&mut self, lang: i18n::Language) { self.app_settings.current_language = lang; diff --git a/src/app/db.rs b/src/app/db.rs index 063fc69..a669b6a 100644 --- a/src/app/db.rs +++ b/src/app/db.rs @@ -8,7 +8,7 @@ pub struct Database { impl Database { // The current schema version - increment this when making schema changes - const SCHEMA_VERSION: i32 = 3; + const SCHEMA_VERSION: i32 = 4; pub fn new() -> Result { // Get the app's configuration directory diff --git a/src/app/services/library_service.rs b/src/app/services/library_service.rs index a9d27c1..8abcc9f 100644 --- a/src/app/services/library_service.rs +++ b/src/app/services/library_service.rs @@ -59,4 +59,26 @@ impl LibraryService { LibraryCommand::AddPathId(path_id) => library.set_path_to_imported(path_id), } } + + /// Update track cover image + #[allow(dead_code)] + pub fn update_track_cover( + track: &mut LibraryItem, + image_path: &std::path::PathBuf, + library: &mut Library, + _playlists: &mut [Playlist], + db_conn: &Arc>, + ) -> bool { + let success = MetadataEditor::update_track_cover(track, image_path); + + // When the cover changes, we simply reload the library from the database + // and playlists just like `update_track_metadata` does because the cover + // path needs to be refreshed from the newly extracted data + if success { + if let Ok(updated_library) = Library::load_from_db(db_conn) { + *library = updated_library; + } + } + success + } } diff --git a/src/lib/lyrics.rs b/src/lib/lyrics.rs index e83376e..4462e46 100644 --- a/src/lib/lyrics.rs +++ b/src/lib/lyrics.rs @@ -1,4 +1,7 @@ -use id3::TagLike; +use lofty::file::TaggedFileExt; +use lofty::probe::Probe; +use lofty::tag::Tag; +use lofty::tag::{ItemKey, TagExt}; use serde::{Deserialize, Serialize}; use std::sync::mpsc::{self, Receiver, Sender}; use std::thread; @@ -256,140 +259,149 @@ impl LyricsService { } } - /// Read lyrics from ID3 tag of an audio file + /// Read lyrics from tag of an audio file pub fn read_lyrics_from_file>( path: P, artist: &str, title: &str, ) -> Option { - match id3::Tag::read_from_path(path) { - Ok(tag) => { - // Check for unsynchronized lyrics (USLT) frames - if let Some(lyrics_frame) = tag.lyrics().next() { - let text = lyrics_frame.text.clone(); - let is_synced = lyrics_frame.description == "SyncedLyrics" - || text.contains("[00:") - || text.contains("[01:") - || text.contains("[02:") - || text.contains("[03:") - || text.contains("[04:") - || text.contains("[05:"); - - tracing::debug!( - "📖 Found lyrics in ID3 tag ({} characters, synced: {})", - text.len(), - is_synced - ); - - let mut lyrics = Lyrics { - id: 0, // Not applicable for cached lyrics - name: "Cached Lyrics".to_string(), - track_name: title.to_string(), - artist_name: artist.to_string(), - album_name: None, - duration: None, - instrumental: false, - plain_lyrics: None, - synced_lyrics: None, - lines: Vec::new(), - }; - - if is_synced { - // Store as synced lyrics and parse the lines - lyrics.synced_lyrics = Some(text); - lyrics.lines = - Lyrics::parse_synced_lyrics(lyrics.synced_lyrics.as_ref().unwrap()); + let probe_result = Probe::open(&path).and_then(|p| p.read()); + match probe_result { + Ok(tagged_file) => { + let tag = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()); + + if let Some(tag) = tag { + // Check for lyrics item + if let Some(text) = tag.get_string(&ItemKey::Lyrics) { + let text = text.to_string(); + let is_synced = text.contains("[00:") + || text.contains("[01:") + || text.contains("[02:") + || text.contains("[03:") + || text.contains("[04:") + || text.contains("[05:"); + tracing::debug!( - "🎵 Parsed {} synced lyric lines from cache", - lyrics.lines.len() + "📖 Found lyrics in tag ({} characters, synced: {})", + text.len(), + is_synced ); + + let mut lyrics = Lyrics { + id: 0, // Not applicable for cached lyrics + name: "Cached Lyrics".to_string(), + track_name: title.to_string(), + artist_name: artist.to_string(), + album_name: None, + duration: None, + instrumental: false, + plain_lyrics: None, + synced_lyrics: None, + lines: Vec::new(), + }; + + if is_synced { + // Store as synced lyrics and parse the lines + lyrics.synced_lyrics = Some(text); + lyrics.lines = + Lyrics::parse_synced_lyrics(lyrics.synced_lyrics.as_ref().unwrap()); + tracing::debug!( + "🎵 Parsed {} synced lyric lines from cache", + lyrics.lines.len() + ); + } else { + // Store as plain lyrics + lyrics.plain_lyrics = Some(text); + tracing::debug!("📝 Loaded plain lyrics from cache"); + } + + Some(lyrics) } else { - // Store as plain lyrics - lyrics.plain_lyrics = Some(text); - tracing::debug!("📝 Loaded plain lyrics from cache"); + tracing::debug!("📖 No lyrics found in tag"); + None } - - Some(lyrics) } else { - tracing::debug!("📖 No lyrics found in ID3 tag"); + tracing::debug!("📖 No tag found in file"); None } } Err(e) => { - tracing::debug!("📖 Failed to read ID3 tag: {}", e); + tracing::debug!("📖 Failed to read tag: {}", e); None } } } - /// Write lyrics to ID3 tag of an audio file + /// Write lyrics to tag of an audio file pub fn write_lyrics_to_file>( path: P, lyrics: &Lyrics, - ) -> Result<(), id3::Error> { - // Read existing tag or create new one - let mut tag = match id3::Tag::read_from_path(&path) { - Ok(tag) => tag, + ) -> Result<(), lofty::error::LoftyError> { + let path = path.as_ref(); + let mut tagged_file = match Probe::open(path).and_then(|p| p.read()) { + Ok(file) => file, Err(e) => { - if let id3::ErrorKind::NoTag = e.kind { - tracing::debug!("📝 Creating new ID3 tag for lyrics storage"); - id3::Tag::new() + tracing::error!("Failed to open file for lyrics writing"); + return Err(e); + } + }; + + let mut tag = match tagged_file.primary_tag_mut() { + Some(t) => t.clone(), + None => { + if let Some(t) = tagged_file.first_tag_mut() { + t.clone() } else { - return Err(e); + Tag::new(tagged_file.primary_tag_type()) } } }; - // Remove existing lyrics frames - tag.remove_all_lyrics(); - - // Determine which lyrics to store (prefer synced over plain for better experience) - let (lyrics_text, is_synced) = if let Some(synced) = &lyrics.synced_lyrics { - (synced.clone(), true) + // Determine which lyrics to store + let lyrics_text = if let Some(synced) = &lyrics.synced_lyrics { + synced.clone() } else if let Some(plain) = &lyrics.plain_lyrics { - (plain.clone(), false) + plain.clone() } else { tracing::warn!("📝 No lyrics content to write to file"); return Ok(()); // Nothing to write }; - // Add the lyrics as unsynchronized lyrics - // For synced lyrics, we store the raw LRC format which can be detected when reading - use id3::frame::Lyrics; - let description = if is_synced { "SyncedLyrics" } else { "Lyrics" }; - tag.add_frame(Lyrics { - lang: "eng".to_string(), - description: description.to_string(), - text: lyrics_text, - }); + // Add the lyrics as text + tag.insert_text(ItemKey::Lyrics, lyrics_text); // Write the tag back to the file - tag.write_to_path(path, id3::Version::Id3v24)?; - tracing::info!("📝 Successfully wrote lyrics to ID3 tag"); + tag.save_to_path(path, lofty::config::WriteOptions::new())?; + tracing::info!("📝 Successfully wrote lyrics to file"); Ok(()) } - /// Remove lyrics from ID3 tag of an audio file - pub fn remove_lyrics_from_file>(path: P) -> Result<(), id3::Error> { - // Read existing tag - let mut tag = match id3::Tag::read_from_path(&path) { - Ok(tag) => tag, - Err(e) => { - if let id3::ErrorKind::NoTag = e.kind { - // No tag means no lyrics to remove - return Ok(()); + /// Remove lyrics from tag of an audio file + pub fn remove_lyrics_from_file>( + path: P, + ) -> Result<(), lofty::error::LoftyError> { + let path = path.as_ref(); + let mut tagged_file = Probe::open(path).and_then(|p| p.read())?; + + let mut tag = match tagged_file.primary_tag_mut() { + Some(t) => t.clone(), + None => { + if let Some(t) = tagged_file.first_tag_mut() { + t.clone() } else { - return Err(e); + return Ok(()); // No tag means no lyrics to remove } } }; - // Remove all lyrics frames - tag.remove_all_lyrics(); + // Remove lyrics frame + tag.remove_key(&ItemKey::Lyrics); // Write the tag back to the file - tag.write_to_path(path, id3::Version::Id3v24)?; - tracing::info!("🗑️ Successfully removed lyrics from ID3 tag"); + tag.save_to_path(path, lofty::config::WriteOptions::new())?; + tracing::info!("🗑️ Successfully removed lyrics from file tag"); Ok(()) } } diff --git a/src/lib/playlist.rs b/src/lib/playlist.rs index d1f7869..644c0d0 100644 --- a/src/lib/playlist.rs +++ b/src/lib/playlist.rs @@ -280,7 +280,8 @@ impl Playlist { // Get the tracks let mut items_stmt = conn_guard.prepare( - "SELECT li.* FROM library_items li + "SELECT li.key, li.library_id, li.path, li.title, li.artist, li.album, li.year, li.genre, li.track_number, li.lyrics + FROM library_items li JOIN playlist_items pi ON li.key = pi.library_item_id WHERE pi.playlist_id = ?1 ORDER BY pi.position", diff --git a/src/lib/services/library_import.rs b/src/lib/services/library_import.rs index d7f8672..49db441 100644 --- a/src/lib/services/library_import.rs +++ b/src/lib/services/library_import.rs @@ -1,4 +1,8 @@ -use id3::{Tag, TagLike}; +use lofty::file::TaggedFileExt; +use lofty::picture::Picture as LoftyPicture; +use lofty::probe::Probe; +use lofty::tag::Accessor; +use lofty::tag::Tag; use rand::Rng; use rayon::prelude::*; use std::fs; @@ -9,8 +13,8 @@ use walkdir::WalkDir; use crate::{ library::{ - LibraryItem, LibraryItemContainer, LibraryPath, LibraryPathId, LibraryPathStatus, - LibraryView, Picture, ViewType, + LibraryItem, LibraryItemContainer, LibraryPath, LibraryPathId, LibraryView, Picture, + ViewType, }, LibraryCommand, }; @@ -32,11 +36,6 @@ impl LibraryImportService { lib_cmd_tx: Sender, album_art_dir: PathBuf, ) { - if lib_path.status() == LibraryPathStatus::Imported { - tracing::info!("Library path already imported, skipping"); - return; - } - tracing::info!("Starting library path import: {:?}", lib_path.path()); let path = lib_path.path().clone(); @@ -76,12 +75,21 @@ impl LibraryImportService { .filter_map(|e| e.ok()) .skip(1) .filter(|entry| { - entry.file_type().is_file() - && entry.path().extension().unwrap_or(std::ffi::OsStr::new("")) == "mp3" + if !entry.file_type().is_file() { + return false; + } + + let ext = entry + .path() + .extension() + .unwrap_or(std::ffi::OsStr::new("")) + .to_string_lossy() + .to_lowercase(); + matches!(ext.as_str(), "mp3" | "flac" | "wav" | "ogg" | "m4a") }) .collect::>(); - tracing::info!("Found {} MP3 files to import", files.len()); + tracing::info!("Found {} audio files to import", files.len()); // Parse files in parallel let items: Vec = files @@ -107,21 +115,27 @@ impl LibraryImportService { path_id: LibraryPathId, album_art_dir: &Path, ) -> LibraryItem { - let tag_result = Tag::read_from_path(file_path); - - match tag_result { - Ok(tag) => { - tracing::debug!("Successfully read ID3 tag from: {}", file_path.display()); - Self::create_item_from_tag(file_path, path_id, &tag, album_art_dir) + let probe_result = Probe::open(file_path).and_then(|p| p.read()); + + match probe_result { + Ok(tagged_file) => { + tracing::debug!("Successfully read metadata from: {}", file_path.display()); + if let Some(tag) = tagged_file.primary_tag() { + Self::create_item_from_tag(file_path, path_id, tag, album_art_dir) + } else if let Some(tag) = tagged_file.first_tag() { + Self::create_item_from_tag(file_path, path_id, tag, album_art_dir) + } else { + Self::create_fallback_item(file_path, path_id) + } } Err(err) => { - tracing::warn!("Failed to read ID3 tag from {:?}: {}", file_path, err); + tracing::warn!("Failed to read metadata from {:?}: {}", file_path, err); Self::create_fallback_item(file_path, path_id) } } } - /// Create LibraryItem from ID3 tag + /// Create LibraryItem from Tag fn create_item_from_tag( file_path: &std::path::Path, path_id: LibraryPathId, @@ -135,16 +149,18 @@ impl LibraryImportService { .unwrap_or("Unknown Title") .to_string(); - let title = tag.title().unwrap_or(&filename_title); + let title = tag + .title() + .unwrap_or(std::borrow::Cow::Borrowed(&filename_title)); let mut item = LibraryItem::new(file_path.to_path_buf(), path_id) - .set_title(Some(title)) - .set_artist(tag.artist()) - .set_album(tag.album()) - .set_year(tag.year()) - .set_genre(tag.genre()) - .set_track_number(Self::extract_track_number(tag)) - .set_lyrics(tag.lyrics().next().map(|l| l.text.as_str())); + .set_title(Some(&title)) + .set_artist(tag.artist().as_deref()) + .set_album(tag.album().as_deref()) + .set_year(tag.year().map(|y| y as i32)) + .set_genre(tag.genre().as_deref()) + .set_track_number(tag.track()) + .set_lyrics(None); // Lyrics will be handled via Lofty's ItemKey::Lyrics in lyrics.rs // Extract and save album art Self::extract_album_art(&mut item, tag, file_path, album_art_dir); @@ -163,20 +179,7 @@ impl LibraryImportService { LibraryItem::new(file_path.to_path_buf(), path_id).set_title(Some(&filename_title)) } - /// Extract track number from ID3 tag - fn extract_track_number(tag: &Tag) -> Option { - tag.get("TRCK").and_then(|frame| { - frame.content().text().map(|t| { - t.split('/') - .next() - .unwrap_or("0") - .parse::() - .unwrap_or(0) - }) - }) - } - - /// Extract and save album art from ID3 tag + /// Extract and save album art from tag fn extract_album_art( item: &mut LibraryItem, tag: &Tag, @@ -187,11 +190,14 @@ impl LibraryImportService { let file_name = Self::generate_picture_filename(file_path, pic, album_art_dir); if let Ok(mut file) = fs::File::create(&file_name) { - if file.write_all(&pic.data).is_ok() { + if file.write_all(pic.data()).is_ok() { item.add_picture(Picture::new( - pic.mime_type.to_string(), - u8::from(pic.picture_type), - pic.description.to_string(), + pic.mime_type() + .map(|m| m.as_str()) + .unwrap_or("image/jpeg") + .to_string(), + pic.pic_type().as_u8(), + pic.description().unwrap_or("").to_string(), file_name, )); } @@ -202,19 +208,20 @@ impl LibraryImportService { /// Generate unique filename for album art fn generate_picture_filename( file_path: &std::path::Path, - pic: &id3::frame::Picture, + pic: &LoftyPicture, album_art_dir: &Path, ) -> PathBuf { - let extension = match pic.mime_type.as_str() { - "image/jpeg" => "jpg", - "image/png" => "png", - _ => "jpg", + let mime_str = pic.mime_type().map(|m| m.as_str()).unwrap_or("image/jpeg"); + let extension = if mime_str.contains("png") { + "png" + } else { + "jpg" }; album_art_dir.join(format!( "{}_{}_{}.{}", file_path.file_stem().unwrap_or_default().to_string_lossy(), - u8::from(pic.picture_type), + pic.pic_type().as_u8(), rand::thread_rng().gen::(), extension )) diff --git a/src/lib/services/metadata_editor.rs b/src/lib/services/metadata_editor.rs index cbd48c9..4665cc4 100644 --- a/src/lib/services/metadata_editor.rs +++ b/src/lib/services/metadata_editor.rs @@ -1,4 +1,9 @@ -use id3::{Tag, TagLike, Version}; +use lofty::file::TaggedFileExt; +use lofty::picture::{MimeType, Picture, PictureType}; +use lofty::probe::Probe; +use lofty::tag::Tag; +use lofty::tag::{Accessor, TagExt}; +use std::fs; use std::path::PathBuf; use crate::library::LibraryItem; @@ -19,61 +24,60 @@ impl MetadataEditor { ) -> bool { let path = track.path(); - // Read or create ID3 tag - let mut tag = match Self::read_or_create_tag(&path) { - Ok(tag) => tag, + let mut tagged_file = match Probe::open(&path).and_then(|p| p.read()) { + Ok(file) => file, Err(e) => { - tracing::error!("Failed to read/create ID3 tag for {:?}: {}", path, e); + tracing::error!("Failed to open file {:?} for metadata editing: {}", path, e); return false; } }; + let mut tag = match tagged_file.primary_tag_mut() { + Some(t) => t.clone(), + None => { + if let Some(t) = tagged_file.first_tag_mut() { + t.clone() + } else { + tracing::info!("Creating new tag for file: {:?}", path); + Tag::new(tagged_file.primary_tag_type()) + } + } + }; + // Update the tag and track if !Self::update_tag_field(&mut tag, track, field, value) { return false; } // Write tag to file - if !Self::write_tag_to_file(&tag, &path) { + if let Err(e) = tag.save_to_path(&path, lofty::config::WriteOptions::new()) { + tracing::error!("Failed to write tag to file {:?}: {}", path, e); return false; } + tracing::info!("Successfully updated metadata in file: {:?}", path); + // Update database Self::update_database(track, field, value, db_conn) } - /// Read existing tag or create a new one - fn read_or_create_tag(path: &PathBuf) -> Result { - match Tag::read_from_path(path) { - Ok(tag) => Ok(tag), - Err(err) => { - if let id3::ErrorKind::NoTag = err.kind { - tracing::info!("Creating new ID3 tag for file: {:?}", path); - Ok(Tag::new()) - } else { - Err(err) - } - } - } - } - /// Update the specified field in both tag and track fn update_tag_field(tag: &mut Tag, track: &mut LibraryItem, field: &str, value: &str) -> bool { match field { "title" => { - tag.set_title(value); + tag.set_title(value.to_string()); track.set_title(Some(value)); } "artist" => { - tag.set_artist(value); + tag.set_artist(value.to_string()); track.set_artist(Some(value)); } "album" => { - tag.set_album(value); + tag.set_album(value.to_string()); track.set_album(Some(value)); } "genre" => { - tag.set_genre(value); + tag.set_genre(value.to_string()); track.set_genre(Some(value)); } _ => { @@ -84,18 +88,66 @@ impl MetadataEditor { true } - /// Write tag to file - fn write_tag_to_file(tag: &Tag, path: &PathBuf) -> bool { - match tag.write_to_path(path, Version::Id3v24) { - Ok(_) => { - tracing::info!("Successfully updated metadata in file: {:?}", path); - true + /// Update the track's album cover + pub fn update_track_cover(track: &mut LibraryItem, image_path: &PathBuf) -> bool { + let path = track.path(); + + let image_data = match fs::read(image_path) { + Ok(data) => data, + Err(e) => { + tracing::error!("Failed to read new cover image: {}", e); + return false; } + }; + + let mut tagged_file = match Probe::open(&path).and_then(|p| p.read()) { + Ok(file) => file, Err(e) => { - tracing::error!("Failed to write tag to file {:?}: {}", path, e); - false + tracing::error!("Failed to open file {:?} for cover editing: {}", path, e); + return false; + } + }; + + let mut tag = match tagged_file.primary_tag_mut() { + Some(t) => t.clone(), + None => { + if let Some(t) = tagged_file.first_tag_mut() { + t.clone() + } else { + Tag::new(tagged_file.primary_tag_type()) + } } + }; + + // Determine MimeType based on extension + let ext = image_path + .extension() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); + let mime_type = match ext.as_str() { + "png" => MimeType::Png, + "jpeg" | "jpg" => MimeType::Jpeg, + "gif" => MimeType::Gif, + "bmp" => MimeType::Bmp, + "tiff" => MimeType::Tiff, + _ => MimeType::Jpeg, + }; + + let picture = + Picture::new_unchecked(PictureType::CoverFront, Some(mime_type), None, image_data); + + // Remove old front covers + tag.remove_picture_type(PictureType::CoverFront); + tag.push_picture(picture); + + if let Err(e) = tag.save_to_path(&path, lofty::config::WriteOptions::new()) { + tracing::error!("Failed to save new cover to file {:?}: {}", path, e); + return false; } + + tracing::info!("Successfully embedded new album cover for {:?}", path); + true } /// Update the database with new metadata