From aa2639be61ca5b34447c87a4410273b94bec50d6 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 25 Jul 2025 04:30:01 +0200 Subject: [PATCH] Apply fixes for albums and songs --- src/controllers/musicbrainz.rs | 109 ++++++++++++++++++----------- src/models/music_files.rs | 89 ++++++++++++++++++++++- src/services/suggestion.rs | 4 +- src/workers/scan_library_worker.rs | 33 ++++++++- 4 files changed, 192 insertions(+), 43 deletions(-) diff --git a/src/controllers/musicbrainz.rs b/src/controllers/musicbrainz.rs index 1db47b9..172af52 100644 --- a/src/controllers/musicbrainz.rs +++ b/src/controllers/musicbrainz.rs @@ -3,20 +3,17 @@ #![allow(clippy::unused_async)] use axum::extract::Query; use loco_rs::prelude::*; -use lofty::{ - config::WriteOptions, - file::{AudioFile, TaggedFileExt}, - read_from_path, - tag::Accessor, -}; -use serde::Deserialize; -use tracing::error; +use serde::{Deserialize, Serialize}; +use serde_json::json; use crate::{ - models::{_entities::music_files as music_files_entity, music_files}, + models::{ + _entities::music_files as music_files_entity, + music_files::{self, apply_metadata_to_file_and_db}, + }, services::{ musicbrainz::{self, get_release_with_tracks, search_album}, - suggestion::match_album_metadata, + suggestion::{match_album_metadata, MetadataFields}, }, }; @@ -36,10 +33,21 @@ pub struct SuggestAlbumQueryParams { #[derive(Deserialize)] pub struct FixParams { pub file_id: i32, - pub title: Option, - pub artist: Option, - pub album: Option, - pub track: Option, + pub metadata: MetadataFields, +} + +#[derive(Deserialize)] +pub struct AlbumFixParams { + pub fixes: Vec, + pub dry_run: bool, +} + +#[derive(Serialize)] +pub struct AlbumFixResult { + pub file_id: i32, + pub path: String, + pub success: bool, + pub message: Option, } pub async fn suggest_album( @@ -115,35 +123,57 @@ pub async fn apply_fix( .await? .ok_or_else(|| Error::NotFound)?; - let path = std::path::Path::new(&file.path); - let mut tagged = read_from_path(path).map_err(|e| { - error!("Failed to read file from path: {}", e.to_string(),); - Error::InternalServerError - })?; + apply_metadata_to_file_and_db(&ctx, file, ¶ms.metadata, true).await?; - if let Some(tag) = tagged.primary_tag_mut() { - if let Some(title) = ¶ms.title { - tag.set_title(title.clone()); - } - if let Some(artist) = ¶ms.artist { - tag.set_artist(artist.clone()); - } - if let Some(album) = ¶ms.album { - tag.set_album(album.clone()); - } - if let Some(track) = ¶ms.track { - tag.set_track(track.clone()); + format::json(json!("Success!")) +} + +pub async fn apply_album_fix( + State(ctx): State, + Json(params): Json, +) -> Result { + let mut results = Vec::new(); + + for fix in ¶ms.fixes { + match music_files::Entity::find_by_id(fix.file_id) + .one(&ctx.db) + .await + { + Ok(Some(file)) => { + let path = file.path.clone(); + match apply_metadata_to_file_and_db(&ctx, file, &fix.metadata, !params.dry_run) + .await + { + Ok(_) => results.push(AlbumFixResult { + file_id: fix.file_id, + path, + success: true, + message: None, + }), + Err(e) => results.push(AlbumFixResult { + file_id: fix.file_id, + path, + success: false, + message: Some(format!("Apply failed: {e}")), + }), + } + } + Ok(None) => results.push(AlbumFixResult { + file_id: fix.file_id, + path: "".into(), + success: false, + message: Some("File not found".into()), + }), + Err(e) => results.push(AlbumFixResult { + file_id: fix.file_id, + path: "".into(), + success: false, + message: Some(format!("DB error: {e}")), + }), } } - tagged - .save_to_path(path, WriteOptions::default()) - .map_err(|e| { - error!("Failed to save file to path: {}", e.to_string(),); - Error::InternalServerError - })?; - - format::empty() + format::json(results) } pub fn routes() -> Routes { @@ -152,4 +182,5 @@ pub fn routes() -> Routes { .add("/suggest", post(suggest)) .add("/suggest_album", post(suggest_album)) .add("/apply", post(apply_fix)) + .add("/apply-album", post(apply_album_fix)) } diff --git a/src/models/music_files.rs b/src/models/music_files.rs index 8597629..9cc3fff 100644 --- a/src/models/music_files.rs +++ b/src/models/music_files.rs @@ -1,5 +1,18 @@ +use loco_rs::prelude::*; use sea_orm::entity::prelude::*; -pub use super::_entities::music_files::{ActiveModel, Model, Entity}; + +use crate::services::suggestion::MetadataFields; + +use lofty::{ + config::WriteOptions, + file::{AudioFile, TaggedFileExt}, + read_from_path, + tag::{Accessor, ItemKey, ItemValue, TagItem}, +}; +use serde_json::json; +use tracing::error; + +pub use super::_entities::music_files::{ActiveModel, Entity, Model}; pub type MusicFiles = Entity; #[async_trait::async_trait] @@ -26,3 +39,77 @@ impl ActiveModel {} // implement your custom finders, selectors oriented logic here impl Entity {} + +pub async fn apply_metadata_to_file_and_db( + ctx: &AppContext, + file: Model, + metadata: &MetadataFields, + write: bool, +) -> Result<()> { + let path = std::path::Path::new(&file.path); + let mut tagged = read_from_path(path).map_err(|e| { + error!("Failed to read {}: {}", file.path, e); + Error::InternalServerError + })?; + + if let Some(tag) = tagged.primary_tag_mut() { + if let Some(title) = &metadata.title { + tag.set_title(title.clone()); + } + if let Some(artist) = &metadata.artist { + tag.set_artist(artist.clone()); + } + if let Some(album) = &metadata.album { + tag.set_album(album.clone()); + } + if let Some(track) = metadata.track { + tag.set_track(track); + } + if let Some(album_artist) = &metadata.album_artist { + tag.insert(TagItem::new( + ItemKey::AlbumArtist, + ItemValue::Text(album_artist.clone()), + )); + } + } else { + return Err(Error::BadRequest("Primary tag not found.".to_string())); + } + + if write { + tagged + .save_to_path(path, WriteOptions::default()) + .map_err(|e| { + error!("Failed to save {}: {}", file.path, e); + Error::InternalServerError + })?; + } + + let mut model: ActiveModel = file.into_active_model(); + + if let Some(title) = &metadata.title { + model.title = Set(Some(title.clone())); + } + if let Some(artist) = &metadata.artist { + model.artist = Set(Some(artist.clone())); + } + if let Some(album) = &metadata.album { + model.album = Set(Some(album.clone())); + } + + let mut json_metadata = model.metadata.unwrap().unwrap_or_default(); + + if let Some(track) = metadata.track { + json_metadata["track"] = json!(track); + } + if let Some(album_artist) = &metadata.album_artist { + json_metadata["album_artist"] = json!(album_artist); + } + + model.metadata = Set(Some(json_metadata)); + + if write { + model.update(&ctx.db).await?; + } + + Ok(()) +} diff --git a/src/services/suggestion.rs b/src/services/suggestion.rs index 6952be5..e7c12e7 100644 --- a/src/services/suggestion.rs +++ b/src/services/suggestion.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use strsim::jaro_winkler; use musicbrainz_rs::entity::release::Track; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use tracing::info; use crate::models::music_files; @@ -17,7 +17,7 @@ pub struct SuggestedTrackFix { pub suggested: MetadataFields, } -#[derive(Debug, Serialize, PartialEq)] +#[derive(Debug, Serialize, PartialEq, Deserialize)] pub struct MetadataFields { pub title: Option, pub artist: Option, diff --git a/src/workers/scan_library_worker.rs b/src/workers/scan_library_worker.rs index d5892a9..2d86112 100644 --- a/src/workers/scan_library_worker.rs +++ b/src/workers/scan_library_worker.rs @@ -72,7 +72,7 @@ impl BackgroundWorker for Worker { if let Some(existing_file) = existing { let existing_metadata: Option = existing_file.metadata.clone(); - if existing_metadata.as_ref() == Some(&metadata) { + if json_equal(existing_metadata.as_ref(), &metadata) { println!("Unchanged metadata for file {}", &path_str); unchanged_files += 1; continue; @@ -115,3 +115,34 @@ impl BackgroundWorker for Worker { Ok(()) } } + +fn json_equal(a: Option<&Value>, b: &Value) -> bool { + match a { + Some(v) => normalize_json(v) == normalize_json(b), + None => false, + } +} + +fn normalize_json(value: &Value) -> Value { + match value { + Value::Object(map) => { + let mut new_map = serde_json::Map::new(); + for (k, v) in map { + new_map.insert(k.clone(), normalize_json(v)); + } + Value::Object(new_map) + } + Value::Number(n) => { + if n.is_f64() { + if let Some(i) = n.as_u64() { + Value::Number(serde_json::Number::from(i)) + } else { + Value::Number(n.clone()) + } + } else { + Value::Number(n.clone()) + } + } + _ => value.clone(), + } +}