Apply fixes for albums and songs
This commit is contained in:
@@ -3,20 +3,17 @@
|
|||||||
#![allow(clippy::unused_async)]
|
#![allow(clippy::unused_async)]
|
||||||
use axum::extract::Query;
|
use axum::extract::Query;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use lofty::{
|
use serde::{Deserialize, Serialize};
|
||||||
config::WriteOptions,
|
use serde_json::json;
|
||||||
file::{AudioFile, TaggedFileExt},
|
|
||||||
read_from_path,
|
|
||||||
tag::Accessor,
|
|
||||||
};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use tracing::error;
|
|
||||||
|
|
||||||
use crate::{
|
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::{
|
services::{
|
||||||
musicbrainz::{self, get_release_with_tracks, search_album},
|
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)]
|
#[derive(Deserialize)]
|
||||||
pub struct FixParams {
|
pub struct FixParams {
|
||||||
pub file_id: i32,
|
pub file_id: i32,
|
||||||
pub title: Option<String>,
|
pub metadata: MetadataFields,
|
||||||
pub artist: Option<String>,
|
}
|
||||||
pub album: Option<String>,
|
|
||||||
pub track: Option<u32>,
|
#[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(
|
pub async fn suggest_album(
|
||||||
@@ -115,35 +123,57 @@ pub async fn apply_fix(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| Error::NotFound)?;
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
|
||||||
let path = std::path::Path::new(&file.path);
|
apply_metadata_to_file_and_db(&ctx, file, ¶ms.metadata, true).await?;
|
||||||
let mut tagged = read_from_path(path).map_err(|e| {
|
|
||||||
error!("Failed to read file from path: {}", e.to_string(),);
|
|
||||||
Error::InternalServerError
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if let Some(tag) = tagged.primary_tag_mut() {
|
format::json(json!("Success!"))
|
||||||
if let Some(title) = ¶ms.title {
|
|
||||||
tag.set_title(title.clone());
|
|
||||||
}
|
}
|
||||||
if let Some(artist) = ¶ms.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 ¶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(album) = ¶ms.album {
|
|
||||||
tag.set_album(album.clone());
|
|
||||||
}
|
}
|
||||||
if let Some(track) = ¶ms.track {
|
Ok(None) => results.push(AlbumFixResult {
|
||||||
tag.set_track(track.clone());
|
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
|
format::json(results)
|
||||||
.save_to_path(path, WriteOptions::default())
|
|
||||||
.map_err(|e| {
|
|
||||||
error!("Failed to save file to path: {}", e.to_string(),);
|
|
||||||
Error::InternalServerError
|
|
||||||
})?;
|
|
||||||
|
|
||||||
format::empty()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
@@ -152,4 +182,5 @@ pub fn routes() -> Routes {
|
|||||||
.add("/suggest", post(suggest))
|
.add("/suggest", post(suggest))
|
||||||
.add("/suggest_album", post(suggest_album))
|
.add("/suggest_album", post(suggest_album))
|
||||||
.add("/apply", post(apply_fix))
|
.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::*;
|
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;
|
pub type MusicFiles = Entity;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -26,3 +39,77 @@ impl ActiveModel {}
|
|||||||
|
|
||||||
// implement your custom finders, selectors oriented logic here
|
// implement your custom finders, selectors oriented logic here
|
||||||
impl Entity {}
|
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 strsim::jaro_winkler;
|
||||||
|
|
||||||
use musicbrainz_rs::entity::release::Track;
|
use musicbrainz_rs::entity::release::Track;
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::models::music_files;
|
use crate::models::music_files;
|
||||||
@@ -17,7 +17,7 @@ pub struct SuggestedTrackFix {
|
|||||||
pub suggested: MetadataFields,
|
pub suggested: MetadataFields,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, PartialEq)]
|
#[derive(Debug, Serialize, PartialEq, Deserialize)]
|
||||||
pub struct MetadataFields {
|
pub struct MetadataFields {
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub artist: Option<String>,
|
pub artist: Option<String>,
|
||||||
|
@@ -72,7 +72,7 @@ impl BackgroundWorker<WorkerArgs> for Worker {
|
|||||||
if let Some(existing_file) = existing {
|
if let Some(existing_file) = existing {
|
||||||
let existing_metadata: Option<Value> = existing_file.metadata.clone();
|
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);
|
println!("Unchanged metadata for file {}", &path_str);
|
||||||
unchanged_files += 1;
|
unchanged_files += 1;
|
||||||
continue;
|
continue;
|
||||||
@@ -115,3 +115,34 @@ impl BackgroundWorker<WorkerArgs> for Worker {
|
|||||||
Ok(())
|
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