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