Apply fixes for albums and songs
This commit is contained in:
@@ -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, ¶ms.metadata, true).await?;
|
||||
|
||||
if let Some(tag) = tagged.primary_tag_mut() {
|
||||
if let Some(title) = ¶ms.title {
|
||||
tag.set_title(title.clone());
|
||||
format::json(json!("Success!"))
|
||||
}
|
||||
|
||||
pub async fn apply_album_fix(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<AlbumFixParams>,
|
||||
) -> Result<Response> {
|
||||
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}")),
|
||||
}),
|
||||
}
|
||||
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());
|
||||
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))
|
||||
}
|
||||
|
@@ -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(())
|
||||
}
|
||||
|
@@ -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>,
|
||||
|
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user