From dcc665563a4e8069bc4ae0ed6d4e33287f347bd4 Mon Sep 17 00:00:00 2001 From: AB Date: Mon, 25 May 2026 13:50:24 +0300 Subject: [PATCH] Reworked Reviews page --- build.rs | 5 +- src/admin/mod.rs | 55 ++- src/admin/views.rs | 645 ++++++++++++++++++++---- src/agent/cover_art.rs | 5 +- src/agent/dto.rs | 3 + src/agent/metadata.rs | 44 +- src/agent/mod.rs | 11 +- src/agent/normalize.rs | 199 ++++++-- src/agent/path_hints.rs | 7 +- src/api/mod.rs | 13 +- src/auth.rs | 12 +- src/config.rs | 73 +-- src/i18n/mod.rs | 18 +- src/i18n/phrases.rs | 15 + src/jobs/artist_image_backfill.rs | 4 +- src/jobs/cover_backfill.rs | 15 +- src/jobs/inbox_discover.rs | 71 ++- src/jobs/inbox_process.rs | 221 +++++---- src/jobs/metadata_backfill.rs | 234 +++++++++ src/jobs/mod.rs | 1 + src/main.rs | 31 +- src/music/mod.rs | 796 +++++++++++++++++------------- src/oidc.rs | 11 +- src/player/mod.rs | 299 +++++------ src/scheduler/mod.rs | 436 +++++++++++----- src/user.rs | 194 ++++---- templates/admin/job_detail.html | 46 +- templates/admin/jobs.html | 6 + templates/admin/layout.html | 3 +- templates/admin/reviews.html | 227 ++++++++- templates/player.html | 111 ++++- 31 files changed, 2674 insertions(+), 1137 deletions(-) create mode 100644 src/jobs/metadata_backfill.rs diff --git a/build.rs b/build.rs index 742978f..42047d0 100644 --- a/build.rs +++ b/build.rs @@ -10,8 +10,5 @@ fn main() { .output() .expect("failed to run rustc --version"); let version = String::from_utf8_lossy(&output.stdout); - println!( - "cargo::rustc-env=FURU_RUSTC_VERSION={}", - version.trim() - ); + println!("cargo::rustc-env=FURU_RUSTC_VERSION={}", version.trim()); } diff --git a/src/admin/mod.rs b/src/admin/mod.rs index f1fd8f5..d088313 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -2,6 +2,7 @@ pub mod views; use std::sync::Arc; +use cot::App; use cot::db::Database; use cot::db::migrations::SyncDynMigration; use cot::json::Json; @@ -10,7 +11,6 @@ use cot::response::IntoResponse; use cot::router::method::get; use cot::router::{Route, Router}; use cot::session::Session; -use cot::App; use serde::Deserialize; use crate::auth; @@ -18,7 +18,10 @@ use crate::config::AppConfig; use crate::i18n::I18n; use crate::scheduler::{JobRegistry, SchedulerHandle}; use crate::user::User; -use views::{ArtistForm, CronForm, OidcSettingsForm, ReleaseForm, SetImageBody, SetupForm, UploadImageBody, UserForm}; +use views::{ + ArtistForm, CronForm, MetadataBackfillForm, OidcSettingsForm, ReleaseForm, ReviewsBulkForm, + SetImageBody, SetupForm, UploadImageBody, UserForm, +}; #[derive(Debug, Deserialize)] struct ReviewsQuery { @@ -59,7 +62,11 @@ impl AdminApp { registry: Arc, scheduler_handle: Arc>>, ) -> Self { - Self { config, registry, scheduler_handle } + Self { + config, + registry, + scheduler_handle, + } } } @@ -536,6 +543,33 @@ impl App for AdminApp { }, "admin_jobs", ), + Route::with_handler_and_name( + "/jobs/metadata_backfill/run-options", + cot::router::method::post({ + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + move |session: Session, db: Database, + form: RequestForm| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + async move { + let admin = match auth::require_admin_or_redirect(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + let pg_pool = pool.get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(3) + .connect(&pool_config.database_url) + .await + .expect("admin pool") + }).await; + views::metadata_backfill_run(admin, &db, pg_pool, form).await + } + } + }), + "admin_metadata_backfill_run", + ), Route::with_handler_and_name( "/jobs/{name}/run", cot::router::method::post({ @@ -651,6 +685,21 @@ impl App for AdminApp { ), "admin_reviews_clear", ), + Route::with_handler_and_name( + "/reviews/bulk", + cot::router::method::post( + |session: Session, db: Database, + form: RequestForm| async move { + let admin = + match auth::require_admin_or_redirect(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::reviews_bulk(admin, &db, form).await + }, + ), + "admin_reviews_bulk", + ), // -- Reviews ------------------------------------------------------ Route::with_handler_and_name( "/reviews", diff --git a/src/admin/views.rs b/src/admin/views.rs index a98a47c..4c34700 100644 --- a/src/admin/views.rs +++ b/src/admin/views.rs @@ -9,14 +9,14 @@ use cot::{Body, Template}; use std::collections::HashMap; use std::sync::Arc; -use crate::auth::{self, AuthenticatedUser}; +use super::BUILD_INFO; use crate::agent; +use crate::auth::{self, AuthenticatedUser}; use crate::config::{AppConfig, ConfigEntry, ConfigSources}; use crate::i18n::{I18n, Translations}; -use crate::music::{Artist, MediaFile, Release, ReleaseArtist, Track, TrackArtist, RELEASE_TYPES}; +use crate::music::{Artist, MediaFile, RELEASE_TYPES, Release, ReleaseArtist, Track, TrackArtist}; use crate::scheduler::{self, JobRegistry, JobRun, PendingReview, ScheduledJob}; use crate::user::User; -use super::BUILD_INFO; use crate::agent::AgentProbeResult; @@ -31,10 +31,7 @@ pub struct ConfigDisplayEntry { } /// Secret field names that should be redacted in the debug view. -const SECRET_FIELDS: &[&str] = &[ - "database_url", - "oidc_client_secret", -]; +const SECRET_FIELDS: &[&str] = &["database_url", "oidc_client_secret"]; fn is_secret(name: &str) -> bool { let lower = name.to_ascii_lowercase(); @@ -66,44 +63,122 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec { - { - let raw = $value; - let default_raw = $default; - let secret = is_secret(stringify!($field)); - let display = if secret { redact(&raw) } else { raw }; - let default_display = if secret { redact(&default_raw) } else { default_raw }; - ConfigDisplayEntry { - key: stringify!($field).into(), - env_var: format!("FURU_{}", stringify!($field).to_ascii_uppercase()), - value: display, - default_value: default_display, - source: sources.$field.code(), - } + ($field:ident, $value:expr, $default:expr) => {{ + let raw = $value; + let default_raw = $default; + let secret = is_secret(stringify!($field)); + let display = if secret { redact(&raw) } else { raw }; + let default_display = if secret { + redact(&default_raw) + } else { + default_raw + }; + ConfigDisplayEntry { + key: stringify!($field).into(), + env_var: format!("FURU_{}", stringify!($field).to_ascii_uppercase()), + value: display, + default_value: default_display, + source: sources.$field.code(), } - }; + }}; } vec![ - entry!(database_url, config.database_url.clone(), defaults.database_url.clone()), - entry!(oidc_issuer, config.oidc_issuer.clone(), defaults.oidc_issuer.clone()), - entry!(oidc_client_id, config.oidc_client_id.clone(), defaults.oidc_client_id.clone()), - entry!(oidc_client_secret, config.oidc_client_secret.clone(), defaults.oidc_client_secret.clone()), - entry!(log_level, config.log_level.clone(), defaults.log_level.clone()), - entry!(auth_password_enabled, config.auth_password_enabled.to_string(), defaults.auth_password_enabled.to_string()), - entry!(auth_sso_enabled, config.auth_sso_enabled.to_string(), defaults.auth_sso_enabled.to_string()), - entry!(oidc_button_text, config.oidc_button_text.clone(), defaults.oidc_button_text.clone()), - entry!(oidc_admin_groups, config.oidc_admin_groups.clone(), defaults.oidc_admin_groups.clone()), - entry!(swagger_enabled, config.swagger_enabled.to_string(), defaults.swagger_enabled.to_string()), - entry!(agent_enabled, config.agent_enabled.to_string(), defaults.agent_enabled.to_string()), - entry!(agent_inbox_dir, config.agent_inbox_dir.clone(), defaults.agent_inbox_dir.clone()), - entry!(agent_storage_dir, config.agent_storage_dir.clone(), defaults.agent_storage_dir.clone()), - entry!(agent_llm_url, config.agent_llm_url.clone(), defaults.agent_llm_url.clone()), - entry!(agent_llm_model, config.agent_llm_model.clone(), defaults.agent_llm_model.clone()), - entry!(agent_llm_auth, config.agent_llm_auth.clone(), defaults.agent_llm_auth.clone()), - entry!(agent_confidence_threshold, config.agent_confidence_threshold.to_string(), defaults.agent_confidence_threshold.to_string()), - entry!(agent_context_limit, config.agent_context_limit.to_string(), defaults.agent_context_limit.to_string()), - entry!(agent_concurrency, config.agent_concurrency.to_string(), defaults.agent_concurrency.to_string()), + entry!( + database_url, + config.database_url.clone(), + defaults.database_url.clone() + ), + entry!( + oidc_issuer, + config.oidc_issuer.clone(), + defaults.oidc_issuer.clone() + ), + entry!( + oidc_client_id, + config.oidc_client_id.clone(), + defaults.oidc_client_id.clone() + ), + entry!( + oidc_client_secret, + config.oidc_client_secret.clone(), + defaults.oidc_client_secret.clone() + ), + entry!( + log_level, + config.log_level.clone(), + defaults.log_level.clone() + ), + entry!( + auth_password_enabled, + config.auth_password_enabled.to_string(), + defaults.auth_password_enabled.to_string() + ), + entry!( + auth_sso_enabled, + config.auth_sso_enabled.to_string(), + defaults.auth_sso_enabled.to_string() + ), + entry!( + oidc_button_text, + config.oidc_button_text.clone(), + defaults.oidc_button_text.clone() + ), + entry!( + oidc_admin_groups, + config.oidc_admin_groups.clone(), + defaults.oidc_admin_groups.clone() + ), + entry!( + swagger_enabled, + config.swagger_enabled.to_string(), + defaults.swagger_enabled.to_string() + ), + entry!( + agent_enabled, + config.agent_enabled.to_string(), + defaults.agent_enabled.to_string() + ), + entry!( + agent_inbox_dir, + config.agent_inbox_dir.clone(), + defaults.agent_inbox_dir.clone() + ), + entry!( + agent_storage_dir, + config.agent_storage_dir.clone(), + defaults.agent_storage_dir.clone() + ), + entry!( + agent_llm_url, + config.agent_llm_url.clone(), + defaults.agent_llm_url.clone() + ), + entry!( + agent_llm_model, + config.agent_llm_model.clone(), + defaults.agent_llm_model.clone() + ), + entry!( + agent_llm_auth, + config.agent_llm_auth.clone(), + defaults.agent_llm_auth.clone() + ), + entry!( + agent_confidence_threshold, + config.agent_confidence_threshold.to_string(), + defaults.agent_confidence_threshold.to_string() + ), + entry!( + agent_context_limit, + config.agent_context_limit.to_string(), + defaults.agent_context_limit.to_string() + ), + entry!( + agent_concurrency, + config.agent_concurrency.to_string(), + defaults.agent_concurrency.to_string() + ), ] } @@ -278,10 +353,26 @@ pub async fn settings_submit( let RequestForm(result) = form; match result { FormResult::Ok(data) => { - let pw_enabled = if data.auth_password_enabled.is_some() { "true" } else { "false" }; - let sso_enabled = if data.auth_sso_enabled.is_some() { "true" } else { "false" }; - let swagger = if data.swagger_enabled.is_some() { "true" } else { "false" }; - let agent_en = if data.agent_enabled.is_some() { "true" } else { "false" }; + let pw_enabled = if data.auth_password_enabled.is_some() { + "true" + } else { + "false" + }; + let sso_enabled = if data.auth_sso_enabled.is_some() { + "true" + } else { + "false" + }; + let swagger = if data.swagger_enabled.is_some() { + "true" + } else { + "false" + }; + let agent_en = if data.agent_enabled.is_some() { + "true" + } else { + "false" + }; let oidc_button_text = data.oidc_button_text.unwrap_or_default(); let oidc_issuer = data.oidc_issuer.unwrap_or_default(); let oidc_client_id = data.oidc_client_id.unwrap_or_default(); @@ -353,7 +444,12 @@ pub async fn settings_probe_handler( let (config, _sources) = AppConfig::load_with_db(db).await; let probe = if config.agent_enabled && !config.agent_llm_url.is_empty() { - agent::probe_llm(&config.agent_llm_url, &config.agent_llm_model, &config.agent_llm_auth).await + agent::probe_llm( + &config.agent_llm_url, + &config.agent_llm_model, + &config.agent_llm_auth, + ) + .await } else { AgentProbeResult::default() }; @@ -437,15 +533,29 @@ pub async fn users_create( let RequestForm(result) = form; match result { FormResult::Ok(data) => { - let email = if data.email.is_empty() { None } else { Some(data.email.as_str()) }; - let display_name = if data.display_name.is_empty() { None } else { Some(data.display_name.as_str()) }; - User::create(db, &data.username, email, display_name, &data.password, &data.role).await - .map_err(|e| cot::Error::internal(format!("failed to create user: {e}")))?; + let email = if data.email.is_empty() { + None + } else { + Some(data.email.as_str()) + }; + let display_name = if data.display_name.is_empty() { + None + } else { + Some(data.display_name.as_str()) + }; + User::create( + db, + &data.username, + email, + display_name, + &data.password, + &data.role, + ) + .await + .map_err(|e| cot::Error::internal(format!("failed to create user: {e}")))?; Ok(auth::redirect("/admin/users")) } - FormResult::ValidationError(_) => { - Ok(auth::redirect("/admin/users/new")) - } + FormResult::ValidationError(_) => Ok(auth::redirect("/admin/users/new")), } } @@ -455,7 +565,8 @@ pub async fn users_edit( db: &Database, user_id: i64, ) -> cot::Result { - let target = User::get_by_id(db, user_id).await + let target = User::get_by_id(db, user_id) + .await .map_err(|e| cot::Error::internal(format!("db error: {e}")))? .ok_or_else(|| cot::Error::internal("user not found"))?; let template = UserFormTemplate { @@ -481,13 +592,35 @@ pub async fn users_update( let RequestForm(result) = form; match result { FormResult::Ok(data) => { - let mut target = User::get_by_id(db, user_id).await + let mut target = User::get_by_id(db, user_id) + .await .map_err(|e| cot::Error::internal(format!("db error: {e}")))? .ok_or_else(|| cot::Error::internal("user not found"))?; - let email = if data.email.is_empty() { None } else { Some(data.email.as_str()) }; - let display_name = if data.display_name.is_empty() { None } else { Some(data.display_name.as_str()) }; - let new_password = if data.password.is_empty() { None } else { Some(data.password.as_str()) }; - target.update_fields(db, &data.username, email, display_name, new_password, &data.role).await + let email = if data.email.is_empty() { + None + } else { + Some(data.email.as_str()) + }; + let display_name = if data.display_name.is_empty() { + None + } else { + Some(data.display_name.as_str()) + }; + let new_password = if data.password.is_empty() { + None + } else { + Some(data.password.as_str()) + }; + target + .update_fields( + db, + &data.username, + email, + display_name, + new_password, + &data.role, + ) + .await .map_err(|e| cot::Error::internal(format!("failed to update user: {e}")))?; Ok(auth::redirect("/admin/users")) } @@ -502,7 +635,8 @@ pub async fn users_delete( db: &Database, user_id: i64, ) -> cot::Result> { - User::delete_by_id(db, user_id).await + User::delete_by_id(db, user_id) + .await .map_err(|e| cot::Error::internal(format!("failed to delete user: {e}")))?; Ok(auth::redirect("/admin/users")) } @@ -519,10 +653,7 @@ struct SetupTemplate { } pub async fn setup_page(i18n: I18n, message: String) -> cot::Result { - let template = SetupTemplate { - t: i18n.t, - message, - }; + let template = SetupTemplate { t: i18n.t, message }; Ok(Html::new(template.render()?)) } @@ -581,13 +712,25 @@ struct ArtistsTemplate { rows: Vec, } -pub async fn artists_list(admin: AuthenticatedUser, i18n: I18n, db: &Database) -> cot::Result { +pub async fn artists_list( + admin: AuthenticatedUser, + i18n: I18n, + db: &Database, +) -> cot::Result { let artists = Artist::list_all(db).await.unwrap_or_default(); let mut rows = Vec::with_capacity(artists.len()); for artist in artists { - let release_count = ReleaseArtist::count_by_artist(db, artist.id_val()).await.unwrap_or(0); - let track_count = TrackArtist::count_by_artist(db, artist.id_val()).await.unwrap_or(0); - rows.push(ArtistRow { artist, release_count, track_count }); + let release_count = ReleaseArtist::count_by_artist(db, artist.id_val()) + .await + .unwrap_or(0); + let track_count = TrackArtist::count_by_artist(db, artist.id_val()) + .await + .unwrap_or(0); + rows.push(ArtistRow { + artist, + release_count, + track_count, + }); } let template = ArtistsTemplate { t: i18n.t, @@ -657,13 +800,11 @@ pub async fn artists_edit( .ok_or_else(|| cot::Error::internal("artist not found"))?; let current_image_url = match artist.image_file_id { - Some(fid) => { - MediaFile::get_by_id(db, fid) - .await - .ok() - .flatten() - .map(|mf| format!("/api/player/cover/{}", mf.id_val())) - } + Some(fid) => MediaFile::get_by_id(db, fid) + .await + .ok() + .flatten() + .map(|mf| format!("/api/player/cover/{}", mf.id_val())), None => None, }; @@ -830,9 +971,9 @@ pub async fn artists_upload_image( let cover = crate::agent::cover_art::CoverImage { data: image_data, mime_type: parsed.mime_type.clone(), - source: crate::agent::cover_art::CoverSource::FolderFile( - std::path::PathBuf::from(&parsed.filename), - ), + source: crate::agent::cover_art::CoverSource::FolderFile(std::path::PathBuf::from( + &parsed.filename, + )), }; let cover_file_id = crate::agent::cover_art::save_cover_to_storage( @@ -922,7 +1063,9 @@ pub async fn releases_list( // If filtering by artist, find the set of release_ids for that artist let filtered_release_ids: Option> = match filter_artist_id { Some(aid) => { - let links = ReleaseArtist::find_by_artist(db, aid).await.unwrap_or_default(); + let links = ReleaseArtist::find_by_artist(db, aid) + .await + .unwrap_or_default(); Some(links.iter().map(|l| l.release_id()).collect()) } None => None, @@ -936,7 +1079,10 @@ pub async fn releases_list( } } let artist_names = resolve_artist_names(db, release.id_val(), &names).await; - rows.push(ReleaseRow { release, artist_names }); + rows.push(ReleaseRow { + release, + artist_names, + }); } let template = ReleasesTemplate { @@ -967,7 +1113,11 @@ struct ReleaseFormTemplate { lang_code: &'static str, } -pub async fn releases_new(admin: AuthenticatedUser, i18n: I18n, db: &Database) -> cot::Result { +pub async fn releases_new( + admin: AuthenticatedUser, + i18n: I18n, + db: &Database, +) -> cot::Result { let artists = Artist::list_all(db).await.unwrap_or_default(); let template = ReleaseFormTemplate { t: i18n.t, @@ -1084,9 +1234,9 @@ pub async fn releases_update( .map_err(|e| cot::Error::internal(format!("failed to update artists: {e}")))?; Ok(auth::redirect("/admin/releases")) } - FormResult::ValidationError(_) => { - Ok(auth::redirect(&format!("/admin/releases/{release_id}/edit"))) - } + FormResult::ValidationError(_) => Ok(auth::redirect(&format!( + "/admin/releases/{release_id}/edit" + ))), } } @@ -1137,10 +1287,7 @@ pub async fn media_files_list( let rows: Vec = files .into_iter() .map(|mf| { - let track_title = track_map - .get(&mf.id_val()) - .cloned() - .unwrap_or_default(); + let track_title = track_map.get(&mf.id_val()).cloned().unwrap_or_default(); MediaFileRow { media_file: mf, track_title, @@ -1204,17 +1351,16 @@ pub async fn jobs_list( /// rows for jobs that are no longer registered. async fn sync_registered_jobs(db: &Database, registry: &JobRegistry) { for job in registry.all_jobs() { - if let Err(e) = ScheduledJob::upsert(db, job.name(), job.description(), job.default_cron()).await { + if let Err(e) = + ScheduledJob::upsert(db, job.name(), job.description(), job.default_cron()).await + { tracing::error!("failed to upsert scheduled job {}: {e}", job.name()); } } if let Ok(all) = ScheduledJob::list_all(db).await { for sched_job in all { if registry.get(sched_job.name_str()).is_none() { - tracing::warn!( - "Removing orphaned scheduled job '{}'", - sched_job.name_str() - ); + tracing::warn!("Removing orphaned scheduled job '{}'", sched_job.name_str()); let _ = ScheduledJob::delete_by_name(db, sched_job.name_str()).await; } } @@ -1231,6 +1377,15 @@ struct JobDetailTemplate { runs: Vec, } +#[derive(Debug, Form)] +pub struct MetadataBackfillForm { + audio_bitrate: Option, + audio_sample_rate: Option, + audio_bit_depth: Option, + duration_seconds: Option, + mode: Option, +} + pub async fn job_detail( admin: AuthenticatedUser, i18n: I18n, @@ -1275,12 +1430,76 @@ pub async fn job_run_now( Ok(auth::redirect(&format!("/admin/jobs/{job_name}"))) } +pub async fn metadata_backfill_run( + _admin: AuthenticatedUser, + db: &Database, + pool: &sqlx::PgPool, + form: RequestForm, +) -> cot::Result> { + let RequestForm(result) = form; + let data = match result { + FormResult::Ok(data) => data, + FormResult::ValidationError(_) => { + return Ok(auth::redirect("/admin/jobs/metadata_backfill")); + } + }; + + let options = crate::jobs::metadata_backfill::MetadataBackfillOptions { + audio_bitrate: data.audio_bitrate.is_some(), + audio_sample_rate: data.audio_sample_rate.is_some(), + audio_bit_depth: data.audio_bit_depth.is_some(), + duration_seconds: data.duration_seconds.is_some(), + overwrite: data.mode.as_deref() == Some("overwrite"), + }; + + let mut run = JobRun::create_running(db, "metadata_backfill", "manual") + .await + .map_err(|e| cot::Error::internal(format!("failed to create job run: {e}")))?; + let run_id = run.id_val(); + let db = db.clone(); + let pool = pool.clone(); + let (live_config, _) = AppConfig::load_with_db(&db).await; + + tokio::spawn(async move { + let start = std::time::Instant::now(); + let ctx = scheduler::JobContext { + config: Arc::new(live_config), + db: db.clone(), + pool: pool.clone(), + run_id, + registry: Arc::new(JobRegistry::new()), + }; + let mut log = scheduler::JobLog::with_live_flush(pool.clone(), run_id); + let result = + crate::jobs::metadata_backfill::run_with_options(&ctx, &mut log, options).await; + let duration_ms = start.elapsed().as_millis() as i64; + match result { + Ok(()) => { + let _ = run.set_completed(&db, duration_ms, &log.output()).await; + } + Err(e) => { + let _ = run + .set_failed(&db, duration_ms, &log.output(), &e.to_string()) + .await; + } + } + }); + + Ok(auth::redirect(&format!( + "/admin/jobs/metadata_backfill/runs/{run_id}" + ))) +} + pub async fn job_toggle_enabled( _admin: AuthenticatedUser, db: &Database, handle_cell: &Arc>>, job_name: &str, ) -> cot::Result> { + if job_name == "metadata_backfill" { + return Ok(auth::redirect("/admin/jobs/metadata_backfill")); + } + let job = ScheduledJob::get_by_name(db, job_name) .await .map_err(|e| cot::Error::internal(format!("db error: {e}")))? @@ -1311,6 +1530,10 @@ pub async fn job_update_cron( job_name: &str, form: RequestForm, ) -> cot::Result> { + if job_name == "metadata_backfill" { + return Ok(auth::redirect("/admin/jobs/metadata_backfill")); + } + let RequestForm(result) = form; if let FormResult::Ok(data) = result { if let Some(handle) = handle_cell.get() { @@ -1366,11 +1589,164 @@ struct ReviewsTemplate { t: &'static Translations, user_name: String, user_role: String, - reviews: Vec, + rows: Vec, stats_map: HashMap, status_filter: String, } +#[derive(Debug)] +struct ReviewListRow { + review: PendingReview, + display_input_path: String, + media_tags: Vec, +} + +#[derive(Debug, Clone)] +struct ReviewMediaTag { + label: String, + kind: &'static str, +} + +#[derive(Debug, sqlx::FromRow)] +struct ReviewMediaTagRow { + sha256_hash: String, + original_filename: String, + file_size_bytes: i64, + audio_format: Option, + audio_bitrate: Option, + audio_sample_rate: Option, + audio_bit_depth: Option, +} + +fn compact_path_tail(path: &str, max_chars: usize) -> String { + let normalized = path.replace('\\', "/"); + if normalized.chars().count() <= max_chars { + return normalized; + } + + let segments = normalized.split('/').collect::>(); + let filename = segments.last().copied().unwrap_or(normalized.as_str()); + let filename_len = filename.chars().count(); + if filename_len + 4 <= max_chars { + return format!(".../{filename}"); + } + + if filename_len > max_chars { + let suffix_len = max_chars.saturating_sub(3); + let suffix = filename + .chars() + .skip(filename_len.saturating_sub(suffix_len)) + .collect::(); + return format!("...{suffix}"); + } + format!(".../{filename}") +} + +fn context_sha256(review: &PendingReview) -> Option { + let value = serde_json::from_str::(review.context_json_str()).ok()?; + let sha = value.get("sha256")?.as_str()?.trim(); + let is_sha256 = sha.len() == 64 && sha.chars().all(|ch| ch.is_ascii_hexdigit()); + is_sha256.then(|| sha.to_ascii_lowercase()) +} + +fn file_extension(filename: &str) -> Option { + std::path::Path::new(filename) + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.trim().to_ascii_lowercase()) + .filter(|ext| !ext.is_empty()) +} + +fn size_display(bytes: i64) -> String { + if bytes >= 1_073_741_824 { + format!("{:.1} GB", bytes as f64 / 1_073_741_824.0) + } else if bytes >= 1_048_576 { + format!("{:.1} MB", bytes as f64 / 1_048_576.0) + } else if bytes >= 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{bytes} B") + } +} + +fn review_tag(label: impl Into, kind: &'static str) -> ReviewMediaTag { + ReviewMediaTag { + label: label.into(), + kind, + } +} + +fn media_tags(row: &ReviewMediaTagRow) -> Vec { + let mut tags = Vec::new(); + if let Some(format) = row.audio_format.as_deref().filter(|s| !s.is_empty()) { + tags.push(review_tag(format.to_ascii_lowercase(), "format")); + } else if let Some(ext) = file_extension(&row.original_filename) { + tags.push(review_tag(ext, "format")); + } + if let Some(bitrate) = row.audio_bitrate { + tags.push(review_tag(format!("{bitrate} kbps"), "bitrate")); + } + if let Some(sample_rate) = row.audio_sample_rate { + if sample_rate % 1000 == 0 { + tags.push(review_tag(format!("{} kHz", sample_rate / 1000), "sample")); + } else { + tags.push(review_tag( + format!("{:.1} kHz", sample_rate as f64 / 1000.0), + "sample", + )); + } + } + if let Some(bit_depth) = row.audio_bit_depth { + tags.push(review_tag(format!("{bit_depth}-bit"), "depth")); + } + tags.push(review_tag(size_display(row.file_size_bytes), "size")); + tags +} + +async fn review_media_tags( + pool: &sqlx::PgPool, + reviews: &[PendingReview], +) -> HashMap> { + let mut hashes = reviews + .iter() + .filter_map(context_sha256) + .collect::>(); + hashes.sort(); + hashes.dedup(); + if hashes.is_empty() { + return HashMap::new(); + } + + let quoted = hashes + .iter() + .map(|hash| format!("'{hash}'")) + .collect::>() + .join(","); + let query = format!( + "SELECT sha256_hash::text AS sha256_hash, \ + original_filename::text AS original_filename, \ + file_size_bytes, \ + audio_format::text AS audio_format, \ + audio_bitrate, audio_sample_rate, audio_bit_depth \ + FROM furumusic__media_file \ + WHERE file_type = 'audio' AND sha256_hash IN ({quoted})" + ); + + match sqlx::query_as::<_, ReviewMediaTagRow>(&query) + .fetch_all(pool) + .await + { + Ok(rows) => rows + .into_iter() + .map(|row| (row.sha256_hash.to_ascii_lowercase(), media_tags(&row))) + .collect(), + Err(e) => { + tracing::warn!(error = %e, "failed to load review media tags"); + HashMap::new() + } + } +} + pub async fn reviews_list( admin: AuthenticatedUser, i18n: I18n, @@ -1389,12 +1765,27 @@ pub async fn reviews_list( let stats_map = scheduler::ProcessingStats::list_by_review_ids(pool, &review_ids) .await .unwrap_or_default(); + let media_tags = review_media_tags(pool, &reviews).await; + let rows = reviews + .into_iter() + .map(|review| { + let media_tags = context_sha256(&review) + .and_then(|sha| media_tags.get(&sha).cloned()) + .unwrap_or_default(); + let display_input_path = compact_path_tail(review.input_path_str(), 80); + ReviewListRow { + review, + display_input_path, + media_tags, + } + }) + .collect(); let template = ReviewsTemplate { t: i18n.t, user_name: admin.name, user_role: admin.role.code().to_owned(), - reviews, + rows, stats_map, status_filter: status.unwrap_or("").to_owned(), }; @@ -1479,9 +1870,8 @@ pub async fn review_approve( return Ok(auth::redirect(&format!("/admin/reviews/{review_id}"))); } - let normalized: crate::agent::dto::NormalizedFields = - serde_json::from_str(&result_str) - .map_err(|e| cot::Error::internal(format!("invalid result_json: {e}")))?; + let normalized: crate::agent::dto::NormalizedFields = serde_json::from_str(&result_str) + .map_err(|e| cot::Error::internal(format!("invalid result_json: {e}")))?; let context: serde_json::Value = serde_json::from_str(&context_str).unwrap_or_default(); @@ -1518,6 +1908,65 @@ pub async fn review_approve( Ok(auth::redirect(&format!("/admin/reviews/{review_id}"))) } +#[derive(Debug, Form)] +pub struct ReviewsBulkForm { + selected_ids: Option, + action: Option, + status_filter: Option, +} + +fn parse_review_ids(raw: &str) -> Vec { + let mut ids = raw + .split(',') + .filter_map(|part| part.trim().parse::().ok()) + .filter(|id| *id > 0) + .collect::>(); + ids.sort_unstable(); + ids.dedup(); + ids +} + +fn reviews_redirect(status: Option<&str>) -> String { + match status { + Some(s) if !s.is_empty() => format!("/admin/reviews?status={s}"), + _ => "/admin/reviews".to_owned(), + } +} + +pub async fn reviews_bulk( + _admin: AuthenticatedUser, + db: &Database, + form: RequestForm, +) -> cot::Result> { + let RequestForm(result) = form; + let data = match result { + FormResult::Ok(data) => data, + FormResult::ValidationError(_) => return Ok(auth::redirect("/admin/reviews")), + }; + + let redirect_url = reviews_redirect(data.status_filter.as_deref()); + let ids = parse_review_ids(data.selected_ids.as_deref().unwrap_or_default()); + if ids.is_empty() { + return Ok(auth::redirect(&redirect_url)); + } + + match data.action.as_deref() { + Some("delete") => { + PendingReview::delete_by_ids(db, &ids) + .await + .map_err(|e| cot::Error::internal(format!("db error: {e}")))?; + } + Some("requeue") => { + PendingReview::requeue_by_ids(db, &ids) + .await + .map_err(|e| cot::Error::internal(format!("db error: {e}")))?; + } + _ => {} + } + + Ok(auth::redirect(&redirect_url)) +} + pub async fn review_reject( _admin: AuthenticatedUser, db: &Database, diff --git a/src/agent/cover_art.rs b/src/agent/cover_art.rs index 4220a4a..b70f6a7 100644 --- a/src/agent/cover_art.rs +++ b/src/agent/cover_art.rs @@ -118,10 +118,7 @@ fn cover_name_priority(path: &Path) -> usize { /// 2. Try to extract embedded cover art from each audio file. /// /// Returns the first usable image found, or None. -pub async fn find_best_cover( - folder: &Path, - audio_files: &[PathBuf], -) -> Option { +pub async fn find_best_cover(folder: &Path, audio_files: &[PathBuf]) -> Option { // Strategy 1: folder images let folder_images = find_folder_images(folder); for img_path in &folder_images { diff --git a/src/agent/dto.rs b/src/agent/dto.rs index 90516e3..a1874dc 100644 --- a/src/agent/dto.rs +++ b/src/agent/dto.rs @@ -10,6 +10,9 @@ pub struct RawMetadata { pub year: Option, pub genre: Option, pub duration_secs: Option, + pub audio_bitrate: Option, + pub audio_sample_rate: Option, + pub audio_bit_depth: Option, } /// Hints parsed from the file path (directory structure + filename). diff --git a/src/agent/metadata.rs b/src/agent/metadata.rs index 6e3f379..1729a79 100644 --- a/src/agent/metadata.rs +++ b/src/agent/metadata.rs @@ -18,7 +18,10 @@ use super::dto::RawMetadata; /// Must be called from a blocking context (`spawn_blocking`). pub fn extract(path: &Path) -> anyhow::Result { match extract_via_symphonia(path) { - Ok(meta) => Ok(meta), + Ok(mut meta) => { + fill_average_bitrate(path, &mut meta); + Ok(meta) + } Err(e) => { let is_mp3 = path .extension() @@ -27,7 +30,9 @@ pub fn extract(path: &Path) -> anyhow::Result { .unwrap_or(false); if is_mp3 { tracing::debug!(error = %e, "Symphonia failed on MP3, falling back to id3 crate"); - extract_mp3_via_id3(path) + let mut meta = extract_mp3_via_id3(path)?; + fill_average_bitrate(path, &mut meta); + Ok(meta) } else { Err(e) } @@ -35,6 +40,22 @@ pub fn extract(path: &Path) -> anyhow::Result { } } +fn fill_average_bitrate(path: &Path, meta: &mut RawMetadata) { + if meta.audio_bitrate.is_some() { + return; + } + let Some(duration_secs) = meta.duration_secs.filter(|duration| *duration > 0.0) else { + return; + }; + let Ok(metadata) = std::fs::metadata(path) else { + return; + }; + let kbps = ((metadata.len() as f64 * 8.0) / duration_secs / 1000.0).round(); + if kbps.is_finite() && kbps > 0.0 && kbps <= i32::MAX as f64 { + meta.audio_bitrate = Some(kbps as i32); + } +} + fn extract_via_symphonia(path: &Path) -> anyhow::Result { let file = std::fs::File::open(path)?; let mss = MediaSourceStream::new(Box::new(file), Default::default()); @@ -68,17 +89,24 @@ fn extract_via_symphonia(path: &Path) -> anyhow::Result { } } - // Duration - meta.duration_secs = probed + let audio_track = probed .format .tracks() .iter() - .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) - .and_then(|t| { - let n_frames = t.codec_params.n_frames?; - let tb = t.codec_params.time_base?; + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL); + + if let Some(track) = audio_track { + let params = &track.codec_params; + meta.duration_secs = params.n_frames.and_then(|n_frames| { + let tb = params.time_base?; Some(n_frames as f64 * tb.numer as f64 / tb.denom as f64) }); + meta.audio_sample_rate = params.sample_rate.and_then(|rate| i32::try_from(rate).ok()); + meta.audio_bit_depth = params + .bits_per_sample + .or(params.bits_per_coded_sample) + .and_then(|bits| i32::try_from(bits).ok()); + } Ok(meta) } diff --git a/src/agent/mod.rs b/src/agent/mod.rs index f69008f..0841064 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -27,11 +27,7 @@ pub struct AgentProbeResult { /// Send a lightweight "introduce yourself" prompt to the LLM and return the /// response together with timing / usage statistics when available. -pub async fn probe_llm( - llm_url: &str, - llm_model: &str, - llm_auth: &str, -) -> AgentProbeResult { +pub async fn probe_llm(llm_url: &str, llm_model: &str, llm_auth: &str) -> AgentProbeResult { let start = std::time::Instant::now(); let client = match reqwest::Client::builder() @@ -85,7 +81,10 @@ pub async fn probe_llm( let body_text = resp.text().await.unwrap_or_default(); return AgentProbeResult { latency_ms, - error: format!("HTTP {status}: {}", body_text.chars().take(300).collect::()), + error: format!( + "HTTP {status}: {}", + body_text.chars().take(300).collect::() + ), ..Default::default() }; } diff --git a/src/agent/normalize.rs b/src/agent/normalize.rs index b1be4d6..41bfc25 100644 --- a/src/agent/normalize.rs +++ b/src/agent/normalize.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; -use super::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata, SimilarArtist, SimilarRelease}; +use super::dto::{ + FolderContext, NormalizedFields, PathHints, RawMetadata, SimilarArtist, SimilarRelease, +}; // --------------------------------------------------------------------------- // Types @@ -171,18 +173,40 @@ fn estimate_batch_tokens( let mut per_file_tokens: u64 = 0; for f in files { let mut chars: u64 = 40 + f.filename.len() as u64; // header - if let Some(v) = &f.raw.title { chars += 10 + v.len() as u64; } - if let Some(v) = &f.raw.artist { chars += 12 + v.len() as u64; } - if let Some(v) = &f.raw.album { chars += 12 + v.len() as u64; } - if f.raw.year.is_some() { chars += 12; } - if f.raw.track_number.is_some() { chars += 18; } - if let Some(v) = &f.raw.genre { chars += 10 + v.len() as u64; } + if let Some(v) = &f.raw.title { + chars += 10 + v.len() as u64; + } + if let Some(v) = &f.raw.artist { + chars += 12 + v.len() as u64; + } + if let Some(v) = &f.raw.album { + chars += 12 + v.len() as u64; + } + if f.raw.year.is_some() { + chars += 12; + } + if f.raw.track_number.is_some() { + chars += 18; + } + if let Some(v) = &f.raw.genre { + chars += 10 + v.len() as u64; + } // hints - if let Some(v) = &f.hints.artist { chars += 16 + v.len() as u64; } - if let Some(v) = &f.hints.album { chars += 16 + v.len() as u64; } - if let Some(v) = &f.hints.title { chars += 15 + v.len() as u64; } - if f.hints.year.is_some() { chars += 14; } - if f.hints.track_number.is_some() { chars += 20; } + if let Some(v) = &f.hints.artist { + chars += 16 + v.len() as u64; + } + if let Some(v) = &f.hints.album { + chars += 16 + v.len() as u64; + } + if let Some(v) = &f.hints.title { + chars += 15 + v.len() as u64; + } + if f.hints.year.is_some() { + chars += 14; + } + if f.hints.track_number.is_some() { + chars += 20; + } per_file_tokens += chars / 4; // Expected response per file (~150 tokens) per_file_tokens += 150; @@ -210,7 +234,10 @@ fn build_batch_user_message( if !similar_artists.is_empty() { msg.push_str("## Existing artists in database\n"); for a in similar_artists { - msg.push_str(&format!("- \"{}\" (similarity: {:.2})\n", a.name, a.similarity)); + msg.push_str(&format!( + "- \"{}\" (similarity: {:.2})\n", + a.name, a.similarity + )); } msg.push('\n'); } @@ -219,7 +246,10 @@ fn build_batch_user_message( msg.push_str("## Existing releases in database\n"); for r in similar_releases { let year_str = r.year.map(|y| format!(", year: {y}")).unwrap_or_default(); - msg.push_str(&format!("- \"{}\" (similarity: {:.2}{})\n", r.title, r.similarity, year_str)); + msg.push_str(&format!( + "- \"{}\" (similarity: {:.2}{})\n", + r.title, r.similarity, year_str + )); } msg.push('\n'); } @@ -230,12 +260,24 @@ fn build_batch_user_message( for f in files { msg.push_str(&format!("### {}\n", f.filename)); - if let Some(v) = &f.raw.title { msg.push_str(&format!("Title: \"{v}\"\n")); } - if let Some(v) = &f.raw.artist { msg.push_str(&format!("Artist: \"{v}\"\n")); } - if let Some(v) = &f.raw.album { msg.push_str(&format!("Release: \"{v}\"\n")); } - if let Some(v) = f.raw.year { msg.push_str(&format!("Year: {v}\n")); } - if let Some(v) = f.raw.track_number { msg.push_str(&format!("Track: {v}\n")); } - if let Some(v) = &f.raw.genre { msg.push_str(&format!("Genre: \"{v}\"\n")); } + if let Some(v) = &f.raw.title { + msg.push_str(&format!("Title: \"{v}\"\n")); + } + if let Some(v) = &f.raw.artist { + msg.push_str(&format!("Artist: \"{v}\"\n")); + } + if let Some(v) = &f.raw.album { + msg.push_str(&format!("Release: \"{v}\"\n")); + } + if let Some(v) = f.raw.year { + msg.push_str(&format!("Year: {v}\n")); + } + if let Some(v) = f.raw.track_number { + msg.push_str(&format!("Track: {v}\n")); + } + if let Some(v) = &f.raw.genre { + msg.push_str(&format!("Genre: \"{v}\"\n")); + } // Path hints (only if different from tag metadata) let has_hints = f.hints.artist.is_some() @@ -244,11 +286,21 @@ fn build_batch_user_message( || f.hints.year.is_some() || f.hints.track_number.is_some(); if has_hints { - if let Some(v) = &f.hints.artist { msg.push_str(&format!("Path artist: \"{v}\"\n")); } - if let Some(v) = &f.hints.album { msg.push_str(&format!("Path release: \"{v}\"\n")); } - if let Some(v) = &f.hints.title { msg.push_str(&format!("Path title: \"{v}\"\n")); } - if let Some(v) = f.hints.year { msg.push_str(&format!("Path year: {v}\n")); } - if let Some(v) = f.hints.track_number { msg.push_str(&format!("Path track: {v}\n")); } + if let Some(v) = &f.hints.artist { + msg.push_str(&format!("Path artist: \"{v}\"\n")); + } + if let Some(v) = &f.hints.album { + msg.push_str(&format!("Path release: \"{v}\"\n")); + } + if let Some(v) = &f.hints.title { + msg.push_str(&format!("Path title: \"{v}\"\n")); + } + if let Some(v) = f.hints.year { + msg.push_str(&format!("Path year: {v}\n")); + } + if let Some(v) = f.hints.track_number { + msg.push_str(&format!("Path track: {v}\n")); + } } msg.push('\n'); } @@ -272,7 +324,11 @@ pub async fn normalize_batch( ) -> anyhow::Result { // Estimate tokens let estimated = estimate_batch_tokens( - system_prompt, &files, similar_artists, similar_releases, folder_ctx, + system_prompt, + &files, + similar_artists, + similar_releases, + folder_ctx, ); // If over 80% of context limit and more than 1 file, split @@ -290,14 +346,30 @@ pub async fn normalize_batch( let left = files_vec; let left_result = Box::pin(normalize_batch( - llm_url, llm_model, llm_auth, system_prompt, context_limit, - left, similar_artists, similar_releases, folder_ctx, - )).await?; + llm_url, + llm_model, + llm_auth, + system_prompt, + context_limit, + left, + similar_artists, + similar_releases, + folder_ctx, + )) + .await?; let right_result = Box::pin(normalize_batch( - llm_url, llm_model, llm_auth, system_prompt, context_limit, - right, similar_artists, similar_releases, folder_ctx, - )).await?; + llm_url, + llm_model, + llm_auth, + system_prompt, + context_limit, + right, + similar_artists, + similar_releases, + folder_ctx, + )) + .await?; // Merge results let mut results = left_result.results; @@ -312,20 +384,32 @@ pub async fn normalize_batch( } // Build and send - let user_message = build_batch_user_message( - &files, similar_artists, similar_releases, folder_ctx, - ); + let user_message = + build_batch_user_message(&files, similar_artists, similar_releases, folder_ctx); let messages = vec![ - ChatMessage { role: "system".into(), content: system_prompt.to_owned() }, - ChatMessage { role: "user".into(), content: user_message }, + ChatMessage { + role: "system".into(), + content: system_prompt.to_owned(), + }, + ChatMessage { + role: "user".into(), + content: user_message, + }, ]; let start = std::time::Instant::now(); let call_result = call_llm_chat( - llm_url, llm_model, &messages, - if llm_auth.is_empty() { None } else { Some(llm_auth) }, - ).await; + llm_url, + llm_model, + &messages, + if llm_auth.is_empty() { + None + } else { + Some(llm_auth) + }, + ) + .await; let duration_ms = start.elapsed().as_millis() as u64; // If LLM error and batch > 1, try splitting (handles context overflow errors) @@ -349,13 +433,29 @@ pub async fn normalize_batch( let left = files_vec; let left_result = Box::pin(normalize_batch( - llm_url, llm_model, llm_auth, system_prompt, context_limit, - left, similar_artists, similar_releases, folder_ctx, - )).await?; + llm_url, + llm_model, + llm_auth, + system_prompt, + context_limit, + left, + similar_artists, + similar_releases, + folder_ctx, + )) + .await?; let right_result = Box::pin(normalize_batch( - llm_url, llm_model, llm_auth, system_prompt, context_limit, - right, similar_artists, similar_releases, folder_ctx, - )).await?; + llm_url, + llm_model, + llm_auth, + system_prompt, + context_limit, + right, + similar_artists, + similar_releases, + folder_ctx, + )) + .await?; let mut results = left_result.results; results.extend(right_result.results); @@ -363,7 +463,8 @@ pub async fn normalize_batch( results, model: left_result.model, prompt_tokens: left_result.prompt_tokens + right_result.prompt_tokens, - completion_tokens: left_result.completion_tokens + right_result.completion_tokens, + completion_tokens: left_result.completion_tokens + + right_result.completion_tokens, duration_ms: left_result.duration_ms + right_result.duration_ms, }); } @@ -398,9 +499,7 @@ fn parse_batch_response( // Strip markdown code fences if present let json_str = if cleaned.starts_with("```") { - let start = cleaned.find('[') - .or_else(|| cleaned.find('{')) - .unwrap_or(0); + let start = cleaned.find('[').or_else(|| cleaned.find('{')).unwrap_or(0); let end_bracket = cleaned.rfind(']').map(|i| i + 1); let end_brace = cleaned.rfind('}').map(|i| i + 1); let end = end_bracket.or(end_brace).unwrap_or(cleaned.len()); diff --git a/src/agent/path_hints.rs b/src/agent/path_hints.rs index 75371ca..9b2c286 100644 --- a/src/agent/path_hints.rs +++ b/src/agent/path_hints.rs @@ -113,11 +113,8 @@ fn parse_album_with_year(dir: &str) -> (String, Option) { let inside = &dir[start + 1..start + end]; if let Ok(year) = inside.trim().parse::() { if (1900..=2100).contains(&year) { - let album = format!( - "{}{}", - &dir[..start].trim(), - &dir[start + end + 1..].trim() - ); + let album = + format!("{}{}", &dir[..start].trim(), &dir[start + end + 1..].trim()); let album = album.trim().to_owned(); return (album, Some(year)); } diff --git a/src/api/mod.rs b/src/api/mod.rs index 28ea0d2..b5e6094 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -34,10 +34,7 @@ struct MeResponse { role: String, } -async fn me_handler( - session: Session, - db: Database, -) -> cot::Result { +async fn me_handler(session: Session, db: Database) -> cot::Result { let Some(user) = auth::get_session_user(&session, &db).await else { return Ok(json_error( cot::http::StatusCode::UNAUTHORIZED, @@ -65,8 +62,10 @@ impl App for ApiApp { } fn router(&self) -> Router { - Router::with_urls([ - Route::with_api_handler_and_name("/me", api_get(me_handler), "api_me"), - ]) + Router::with_urls([Route::with_api_handler_and_name( + "/me", + api_get(me_handler), + "api_me", + )]) } } diff --git a/src/auth.rs b/src/auth.rs index c96b073..ccb8f1c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,7 +1,7 @@ +use cot::Body; use cot::db::Database; use cot::response::IntoResponse; use cot::session::Session; -use cot::Body; use crate::user::User; @@ -78,12 +78,10 @@ pub async fn require_admin_or_redirect( return Err(redirect("/login")); }; if user.role != Role::Admin { - return Err( - "Forbidden" - .with_status(cot::http::StatusCode::FORBIDDEN) - .into_response() - .expect("valid response"), - ); + return Err("Forbidden" + .with_status(cot::http::StatusCode::FORBIDDEN) + .into_response() + .expect("valid response")); } Ok(user) } diff --git a/src/config.rs b/src/config.rs index 9abf3e8..0675b3d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -66,24 +66,19 @@ pub mod db_migrations { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0001_create_config"; const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[]; - const OPERATIONS: &'static [Operation] = &[ - Operation::create_model() - .table_name(Identifier::new("furu__config")) - .fields(&[ - Field::new( - Identifier::new("key"), - as DatabaseField>::TYPE, - ) - .primary_key() - .set_null( as DatabaseField>::NULLABLE), - Field::new( - Identifier::new("value"), - ::TYPE, - ) + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furu__config")) + .fields(&[ + Field::new( + Identifier::new("key"), + as DatabaseField>::TYPE, + ) + .primary_key() + .set_null( as DatabaseField>::NULLABLE), + Field::new(Identifier::new("value"), ::TYPE) .set_null(::NULLABLE), - ]) - .build(), - ]; + ]) + .build()]; } // -- M0002: rename furu__config → furumusic__config_entry --------------- @@ -102,12 +97,12 @@ pub mod db_migrations { impl migrations::Migration for M0002RenameConfigTable { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0002_rename_config_table"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration("furumusic", "m_0001_create_config"), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::custom(rename_config_table).build(), - ]; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "furumusic", + "m_0001_create_config", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::custom(rename_config_table).build()]; } pub const MIGRATIONS: &[&SyncDynMigration] = &[&M0001CreateConfig, &M0002RenameConfigTable]; @@ -402,35 +397,51 @@ mod tests { } // SAFETY: tests run with --test-threads=1 so no concurrent env access. - unsafe fn set(k: &str, v: &str) { unsafe { std::env::set_var(k, v) }; } - unsafe fn unset(k: &str) { unsafe { std::env::remove_var(k) }; } + unsafe fn set(k: &str, v: &str) { + unsafe { std::env::set_var(k, v) }; + } + unsafe fn unset(k: &str) { + unsafe { std::env::remove_var(k) }; + } #[test] fn env_override_string_field() { - unsafe { set("FURU_OIDC_ISSUER", "https://example.com"); } + unsafe { + set("FURU_OIDC_ISSUER", "https://example.com"); + } let cfg = AppConfig::load(); assert_eq!(cfg.oidc_issuer, "https://example.com"); - unsafe { unset("FURU_OIDC_ISSUER"); } + unsafe { + unset("FURU_OIDC_ISSUER"); + } } #[test] fn env_override_bool_field() { - unsafe { set("FURU_AUTH_SSO_ENABLED", "true"); } + unsafe { + set("FURU_AUTH_SSO_ENABLED", "true"); + } let cfg = AppConfig::load(); assert!(cfg.auth_sso_enabled); - unsafe { unset("FURU_AUTH_SSO_ENABLED"); } + unsafe { + unset("FURU_AUTH_SSO_ENABLED"); + } } #[test] fn source_tracking_env() { - unsafe { set("FURU_OIDC_ISSUER", "https://tracked.example.com"); } + unsafe { + set("FURU_OIDC_ISSUER", "https://tracked.example.com"); + } let mut cfg = AppConfig::default(); let mut sources = ConfigSources::default(); cfg.apply_env_overrides_tracked(&mut sources); assert_eq!(cfg.oidc_issuer, "https://tracked.example.com"); assert_eq!(sources.oidc_issuer, ConfigSource::Env); assert_eq!(sources.database_url, ConfigSource::Default); - unsafe { unset("FURU_OIDC_ISSUER"); } + unsafe { + unset("FURU_OIDC_ISSUER"); + } } #[test] diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs index 35b4835..4ae4312 100644 --- a/src/i18n/mod.rs +++ b/src/i18n/mod.rs @@ -2,10 +2,16 @@ mod phrases; pub use phrases::Translations; -use cot::request::extractors::FromRequestHead; use cot::request::RequestHead; +use cot::request::extractors::FromRequestHead; use serde::{Deserialize, Serialize}; +impl Translations { + pub fn app_version(&self) -> &'static str { + env!("CARGO_PKG_VERSION") + } +} + // --------------------------------------------------------------------------- // Lang enum // --------------------------------------------------------------------------- @@ -77,7 +83,10 @@ const COOKIE_NAME: &str = "furu_lang"; /// Build a `Set-Cookie` header value that persists the language choice for 1 year. pub fn lang_cookie(lang: Lang) -> String { - format!("{COOKIE_NAME}={}; Path=/; SameSite=Lax; Max-Age=31536000", lang.code()) + format!( + "{COOKIE_NAME}={}; Path=/; SameSite=Lax; Max-Age=31536000", + lang.code() + ) } /// Parse `furu_lang` from the `Cookie` request header. @@ -203,10 +212,7 @@ mod tests { #[test] fn parse_unknown_falls_through() { - assert_eq!( - parse_accept_language("de;q=1.0,ru;q=0.5"), - Some(Lang::Ru) - ); + assert_eq!(parse_accept_language("de;q=1.0,ru;q=0.5"), Some(Lang::Ru)); assert_eq!(parse_accept_language("de,fr,ja"), None); } diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index f668b0c..ed76e9f 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -187,6 +187,11 @@ translations! { jobs_back_to_list: "Back to jobs" , "Назад к заданиям"; jobs_run_detail: "Run detail" , "Детали запуска"; jobs_back_to_job: "Back to job" , "Назад к заданию"; + jobs_metadata_backfill_options: "Metadata backfill options" , "Параметры обновления метадаты"; + jobs_metadata_backfill_fields: "Fields to update" , "Поля для обновления"; + jobs_metadata_backfill_fill_missing: "Fill missing only" , "Заполнить только пустые"; + jobs_metadata_backfill_overwrite: "Overwrite existing values" , "Перезаписать существующие"; + jobs_metadata_backfill_run: "Run metadata backfill" , "Запустить обновление метадаты"; // Review management reviews_heading: "Pending Reviews" , "Ожидающие проверки"; @@ -194,6 +199,7 @@ translations! { reviews_status: "Status" , "Статус"; reviews_type: "Type" , "Тип"; reviews_input_path: "Input" , "Файл"; + reviews_tags: "Tags" , "Теги"; reviews_confidence: "Confidence" , "Уверенность"; reviews_approve: "Approve" , "Подтвердить"; reviews_reject: "Reject" , "Отклонить"; @@ -204,6 +210,15 @@ translations! { reviews_clear_all: "Clear all" , "Очистить все"; reviews_clear_filtered: "Clear shown" , "Очистить показанные"; reviews_clear_confirm: "Are you sure? This will delete the selected reviews." , "Вы уверены? Выбранные проверки будут удалены."; + reviews_select_all: "Select shown" , "Выбрать показанные"; + reviews_clear_selection: "Clear selection" , "Снять выбор"; + reviews_delete_selected: "Delete selected" , "Удалить выбранные"; + reviews_requeue_selected: "Re-queue selected" , "В очередь выбранные"; + reviews_selected_none: "Selected: 0" , "Выбрано: 0"; + reviews_selected_prefix: "Selected" , "Выбрано"; + reviews_none_selected_confirm: "Select at least one review." , "Выберите хотя бы одну проверку."; + reviews_delete_selected_confirm: "Delete selected reviews?" , "Удалить выбранные проверки?"; + reviews_requeue_selected_confirm: "Re-queue selected reviews?" , "Поставить выбранные проверки в очередь?"; reviews_back_to_list: "Back to reviews" , "Назад к проверкам"; reviews_filter_all: "All" , "Все"; reviews_filter_pending: "Pending" , "Ожидают"; diff --git a/src/jobs/artist_image_backfill.rs b/src/jobs/artist_image_backfill.rs index 60f1133..fdcca53 100644 --- a/src/jobs/artist_image_backfill.rs +++ b/src/jobs/artist_image_backfill.rs @@ -48,7 +48,9 @@ impl Job for ArtistImageBackfillJob { let count = result.rows_affected(); if count > 0 { - log.info(&format!("Assigned images to {count} artists from release covers")); + log.info(&format!( + "Assigned images to {count} artists from release covers" + )); } else { log.info("All artists already have images (or no covers available)"); } diff --git a/src/jobs/cover_backfill.rs b/src/jobs/cover_backfill.rs index 0fede17..7a8349f 100644 --- a/src/jobs/cover_backfill.rs +++ b/src/jobs/cover_backfill.rs @@ -87,10 +87,8 @@ impl Job for CoverBackfillJob { let folder = first_path.parent().unwrap_or(Path::new(".")); // Collect all audio file paths as PathBuf - let audio_files: Vec = audio_paths - .iter() - .map(|(p,)| PathBuf::from(p)) - .collect(); + let audio_files: Vec = + audio_paths.iter().map(|(p,)| PathBuf::from(p)).collect(); // Try to find cover art let cover = match cover_art::find_best_cover(folder, &audio_files).await { @@ -135,12 +133,9 @@ impl Job for CoverBackfillJob { .await { Ok(cover_file_id) => { - if let Err(e) = cover_art::assign_cover_to_release( - &ctx.pool, - *release_id, - cover_file_id, - ) - .await + if let Err(e) = + cover_art::assign_cover_to_release(&ctx.pool, *release_id, cover_file_id) + .await { log.warn(&format!( "Release {release_id} \"{release_title}\": saved cover but failed to assign: {e}" diff --git a/src/jobs/inbox_discover.rs b/src/jobs/inbox_discover.rs index 6c93c75..8968173 100644 --- a/src/jobs/inbox_discover.rs +++ b/src/jobs/inbox_discover.rs @@ -30,7 +30,10 @@ impl Job for InboxDiscoverJob { async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> { // Prevent overlapping discover runs - if DISCOVER_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() { + if DISCOVER_RUNNING + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { log.info("Another inbox_discover is already running, skipping"); return Ok(()); } @@ -82,31 +85,38 @@ impl Job for InboxDiscoverJob { } Ok(false) => {} Err(e) => { - log.warn(&format!("Error checking existing review for {}: {e}", input_path_str)); + log.warn(&format!( + "Error checking existing review for {}: {e}", + input_path_str + )); continue; } } // Compute SHA-256 hash let path_clone = file_path.to_path_buf(); - let (hash, file_size) = match tokio::task::spawn_blocking(move || -> anyhow::Result<(String, i64)> { - let data = std::fs::read(&path_clone)?; - let digest = Sha256::digest(&data); - let hash = format!("{:x}", digest); - let size = data.len() as i64; - Ok((hash, size)) - }) - .await? - { - Ok(v) => v, - Err(e) => { - log.warn(&format!("Failed to hash {}: {e}", file_path.display())); - continue; - } - }; + let (hash, file_size) = + match tokio::task::spawn_blocking(move || -> anyhow::Result<(String, i64)> { + let data = std::fs::read(&path_clone)?; + let digest = Sha256::digest(&data); + let hash = format!("{:x}", digest); + let size = data.len() as i64; + Ok((hash, size)) + }) + .await? + { + Ok(v) => v, + Err(e) => { + log.warn(&format!("Failed to hash {}: {e}", file_path.display())); + continue; + } + }; // Skip if hash already in media_files - if crate::agent::rag::file_hash_exists(&ctx.pool, &hash).await.unwrap_or(false) { + if crate::agent::rag::file_hash_exists(&ctx.pool, &hash) + .await + .unwrap_or(false) + { skipped_hash += 1; continue; } @@ -120,7 +130,10 @@ impl Job for InboxDiscoverJob { { Ok(m) => m, Err(e) => { - log.warn(&format!("Failed to extract metadata from {}: {e}", file_path.display())); + log.warn(&format!( + "Failed to extract metadata from {}: {e}", + file_path.display() + )); continue; } }; @@ -140,6 +153,9 @@ impl Job for InboxDiscoverJob { "raw_year": raw_meta.year, "raw_genre": raw_meta.genre, "duration_secs": raw_meta.duration_secs, + "audio_bitrate": raw_meta.audio_bitrate, + "audio_sample_rate": raw_meta.audio_sample_rate, + "audio_bit_depth": raw_meta.audio_bit_depth, "path_title": hints.title, "path_artist": hints.artist, "path_album": hints.album, @@ -172,7 +188,9 @@ impl Job for InboxDiscoverJob { // and no orchestrator is already running if discovered > 0 { if crate::jobs::inbox_process::is_orchestrator_running() { - log.info("New files discovered but inbox_process already running, it will pick them up"); + log.info( + "New files discovered but inbox_process already running, it will pick them up", + ); } else { log.info("Spawning inbox_process in background..."); let config = ctx.config.clone(); @@ -181,11 +199,15 @@ impl Job for InboxDiscoverJob { let registry = ctx.registry.clone(); tokio::spawn(async move { if let Err(e) = crate::scheduler::trigger_job_now( - &config, &db, &pool, ®istry, "inbox_process", + &config, + &db, + &pool, + ®istry, + "inbox_process", ) .await { - tracing::error!("Background inbox_process trigger failed: {e}"); + tracing::error!("Background inbox_process trigger failed: {e}"); } }); } @@ -214,10 +236,7 @@ pub fn group_by_folder(files: &[PathBuf]) -> Vec<(PathBuf, Vec)> { groups } -pub async fn collect_audio_files( - dir: &Path, - audio: &mut Vec, -) -> anyhow::Result<()> { +pub async fn collect_audio_files(dir: &Path, audio: &mut Vec) -> anyhow::Result<()> { let mut entries = tokio::fs::read_dir(dir).await?; while let Some(entry) = entries.next_entry().await? { let name = entry.file_name().to_string_lossy().into_owned(); diff --git a/src/jobs/inbox_process.rs b/src/jobs/inbox_process.rs index c58ed39..78acf23 100644 --- a/src/jobs/inbox_process.rs +++ b/src/jobs/inbox_process.rs @@ -20,12 +20,10 @@ pub fn is_orchestrator_running() -> bool { /// Try to acquire the PostgreSQL advisory lock for the orchestrator. /// Returns true if the lock was acquired (no other orchestrator is running). async fn try_acquire_orchestrator_lock(pool: &sqlx::PgPool) -> bool { - match sqlx::query_scalar::<_, bool>( - "SELECT pg_try_advisory_lock($1)" - ) - .bind(ORCHESTRATOR_ADVISORY_LOCK_ID) - .fetch_one(pool) - .await + match sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)") + .bind(ORCHESTRATOR_ADVISORY_LOCK_ID) + .fetch_one(pool) + .await { Ok(acquired) => acquired, Err(e) => { @@ -43,14 +41,12 @@ async fn release_orchestrator_lock(pool: &sqlx::PgPool) { .await; } -use crate::config::AppConfig; -use crate::music::{ - Artist, MediaFile, Release, ReleaseArtist, Track, TrackArtist, -}; -use crate::scheduler::{Job, JobContext, JobLog, JobRun, PendingReview, ProcessingStats}; -use crate::agent::dto::{FolderContext, NormalizedFields, RawMetadata, PathHints}; -use crate::agent::normalize::BatchFileInput; +use crate::agent::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata}; use crate::agent::mover; +use crate::agent::normalize::BatchFileInput; +use crate::config::AppConfig; +use crate::music::{Artist, MediaFile, Release, ReleaseArtist, Track, TrackArtist}; +use crate::scheduler::{Job, JobContext, JobLog, JobRun, PendingReview, ProcessingStats}; const AUDIO_EXTENSIONS: &[&str] = &[ "mp3", "flac", "ogg", "opus", "aac", "m4a", "wav", "ape", "wv", "wma", "tta", "aiff", "aif", @@ -83,8 +79,13 @@ impl Job for InboxProcessJob { previous_value = prev, "inbox_process: checking ORCHESTRATOR_RUNNING AtomicBool" ); - if ORCHESTRATOR_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() { - log.info("Another inbox_process orchestrator is already running (AtomicBool), skipping"); + if ORCHESTRATOR_RUNNING + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + log.info( + "Another inbox_process orchestrator is already running (AtomicBool), skipping", + ); return Ok(()); } struct AtomicGuard; @@ -115,7 +116,9 @@ impl Job for InboxProcessJob { }); } } - let _advisory_guard = AdvisoryGuard { pool: pool_for_unlock }; + let _advisory_guard = AdvisoryGuard { + pool: pool_for_unlock, + }; let config = Arc::clone(&ctx.config); let mut total_ok = 0u64; @@ -151,9 +154,9 @@ impl Job for InboxProcessJob { folder_rel, file_count, )); - let (ok, fail) = process_folder_batch( - &ctx.db, &config, &ctx.pool, &folder_rel, reviews, log, - ).await; + let (ok, fail) = + process_folder_batch(&ctx.db, &config, &ctx.pool, &folder_rel, reviews, log) + .await; total_ok += ok; total_fail += fail; @@ -296,7 +299,7 @@ async fn process_folder_batch( let _ = review.set_processing(db).await; // Parse context_json - let context: serde_json::Value = review + let mut context: serde_json::Value = review .context_json .as_deref() .and_then(|s| serde_json::from_str(s).ok()) @@ -304,40 +307,51 @@ async fn process_folder_batch( // Extract metadata (with 60s timeout) let path_for_meta = file_path.to_path_buf(); - let meta_future = tokio::task::spawn_blocking(move || { - crate::agent::metadata::extract(&path_for_meta) - }); - let raw_meta = match tokio::time::timeout( - std::time::Duration::from_secs(60), - meta_future, - ).await { - Ok(Ok(Ok(m))) => m, - Ok(Ok(Err(e))) => { - let msg = format!("{filename}: metadata error: {e}"); - log.error(&msg); - let _ = review.set_failed(db, &msg).await; - failed_reviews.push(review); - continue; - } - Ok(Err(e)) => { - let msg = format!("{filename}: metadata panic: {e}"); - log.error(&msg); - let _ = review.set_failed(db, &msg).await; - failed_reviews.push(review); - continue; - } - Err(_) => { - let msg = format!("{filename}: metadata timeout (60s)"); - log.error(&msg); - let _ = review.set_failed(db, &msg).await; - failed_reviews.push(review); - continue; - } - }; + let meta_future = + tokio::task::spawn_blocking(move || crate::agent::metadata::extract(&path_for_meta)); + let raw_meta = + match tokio::time::timeout(std::time::Duration::from_secs(60), meta_future).await { + Ok(Ok(Ok(m))) => m, + Ok(Ok(Err(e))) => { + let msg = format!("{filename}: metadata error: {e}"); + log.error(&msg); + let _ = review.set_failed(db, &msg).await; + failed_reviews.push(review); + continue; + } + Ok(Err(e)) => { + let msg = format!("{filename}: metadata panic: {e}"); + log.error(&msg); + let _ = review.set_failed(db, &msg).await; + failed_reviews.push(review); + continue; + } + Err(_) => { + let msg = format!("{filename}: metadata timeout (60s)"); + log.error(&msg); + let _ = review.set_failed(db, &msg).await; + failed_reviews.push(review); + continue; + } + }; // Parse path hints let relative = file_path.strip_prefix(inbox_path).unwrap_or(file_path); let hints = crate::agent::path_hints::parse(relative); + if let Some(context_obj) = context.as_object_mut() { + context_obj.insert( + "audio_bitrate".to_owned(), + serde_json::json!(raw_meta.audio_bitrate), + ); + context_obj.insert( + "audio_sample_rate".to_owned(), + serde_json::json!(raw_meta.audio_sample_rate), + ); + context_obj.insert( + "audio_bit_depth".to_owned(), + serde_json::json!(raw_meta.audio_bit_depth), + ); + } prepared.push(PreparedFile { review, @@ -366,14 +380,20 @@ async fn process_folder_batch( let mut album_queries: Vec = Vec::new(); for p in &prepared { - let artist_q = p.raw_meta.artist.as_deref() + let artist_q = p + .raw_meta + .artist + .as_deref() .or(p.hints.artist.as_deref()) .unwrap_or("") .to_owned(); if !artist_q.is_empty() && !artist_queries.contains(&artist_q) { artist_queries.push(artist_q); } - let album_q = p.raw_meta.album.as_deref() + let album_q = p + .raw_meta + .album + .as_deref() .or(p.hints.album.as_deref()) .unwrap_or("") .to_owned(); @@ -388,10 +408,15 @@ async fn process_folder_batch( match tokio::time::timeout( std::time::Duration::from_secs(30), crate::agent::rag::find_similar_artists(pool, q, 5), - ).await { + ) + .await + { Ok(Ok(results)) => { for a in results { - if !all_similar_artists.iter().any(|x: &crate::agent::dto::SimilarArtist| x.id == a.id) { + if !all_similar_artists + .iter() + .any(|x: &crate::agent::dto::SimilarArtist| x.id == a.id) + { all_similar_artists.push(a); } } @@ -406,10 +431,15 @@ async fn process_folder_batch( match tokio::time::timeout( std::time::Duration::from_secs(30), crate::agent::rag::find_similar_releases(pool, q, 5), - ).await { + ) + .await + { Ok(Ok(results)) => { for r in results { - if !all_similar_releases.iter().any(|x: &crate::agent::dto::SimilarRelease| x.id == r.id) { + if !all_similar_releases + .iter() + .any(|x: &crate::agent::dto::SimilarRelease| x.id == r.id) + { all_similar_releases.push(r); } } @@ -458,8 +488,9 @@ async fn process_folder_batch( }; // Build batch input - let batch_files: Vec = prepared.iter().map(|p| { - BatchFileInput { + let batch_files: Vec = prepared + .iter() + .map(|p| BatchFileInput { filename: p.filename.clone(), raw: RawMetadata { title: p.raw_meta.title.clone(), @@ -469,6 +500,9 @@ async fn process_folder_batch( year: p.raw_meta.year, genre: p.raw_meta.genre.clone(), duration_secs: p.raw_meta.duration_secs, + audio_bitrate: p.raw_meta.audio_bitrate, + audio_sample_rate: p.raw_meta.audio_sample_rate, + audio_bit_depth: p.raw_meta.audio_bit_depth, }, hints: PathHints { title: p.hints.title.clone(), @@ -477,8 +511,8 @@ async fn process_folder_batch( year: p.hints.year, track_number: p.hints.track_number, }, - } - }).collect(); + }) + .collect(); let system_prompt = include_str!("../../prompts/normalize_batch.txt"); let context_limit = config.agent_context_limit; @@ -493,7 +527,8 @@ async fn process_folder_batch( &all_similar_artists, &all_similar_releases, Some(&folder_ctx), - ).await; + ) + .await; let batch_result = match llm_result { Ok(r) => r, @@ -506,7 +541,9 @@ async fn process_folder_batch( } let total_fail_count = failed_reviews.len() as u64 + file_count as u64; let duration_ms = batch_start.elapsed().as_millis() as i64; - let _ = run.set_failed(db, duration_ms, &log.output(), &err_msg).await; + let _ = run + .set_failed(db, duration_ms, &log.output(), &err_msg) + .await; return (0, total_fail_count); } }; @@ -524,9 +561,7 @@ async fn process_folder_batch( log.info("Phase 4: finalizing..."); // Build lookup map: filename → NormalizedFields - let result_map: HashMap = batch_result.results - .into_iter() - .collect(); + let result_map: HashMap = batch_result.results.into_iter().collect(); let llm_model = &batch_result.model; let prompt_per_file = batch_result.prompt_tokens / prepared.len().max(1) as u64; @@ -558,7 +593,8 @@ async fn process_folder_batch( duration_per_file, prompt_per_file as i64, completion_per_file as i64, - ).await; + ) + .await; let result_json = serde_json::to_string(normalized).unwrap_or_default(); let confidence = normalized.confidence.unwrap_or(0.0); @@ -573,7 +609,9 @@ async fn process_folder_batch( normalized.artist.as_deref().unwrap_or("-"), normalized.album.as_deref().unwrap_or("-"), normalized.title.as_deref().unwrap_or("-"), - normalized.track_number.map_or("-".into(), |n| n.to_string()), + normalized + .track_number + .map_or("-".into(), |n| n.to_string()), normalized.year.map_or("-".into(), |y| y.to_string()), confidence, feat, @@ -586,9 +624,17 @@ async fn process_folder_batch( if confidence >= config.agent_confidence_threshold { match finalize_approved( - db, pool, config, &input_path_str, normalized, &p.context, - &config.agent_storage_dir, Some(llm_model), - ).await { + db, + pool, + config, + &input_path_str, + normalized, + &p.context, + &config.agent_storage_dir, + Some(llm_model), + ) + .await + { Ok(()) => { let _ = p.review.set_auto_approved(db).await; ok_count += 1; @@ -604,7 +650,8 @@ async fn process_folder_batch( p.review.status = cot::db::LimitedString::new("pending").unwrap(); p.review.updated_at = cot::db::LimitedString::new( &chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(), - ).unwrap(); + ) + .unwrap(); let _ = p.review.save(db).await; log.info(&format!( "{filename}: manual review (confidence {confidence} < {})", @@ -669,10 +716,7 @@ pub async fn finalize_approved( .map_err(|e| anyhow::anyhow!("failed to link release-artist: {e}"))?; } - let sha256 = context - .get("sha256") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let sha256 = context.get("sha256").and_then(|v| v.as_str()).unwrap_or(""); let file_size = context .get("file_size") .and_then(|v| v.as_i64()) @@ -681,6 +725,18 @@ pub async fn finalize_approved( .get("duration_secs") .and_then(|v| v.as_f64()) .unwrap_or(0.0); + let audio_bitrate = context + .get("audio_bitrate") + .and_then(|v| v.as_i64()) + .and_then(|v| i32::try_from(v).ok()); + let audio_sample_rate = context + .get("audio_sample_rate") + .and_then(|v| v.as_i64()) + .and_then(|v| i32::try_from(v).ok()); + let audio_bit_depth = context + .get("audio_bit_depth") + .and_then(|v| v.as_i64()) + .and_then(|v| i32::try_from(v).ok()); let source_path = Path::new(input_path_str); let original_filename = source_path @@ -746,9 +802,9 @@ pub async fn finalize_approved( file_size, sha256, Some(ext), - None, - None, - None, + audio_bitrate, + audio_sample_rate, + audio_bit_depth, ) .await .map_err(|e| anyhow::anyhow!("failed to create media file: {e}"))?; @@ -785,9 +841,7 @@ pub async fn finalize_approved( // Cover art: if the release has no cover yet, try to find one if release.cover_file_id.is_none() { - let source_folder = Path::new(input_path_str) - .parent() - .unwrap_or(Path::new(".")); + let source_folder = Path::new(input_path_str).parent().unwrap_or(Path::new(".")); // Collect audio files in the same folder to try embedded extraction let audio_files_in_folder: Vec = std::fs::read_dir(source_folder) @@ -955,10 +1009,7 @@ fn truncate_path(path: &str, max_len: usize) -> String { } else if max_len <= 3 { ".".repeat(max_len) } else { - let suffix: String = path - .chars() - .skip(char_count - (max_len - 3)) - .collect(); + let suffix: String = path.chars().skip(char_count - (max_len - 3)).collect(); format!("...{suffix}") } } diff --git a/src/jobs/metadata_backfill.rs b/src/jobs/metadata_backfill.rs new file mode 100644 index 0000000..d936dc2 --- /dev/null +++ b/src/jobs/metadata_backfill.rs @@ -0,0 +1,234 @@ +use std::path::{Path, PathBuf}; + +use crate::scheduler::{Job, JobContext, JobLog}; + +#[derive(Debug, Clone, Copy)] +pub struct MetadataBackfillOptions { + pub audio_bitrate: bool, + pub audio_sample_rate: bool, + pub audio_bit_depth: bool, + pub duration_seconds: bool, + pub overwrite: bool, +} + +impl MetadataBackfillOptions { + pub fn any_field(self) -> bool { + self.audio_bitrate + || self.audio_sample_rate + || self.audio_bit_depth + || self.duration_seconds + } +} + +#[derive(sqlx::FromRow)] +struct BackfillRow { + media_file_id: i64, + file_path: String, + audio_bitrate: Option, + audio_sample_rate: Option, + audio_bit_depth: Option, + track_id: Option, + duration_seconds: Option, +} + +pub struct MetadataBackfillJob; + +#[async_trait::async_trait] +impl Job for MetadataBackfillJob { + fn name(&self) -> &'static str { + "metadata_backfill" + } + + fn description(&self) -> &'static str { + "Backfill technical audio metadata from existing files" + } + + fn default_cron(&self) -> &'static str { + "" + } + + async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> { + run_with_options( + ctx, + log, + MetadataBackfillOptions { + audio_bitrate: true, + audio_sample_rate: true, + audio_bit_depth: true, + duration_seconds: true, + overwrite: false, + }, + ) + .await + } +} + +pub async fn run_with_options( + ctx: &JobContext, + log: &mut JobLog, + options: MetadataBackfillOptions, +) -> anyhow::Result<()> { + if !options.any_field() { + log.warn("No metadata fields selected; nothing to backfill"); + return Ok(()); + } + + let rows = sqlx::query_as::<_, BackfillRow>( + "SELECT mf.id AS media_file_id, mf.file_path, \ + mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, \ + t.id AS track_id, t.duration_seconds \ + FROM furumusic__media_file mf \ + LEFT JOIN furumusic__track t ON t.audio_file_id = mf.id \ + WHERE mf.file_type = 'audio' \ + ORDER BY mf.id", + ) + .fetch_all(&ctx.pool) + .await?; + + log.info(&format!( + "Metadata backfill started: {} audio file(s), mode={}", + rows.len(), + if options.overwrite { + "overwrite" + } else { + "fill_missing" + } + )); + + let mut scanned = 0u64; + let mut media_updated = 0u64; + let mut track_updated = 0u64; + let mut unchanged = 0u64; + let mut missing = 0u64; + let mut failed = 0u64; + + for row in rows { + scanned += 1; + let Some(path) = resolve_media_path(&row.file_path, &ctx.config.agent_storage_dir) else { + missing += 1; + log.warn(&format!("missing file: {}", row.file_path)); + continue; + }; + + let extract_path = path.clone(); + let raw_meta = match tokio::task::spawn_blocking(move || { + crate::agent::metadata::extract(&extract_path) + }) + .await + { + Ok(Ok(meta)) => meta, + Ok(Err(e)) => { + failed += 1; + log.warn(&format!("metadata error for {}: {e}", path.display())); + continue; + } + Err(e) => { + failed += 1; + log.warn(&format!("metadata task failed for {}: {e}", path.display())); + continue; + } + }; + + let mut changed_media = false; + let mut next_bitrate = row.audio_bitrate; + let mut next_sample_rate = row.audio_sample_rate; + let mut next_bit_depth = row.audio_bit_depth; + + if options.audio_bitrate && should_update(row.audio_bitrate, options.overwrite) { + if let Some(value) = raw_meta.audio_bitrate { + next_bitrate = Some(value); + changed_media = next_bitrate != row.audio_bitrate || changed_media; + } + } + if options.audio_sample_rate && should_update(row.audio_sample_rate, options.overwrite) { + if let Some(value) = raw_meta.audio_sample_rate { + next_sample_rate = Some(value); + changed_media = next_sample_rate != row.audio_sample_rate || changed_media; + } + } + if options.audio_bit_depth && should_update(row.audio_bit_depth, options.overwrite) { + if let Some(value) = raw_meta.audio_bit_depth { + next_bit_depth = Some(value); + changed_media = next_bit_depth != row.audio_bit_depth || changed_media; + } + } + + let mut changed_track = false; + let mut next_duration = row.duration_seconds; + if options.duration_seconds + && row.track_id.is_some() + && should_update_duration(row.duration_seconds, options.overwrite) + { + if let Some(value) = raw_meta.duration_secs { + next_duration = Some(value); + changed_track = row + .duration_seconds + .map(|current| (current - value).abs() > 0.001) + .unwrap_or(true); + } + } + + if changed_media { + sqlx::query( + "UPDATE furumusic__media_file \ + SET audio_bitrate = $1, audio_sample_rate = $2, audio_bit_depth = $3 \ + WHERE id = $4", + ) + .bind(next_bitrate) + .bind(next_sample_rate) + .bind(next_bit_depth) + .bind(row.media_file_id) + .execute(&ctx.pool) + .await?; + media_updated += 1; + } + + if changed_track { + if let (Some(track_id), Some(duration)) = (row.track_id, next_duration) { + sqlx::query("UPDATE furumusic__track SET duration_seconds = $1 WHERE id = $2") + .bind(duration) + .bind(track_id) + .execute(&ctx.pool) + .await?; + track_updated += 1; + } + } + + if !changed_media && !changed_track { + unchanged += 1; + } + + if scanned % 100 == 0 { + log.info(&format!( + "Progress: {scanned} scanned, {media_updated} media updated, {track_updated} tracks updated, {unchanged} unchanged, {missing} missing, {failed} failed" + )); + } + } + + log.info(&format!( + "Metadata backfill complete: {scanned} scanned, {media_updated} media updated, {track_updated} tracks updated, {unchanged} unchanged, {missing} missing, {failed} failed" + )); + Ok(()) +} + +fn should_update(current: Option, overwrite: bool) -> bool { + overwrite || current.is_none() +} + +fn should_update_duration(current: Option, overwrite: bool) -> bool { + overwrite || current.unwrap_or(0.0) <= 0.0 +} + +fn resolve_media_path(file_path: &str, storage_dir: &str) -> Option { + let path = Path::new(file_path); + if path.exists() { + return Some(path.to_path_buf()); + } + if path.is_relative() && !storage_dir.is_empty() { + let joined = Path::new(storage_dir).join(path); + if joined.exists() { + return Some(joined); + } + } + None +} diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs index ab9cec3..a6c8e75 100644 --- a/src/jobs/mod.rs +++ b/src/jobs/mod.rs @@ -3,3 +3,4 @@ pub mod artist_track_image_backfill; pub mod cover_backfill; pub mod inbox_discover; pub mod inbox_process; +pub mod metadata_backfill; diff --git a/src/main.rs b/src/main.rs index ee4c400..79a4084 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,13 +24,13 @@ use cot::db::Database; use cot::form::{Form, FormResult}; use cot::html::Html; use cot::middleware::SessionMiddleware; -use cot::static_files::StaticFilesMiddleware; use cot::project::RegisterAppsContext; use cot::request::extractors::{RequestForm, UrlQuery}; use cot::response::IntoResponse; use cot::router::method::get; use cot::router::{Route, Router}; use cot::session::Session; +use cot::static_files::StaticFilesMiddleware; use cot::{App, AppBuilder, Body, Project, Template}; use serde::Deserialize; @@ -51,6 +51,7 @@ fn build_registry() -> Arc { registry.register(jobs::cover_backfill::CoverBackfillJob); registry.register(jobs::artist_image_backfill::ArtistImageBackfillJob); registry.register(jobs::artist_track_image_backfill::ArtistTrackImageBackfillJob); + registry.register(jobs::metadata_backfill::MetadataBackfillJob); Arc::new(registry) } @@ -58,11 +59,7 @@ fn build_registry() -> Arc { // Handlers // --------------------------------------------------------------------------- -async fn index( - session: Session, - db: Database, - i18n: I18n, -) -> cot::Result { +async fn index(session: Session, db: Database, i18n: I18n) -> cot::Result { let _user = match auth::get_session_user(&session, &db).await { Some(u) => u, None => return Ok(auth::redirect("/login")), @@ -164,7 +161,8 @@ impl App for FuruApp { get(|| async { Ok::<_, cot::Error>(auth::redirect("/swagger/")) }), "swagger_redirect", ), - Route::with_handler_and_name("/", + Route::with_handler_and_name( + "/", |session: Session, db: Database, i18n: I18n| async move { index(session, db, i18n).await }, @@ -186,9 +184,12 @@ impl App for FuruApp { .into_response() } } - }).post({ + }) + .post({ let config = Arc::clone(&self.config); - move |i18n: I18n, db: Database, session: Session, + move |i18n: I18n, + db: Database, + session: Session, form: RequestForm| { let config = Arc::clone(&config); async move { @@ -204,8 +205,7 @@ impl App for FuruApp { }; // Try to authenticate - if let Ok(Some(user)) = - User::get_by_username(&db, &data.username).await + if let Ok(Some(user)) = User::get_by_username(&db, &data.username).await { if let Some(hash) = user.password_ref() { let password = Password::new(&data.password); @@ -374,10 +374,7 @@ impl Project for FuruProject { "/api/player", ); if self.app_config.swagger_enabled { - apps.register_with_views( - cot::openapi::swagger_ui::SwaggerUi::new(), - "/swagger", - ); + apps.register_with_views(cot::openapi::swagger_ui::SwaggerUi::new(), "/swagger"); } } } @@ -393,8 +390,8 @@ fn main() -> impl Project { // Initialise tracing subscriber with the configured log level. // FURU_LOG_LEVEL (or the default "info") is parsed as an EnvFilter // directive, so values like "debug", "warn,furumusic=trace" all work. - let filter = tracing_subscriber::EnvFilter::try_new(&app_config.log_level) - .unwrap_or_else(|e| { + let filter = + tracing_subscriber::EnvFilter::try_new(&app_config.log_level).unwrap_or_else(|e| { eprintln!( "WARNING: invalid FURU_LOG_LEVEL {:?}: {e}; falling back to \"info\"", app_config.log_level, diff --git a/src/music/mod.rs b/src/music/mod.rs index 7213882..df0d786 100644 --- a/src/music/mod.rs +++ b/src/music/mod.rs @@ -4,7 +4,6 @@ /// content (files, artists, releases, tracks, genres), user interactions /// (likes, follows, playlists, play history, playback state), and the /// AI-agent processing queue. - use cot::db::{Auto, Database, LimitedString, Model}; // --------------------------------------------------------------------------- @@ -99,11 +98,7 @@ impl Artist { Ok(artist) } - pub async fn update_name( - &mut self, - db: &Database, - name: &str, - ) -> cot::db::Result<()> { + pub async fn update_name(&mut self, db: &Database, name: &str) -> cot::db::Result<()> { self.name = LimitedString::new(name).unwrap(); self.name_sort = LimitedString::new(&normalize_name(name)).unwrap(); self.updated_at = now_iso(); @@ -711,37 +706,67 @@ pub mod db_migrations { impl migrations::Migration for M0006CreateMediaFile { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0006_create_media_file"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0005_oidc_link_indexes", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::create_model() - .table_name(Identifier::new("furumusic__media_file")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - Field::new(Identifier::new("file_type"), as DatabaseField>::TYPE), - Field::new(Identifier::new("file_path"), ::TYPE), - Field::new(Identifier::new("original_filename"), as DatabaseField>::TYPE), - Field::new(Identifier::new("mime_type"), as DatabaseField>::TYPE), - Field::new(Identifier::new("file_size_bytes"), ::TYPE), - Field::new(Identifier::new("sha256_hash"), as DatabaseField>::TYPE), - Field::new(Identifier::new("audio_format"), as DatabaseField>::TYPE) - .set_null(true), - Field::new(Identifier::new("audio_bitrate"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("audio_sample_rate"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("audio_bit_depth"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("created_at"), as DatabaseField>::TYPE), - ]) - .build(), - ]; + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furumusic__media_file")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + Field::new( + Identifier::new("file_type"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("file_path"), + ::TYPE, + ), + Field::new( + Identifier::new("original_filename"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("mime_type"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("file_size_bytes"), + ::TYPE, + ), + Field::new( + Identifier::new("sha256_hash"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("audio_format"), + as DatabaseField>::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("audio_bitrate"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("audio_sample_rate"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("audio_bit_depth"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("created_at"), + as DatabaseField>::TYPE, + ), + ]) + .build()]; } // -- M0007: create furumusic__artist -------------------------------------- @@ -752,29 +777,41 @@ pub mod db_migrations { impl migrations::Migration for M0007CreateArtist { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0007_create_artist"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0006_create_media_file", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::create_model() - .table_name(Identifier::new("furumusic__artist")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - Field::new(Identifier::new("name"), as DatabaseField>::TYPE), - Field::new(Identifier::new("name_sort"), as DatabaseField>::TYPE), - Field::new(Identifier::new("image_file_id"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("is_hidden"), ::TYPE), - Field::new(Identifier::new("created_at"), as DatabaseField>::TYPE), - Field::new(Identifier::new("updated_at"), as DatabaseField>::TYPE), - ]) - .build(), - ]; + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furumusic__artist")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + Field::new( + Identifier::new("name"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("name_sort"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("image_file_id"), + ::TYPE, + ) + .set_null(true), + Field::new(Identifier::new("is_hidden"), ::TYPE), + Field::new( + Identifier::new("created_at"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("updated_at"), + as DatabaseField>::TYPE, + ), + ]) + .build()]; } // -- M0008: create furumusic__release ------------------------------------- @@ -785,36 +822,53 @@ pub mod db_migrations { impl migrations::Migration for M0008CreateRelease { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0008_create_release"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0007_create_artist", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::create_model() - .table_name(Identifier::new("furumusic__release")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - Field::new(Identifier::new("title"), as DatabaseField>::TYPE), - Field::new(Identifier::new("title_sort"), as DatabaseField>::TYPE), - Field::new(Identifier::new("release_type"), as DatabaseField>::TYPE), - Field::new(Identifier::new("year"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("cover_file_id"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("total_tracks"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("total_discs"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("is_hidden"), ::TYPE), - Field::new(Identifier::new("created_at"), as DatabaseField>::TYPE), - Field::new(Identifier::new("updated_at"), as DatabaseField>::TYPE), - ]) - .build(), - ]; + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furumusic__release")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + Field::new( + Identifier::new("title"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("title_sort"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("release_type"), + as DatabaseField>::TYPE, + ), + Field::new(Identifier::new("year"), ::TYPE).set_null(true), + Field::new( + Identifier::new("cover_file_id"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("total_tracks"), + ::TYPE, + ) + .set_null(true), + Field::new(Identifier::new("total_discs"), ::TYPE) + .set_null(true), + Field::new(Identifier::new("is_hidden"), ::TYPE), + Field::new( + Identifier::new("created_at"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("updated_at"), + as DatabaseField>::TYPE, + ), + ]) + .build()]; } // -- M0009: create furumusic__release_artist ------------------------------ @@ -825,25 +879,22 @@ pub mod db_migrations { impl migrations::Migration for M0009CreateReleaseArtist { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0009_create_release_artist"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0008_create_release", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::create_model() - .table_name(Identifier::new("furumusic__release_artist")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - Field::new(Identifier::new("release_id"), ::TYPE), - Field::new(Identifier::new("artist_id"), ::TYPE), - Field::new(Identifier::new("position"), ::TYPE), - ]) - .build(), - ]; + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furumusic__release_artist")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + Field::new(Identifier::new("release_id"), ::TYPE), + Field::new(Identifier::new("artist_id"), ::TYPE), + Field::new(Identifier::new("position"), ::TYPE), + ]) + .build()]; } // -- M0010: create furumusic__track --------------------------------------- @@ -854,38 +905,58 @@ pub mod db_migrations { impl migrations::Migration for M0010CreateTrack { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0010_create_track"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0009_create_release_artist", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::create_model() - .table_name(Identifier::new("furumusic__track")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - Field::new(Identifier::new("title"), as DatabaseField>::TYPE), - Field::new(Identifier::new("title_sort"), as DatabaseField>::TYPE), - Field::new(Identifier::new("release_id"), ::TYPE), - Field::new(Identifier::new("track_number"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("disc_number"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("duration_seconds"), ::TYPE), - Field::new(Identifier::new("audio_file_id"), ::TYPE), - Field::new(Identifier::new("cover_file_id"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("year"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("is_hidden"), ::TYPE), - Field::new(Identifier::new("created_at"), as DatabaseField>::TYPE), - Field::new(Identifier::new("updated_at"), as DatabaseField>::TYPE), - ]) - .build(), - ]; + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furumusic__track")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + Field::new( + Identifier::new("title"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("title_sort"), + as DatabaseField>::TYPE, + ), + Field::new(Identifier::new("release_id"), ::TYPE), + Field::new( + Identifier::new("track_number"), + ::TYPE, + ) + .set_null(true), + Field::new(Identifier::new("disc_number"), ::TYPE) + .set_null(true), + Field::new( + Identifier::new("duration_seconds"), + ::TYPE, + ), + Field::new( + Identifier::new("audio_file_id"), + ::TYPE, + ), + Field::new( + Identifier::new("cover_file_id"), + ::TYPE, + ) + .set_null(true), + Field::new(Identifier::new("year"), ::TYPE).set_null(true), + Field::new(Identifier::new("is_hidden"), ::TYPE), + Field::new( + Identifier::new("created_at"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("updated_at"), + as DatabaseField>::TYPE, + ), + ]) + .build()]; } // -- M0011: create furumusic__track_artist -------------------------------- @@ -896,26 +967,26 @@ pub mod db_migrations { impl migrations::Migration for M0011CreateTrackArtist { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0011_create_track_artist"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0010_create_track", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::create_model() - .table_name(Identifier::new("furumusic__track_artist")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - Field::new(Identifier::new("track_id"), ::TYPE), - Field::new(Identifier::new("artist_id"), ::TYPE), - Field::new(Identifier::new("role"), as DatabaseField>::TYPE), - Field::new(Identifier::new("position"), ::TYPE), - ]) - .build(), - ]; + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furumusic__track_artist")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + Field::new(Identifier::new("track_id"), ::TYPE), + Field::new(Identifier::new("artist_id"), ::TYPE), + Field::new( + Identifier::new("role"), + as DatabaseField>::TYPE, + ), + Field::new(Identifier::new("position"), ::TYPE), + ]) + .build()]; } // -- M0012: create furumusic__genre + furumusic__track_genre --------------- @@ -926,12 +997,11 @@ pub mod db_migrations { impl migrations::Migration for M0012CreateGenreTables { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0012_create_genre_tables"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0011_create_track_artist", - ), - ]; + )]; const OPERATIONS: &'static [Operation] = &[ Operation::create_model() .table_name(Identifier::new("furumusic__genre")) @@ -939,9 +1009,15 @@ pub mod db_migrations { Field::new(Identifier::new("id"), ::TYPE) .primary_key() .auto(), - Field::new(Identifier::new("name"), as DatabaseField>::TYPE) - .unique(), - Field::new(Identifier::new("name_normalized"), as DatabaseField>::TYPE), + Field::new( + Identifier::new("name"), + as DatabaseField>::TYPE, + ) + .unique(), + Field::new( + Identifier::new("name_normalized"), + as DatabaseField>::TYPE, + ), ]) .build(), Operation::create_model() @@ -965,25 +1041,25 @@ pub mod db_migrations { impl migrations::Migration for M0013CreateUserLikedTrack { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0013_create_user_liked_track"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0012_create_genre_tables", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::create_model() - .table_name(Identifier::new("furumusic__user_liked_track")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - Field::new(Identifier::new("user_id"), ::TYPE), - Field::new(Identifier::new("track_id"), ::TYPE), - Field::new(Identifier::new("created_at"), as DatabaseField>::TYPE), - ]) - .build(), - ]; + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furumusic__user_liked_track")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + Field::new(Identifier::new("user_id"), ::TYPE), + Field::new(Identifier::new("track_id"), ::TYPE), + Field::new( + Identifier::new("created_at"), + as DatabaseField>::TYPE, + ), + ]) + .build()]; } // -- M0014: create furumusic__user_followed_artist ------------------------ @@ -994,25 +1070,25 @@ pub mod db_migrations { impl migrations::Migration for M0014CreateUserFollowedArtist { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0014_create_user_followed_artist"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0013_create_user_liked_track", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::create_model() - .table_name(Identifier::new("furumusic__user_followed_artist")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - Field::new(Identifier::new("user_id"), ::TYPE), - Field::new(Identifier::new("artist_id"), ::TYPE), - Field::new(Identifier::new("created_at"), as DatabaseField>::TYPE), - ]) - .build(), - ]; + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furumusic__user_followed_artist")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + Field::new(Identifier::new("user_id"), ::TYPE), + Field::new(Identifier::new("artist_id"), ::TYPE), + Field::new( + Identifier::new("created_at"), + as DatabaseField>::TYPE, + ), + ]) + .build()]; } // -- M0015: create playlist tables ---------------------------------------- @@ -1023,12 +1099,11 @@ pub mod db_migrations { impl migrations::Migration for M0015CreatePlaylistTables { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0015_create_playlist_tables"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0014_create_user_followed_artist", - ), - ]; + )]; const OPERATIONS: &'static [Operation] = &[ Operation::create_model() .table_name(Identifier::new("furumusic__playlist")) @@ -1037,16 +1112,34 @@ pub mod db_migrations { .primary_key() .auto(), Field::new(Identifier::new("owner_id"), ::TYPE), - Field::new(Identifier::new("title"), as DatabaseField>::TYPE), - Field::new(Identifier::new("description"), ::TYPE) - .set_null(true), + Field::new( + Identifier::new("title"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("description"), + ::TYPE, + ) + .set_null(true), Field::new(Identifier::new("is_public"), ::TYPE), - Field::new(Identifier::new("cover_file_id"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("forked_from_id"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("created_at"), as DatabaseField>::TYPE), - Field::new(Identifier::new("updated_at"), as DatabaseField>::TYPE), + Field::new( + Identifier::new("cover_file_id"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("forked_from_id"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("created_at"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("updated_at"), + as DatabaseField>::TYPE, + ), ]) .build(), Operation::create_model() @@ -1058,8 +1151,14 @@ pub mod db_migrations { Field::new(Identifier::new("playlist_id"), ::TYPE), Field::new(Identifier::new("track_id"), ::TYPE), Field::new(Identifier::new("position"), ::TYPE), - Field::new(Identifier::new("added_at"), as DatabaseField>::TYPE), - Field::new(Identifier::new("added_by_user_id"), ::TYPE), + Field::new( + Identifier::new("added_at"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("added_by_user_id"), + ::TYPE, + ), ]) .build(), Operation::create_model() @@ -1070,7 +1169,10 @@ pub mod db_migrations { .auto(), Field::new(Identifier::new("user_id"), ::TYPE), Field::new(Identifier::new("playlist_id"), ::TYPE), - Field::new(Identifier::new("saved_at"), as DatabaseField>::TYPE), + Field::new( + Identifier::new("saved_at"), + as DatabaseField>::TYPE, + ), ]) .build(), ]; @@ -1084,28 +1186,31 @@ pub mod db_migrations { impl migrations::Migration for M0016CreatePlayHistory { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0016_create_play_history"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0015_create_playlist_tables", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::create_model() - .table_name(Identifier::new("furumusic__play_history")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - Field::new(Identifier::new("user_id"), ::TYPE), - Field::new(Identifier::new("track_id"), ::TYPE), - Field::new(Identifier::new("played_at"), as DatabaseField>::TYPE), - Field::new(Identifier::new("duration_listened"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("completed"), ::TYPE), - ]) - .build(), - ]; + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furumusic__play_history")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + Field::new(Identifier::new("user_id"), ::TYPE), + Field::new(Identifier::new("track_id"), ::TYPE), + Field::new( + Identifier::new("played_at"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("duration_listened"), + ::TYPE, + ) + .set_null(true), + Field::new(Identifier::new("completed"), ::TYPE), + ]) + .build()]; } // -- M0017: create furumusic__playback_state ------------------------------ @@ -1116,31 +1221,43 @@ pub mod db_migrations { impl migrations::Migration for M0017CreatePlaybackState { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0017_create_playback_state"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0016_create_play_history", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::create_model() - .table_name(Identifier::new("furumusic__playback_state")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - Field::new(Identifier::new("user_id"), ::TYPE), - Field::new(Identifier::new("current_track_id"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("position_ms"), ::TYPE), - Field::new(Identifier::new("queue_json"), ::TYPE), - Field::new(Identifier::new("queue_position"), ::TYPE), - Field::new(Identifier::new("shuffle"), ::TYPE), - Field::new(Identifier::new("repeat_mode"), as DatabaseField>::TYPE), - Field::new(Identifier::new("updated_at"), as DatabaseField>::TYPE), - ]) - .build(), - ]; + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furumusic__playback_state")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + Field::new(Identifier::new("user_id"), ::TYPE), + Field::new( + Identifier::new("current_track_id"), + ::TYPE, + ) + .set_null(true), + Field::new(Identifier::new("position_ms"), ::TYPE), + Field::new( + Identifier::new("queue_json"), + ::TYPE, + ), + Field::new( + Identifier::new("queue_position"), + ::TYPE, + ), + Field::new(Identifier::new("shuffle"), ::TYPE), + Field::new( + Identifier::new("repeat_mode"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("updated_at"), + as DatabaseField>::TYPE, + ), + ]) + .build()]; } // -- M0018: create furumusic__processing_task ----------------------------- @@ -1151,110 +1268,123 @@ pub mod db_migrations { impl migrations::Migration for M0018CreateProcessingTask { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0018_create_processing_task"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0017_create_playback_state", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::create_model() - .table_name(Identifier::new("furumusic__processing_task")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - Field::new(Identifier::new("status"), as DatabaseField>::TYPE), - Field::new(Identifier::new("task_type"), as DatabaseField>::TYPE), - Field::new(Identifier::new("input_path"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("context_json"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("result_json"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("error_message"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("attempts"), ::TYPE), - Field::new(Identifier::new("max_attempts"), ::TYPE), - Field::new(Identifier::new("created_at"), as DatabaseField>::TYPE), - Field::new(Identifier::new("updated_at"), as DatabaseField>::TYPE), - Field::new(Identifier::new("started_at"), as DatabaseField>::TYPE) - .set_null(true), - Field::new(Identifier::new("completed_at"), as DatabaseField>::TYPE) - .set_null(true), - ]) - .build(), - ]; + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furumusic__processing_task")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + Field::new( + Identifier::new("status"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("task_type"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("input_path"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("context_json"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("result_json"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("error_message"), + ::TYPE, + ) + .set_null(true), + Field::new(Identifier::new("attempts"), ::TYPE), + Field::new( + Identifier::new("max_attempts"), + ::TYPE, + ), + Field::new( + Identifier::new("created_at"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("updated_at"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("started_at"), + as DatabaseField>::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("completed_at"), + as DatabaseField>::TYPE, + ) + .set_null(true), + ]) + .build()]; } // -- M0019: indexes for all music tables ---------------------------------- #[cot::db::migrations::migration_op] - async fn create_music_indexes( - ctx: migrations::MigrationContext<'_>, - ) -> cot::db::Result<()> { + async fn create_music_indexes(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> { let stmts = [ // media_file: lookup by hash for dedup "CREATE INDEX idx_media_file_sha256 ON furumusic__media_file (sha256_hash)", // media_file: filter by type "CREATE INDEX idx_media_file_type ON furumusic__media_file (file_type)", - // artist: search by normalized name "CREATE INDEX idx_artist_name_sort ON furumusic__artist (name_sort)", - // release: search by normalized title "CREATE INDEX idx_release_title_sort ON furumusic__release (title_sort)", // release: filter by type "CREATE INDEX idx_release_type ON furumusic__release (release_type)", - // release_artist: unique pair + lookup "CREATE UNIQUE INDEX idx_release_artist_uniq ON furumusic__release_artist (release_id, artist_id)", "CREATE INDEX idx_release_artist_artist ON furumusic__release_artist (artist_id)", - // track: search by normalized title "CREATE INDEX idx_track_title_sort ON furumusic__track (title_sort)", // track: FK to release "CREATE INDEX idx_track_release ON furumusic__track (release_id)", // track: FK to audio file "CREATE INDEX idx_track_audio_file ON furumusic__track (audio_file_id)", - // track_artist: unique triple + lookups "CREATE UNIQUE INDEX idx_track_artist_uniq ON furumusic__track_artist (track_id, artist_id, role)", "CREATE INDEX idx_track_artist_artist ON furumusic__track_artist (artist_id)", - // track_genre: unique pair + lookup "CREATE UNIQUE INDEX idx_track_genre_uniq ON furumusic__track_genre (track_id, genre_id)", "CREATE INDEX idx_track_genre_genre ON furumusic__track_genre (genre_id)", - // genre: lookup by normalized name "CREATE INDEX idx_genre_normalized ON furumusic__genre (name_normalized)", - // user_liked_track: unique pair + lookup by track "CREATE UNIQUE INDEX idx_user_liked_track_uniq ON furumusic__user_liked_track (user_id, track_id)", "CREATE INDEX idx_user_liked_track_track ON furumusic__user_liked_track (track_id)", - // user_followed_artist: unique pair + lookup by artist "CREATE UNIQUE INDEX idx_user_followed_artist_uniq ON furumusic__user_followed_artist (user_id, artist_id)", "CREATE INDEX idx_user_followed_artist_artist ON furumusic__user_followed_artist (artist_id)", - // playlist: owner lookup "CREATE INDEX idx_playlist_owner ON furumusic__playlist (owner_id)", - // playlist_track: ordered tracks in playlist + lookup by track "CREATE INDEX idx_playlist_track_playlist ON furumusic__playlist_track (playlist_id, position)", "CREATE INDEX idx_playlist_track_track ON furumusic__playlist_track (track_id)", - // saved_playlist: unique pair + lookup by playlist "CREATE UNIQUE INDEX idx_saved_playlist_uniq ON furumusic__saved_playlist (user_id, playlist_id)", "CREATE INDEX idx_saved_playlist_playlist ON furumusic__saved_playlist (playlist_id)", - // play_history: user timeline + lookup by track "CREATE INDEX idx_play_history_user ON furumusic__play_history (user_id, played_at)", "CREATE INDEX idx_play_history_track ON furumusic__play_history (track_id)", - // playback_state: one per user "CREATE UNIQUE INDEX idx_playback_state_user ON furumusic__playback_state (user_id)", - // processing_task: queue polling (status + created_at) "CREATE INDEX idx_processing_task_status ON furumusic__processing_task (status, created_at)", ]; @@ -1272,15 +1402,12 @@ pub mod db_migrations { impl migrations::Migration for M0019CreateMusicIndexes { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0019_create_music_indexes"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0018_create_processing_task", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::custom(create_music_indexes).build(), - ]; + )]; + const OPERATIONS: &'static [Operation] = &[Operation::custom(create_music_indexes).build()]; } // -- M0020: enable pg_trgm extension -------------------------------------- @@ -1297,15 +1424,12 @@ pub mod db_migrations { impl migrations::Migration for M0020EnablePgTrgm { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0020_enable_pg_trgm"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0019_create_music_indexes", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::custom(enable_pg_trgm).build(), - ]; + )]; + const OPERATIONS: &'static [Operation] = &[Operation::custom(enable_pg_trgm).build()]; } // -- M0021: GIN trigram indexes for fuzzy search -------------------------- @@ -1323,15 +1447,12 @@ pub mod db_migrations { impl migrations::Migration for M0021CreateTrgmIndexes { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0021_create_trgm_indexes"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0020_enable_pg_trgm", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::custom(create_trgm_indexes).build(), - ]; + )]; + const OPERATIONS: &'static [Operation] = &[Operation::custom(create_trgm_indexes).build()]; } // -- M0022: GIN trigram index on track.title_sort --------------------------- @@ -1348,15 +1469,13 @@ pub mod db_migrations { impl migrations::Migration for M0022CreateTrackTrgmIndex { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0022_create_track_trgm_index"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0021_create_trgm_indexes", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::custom(create_track_trgm_index).build(), - ]; + )]; + const OPERATIONS: &'static [Operation] = + &[Operation::custom(create_track_trgm_index).build()]; } // -- M0028: add model_name to artist, release, track ----------------------- @@ -1381,15 +1500,13 @@ pub mod db_migrations { impl migrations::Migration for M0028AddModelNameColumns { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0028_add_model_name_columns"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0027_create_processing_stats", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::custom(add_model_name_columns).build(), - ]; + )]; + const OPERATIONS: &'static [Operation] = + &[Operation::custom(add_model_name_columns).build()]; } // -- M0029: add volume column to playback_state ---------------------------- @@ -1408,15 +1525,12 @@ pub mod db_migrations { impl migrations::Migration for M0029AddPlaybackVolume { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0029_add_playback_volume"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0028_add_model_name_columns", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::custom(add_playback_volume).build(), - ]; + )]; + const OPERATIONS: &'static [Operation] = &[Operation::custom(add_playback_volume).build()]; } pub const MIGRATIONS: &[&SyncDynMigration] = &[ diff --git a/src/oidc.rs b/src/oidc.rs index 4339c2e..af414c8 100644 --- a/src/oidc.rs +++ b/src/oidc.rs @@ -131,8 +131,7 @@ async fn get_or_refresh_provider( .unwrap_or(config.oidc_issuer.trim_end_matches('/')) .to_owned(); - let issuer_url = IssuerUrl::new(issuer) - .map_err(|e| format!("invalid issuer URL: {e}"))?; + let issuer_url = IssuerUrl::new(issuer).map_err(|e| format!("invalid issuer URL: {e}"))?; let metadata = CoreProviderMetadata::discover_async(issuer_url, http) .await @@ -250,7 +249,9 @@ pub async fn oidc_callback_handler( i18n: I18n, db: Database, session: Session, - cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery, + cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery< + OidcCallbackQuery, + >, ) -> cot::Result { let (config, _) = AppConfig::load_with_db(&db).await; @@ -313,9 +314,7 @@ pub async fn oidc_callback_handler( }; // Exchange code for tokens. - let token_request = match client - .exchange_code(AuthorizationCode::new(query.code.clone())) - { + let token_request = match client.exchange_code(AuthorizationCode::new(query.code.clone())) { Ok(req) => req, Err(e) => { tracing::error!("OIDC token endpoint not configured: {e}"); diff --git a/src/player/mod.rs b/src/player/mod.rs index 07199c1..c689d52 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use cot::db::Database; -use cot::http::header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, RANGE}; use cot::http::StatusCode; +use cot::http::header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, RANGE}; use cot::json::Json; use cot::request::extractors::Path; use cot::response::IntoResponse; @@ -65,6 +65,8 @@ struct ArtistDetail { id: i64, name: String, image_url: Option, + total_track_count: i64, + total_play_count: i64, releases: Vec, } @@ -178,7 +180,6 @@ struct LikedIds { track_ids: Vec, } - // --------------------------------------------------------------------------- // Query helpers // --------------------------------------------------------------------------- @@ -455,13 +456,12 @@ async fn artist_detail_handler( return Ok(json_error(StatusCode::NOT_FOUND, "artist not found")); }; - let image_file_id: Option = sqlx::query_scalar( - "SELECT image_file_id FROM furumusic__artist WHERE id = $1", - ) - .bind(artist_id) - .fetch_one(pool) - .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + let image_file_id: Option = + sqlx::query_scalar("SELECT image_file_id FROM furumusic__artist WHERE id = $1") + .bind(artist_id) + .fetch_one(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; let releases = sqlx::query_as::<_, ReleaseRow>( r#"SELECT r.id, r.title::text as title, r.release_type::text as release_type, @@ -489,10 +489,26 @@ async fn artist_detail_handler( }) .collect(); + let total_track_count = release_cards.iter().map(|r| r.track_count).sum(); + let total_play_count: i64 = sqlx::query_scalar( + r#"SELECT COUNT(*) + FROM furumusic__play_history ph + JOIN furumusic__track t ON t.id = ph.track_id + JOIN furumusic__release_artist ra ON ra.release_id = t.release_id + JOIN furumusic__release r ON r.id = t.release_id + WHERE ra.artist_id = $1 AND t.is_hidden = false AND r.is_hidden = false"#, + ) + .bind(artist_id) + .fetch_one(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + Json(ArtistDetail { id: artist.id, name: artist.name, image_url: cover_url(image_file_id), + total_track_count, + total_play_count, releases: release_cards, }) .into_response() @@ -648,13 +664,12 @@ async fn playlists_handler( }; // Count liked tracks for the virtual Likes playlist - let likes_count: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM furumusic__user_liked_track WHERE user_id = $1", - ) - .bind(user.id) - .fetch_one(pool) - .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + let likes_count: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM furumusic__user_liked_track WHERE user_id = $1") + .bind(user.id) + .fetch_one(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; let mut cards = vec![PlaylistCard { id: -1, @@ -909,10 +924,7 @@ async fn stream_handler( .status(StatusCode::PARTIAL_CONTENT) .header(CONTENT_TYPE, media.mime_type.as_str()) .header(ACCEPT_RANGES, "bytes") - .header( - CONTENT_RANGE, - format!("bytes {start}-{end}/{file_size}"), - ) + .header(CONTENT_RANGE, format!("bytes {start}-{end}/{file_size}")) .header(CONTENT_LENGTH, chunk_size.to_string()) .body(Body::fixed(data)) .expect("valid response"); @@ -969,11 +981,7 @@ fn parse_range(header: &str, file_size: u64) -> Option<(u64, u64)> { Some((start, end)) } -async fn read_file_range( - path: &std::path::Path, - start: u64, - length: u64, -) -> cot::Result> { +async fn read_file_range(path: &std::path::Path, start: u64, length: u64) -> cot::Result> { use tokio::io::{AsyncReadExt, AsyncSeekExt}; let mut file = tokio::fs::File::open(path) @@ -1066,8 +1074,7 @@ async fn get_state_handler( let dto = match state { Some(s) => { - let queue: Vec = - serde_json::from_str(&s.queue_json).unwrap_or_default(); + let queue: Vec = serde_json::from_str(&s.queue_json).unwrap_or_default(); PlaybackStateDto { current_track_id: s.current_track_id, position_ms: s.position_ms, @@ -1106,8 +1113,8 @@ async fn put_state_handler( return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; - let queue_json = serde_json::to_string(&dto.queue) - .map_err(|e| cot::Error::internal(e.to_string()))?; + let queue_json = + serde_json::to_string(&dto.queue).map_err(|e| cot::Error::internal(e.to_string()))?; let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); @@ -1452,13 +1459,12 @@ async fn update_playlist_handler( }; let playlist_id = path.0.id; // Verify ownership - let owner: Option<(i64,)> = sqlx::query_as( - "SELECT owner_id FROM furumusic__playlist WHERE id = $1", - ) - .bind(playlist_id) - .fetch_optional(pool) - .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + let owner: Option<(i64,)> = + sqlx::query_as("SELECT owner_id FROM furumusic__playlist WHERE id = $1") + .bind(playlist_id) + .fetch_optional(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; let Some(owner) = owner else { return Ok(json_error(StatusCode::NOT_FOUND, "playlist not found")); }; @@ -1479,13 +1485,15 @@ async fn update_playlist_handler( } } if let Some(desc) = &body.description { - sqlx::query("UPDATE furumusic__playlist SET description = $1, updated_at = $2 WHERE id = $3") - .bind(desc) - .bind(&now) - .bind(playlist_id) - .execute(pool) - .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + sqlx::query( + "UPDATE furumusic__playlist SET description = $1, updated_at = $2 WHERE id = $3", + ) + .bind(desc) + .bind(&now) + .bind(playlist_id) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; } Json(serde_json::json!({"ok": true})).into_response() } @@ -1504,13 +1512,12 @@ async fn delete_playlist_handler( return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let playlist_id = path.0.id; - let owner: Option<(i64,)> = sqlx::query_as( - "SELECT owner_id FROM furumusic__playlist WHERE id = $1", - ) - .bind(playlist_id) - .fetch_optional(pool) - .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + let owner: Option<(i64,)> = + sqlx::query_as("SELECT owner_id FROM furumusic__playlist WHERE id = $1") + .bind(playlist_id) + .fetch_optional(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; let Some(owner) = owner else { return Ok(json_error(StatusCode::NOT_FOUND, "playlist not found")); }; @@ -1550,13 +1557,12 @@ async fn add_tracks_to_playlist_handler( return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let playlist_id = path.0.id; - let owner: Option<(i64,)> = sqlx::query_as( - "SELECT owner_id FROM furumusic__playlist WHERE id = $1", - ) - .bind(playlist_id) - .fetch_optional(pool) - .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + let owner: Option<(i64,)> = + sqlx::query_as("SELECT owner_id FROM furumusic__playlist WHERE id = $1") + .bind(playlist_id) + .fetch_optional(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; let Some(owner) = owner else { return Ok(json_error(StatusCode::NOT_FOUND, "playlist not found")); }; @@ -1617,13 +1623,12 @@ async fn remove_track_from_playlist_handler( return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let playlist_id = path.0.id; - let owner: Option<(i64,)> = sqlx::query_as( - "SELECT owner_id FROM furumusic__playlist WHERE id = $1", - ) - .bind(playlist_id) - .fetch_optional(pool) - .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + let owner: Option<(i64,)> = + sqlx::query_as("SELECT owner_id FROM furumusic__playlist WHERE id = $1") + .bind(playlist_id) + .fetch_optional(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; let Some(owner) = owner else { return Ok(json_error(StatusCode::NOT_FOUND, "playlist not found")); }; @@ -1631,14 +1636,12 @@ async fn remove_track_from_playlist_handler( return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist")); } - sqlx::query( - "DELETE FROM furumusic__playlist_track WHERE playlist_id = $1 AND track_id = $2", - ) - .bind(playlist_id) - .bind(body.track_id) - .execute(pool) - .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + sqlx::query("DELETE FROM furumusic__playlist_track WHERE playlist_id = $1 AND track_id = $2") + .bind(playlist_id) + .bind(body.track_id) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; // Re-number positions sqlx::query( @@ -1682,14 +1685,12 @@ async fn toggle_like_track_handler( .map_err(|e| cot::Error::internal(e.to_string()))?; if existing.is_some() { - sqlx::query( - "DELETE FROM furumusic__user_liked_track WHERE user_id = $1 AND track_id = $2", - ) - .bind(user.id) - .bind(track_id) - .execute(pool) - .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + sqlx::query("DELETE FROM furumusic__user_liked_track WHERE user_id = $1 AND track_id = $2") + .bind(user.id) + .bind(track_id) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; Json(LikeStatus { liked: false }).into_response() } else { let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); @@ -1790,13 +1791,12 @@ async fn liked_ids_handler( let Some(user) = auth::get_session_user(&session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; - let rows: Vec<(i64,)> = sqlx::query_as( - "SELECT track_id FROM furumusic__user_liked_track WHERE user_id = $1", - ) - .bind(user.id) - .fetch_all(pool) - .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + let rows: Vec<(i64,)> = + sqlx::query_as("SELECT track_id FROM furumusic__user_liked_track WHERE user_id = $1") + .bind(user.id) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; Json(LikedIds { track_ids: rows.into_iter().map(|r| r.0).collect(), @@ -1883,24 +1883,24 @@ async fn tracks_by_ids_handler( let mut track_map: std::collections::HashMap = std::collections::HashMap::new(); for t in tracks { let tid = t.id; - track_map.insert(tid, TrackItem { - id: t.id, - title: t.title, - track_number: t.track_number, - disc_number: t.disc_number, - duration_seconds: t.duration_seconds, - artists: track_main_artists.remove(&tid).unwrap_or_default(), - featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), - cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id), - stream_url: format!("/api/player/stream/{tid}"), - }); + track_map.insert( + tid, + TrackItem { + id: t.id, + title: t.title, + track_number: t.track_number, + disc_number: t.disc_number, + duration_seconds: t.duration_seconds, + artists: track_main_artists.remove(&tid).unwrap_or_default(), + featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), + cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id), + stream_url: format!("/api/player/stream/{tid}"), + }, + ); } // Reorder results to match input order - let result: Vec = ids - .iter() - .filter_map(|id| track_map.remove(id)) - .collect(); + let result: Vec = ids.iter().filter_map(|id| track_map.remove(id)).collect(); Json(result).into_response() } @@ -1926,8 +1926,7 @@ impl App for PlayerApp { fn router(&self) -> Router { let pool_config = Arc::clone(&self.config); - let pool: Arc> = - Arc::new(tokio::sync::OnceCell::new()); + let pool: Arc> = Arc::new(tokio::sync::OnceCell::new()); Router::with_urls([ // -- Artists (paginated) -- @@ -2077,7 +2076,10 @@ impl App for PlayerApp { .put({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, path: Path, json: Json| { + move |session: Session, + db: Database, + path: Path, + json: Json| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -2122,7 +2124,10 @@ impl App for PlayerApp { post({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, path: Path, json: Json| { + move |session: Session, + db: Database, + path: Path, + json: Json| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -2142,7 +2147,10 @@ impl App for PlayerApp { .delete({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, path: Path, json: Json| { + move |session: Session, + db: Database, + path: Path, + json: Json| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -2155,7 +2163,8 @@ impl App for PlayerApp { .expect("player pool") }) .await; - remove_track_from_playlist_handler(session, db, pg_pool, path, json).await + remove_track_from_playlist_handler(session, db, pg_pool, path, json) + .await } } }), @@ -2243,25 +2252,28 @@ impl App for PlayerApp { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); let config = Arc::clone(&self.config); - get(move |session: Session, db: Database, + get( + move |session: Session, + db: Database, path: Path, request: cot::request::Request| { - let pool = Arc::clone(&pool); - let pool_config = Arc::clone(&pool_config); - let config = Arc::clone(&config); - async move { - let pg_pool = pool - .get_or_init(|| async { - sqlx::postgres::PgPoolOptions::new() - .max_connections(5) - .connect(&pool_config.database_url) - .await - .expect("player pool") - }) - .await; - stream_handler(session, db, pg_pool, &config, &request, path).await - } - }) + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + let config = Arc::clone(&config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + stream_handler(session, db, pg_pool, &config, &request, path).await + } + }, + ) }, "player_stream", ), @@ -2272,24 +2284,25 @@ impl App for PlayerApp { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); let config = Arc::clone(&self.config); - get(move |session: Session, db: Database, - path: Path| { - let pool = Arc::clone(&pool); - let pool_config = Arc::clone(&pool_config); - let config = Arc::clone(&config); - async move { - let pg_pool = pool - .get_or_init(|| async { - sqlx::postgres::PgPoolOptions::new() - .max_connections(5) - .connect(&pool_config.database_url) - .await - .expect("player pool") - }) - .await; - cover_handler(session, db, pg_pool, &config, path).await - } - }) + get( + move |session: Session, db: Database, path: Path| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + let config = Arc::clone(&config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + cover_handler(session, db, pg_pool, &config, path).await + } + }, + ) }, "player_cover", ), diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs index 1a7eb8a..23932ec 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -1,5 +1,4 @@ /// Job scheduler: models, migrations, Job trait, JobRegistry, and scheduler loop. - use std::collections::HashMap; use std::sync::Arc; @@ -74,7 +73,12 @@ impl ScheduledJob { Self::get_by_primary_key(db, name.to_owned()).await } - pub async fn upsert(db: &Database, name: &str, description: &str, cron_expression: &str) -> cot::db::Result { + pub async fn upsert( + db: &Database, + name: &str, + description: &str, + cron_expression: &str, + ) -> cot::db::Result { if let Some(mut existing) = Self::get_by_name(db, name).await? { // Update cron expression and description if they changed let mut changed = false; @@ -170,7 +174,11 @@ pub struct JobRun { #[allow(dead_code)] impl JobRun { - pub async fn create_running(db: &Database, job_name: &str, trigger: &str) -> cot::db::Result { + pub async fn create_running( + db: &Database, + job_name: &str, + trigger: &str, + ) -> cot::db::Result { let mut run = Self { id: Auto::auto(), job_name: limited_string(job_name), @@ -186,7 +194,12 @@ impl JobRun { Ok(run) } - pub async fn set_completed(&mut self, db: &Database, duration_ms: i64, log: &str) -> cot::db::Result<()> { + pub async fn set_completed( + &mut self, + db: &Database, + duration_ms: i64, + log: &str, + ) -> cot::db::Result<()> { self.status = LimitedString::new("completed").unwrap(); self.finished_at = Some(now_iso().to_string()); self.duration_ms = Some(duration_ms); @@ -194,7 +207,13 @@ impl JobRun { self.save(db).await } - pub async fn set_failed(&mut self, db: &Database, duration_ms: i64, log: &str, error: &str) -> cot::db::Result<()> { + pub async fn set_failed( + &mut self, + db: &Database, + duration_ms: i64, + log: &str, + error: &str, + ) -> cot::db::Result<()> { self.status = LimitedString::new("failed").unwrap(); self.finished_at = Some(now_iso().to_string()); self.duration_ms = Some(duration_ms); @@ -207,7 +226,11 @@ impl JobRun { Self::get_by_primary_key(db, Auto::Fixed(id)).await } - pub async fn list_by_job(pool: &sqlx::PgPool, job_name: &str, limit: i64) -> anyhow::Result> { + pub async fn list_by_job( + pool: &sqlx::PgPool, + job_name: &str, + limit: i64, + ) -> anyhow::Result> { let rows = sqlx::query_as::<_, JobRunRow>( "SELECT id, job_name, status, started_at, finished_at, duration_ms, log_output, error_message, trigger \ FROM furumusic__job_run WHERE job_name = $1 ORDER BY id DESC LIMIT $2" @@ -229,7 +252,7 @@ impl JobRun { SET status = 'failed', \ finished_at = $1, \ error_message = 'Process restarted while job was running' \ - WHERE status = 'running'" + WHERE status = 'running'", ) .bind(&now) .execute(pool) @@ -472,7 +495,7 @@ impl PendingReview { SET status = 'failed', \ error_message = 'Process restarted while review was being processed', \ updated_at = $1 \ - WHERE status = 'processing'" + WHERE status = 'processing'", ) .bind(&now) .execute(pool) @@ -497,6 +520,46 @@ impl PendingReview { Ok(()) } + pub async fn delete_by_ids(db: &Database, ids: &[i64]) -> cot::db::Result<()> { + for chunk in ids.chunks(1000) { + if chunk.is_empty() { + continue; + } + let id_list = chunk + .iter() + .map(i64::to_string) + .collect::>() + .join(","); + db.raw(&format!( + "DELETE FROM furumusic__pending_review WHERE id IN ({id_list})" + )) + .await?; + } + Ok(()) + } + + pub async fn requeue_by_ids(db: &Database, ids: &[i64]) -> cot::db::Result<()> { + let now = now_iso().to_string(); + for chunk in ids.chunks(1000) { + if chunk.is_empty() { + continue; + } + let id_list = chunk + .iter() + .map(i64::to_string) + .collect::>() + .join(","); + db.raw(&format!( + "UPDATE furumusic__pending_review \ + SET status = 'queued', error_message = NULL, updated_at = '{}' \ + WHERE id IN ({id_list})", + now.replace('\'', "''") + )) + .await?; + } + Ok(()) + } + pub fn id_val(&self) -> i64 { self.id.unwrap() } @@ -589,12 +652,19 @@ impl ProcessingStats { Ok(all.into_iter().next()) } - pub async fn list_by_review_ids(pool: &sqlx::PgPool, ids: &[i64]) -> anyhow::Result> { + pub async fn list_by_review_ids( + pool: &sqlx::PgPool, + ids: &[i64], + ) -> anyhow::Result> { if ids.is_empty() { return Ok(HashMap::new()); } // Build comma-separated ID list - let id_list: String = ids.iter().map(|id| id.to_string()).collect::>().join(","); + let id_list: String = ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); let query = format!( "SELECT pending_review_id, model_name, llm_duration_ms, prompt_tokens, completion_tokens \ FROM furumusic__processing_stats WHERE pending_review_id IN ({id_list})" @@ -659,28 +729,46 @@ pub mod db_migrations { impl migrations::Migration for M0022CreateScheduledJob { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0022_create_scheduled_job"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration("furumusic", "m_0021_create_trgm_indexes"), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::create_model() - .table_name(Identifier::new("furumusic__scheduled_job")) - .fields(&[ - Field::new(Identifier::new("name"), ::TYPE) - .primary_key() - .set_null(::NULLABLE), - Field::new(Identifier::new("description"), ::TYPE), - Field::new(Identifier::new("cron_expression"), as DatabaseField>::TYPE), - Field::new(Identifier::new("enabled"), ::TYPE), - Field::new(Identifier::new("last_run_at"), as DatabaseField>::TYPE) - .set_null(true), - Field::new(Identifier::new("next_run_at"), as DatabaseField>::TYPE) - .set_null(true), - Field::new(Identifier::new("created_at"), as DatabaseField>::TYPE), - Field::new(Identifier::new("updated_at"), as DatabaseField>::TYPE), - ]) - .build(), - ]; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "furumusic", + "m_0021_create_trgm_indexes", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furumusic__scheduled_job")) + .fields(&[ + Field::new(Identifier::new("name"), ::TYPE) + .primary_key() + .set_null(::NULLABLE), + Field::new( + Identifier::new("description"), + ::TYPE, + ), + Field::new( + Identifier::new("cron_expression"), + as DatabaseField>::TYPE, + ), + Field::new(Identifier::new("enabled"), ::TYPE), + Field::new( + Identifier::new("last_run_at"), + as DatabaseField>::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("next_run_at"), + as DatabaseField>::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("created_at"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("updated_at"), + as DatabaseField>::TYPE, + ), + ]) + .build()]; } #[derive(Debug, Copy, Clone)] @@ -689,31 +777,52 @@ pub mod db_migrations { impl migrations::Migration for M0023CreateJobRun { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0023_create_job_run"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration("furumusic", "m_0022_create_scheduled_job"), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::create_model() - .table_name(Identifier::new("furumusic__job_run")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - Field::new(Identifier::new("job_name"), as DatabaseField>::TYPE), - Field::new(Identifier::new("status"), as DatabaseField>::TYPE), - Field::new(Identifier::new("started_at"), as DatabaseField>::TYPE), - Field::new(Identifier::new("finished_at"), as DatabaseField>::TYPE) - .set_null(true), - Field::new(Identifier::new("duration_ms"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("log_output"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("error_message"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("trigger"), as DatabaseField>::TYPE), - ]) - .build(), - ]; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "furumusic", + "m_0022_create_scheduled_job", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furumusic__job_run")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + Field::new( + Identifier::new("job_name"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("status"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("started_at"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("finished_at"), + as DatabaseField>::TYPE, + ) + .set_null(true), + Field::new(Identifier::new("duration_ms"), ::TYPE) + .set_null(true), + Field::new( + Identifier::new("log_output"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("error_message"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("trigger"), + as DatabaseField>::TYPE, + ), + ]) + .build()]; } #[derive(Debug, Copy, Clone)] @@ -722,34 +831,57 @@ pub mod db_migrations { impl migrations::Migration for M0024CreatePendingReview { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0024_create_pending_review"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration("furumusic", "m_0023_create_job_run"), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::create_model() - .table_name(Identifier::new("furumusic__pending_review")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - Field::new(Identifier::new("job_run_id"), ::TYPE), - Field::new(Identifier::new("review_type"), as DatabaseField>::TYPE), - Field::new(Identifier::new("input_path"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("context_json"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("result_json"), ::TYPE) - .set_null(true), - Field::new(Identifier::new("status"), as DatabaseField>::TYPE), - Field::new(Identifier::new("created_at"), as DatabaseField>::TYPE), - Field::new(Identifier::new("updated_at"), as DatabaseField>::TYPE), - ]) - .build(), - ]; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "furumusic", + "m_0023_create_job_run", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furumusic__pending_review")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + Field::new(Identifier::new("job_run_id"), ::TYPE), + Field::new( + Identifier::new("review_type"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("input_path"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("context_json"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("result_json"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("status"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("created_at"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("updated_at"), + as DatabaseField>::TYPE, + ), + ]) + .build()]; } #[cot::db::migrations::migration_op] - async fn create_scheduler_indexes(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> { + async fn create_scheduler_indexes( + ctx: migrations::MigrationContext<'_>, + ) -> cot::db::Result<()> { let stmts = [ "CREATE INDEX idx_job_run_job_name ON furumusic__job_run (job_name, id DESC)", "CREATE INDEX idx_job_run_status ON furumusic__job_run (status)", @@ -768,16 +900,19 @@ pub mod db_migrations { impl migrations::Migration for M0025CreateSchedulerIndexes { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0025_create_scheduler_indexes"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration("furumusic", "m_0024_create_pending_review"), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::custom(create_scheduler_indexes).build(), - ]; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "furumusic", + "m_0024_create_pending_review", + )]; + const OPERATIONS: &'static [Operation] = + &[Operation::custom(create_scheduler_indexes).build()]; } #[cot::db::migrations::migration_op] - async fn add_pending_review_error_message(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> { + async fn add_pending_review_error_message( + ctx: migrations::MigrationContext<'_>, + ) -> cot::db::Result<()> { ctx.db .raw("ALTER TABLE furumusic__pending_review ADD COLUMN error_message TEXT") .await?; @@ -790,12 +925,13 @@ pub mod db_migrations { impl migrations::Migration for M0026AddPendingReviewErrorMessage { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0026_add_pending_review_error_message"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration("furumusic", "m_0025_create_scheduler_indexes"), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::custom(add_pending_review_error_message).build(), - ]; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "furumusic", + "m_0025_create_scheduler_indexes", + )]; + const OPERATIONS: &'static [Operation] = + &[Operation::custom(add_pending_review_error_message).build()]; } #[derive(Debug, Copy, Clone)] @@ -804,25 +940,43 @@ pub mod db_migrations { impl migrations::Migration for M0027CreateProcessingStats { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0027_create_processing_stats"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration("furumusic", "m_0026_add_pending_review_error_message"), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::create_model() - .table_name(Identifier::new("furumusic__processing_stats")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - Field::new(Identifier::new("pending_review_id"), ::TYPE), - Field::new(Identifier::new("model_name"), as DatabaseField>::TYPE), - Field::new(Identifier::new("llm_duration_ms"), ::TYPE), - Field::new(Identifier::new("prompt_tokens"), ::TYPE), - Field::new(Identifier::new("completion_tokens"), ::TYPE), - Field::new(Identifier::new("created_at"), as DatabaseField>::TYPE), - ]) - .build(), - ]; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "furumusic", + "m_0026_add_pending_review_error_message", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furumusic__processing_stats")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + Field::new( + Identifier::new("pending_review_id"), + ::TYPE, + ), + Field::new( + Identifier::new("model_name"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("llm_duration_ms"), + ::TYPE, + ), + Field::new( + Identifier::new("prompt_tokens"), + ::TYPE, + ), + Field::new( + Identifier::new("completion_tokens"), + ::TYPE, + ), + Field::new( + Identifier::new("created_at"), + as DatabaseField>::TYPE, + ), + ]) + .build()]; } pub const MIGRATIONS: &[&SyncDynMigration] = &[ @@ -856,11 +1010,19 @@ pub struct JobLog { #[allow(dead_code)] impl JobLog { pub fn new() -> Self { - Self { lines: Vec::new(), pool: None, run_id: 0 } + Self { + lines: Vec::new(), + pool: None, + run_id: 0, + } } pub fn with_live_flush(pool: sqlx::PgPool, run_id: i64) -> Self { - Self { lines: Vec::new(), pool: Some(pool), run_id } + Self { + lines: Vec::new(), + pool: Some(pool), + run_id, + } } pub fn info(&mut self, msg: &str) { @@ -894,13 +1056,11 @@ impl JobLog { let run_id = self.run_id; let pool = pool.clone(); tokio::spawn(async move { - let _ = sqlx::query( - "UPDATE furumusic__job_run SET log_output = $1 WHERE id = $2" - ) - .bind(&output) - .bind(run_id) - .execute(&pool) - .await; + let _ = sqlx::query("UPDATE furumusic__job_run SET log_output = $1 WHERE id = $2") + .bind(&output) + .bind(run_id) + .execute(&pool) + .await; }); } } @@ -997,7 +1157,9 @@ impl SchedulerHandle { } Err(e) => { let duration_ms = start.elapsed().as_millis() as i64; - let _ = run.set_failed(db, duration_ms, &log.output(), &e.to_string()).await; + let _ = run + .set_failed(db, duration_ms, &log.output(), &e.to_string()) + .await; } } @@ -1025,7 +1187,8 @@ impl SchedulerHandle { self.add_cron_job(job_name, new_cron).await?; // Update DB - if let Ok(Some(mut sched_job)) = ScheduledJob::get_by_name(&self.shared_db, job_name).await { + if let Ok(Some(mut sched_job)) = ScheduledJob::get_by_name(&self.shared_db, job_name).await + { sched_job.cron_expression = LimitedString::new(new_cron).unwrap(); sched_job.next_run_at = compute_next_run(new_cron); sched_job.updated_at = now_iso(); @@ -1083,7 +1246,10 @@ impl SchedulerHandle { })?; let uuid = self.scheduler.add(cron_job).await?; - self.job_uuids.write().await.insert(job_name.to_owned(), uuid); + self.job_uuids + .write() + .await + .insert(job_name.to_owned(), uuid); Ok(()) } @@ -1161,7 +1327,9 @@ async fn run_scheduled_job( Err(e) => { let duration_ms = start.elapsed().as_millis() as i64; tracing::error!(job = job_name, duration_ms, "Job failed: {e}"); - let _ = run.set_failed(db, duration_ms, &log.output(), &e.to_string()).await; + let _ = run + .set_failed(db, duration_ms, &log.output(), &e.to_string()) + .await; } } @@ -1264,12 +1432,12 @@ pub async fn start_scheduler( // Update next_run_at in DB if let Some(next) = compute_next_run(cron_expr) { let _ = sqlx::query( - "UPDATE furumusic__scheduled_job SET next_run_at = $1 WHERE name = $2" + "UPDATE furumusic__scheduled_job SET next_run_at = $1 WHERE name = $2", ) - .bind(&next) - .bind(sched_job.name_str()) - .execute(&pool) - .await; + .bind(&next) + .bind(sched_job.name_str()) + .execute(&pool) + .await; } } Err(e) => { @@ -1339,7 +1507,9 @@ pub async fn trigger_job_now( } Err(e) => { let duration_ms = start.elapsed().as_millis() as i64; - let _ = run.set_failed(db, duration_ms, &log.output(), &e.to_string()).await; + let _ = run + .set_failed(db, duration_ms, &log.output(), &e.to_string()) + .await; } } diff --git a/src/user.rs b/src/user.rs index 0d48337..ef36e88 100644 --- a/src/user.rs +++ b/src/user.rs @@ -108,7 +108,9 @@ impl User { /// Delete this user by primary key. pub async fn delete_by_id(db: &Database, user_id: i64) -> cot::db::Result<()> { - cot::db::query!(User, $id == Auto::Fixed(user_id)).delete(db).await?; + cot::db::query!(User, $id == Auto::Fixed(user_id)) + .delete(db) + .await?; Ok(()) } @@ -120,10 +122,16 @@ impl User { &self.username } pub fn email_str(&self) -> String { - self.email.as_ref().map(|e| e.to_string()).unwrap_or_default() + self.email + .as_ref() + .map(|e| e.to_string()) + .unwrap_or_default() } pub fn display_name_str(&self) -> String { - self.display_name.as_ref().map(|d| d.to_string()).unwrap_or_default() + self.display_name + .as_ref() + .map(|d| d.to_string()) + .unwrap_or_default() } pub fn role_str(&self) -> &str { &self.role @@ -162,7 +170,9 @@ impl User { /// Find a user by email address. pub async fn get_by_email(db: &Database, email: &str) -> cot::db::Result> { - cot::db::query!(User, $email == Some(email.to_owned())).get(db).await + cot::db::query!(User, $email == Some(email.to_owned())) + .get(db) + .await } } @@ -257,9 +267,9 @@ impl OidcLink { // --------------------------------------------------------------------------- pub mod db_migrations { + use cot::auth::PasswordHash; use cot::db::migrations::{self, Field, Operation, SyncDynMigration}; use cot::db::{DatabaseField, Identifier, LimitedString}; - use cot::auth::PasswordHash; // -- M0003: create furumusic__user ------------------------------------- @@ -269,58 +279,49 @@ pub mod db_migrations { impl migrations::Migration for M0003CreateUser { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0003_create_user"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0002_rename_config_table", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::create_model() - .table_name(Identifier::new("furumusic__user")) - .fields(&[ - Field::new( - Identifier::new("id"), - ::TYPE, - ) + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furumusic__user")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) .primary_key() .auto(), - Field::new( - Identifier::new("username"), - as DatabaseField>::TYPE, - ) - .unique(), - Field::new( - Identifier::new("password"), - ::TYPE, - ) - .set_null(true), - Field::new( - Identifier::new("email"), - as DatabaseField>::TYPE, - ) - .set_null(true), - Field::new( - Identifier::new("display_name"), - as DatabaseField>::TYPE, - ) - .set_null(true), - Field::new( - Identifier::new("avatar_url"), - ::TYPE, - ) - .set_null(true), - Field::new( - Identifier::new("role"), - as DatabaseField>::TYPE, - ), - Field::new( - Identifier::new("is_active"), - ::TYPE, - ), - ]) - .build(), - ]; + Field::new( + Identifier::new("username"), + as DatabaseField>::TYPE, + ) + .unique(), + Field::new( + Identifier::new("password"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("email"), + as DatabaseField>::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("display_name"), + as DatabaseField>::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("avatar_url"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("role"), + as DatabaseField>::TYPE, + ), + Field::new(Identifier::new("is_active"), ::TYPE), + ]) + .build()]; } // -- M0004: create furumusic__oidc_link -------------------------------- @@ -331,52 +332,43 @@ pub mod db_migrations { impl migrations::Migration for M0004CreateOidcLink { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0004_create_oidc_link"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0003_create_user", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::create_model() - .table_name(Identifier::new("furumusic__oidc_link")) - .fields(&[ - Field::new( - Identifier::new("id"), - ::TYPE, - ) + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furumusic__oidc_link")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) .primary_key() .auto(), - Field::new( - Identifier::new("user_id"), - ::TYPE, - ), - Field::new( - Identifier::new("issuer"), - as DatabaseField>::TYPE, - ), - Field::new( - Identifier::new("sub"), - as DatabaseField>::TYPE, - ), - Field::new( - Identifier::new("email"), - as DatabaseField>::TYPE, - ) - .set_null(true), - Field::new( - Identifier::new("name"), - as DatabaseField>::TYPE, - ) - .set_null(true), - Field::new( - Identifier::new("avatar_url"), - ::TYPE, - ) - .set_null(true), - ]) - .build(), - ]; + Field::new(Identifier::new("user_id"), ::TYPE), + Field::new( + Identifier::new("issuer"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("sub"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("email"), + as DatabaseField>::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("name"), + as DatabaseField>::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("avatar_url"), + ::TYPE, + ) + .set_null(true), + ]) + .build()]; } // -- M0005: indexes on furumusic__oidc_link ---------------------------- @@ -406,15 +398,13 @@ pub mod db_migrations { impl migrations::Migration for M0005OidcLinkIndexes { const APP_NAME: &'static str = "furumusic"; const MIGRATION_NAME: &'static str = "m_0005_oidc_link_indexes"; - const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ - migrations::MigrationDependency::migration( + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( "furumusic", "m_0004_create_oidc_link", - ), - ]; - const OPERATIONS: &'static [Operation] = &[ - Operation::custom(create_oidc_link_indexes).build(), - ]; + )]; + const OPERATIONS: &'static [Operation] = + &[Operation::custom(create_oidc_link_indexes).build()]; } pub const MIGRATIONS: &[&SyncDynMigration] = &[ diff --git a/templates/admin/job_detail.html b/templates/admin/job_detail.html index 5e2997b..5dee35f 100644 --- a/templates/admin/job_detail.html +++ b/templates/admin/job_detail.html @@ -13,9 +13,12 @@
+ {% if job.name_str() != "metadata_backfill" %}
+ {% endif %} + {% if job.name_str() != "metadata_backfill" %}
{% if job.enabled() %} @@ -23,14 +26,47 @@ {% endif %}
+ {% endif %}
-

