Apply fixes for albums and songs
Some checks failed
CI / Check Style (push) Failing after 1m2s
CI / Run Clippy (push) Failing after 5m15s
CI / Run Tests (push) Failing after 36s

This commit is contained in:
2025-07-25 04:30:01 +02:00
parent 43bd076d04
commit aa2639be61
4 changed files with 192 additions and 43 deletions

View File

@@ -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<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub track: Option<u32>,
pub metadata: MetadataFields,
}
#[derive(Deserialize)]
pub struct AlbumFixParams {
pub fixes: Vec<FixParams>,
pub dry_run: bool,
}
#[derive(Serialize)]
pub struct AlbumFixResult {
pub file_id: i32,
pub path: String,
pub success: bool,
pub message: Option<String>,
}
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, &params.metadata, true).await?;
if let Some(tag) = tagged.primary_tag_mut() {
if let Some(title) = &params.title {
tag.set_title(title.clone());
format::json(json!("Success!"))
}
if let Some(artist) = &params.artist {
tag.set_artist(artist.clone());
pub async fn apply_album_fix(
State(ctx): State<AppContext>,
Json(params): Json<AlbumFixParams>,
) -> Result<Response> {
let mut results = Vec::new();
for fix in &params.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}")),
}),
}
if let Some(album) = &params.album {
tag.set_album(album.clone());
}
if let Some(track) = &params.track {
tag.set_track(track.clone());
Ok(None) => results.push(AlbumFixResult {
file_id: fix.file_id,
path: "<unknown>".into(),
success: false,
message: Some("File not found".into()),
}),
Err(e) => results.push(AlbumFixResult {
file_id: fix.file_id,
path: "<unknown>".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))
}

View File

@@ -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(())
}

View File

@@ -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<String>,
pub artist: Option<String>,

View File

@@ -72,7 +72,7 @@ impl BackgroundWorker<WorkerArgs> for Worker {
if let Some(existing_file) = existing {
let existing_metadata: Option<Value> = 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<WorkerArgs> 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(),
}
}