{{ t.jobs_cron }}

-

{{ t.jobs_cron_help }}

-
- - +{% if job.name_str() == "metadata_backfill" %} +

{{ t.jobs_metadata_backfill_options }}

+ +
+ {{ t.jobs_metadata_backfill_fields }} + + + + +
+
+ + +
+
+{% endif %} + +{% if job.name_str() != "metadata_backfill" %} +

{{ t.jobs_cron }}

+

{{ t.jobs_cron_help }}

+
+ + +
+{% endif %}

{{ t.jobs_run_history }}

{% if runs.is_empty() %} diff --git a/templates/admin/jobs.html b/templates/admin/jobs.html index 5c2d700..04ea248 100644 --- a/templates/admin/jobs.html +++ b/templates/admin/jobs.html @@ -23,9 +23,14 @@ {{ job.last_run_at_str() }} {{ job.next_run_at_str() }} + {% if job.name_str() == "metadata_backfill" %} + {{ t.jobs_metadata_backfill_options }} + {% else %}
+ {% endif %} + {% if job.name_str() != "metadata_backfill" %}
{% if job.enabled() %} @@ -33,6 +38,7 @@ {% endif %}
+ {% endif %} {% endfor %} diff --git a/templates/admin/layout.html b/templates/admin/layout.html index 8878540..3ff1be1 100644 --- a/templates/admin/layout.html +++ b/templates/admin/layout.html @@ -9,6 +9,7 @@ body { font-family: system-ui, -apple-system, sans-serif; display: flex; min-height: 100vh; background: #f5f5f5; color: #333; } nav.sidebar { width: 220px; background: #1a1a2e; color: #eee; padding: 1rem; } nav.sidebar h2 { font-size: 1.1rem; margin-bottom: 1.5rem; } + .admin-version { display: inline-block; margin-left: .35rem; color: #999; font-size: .72rem; font-weight: 500; vertical-align: baseline; } nav.sidebar a { color: #ccc; text-decoration: none; display: block; padding: .4rem .6rem; border-radius: 4px; } nav.sidebar a:hover { background: #16213e; color: #fff; } .main-wrap { flex: 1; display: flex; flex-direction: column; } @@ -36,7 +37,7 @@ {% block body %}