Files
furumusic/src/admin/views.rs
T
Ultradesu 015d75c701
Build and Publish / Build and Publish Docker Image (push) Failing after 1m42s
CORE: Added Last.FM scrobbling
2026-05-27 16:40:06 +03:00

2140 lines
67 KiB
Rust

use cot::db::{Database, Model};
use cot::form::{Form, FormResult};
use cot::html::Html;
use cot::request::extractors::RequestForm;
use cot::response::IntoResponse;
use cot::session::Session;
use cot::{Body, Template};
use std::collections::HashMap;
use std::sync::Arc;
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_TYPES, Release, ReleaseArtist, Track, TrackArtist};
use crate::scheduler::{self, JobRegistry, JobRun, PendingReview, ScheduledJob};
use crate::user::User;
use crate::agent::AgentProbeResult;
/// A config entry for display in the unified debug table.
#[derive(Debug)]
pub struct ConfigDisplayEntry {
pub key: String,
pub env_var: String,
pub value: String,
pub default_value: String,
pub source: &'static str,
}
/// Secret field names that should be redacted in the debug view.
const SECRET_FIELDS: &[&str] = &["database_url", "oidc_client_secret"];
fn is_secret(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
SECRET_FIELDS.iter().any(|s| lower.contains(s))
|| lower.contains("secret")
|| lower.contains("token")
}
fn redact(value: &str) -> String {
if value.is_empty() {
String::new()
} else {
"********".into()
}
}
#[derive(Debug, Template)]
#[template(path = "admin/debug.html")]
struct DebugTemplate<'a> {
t: &'static Translations,
user_name: String,
user_role: String,
build: &'a super::BuildInfo,
config_entries: Vec<ConfigDisplayEntry>,
db_status: String,
}
fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<ConfigDisplayEntry> {
let defaults = AppConfig::default();
macro_rules! entry {
($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!(
oidc_user_groups,
config.oidc_user_groups.clone(),
defaults.oidc_user_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!(
lastfm_api_key,
config.lastfm_api_key.clone(),
defaults.lastfm_api_key.clone()
),
entry!(
lastfm_shared_secret,
config.lastfm_shared_secret.clone(),
defaults.lastfm_shared_secret.clone()
),
]
}
pub async fn debug_handler(
admin: AuthenticatedUser,
i18n: I18n,
_startup_config: &AppConfig,
db: &Database,
) -> cot::Result<Html> {
let (config, sources) = AppConfig::load_with_db(db).await;
let db_status = match db.raw("SELECT 1").await {
Ok(_) => i18n.t.debug_db_connected.to_owned(),
Err(e) => format!("{}: {e}", i18n.t.debug_db_error),
};
let template = DebugTemplate {
t: i18n.t,
user_name: admin.name,
user_role: admin.role.code().to_owned(),
build: &BUILD_INFO,
config_entries: config_display_entries(&config, &sources),
db_status,
};
Ok(Html::new(template.render()?))
}
// ---------------------------------------------------------------------------
// Settings page
// ---------------------------------------------------------------------------
#[derive(Debug, Template)]
#[template(path = "admin/settings.html")]
struct SettingsTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
saved: bool,
auth_password_enabled: bool,
auth_password_enabled_source: &'static str,
auth_sso_enabled: bool,
auth_sso_enabled_source: &'static str,
oidc_button_text: String,
oidc_button_text_source: &'static str,
oidc_issuer: String,
oidc_issuer_source: &'static str,
oidc_client_id: String,
oidc_client_id_source: &'static str,
oidc_client_secret: String,
oidc_client_secret_source: &'static str,
oidc_admin_groups: String,
oidc_admin_groups_source: &'static str,
oidc_user_groups: String,
oidc_user_groups_source: &'static str,
swagger_enabled: bool,
swagger_enabled_source: &'static str,
agent_enabled: bool,
agent_enabled_source: &'static str,
agent_inbox_dir: String,
agent_inbox_dir_source: &'static str,
agent_storage_dir: String,
agent_storage_dir_source: &'static str,
agent_llm_url: String,
agent_llm_url_source: &'static str,
agent_llm_model: String,
agent_llm_model_source: &'static str,
agent_llm_auth: String,
agent_llm_auth_source: &'static str,
agent_confidence_threshold: String,
agent_confidence_threshold_source: &'static str,
agent_context_limit: String,
agent_context_limit_source: &'static str,
agent_concurrency: String,
agent_concurrency_source: &'static str,
lastfm_api_key: String,
lastfm_api_key_source: &'static str,
lastfm_shared_secret: String,
lastfm_shared_secret_source: &'static str,
}
pub async fn settings_handler(
admin: AuthenticatedUser,
i18n: I18n,
_startup_config: &AppConfig,
db: &Database,
saved: bool,
) -> cot::Result<Html> {
let (config, sources) = AppConfig::load_with_db(db).await;
let template = SettingsTemplate {
t: i18n.t,
user_name: admin.name,
user_role: admin.role.code().to_owned(),
saved,
auth_password_enabled: config.auth_password_enabled,
auth_password_enabled_source: sources.auth_password_enabled.code(),
auth_sso_enabled: config.auth_sso_enabled,
auth_sso_enabled_source: sources.auth_sso_enabled.code(),
oidc_button_text: config.oidc_button_text,
oidc_button_text_source: sources.oidc_button_text.code(),
oidc_issuer: config.oidc_issuer,
oidc_issuer_source: sources.oidc_issuer.code(),
oidc_client_id: config.oidc_client_id,
oidc_client_id_source: sources.oidc_client_id.code(),
oidc_client_secret: config.oidc_client_secret,
oidc_client_secret_source: sources.oidc_client_secret.code(),
oidc_admin_groups: config.oidc_admin_groups,
oidc_admin_groups_source: sources.oidc_admin_groups.code(),
oidc_user_groups: config.oidc_user_groups,
oidc_user_groups_source: sources.oidc_user_groups.code(),
swagger_enabled: config.swagger_enabled,
swagger_enabled_source: sources.swagger_enabled.code(),
agent_enabled: config.agent_enabled,
agent_enabled_source: sources.agent_enabled.code(),
agent_inbox_dir: config.agent_inbox_dir.clone(),
agent_inbox_dir_source: sources.agent_inbox_dir.code(),
agent_storage_dir: config.agent_storage_dir.clone(),
agent_storage_dir_source: sources.agent_storage_dir.code(),
agent_llm_url: config.agent_llm_url.clone(),
agent_llm_url_source: sources.agent_llm_url.code(),
agent_llm_model: config.agent_llm_model.clone(),
agent_llm_model_source: sources.agent_llm_model.code(),
agent_llm_auth: config.agent_llm_auth.clone(),
agent_llm_auth_source: sources.agent_llm_auth.code(),
agent_confidence_threshold: config.agent_confidence_threshold.to_string(),
agent_confidence_threshold_source: sources.agent_confidence_threshold.code(),
agent_context_limit: config.agent_context_limit.to_string(),
agent_context_limit_source: sources.agent_context_limit.code(),
agent_concurrency: config.agent_concurrency.to_string(),
agent_concurrency_source: sources.agent_concurrency.code(),
lastfm_api_key: config.lastfm_api_key.clone(),
lastfm_api_key_source: sources.lastfm_api_key.code(),
lastfm_shared_secret: config.lastfm_shared_secret.clone(),
lastfm_shared_secret_source: sources.lastfm_shared_secret.code(),
};
Ok(Html::new(template.render()?))
}
#[derive(Debug, Form)]
pub struct OidcSettingsForm {
auth_password_enabled: Option<String>,
auth_sso_enabled: Option<String>,
oidc_button_text: Option<String>,
oidc_issuer: Option<String>,
oidc_client_id: Option<String>,
oidc_client_secret: Option<String>,
oidc_admin_groups: Option<String>,
oidc_user_groups: Option<String>,
swagger_enabled: Option<String>,
agent_enabled: Option<String>,
agent_inbox_dir: Option<String>,
agent_storage_dir: Option<String>,
agent_llm_url: Option<String>,
agent_llm_model: Option<String>,
agent_llm_auth: Option<String>,
agent_confidence_threshold: Option<String>,
agent_context_limit: Option<String>,
agent_concurrency: Option<String>,
lastfm_api_key: Option<String>,
lastfm_shared_secret: Option<String>,
}
pub async fn settings_submit(
_admin: AuthenticatedUser,
_i18n: I18n,
_startup_config: &AppConfig,
db: &Database,
form: RequestForm<OidcSettingsForm>,
) -> cot::Result<cot::http::Response<Body>> {
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 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();
let oidc_client_secret = data.oidc_client_secret.unwrap_or_default();
let oidc_admin_groups = data.oidc_admin_groups.unwrap_or_default();
let oidc_user_groups = data.oidc_user_groups.unwrap_or_default();
let agent_inbox_dir = data.agent_inbox_dir.unwrap_or_default();
let agent_storage_dir = data.agent_storage_dir.unwrap_or_default();
let agent_llm_url = data.agent_llm_url.unwrap_or_default();
let agent_llm_model = data.agent_llm_model.unwrap_or_default();
let agent_llm_auth = data.agent_llm_auth.unwrap_or_default();
let agent_confidence_threshold = data.agent_confidence_threshold.unwrap_or_default();
let agent_context_limit = data.agent_context_limit.unwrap_or_default();
let agent_concurrency = data.agent_concurrency.unwrap_or_default();
let lastfm_api_key = data.lastfm_api_key.unwrap_or_default();
let lastfm_shared_secret = data.lastfm_shared_secret.unwrap_or_default();
let fields: [(&str, &str); 20] = [
("auth_password_enabled", pw_enabled),
("auth_sso_enabled", sso_enabled),
("oidc_button_text", &oidc_button_text),
("oidc_issuer", &oidc_issuer),
("oidc_client_id", &oidc_client_id),
("oidc_client_secret", &oidc_client_secret),
("oidc_admin_groups", &oidc_admin_groups),
("oidc_user_groups", &oidc_user_groups),
("swagger_enabled", swagger),
("agent_enabled", agent_en),
("agent_inbox_dir", &agent_inbox_dir),
("agent_storage_dir", &agent_storage_dir),
("agent_llm_url", &agent_llm_url),
("agent_llm_model", &agent_llm_model),
("agent_llm_auth", &agent_llm_auth),
("agent_confidence_threshold", &agent_confidence_threshold),
("agent_context_limit", &agent_context_limit),
("agent_concurrency", &agent_concurrency),
("lastfm_api_key", &lastfm_api_key),
("lastfm_shared_secret", &lastfm_shared_secret),
];
for (key, value) in fields {
let mut entry = ConfigEntry::new(key.to_owned(), value.to_owned());
if let Err(e) = entry.save(db).await {
tracing::error!(key, error = %e, "failed to save config entry");
return Err(e.into());
}
}
Ok(auth::redirect("/admin/settings?saved=1"))
}
FormResult::ValidationError(_ctx) => {
tracing::warn!("settings form validation failed");
Ok(auth::redirect("/admin/settings"))
}
}
}
// ---------------------------------------------------------------------------
// Agent probe fragment (loaded via HTMX)
// ---------------------------------------------------------------------------
#[derive(Debug, Template)]
#[template(path = "admin/probe_fragment.html")]
struct ProbeFragmentTemplate {
t: &'static Translations,
agent_enabled: bool,
agent_llm_url: String,
agent_probe: AgentProbeResult,
}
pub async fn settings_probe_handler(
_admin: AuthenticatedUser,
i18n: I18n,
_startup_config: &AppConfig,
db: &Database,
) -> cot::Result<Html> {
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
} else {
AgentProbeResult::default()
};
let template = ProbeFragmentTemplate {
t: i18n.t,
agent_enabled: config.agent_enabled,
agent_llm_url: config.agent_llm_url,
agent_probe: probe,
};
Ok(Html::new(template.render()?))
}
// ---------------------------------------------------------------------------
// User management
// ---------------------------------------------------------------------------
#[derive(Debug, Template)]
#[template(path = "admin/users.html")]
struct UsersTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
users: Vec<User>,
}
pub async fn users_list(admin: AuthenticatedUser, i18n: I18n, db: &Database) -> cot::Result<Html> {
let users = User::list_all(db).await.unwrap_or_default();
let template = UsersTemplate {
t: i18n.t,
user_name: admin.name,
user_role: admin.role.code().to_owned(),
users,
};
Ok(Html::new(template.render()?))
}
#[derive(Debug, Template)]
#[template(path = "admin/user_form.html")]
struct UserFormTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
is_edit: bool,
form_user_id: i64,
form_username: String,
form_email: String,
form_display_name: String,
form_role: String,
}
pub async fn users_new(admin: AuthenticatedUser, i18n: I18n) -> cot::Result<Html> {
let template = UserFormTemplate {
t: i18n.t,
user_name: admin.name,
user_role: admin.role.code().to_owned(),
is_edit: false,
form_user_id: 0,
form_username: String::new(),
form_email: String::new(),
form_display_name: String::new(),
form_role: "user".into(),
};
Ok(Html::new(template.render()?))
}
#[derive(Debug, Form)]
pub struct UserForm {
username: String,
email: String,
display_name: String,
password: String,
role: String,
}
pub async fn users_create(
_admin: AuthenticatedUser,
db: &Database,
form: RequestForm<UserForm>,
) -> cot::Result<cot::http::Response<Body>> {
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}")))?;
Ok(auth::redirect("/admin/users"))
}
FormResult::ValidationError(_) => Ok(auth::redirect("/admin/users/new")),
}
}
pub async fn users_edit(
admin: AuthenticatedUser,
i18n: I18n,
db: &Database,
user_id: i64,
) -> cot::Result<Html> {
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 {
t: i18n.t,
user_name: admin.name,
user_role: admin.role.code().to_owned(),
is_edit: true,
form_user_id: target.id_val(),
form_username: target.username_str().to_owned(),
form_email: target.email_str(),
form_display_name: target.display_name_str(),
form_role: target.role_str().to_owned(),
};
Ok(Html::new(template.render()?))
}
pub async fn users_update(
_admin: AuthenticatedUser,
db: &Database,
user_id: i64,
form: RequestForm<UserForm>,
) -> cot::Result<cot::http::Response<Body>> {
let RequestForm(result) = form;
match result {
FormResult::Ok(data) => {
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
.map_err(|e| cot::Error::internal(format!("failed to update user: {e}")))?;
Ok(auth::redirect("/admin/users"))
}
FormResult::ValidationError(_) => {
Ok(auth::redirect(&format!("/admin/users/{user_id}/edit")))
}
}
}
pub async fn users_delete(
_admin: AuthenticatedUser,
db: &Database,
user_id: i64,
) -> cot::Result<cot::http::Response<Body>> {
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"))
}
// ---------------------------------------------------------------------------
// First-run setup page
// ---------------------------------------------------------------------------
#[derive(Debug, Template)]
#[template(path = "admin/setup.html")]
struct SetupTemplate {
t: &'static Translations,
message: String,
}
pub async fn setup_page(i18n: I18n, message: String) -> cot::Result<Html> {
let template = SetupTemplate { t: i18n.t, message };
Ok(Html::new(template.render()?))
}
#[derive(Debug, Form)]
pub struct SetupForm {
username: String,
password: String,
confirm_password: String,
}
pub async fn setup_submit(
i18n: I18n,
db: &Database,
session: &Session,
form: RequestForm<SetupForm>,
) -> cot::Result<cot::response::Response> {
let RequestForm(result) = form;
let data = match result {
FormResult::Ok(data) => data,
FormResult::ValidationError(_) => {
return setup_page(i18n, String::new()).await?.into_response();
}
};
if data.password != data.confirm_password {
let msg = i18n.t.setup_mismatch.to_owned();
return setup_page(i18n, msg).await?.into_response();
}
let user = User::create(db, &data.username, None, None, &data.password, "admin")
.await
.map_err(|e| cot::Error::internal(format!("failed to create admin: {e}")))?;
auth::login(session, user.id_val()).await?;
Ok(auth::redirect("/admin/"))
}
// ---------------------------------------------------------------------------
// Artist management
// ---------------------------------------------------------------------------
/// Row for artist list with computed stats.
#[derive(Debug)]
pub struct ArtistRow {
pub artist: Artist,
pub release_count: u64,
pub track_count: u64,
}
#[derive(Debug, Template)]
#[template(path = "admin/artists.html")]
struct ArtistsTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
rows: Vec<ArtistRow>,
}
pub async fn artists_list(
admin: AuthenticatedUser,
i18n: I18n,
db: &Database,
) -> cot::Result<Html> {
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 template = ArtistsTemplate {
t: i18n.t,
user_name: admin.name,
user_role: admin.role.code().to_owned(),
rows,
};
Ok(Html::new(template.render()?))
}
#[derive(Debug, Template)]
#[template(path = "admin/artist_form.html")]
struct ArtistFormTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
is_edit: bool,
form_artist_id: i64,
form_name: String,
current_image_url: Option<String>,
}
pub async fn artists_new(admin: AuthenticatedUser, i18n: I18n) -> cot::Result<Html> {
let template = ArtistFormTemplate {
t: i18n.t,
user_name: admin.name,
user_role: admin.role.code().to_owned(),
is_edit: false,
form_artist_id: 0,
form_name: String::new(),
current_image_url: None,
};
Ok(Html::new(template.render()?))
}
#[derive(Debug, Form)]
pub struct ArtistForm {
name: String,
}
pub async fn artists_create(
_admin: AuthenticatedUser,
db: &Database,
form: RequestForm<ArtistForm>,
) -> cot::Result<cot::http::Response<Body>> {
let RequestForm(result) = form;
match result {
FormResult::Ok(data) => {
Artist::create(db, &data.name, None)
.await
.map_err(|e| cot::Error::internal(format!("failed to create artist: {e}")))?;
Ok(auth::redirect("/admin/artists"))
}
FormResult::ValidationError(_) => Ok(auth::redirect("/admin/artists/new")),
}
}
pub async fn artists_edit(
admin: AuthenticatedUser,
i18n: I18n,
db: &Database,
artist_id: i64,
) -> cot::Result<Html> {
let artist = Artist::get_by_id(db, artist_id)
.await
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
.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/{}/large", mf.id_val())),
None => None,
};
let template = ArtistFormTemplate {
t: i18n.t,
user_name: admin.name,
user_role: admin.role.code().to_owned(),
is_edit: true,
form_artist_id: artist.id_val(),
form_name: artist.name_str().to_owned(),
current_image_url,
};
Ok(Html::new(template.render()?))
}
pub async fn artists_update(
_admin: AuthenticatedUser,
db: &Database,
artist_id: i64,
form: RequestForm<ArtistForm>,
) -> cot::Result<cot::http::Response<Body>> {
let RequestForm(result) = form;
match result {
FormResult::Ok(data) => {
let mut artist = Artist::get_by_id(db, artist_id)
.await
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
.ok_or_else(|| cot::Error::internal("artist not found"))?;
artist
.update_name(db, &data.name)
.await
.map_err(|e| cot::Error::internal(format!("failed to update artist: {e}")))?;
Ok(auth::redirect("/admin/artists"))
}
FormResult::ValidationError(_) => {
Ok(auth::redirect(&format!("/admin/artists/{artist_id}/edit")))
}
}
}
pub async fn artists_delete(
_admin: AuthenticatedUser,
db: &Database,
artist_id: i64,
) -> cot::Result<cot::http::Response<Body>> {
Artist::delete_by_id(db, artist_id)
.await
.map_err(|e| cot::Error::internal(format!("failed to delete artist: {e}")))?;
Ok(auth::redirect("/admin/artists"))
}
// ---------------------------------------------------------------------------
// Artist image endpoints
// ---------------------------------------------------------------------------
/// JSON response for available album covers for an artist.
#[derive(serde::Serialize)]
pub struct AvailableCover {
pub media_file_id: i64,
pub release_title: String,
pub cover_url: String,
}
pub async fn artists_available_covers(
_admin: AuthenticatedUser,
db: &Database,
artist_id: i64,
) -> cot::Result<cot::http::Response<Body>> {
let links = ReleaseArtist::find_by_artist(db, artist_id)
.await
.unwrap_or_default();
let mut covers: Vec<AvailableCover> = Vec::new();
for link in &links {
if let Ok(Some(release)) = Release::get_by_id(db, link.release_id()).await {
if let Some(cover_fid) = release.cover_file_id {
covers.push(AvailableCover {
media_file_id: cover_fid,
release_title: release.title_str().to_owned(),
cover_url: format!("/api/player/cover/{cover_fid}/medium"),
});
}
}
}
let json = serde_json::to_string(&covers).unwrap_or_else(|_| "[]".into());
let resp = cot::http::Response::builder()
.status(cot::http::StatusCode::OK)
.header(cot::http::header::CONTENT_TYPE, "application/json")
.body(Body::fixed(json))
.expect("valid response");
Ok(resp)
}
#[derive(serde::Deserialize)]
pub struct SetImageBody {
pub media_file_id: Option<i64>,
}
pub async fn artists_set_image(
_admin: AuthenticatedUser,
db: &Database,
artist_id: i64,
parsed: SetImageBody,
) -> cot::Result<cot::http::Response<Body>> {
let mut artist = Artist::get_by_id(db, artist_id)
.await
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
.ok_or_else(|| cot::Error::internal("artist not found"))?;
// Validate media file exists when setting (not removing)
if let Some(fid) = parsed.media_file_id {
MediaFile::get_by_id(db, fid)
.await
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
.ok_or_else(|| cot::Error::internal("media file not found"))?;
}
artist
.set_image_file_id(db, parsed.media_file_id)
.await
.map_err(|e| cot::Error::internal(format!("failed to set image: {e}")))?;
let resp = cot::http::Response::builder()
.status(cot::http::StatusCode::OK)
.header(cot::http::header::CONTENT_TYPE, "application/json")
.body(Body::fixed(r#"{"ok":true}"#))
.expect("valid response");
Ok(resp)
}
#[derive(serde::Deserialize)]
pub struct UploadImageBody {
pub data: String,
pub filename: String,
pub mime_type: String,
}
pub async fn artists_upload_image(
_admin: AuthenticatedUser,
db: &Database,
pool: &sqlx::PgPool,
config: &AppConfig,
artist_id: i64,
parsed: UploadImageBody,
) -> cot::Result<cot::http::Response<Body>> {
let mut artist = Artist::get_by_id(db, artist_id)
.await
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
.ok_or_else(|| cot::Error::internal("artist not found"))?;
let storage_dir = &config.agent_storage_dir;
if storage_dir.is_empty() {
return Err(cot::Error::internal("agent_storage_dir is not configured"));
}
// Decode base64 image data
use base64::Engine;
let image_data = base64::engine::general_purpose::STANDARD
.decode(&parsed.data)
.map_err(|e| cot::Error::internal(format!("invalid base64: {e}")))?;
// Build a CoverImage and reuse save_cover_to_storage
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,
)),
};
let cover_file_id = crate::agent::cover_art::save_cover_to_storage(
db,
pool,
storage_dir,
artist.name_str(),
"__artist_image__",
&cover,
)
.await
.map_err(|e| cot::Error::internal(format!("failed to save image: {e}")))?;
artist
.set_image_file_id(db, Some(cover_file_id))
.await
.map_err(|e| cot::Error::internal(format!("failed to set image: {e}")))?;
let resp = cot::http::Response::builder()
.status(cot::http::StatusCode::OK)
.header(cot::http::header::CONTENT_TYPE, "application/json")
.body(Body::fixed(r#"{"ok":true}"#))
.expect("valid response");
Ok(resp)
}
// ---------------------------------------------------------------------------
// Release management
// ---------------------------------------------------------------------------
/// Row for release list with resolved artist names.
#[derive(Debug)]
pub struct ReleaseRow {
pub release: Release,
pub artist_names: String,
}
#[derive(Debug, Template)]
#[template(path = "admin/releases.html")]
struct ReleasesTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
rows: Vec<ReleaseRow>,
artists: Vec<Artist>,
filter_artist_id: Option<i64>,
}
/// Build a map of artist_id → artist_name from a list of artists.
fn artist_name_map(artists: &[Artist]) -> HashMap<i64, String> {
artists
.iter()
.map(|a| (a.id_val(), a.name_str().to_owned()))
.collect()
}
/// Resolve artist names for a release, joined by ", ".
async fn resolve_artist_names(
db: &Database,
release_id: i64,
names: &HashMap<i64, String>,
) -> String {
let links = ReleaseArtist::find_by_release(db, release_id)
.await
.unwrap_or_default();
let mut sorted = links;
sorted.sort_by_key(|l| l.position);
sorted
.iter()
.filter_map(|l| names.get(&l.artist_id()))
.cloned()
.collect::<Vec<_>>()
.join(", ")
}
pub async fn releases_list(
admin: AuthenticatedUser,
i18n: I18n,
db: &Database,
filter_artist_id: Option<i64>,
) -> cot::Result<Html> {
let all_artists = Artist::list_all(db).await.unwrap_or_default();
let names = artist_name_map(&all_artists);
let releases = Release::list_all(db).await.unwrap_or_default();
// If filtering by artist, find the set of release_ids for that artist
let filtered_release_ids: Option<Vec<i64>> = match filter_artist_id {
Some(aid) => {
let links = ReleaseArtist::find_by_artist(db, aid)
.await
.unwrap_or_default();
Some(links.iter().map(|l| l.release_id()).collect())
}
None => None,
};
let mut rows = Vec::new();
for release in releases {
if let Some(ref ids) = filtered_release_ids {
if !ids.contains(&release.id_val()) {
continue;
}
}
let artist_names = resolve_artist_names(db, release.id_val(), &names).await;
rows.push(ReleaseRow {
release,
artist_names,
});
}
let template = ReleasesTemplate {
t: i18n.t,
user_name: admin.name,
user_role: admin.role.code().to_owned(),
rows,
artists: all_artists,
filter_artist_id,
};
Ok(Html::new(template.render()?))
}
#[derive(Debug, Template)]
#[template(path = "admin/release_form.html")]
struct ReleaseFormTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
is_edit: bool,
form_release_id: i64,
form_title: String,
form_release_type: String,
form_year: String,
form_artist_ids: Vec<i64>,
artists: Vec<Artist>,
release_types: &'static [(&'static str, &'static str, &'static str)],
lang_code: &'static str,
}
pub async fn releases_new(
admin: AuthenticatedUser,
i18n: I18n,
db: &Database,
) -> cot::Result<Html> {
let artists = Artist::list_all(db).await.unwrap_or_default();
let template = ReleaseFormTemplate {
t: i18n.t,
user_name: admin.name,
user_role: admin.role.code().to_owned(),
is_edit: false,
form_release_id: 0,
form_title: String::new(),
form_release_type: "album".into(),
form_year: String::new(),
form_artist_ids: Vec::new(),
artists,
release_types: RELEASE_TYPES,
lang_code: i18n.t.lang.code(),
};
Ok(Html::new(template.render()?))
}
#[derive(Debug, Form)]
pub struct ReleaseForm {
title: String,
release_type: String,
year: String,
artist_id: String, // comma-separated IDs or single ID
}
fn parse_artist_ids(raw: &str) -> Vec<i64> {
raw.split(',')
.filter_map(|s| s.trim().parse::<i64>().ok())
.collect()
}
pub async fn releases_create(
_admin: AuthenticatedUser,
db: &Database,
form: RequestForm<ReleaseForm>,
) -> cot::Result<cot::http::Response<Body>> {
let RequestForm(result) = form;
match result {
FormResult::Ok(data) => {
let year = data.year.trim().parse::<i32>().ok();
let release = Release::create(db, &data.title, &data.release_type, year, None)
.await
.map_err(|e| cot::Error::internal(format!("failed to create release: {e}")))?;
let artist_ids = parse_artist_ids(&data.artist_id);
if !artist_ids.is_empty() {
ReleaseArtist::set_artists(db, release.id_val(), &artist_ids)
.await
.map_err(|e| cot::Error::internal(format!("failed to link artists: {e}")))?;
}
Ok(auth::redirect("/admin/releases"))
}
FormResult::ValidationError(_) => Ok(auth::redirect("/admin/releases/new")),
}
}
pub async fn releases_edit(
admin: AuthenticatedUser,
i18n: I18n,
db: &Database,
release_id: i64,
) -> cot::Result<Html> {
let release = Release::get_by_id(db, release_id)
.await
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
.ok_or_else(|| cot::Error::internal("release not found"))?;
let links = ReleaseArtist::find_by_release(db, release_id)
.await
.unwrap_or_default();
let mut current_artist_ids: Vec<i64> = links.iter().map(|l| l.artist_id()).collect();
current_artist_ids.sort();
let artists = Artist::list_all(db).await.unwrap_or_default();
let template = ReleaseFormTemplate {
t: i18n.t,
user_name: admin.name,
user_role: admin.role.code().to_owned(),
is_edit: true,
form_release_id: release.id_val(),
form_title: release.title_str().to_owned(),
form_release_type: release.release_type_str().to_owned(),
form_year: release.year_display(),
form_artist_ids: current_artist_ids,
artists,
release_types: RELEASE_TYPES,
lang_code: i18n.t.lang.code(),
};
Ok(Html::new(template.render()?))
}
pub async fn releases_update(
_admin: AuthenticatedUser,
db: &Database,
release_id: i64,
form: RequestForm<ReleaseForm>,
) -> cot::Result<cot::http::Response<Body>> {
let RequestForm(result) = form;
match result {
FormResult::Ok(data) => {
let mut release = Release::get_by_id(db, release_id)
.await
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
.ok_or_else(|| cot::Error::internal("release not found"))?;
let year = data.year.trim().parse::<i32>().ok();
release
.update_fields(db, &data.title, &data.release_type, year)
.await
.map_err(|e| cot::Error::internal(format!("failed to update release: {e}")))?;
let artist_ids = parse_artist_ids(&data.artist_id);
ReleaseArtist::set_artists(db, release_id, &artist_ids)
.await
.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"
))),
}
}
pub async fn releases_delete(
_admin: AuthenticatedUser,
db: &Database,
release_id: i64,
) -> cot::Result<cot::http::Response<Body>> {
Release::delete_by_id(db, release_id)
.await
.map_err(|e| cot::Error::internal(format!("failed to delete release: {e}")))?;
Ok(auth::redirect("/admin/releases"))
}
// ===========================================================================
// Media Files
// ===========================================================================
#[derive(Debug)]
pub struct MediaFileRow {
pub media_file: MediaFile,
pub track_title: String,
}
#[derive(Debug, Template)]
#[template(path = "admin/media_files.html")]
struct MediaFilesTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
rows: Vec<MediaFileRow>,
}
pub async fn media_files_list(
admin: AuthenticatedUser,
i18n: I18n,
db: &Database,
) -> cot::Result<Html> {
let files = MediaFile::list_all(db).await.unwrap_or_default();
let tracks = Track::list_all(db).await.unwrap_or_default();
// Build a map of audio_file_id → track title
let track_map: HashMap<i64, String> = tracks
.iter()
.map(|t| (t.audio_file_id, t.title.to_string()))
.collect();
let rows: Vec<MediaFileRow> = files
.into_iter()
.map(|mf| {
let track_title = track_map.get(&mf.id_val()).cloned().unwrap_or_default();
MediaFileRow {
media_file: mf,
track_title,
}
})
.collect();
let template = MediaFilesTemplate {
t: i18n.t,
user_name: admin.name,
user_role: admin.role.code().to_owned(),
rows,
};
Ok(Html::new(template.render()?))
}
pub async fn media_files_delete(
_admin: AuthenticatedUser,
db: &Database,
file_id: i64,
) -> cot::Result<cot::http::Response<Body>> {
MediaFile::delete_by_id(db, file_id)
.await
.map_err(|e| cot::Error::internal(format!("failed to delete media file: {e}")))?;
Ok(auth::redirect("/admin/media-files"))
}
// ===========================================================================
// Jobs
// ===========================================================================
#[derive(Debug, Template)]
#[template(path = "admin/jobs.html")]
struct JobsTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
jobs: Vec<ScheduledJob>,
}
pub async fn jobs_list(
admin: AuthenticatedUser,
i18n: I18n,
db: &Database,
registry: &JobRegistry,
) -> cot::Result<Html> {
// Ensure all registered jobs exist in DB and remove orphans
sync_registered_jobs(db, registry).await;
let jobs = ScheduledJob::list_all(db).await.unwrap_or_default();
let template = JobsTemplate {
t: i18n.t,
user_name: admin.name,
user_role: admin.role.code().to_owned(),
jobs,
};
Ok(Html::new(template.render()?))
}
/// Ensure the DB has a ScheduledJob row for every registered job and remove
/// 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
{
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());
let _ = ScheduledJob::delete_by_name(db, sched_job.name_str()).await;
}
}
}
}
#[derive(Debug, Template)]
#[template(path = "admin/job_detail.html")]
struct JobDetailTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
job: ScheduledJob,
runs: Vec<JobRun>,
}
#[derive(Debug, Form)]
pub struct MetadataBackfillForm {
audio_bitrate: Option<String>,
audio_sample_rate: Option<String>,
audio_bit_depth: Option<String>,
duration_seconds: Option<String>,
mode: Option<String>,
}
pub async fn job_detail(
admin: AuthenticatedUser,
i18n: I18n,
db: &Database,
pool: &sqlx::PgPool,
job_name: &str,
) -> cot::Result<Html> {
let job = ScheduledJob::get_by_name(db, job_name)
.await
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
.ok_or_else(|| cot::Error::internal("job not found"))?;
let runs = JobRun::list_by_job(pool, job_name, 50)
.await
.unwrap_or_default();
let template = JobDetailTemplate {
t: i18n.t,
user_name: admin.name,
user_role: admin.role.code().to_owned(),
job,
runs,
};
Ok(Html::new(template.render()?))
}
pub async fn job_run_now(
_admin: AuthenticatedUser,
handle_cell: &Arc<tokio::sync::OnceCell<Arc<scheduler::SchedulerHandle>>>,
job_name: &str,
) -> cot::Result<cot::http::Response<Body>> {
if let Some(handle) = handle_cell.get() {
match handle.trigger_job_now(job_name).await {
Ok(_run_id) => {}
Err(e) => {
tracing::error!(?e, job_name, "manual job trigger failed");
}
}
} else {
tracing::error!(job_name, "scheduler not ready, cannot trigger job");
}
Ok(auth::redirect(&format!("/admin/jobs/{job_name}")))
}
pub async fn metadata_backfill_run(
_admin: AuthenticatedUser,
db: &Database,
pool: &sqlx::PgPool,
form: RequestForm<MetadataBackfillForm>,
) -> cot::Result<cot::http::Response<Body>> {
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<tokio::sync::OnceCell<Arc<scheduler::SchedulerHandle>>>,
job_name: &str,
) -> cot::Result<cot::http::Response<Body>> {
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}")))?
.ok_or_else(|| cot::Error::internal("job not found"))?;
let new_enabled = !job.enabled;
if let Some(handle) = handle_cell.get() {
if let Err(e) = handle.toggle_job(job_name, new_enabled).await {
tracing::error!(?e, job_name, new_enabled, "toggle_job failed");
}
} else {
tracing::error!(job_name, "scheduler not ready, cannot toggle job");
}
Ok(auth::redirect("/admin/jobs"))
}
#[derive(Debug, Form)]
pub struct CronForm {
cron_expression: String,
}
pub async fn job_update_cron(
_admin: AuthenticatedUser,
_db: &Database,
handle_cell: &Arc<tokio::sync::OnceCell<Arc<scheduler::SchedulerHandle>>>,
job_name: &str,
form: RequestForm<CronForm>,
) -> cot::Result<cot::http::Response<Body>> {
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() {
if let Err(e) = handle.reschedule_job(job_name, &data.cron_expression).await {
tracing::error!(?e, job_name, "reschedule_job failed");
}
} else {
tracing::error!(job_name, "scheduler not ready, cannot update cron");
}
}
Ok(auth::redirect(&format!("/admin/jobs/{job_name}")))
}
#[derive(Debug, Template)]
#[template(path = "admin/job_run_detail.html")]
struct JobRunDetailTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
run: JobRun,
job_name: String,
}
pub async fn job_run_detail(
admin: AuthenticatedUser,
i18n: I18n,
db: &Database,
_job_name: &str,
run_id: i64,
) -> cot::Result<Html> {
let run = JobRun::get_by_id(db, run_id)
.await
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
.ok_or_else(|| cot::Error::internal("run not found"))?;
let template = JobRunDetailTemplate {
t: i18n.t,
user_name: admin.name,
user_role: admin.role.code().to_owned(),
job_name: run.job_name.to_string(),
run,
};
Ok(Html::new(template.render()?))
}
// ===========================================================================
// Reviews
// ===========================================================================
#[derive(Debug, Template)]
#[template(path = "admin/reviews.html")]
struct ReviewsTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
rows: Vec<ReviewListRow>,
stats_map: HashMap<i64, scheduler::ProcessingStatsRow>,
status_filter: String,
}
#[derive(Debug)]
struct ReviewListRow {
review: PendingReview,
display_input_path: String,
media_tags: Vec<ReviewMediaTag>,
}
#[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<String>,
audio_bitrate: Option<i32>,
audio_sample_rate: Option<i32>,
audio_bit_depth: Option<i32>,
}
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::<Vec<_>>();
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::<String>();
return format!("...{suffix}");
}
format!(".../{filename}")
}
fn context_sha256(review: &PendingReview) -> Option<String> {
let value = serde_json::from_str::<serde_json::Value>(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<String> {
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<String>, kind: &'static str) -> ReviewMediaTag {
ReviewMediaTag {
label: label.into(),
kind,
}
}
fn media_tags(row: &ReviewMediaTagRow) -> Vec<ReviewMediaTag> {
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<String, Vec<ReviewMediaTag>> {
let mut hashes = reviews
.iter()
.filter_map(context_sha256)
.collect::<Vec<_>>();
hashes.sort();
hashes.dedup();
if hashes.is_empty() {
return HashMap::new();
}
let quoted = hashes
.iter()
.map(|hash| format!("'{hash}'"))
.collect::<Vec<_>>()
.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,
db: &Database,
pool: &sqlx::PgPool,
status: Option<&str>,
) -> cot::Result<Html> {
let reviews = match status {
Some(s) if !s.is_empty() => PendingReview::list_by_status(db, s)
.await
.unwrap_or_default(),
_ => PendingReview::list_all(db).await.unwrap_or_default(),
};
let review_ids: Vec<i64> = reviews.iter().map(|r| r.id_val()).collect();
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(),
rows,
stats_map,
status_filter: status.unwrap_or("").to_owned(),
};
Ok(Html::new(template.render()?))
}
#[derive(Debug, Template)]
#[template(path = "admin/review_detail.html")]
struct ReviewDetailTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
review: PendingReview,
edit: ReviewEditFields,
release_types: &'static [(&'static str, &'static str, &'static str)],
lang_code: &'static str,
context_pretty: String,
result_pretty: String,
error_message: String,
stats: Option<scheduler::ProcessingStats>,
}
#[derive(Debug, Default)]
struct ReviewEditFields {
title: String,
artist: String,
album: String,
year: String,
track_number: String,
genre: String,
featured_artists: String,
release_type: String,
notes: String,
}
#[derive(Debug, Form)]
pub struct ReviewApproveForm {
title: String,
artist: String,
album: String,
year: String,
track_number: String,
genre: String,
featured_artists: String,
release_type: String,
notes: String,
}
fn optional_trimmed(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
}
fn parse_optional_i32(value: &str) -> Option<i32> {
value.trim().parse::<i32>().ok()
}
fn parse_featured_artists(value: &str) -> Vec<String> {
value
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(str::to_owned)
.collect()
}
fn edit_fields_from_normalized(
normalized: &crate::agent::dto::NormalizedFields,
) -> ReviewEditFields {
ReviewEditFields {
title: normalized.title.clone().unwrap_or_default(),
artist: normalized.artist.clone().unwrap_or_default(),
album: normalized.album.clone().unwrap_or_default(),
year: normalized.year.map(|v| v.to_string()).unwrap_or_default(),
track_number: normalized
.track_number
.map(|v| v.to_string())
.unwrap_or_default(),
genre: normalized.genre.clone().unwrap_or_default(),
featured_artists: normalized.featured_artists.join(", "),
release_type: normalized
.release_type
.clone()
.unwrap_or_else(|| "album".to_owned()),
notes: normalized.notes.clone().unwrap_or_default(),
}
}
fn normalized_from_result_json(result_json: &str) -> crate::agent::dto::NormalizedFields {
serde_json::from_str(result_json).unwrap_or_default()
}
fn normalized_from_review_form(form: &ReviewApproveForm) -> crate::agent::dto::NormalizedFields {
crate::agent::dto::NormalizedFields {
title: optional_trimmed(&form.title),
artist: optional_trimmed(&form.artist),
album: optional_trimmed(&form.album),
year: parse_optional_i32(&form.year),
track_number: parse_optional_i32(&form.track_number),
genre: optional_trimmed(&form.genre),
featured_artists: parse_featured_artists(&form.featured_artists),
release_type: optional_trimmed(&form.release_type).or_else(|| Some("album".to_owned())),
confidence: Some(1.0),
notes: optional_trimmed(&form.notes),
}
}
pub async fn review_detail(
admin: AuthenticatedUser,
i18n: I18n,
db: &Database,
review_id: i64,
) -> cot::Result<Html> {
let review = PendingReview::get_by_id(db, review_id)
.await
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
.ok_or_else(|| cot::Error::internal("review not found"))?;
let context_pretty = review
.context_json
.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.map(|v| serde_json::to_string_pretty(&v).unwrap_or_default())
.unwrap_or_default();
let result_pretty = review
.result_json
.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.map(|v| serde_json::to_string_pretty(&v).unwrap_or_default())
.unwrap_or_default();
let error_message = review.error_message_str().to_owned();
let stats = scheduler::ProcessingStats::get_by_review_id(db, review_id)
.await
.unwrap_or(None);
let normalized = normalized_from_result_json(review.result_json_str());
let edit = edit_fields_from_normalized(&normalized);
let template = ReviewDetailTemplate {
t: i18n.t,
user_name: admin.name,
user_role: admin.role.code().to_owned(),
review,
edit,
release_types: RELEASE_TYPES,
lang_code: i18n.t.lang.code(),
context_pretty,
result_pretty,
error_message,
stats,
};
Ok(Html::new(template.render()?))
}
pub async fn review_approve(
_admin: AuthenticatedUser,
_config: &Arc<AppConfig>,
db: &Database,
pool: &sqlx::PgPool,
review_id: i64,
form: RequestForm<ReviewApproveForm>,
) -> cot::Result<cot::http::Response<Body>> {
let mut review = PendingReview::get_by_id(db, review_id)
.await
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
.ok_or_else(|| cot::Error::internal("review not found"))?;
let RequestForm(form_result) = form;
let normalized = match form_result {
FormResult::Ok(data) => normalized_from_review_form(&data),
FormResult::ValidationError(_) => {
return Ok(auth::redirect(&format!("/admin/reviews/{review_id}")));
}
};
let result_str = serde_json::to_string(&normalized)
.map_err(|e| cot::Error::internal(format!("failed to serialize review fields: {e}")))?;
review
.set_result_json(db, result_str)
.await
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?;
let context_str = review.context_json_str().to_owned();
let input_path = review.input_path_str().to_owned();
let context: serde_json::Value = serde_json::from_str(&context_str).unwrap_or_default();
// Load live config from DB so admin-set values are used
let (live_config, _) = AppConfig::load_with_db(db).await;
// Look up the model name from processing stats (if LLM processed this review)
let stats = scheduler::ProcessingStats::get_by_review_id(db, review_id)
.await
.unwrap_or(None);
let model_name_str = stats.as_ref().map(|s| s.model_name.to_string());
match crate::jobs::inbox_process::finalize_approved(
db,
pool,
&live_config,
&input_path,
&normalized,
&context,
&live_config.agent_storage_dir,
model_name_str.as_deref(),
)
.await
{
Ok(()) => {
let _ = review.set_approved(db).await;
}
Err(e) => {
tracing::error!(?e, "review approval failed");
let _ = review.set_rejected(db).await;
}
}
Ok(auth::redirect(&format!("/admin/reviews/{review_id}")))
}
#[derive(Debug, Form)]
pub struct ReviewsBulkForm {
selected_ids: Option<String>,
action: Option<String>,
status_filter: Option<String>,
}
fn parse_review_ids(raw: &str) -> Vec<i64> {
let mut ids = raw
.split(',')
.filter_map(|part| part.trim().parse::<i64>().ok())
.filter(|id| *id > 0)
.collect::<Vec<_>>();
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<ReviewsBulkForm>,
) -> cot::Result<cot::http::Response<Body>> {
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,
review_id: i64,
) -> cot::Result<cot::http::Response<Body>> {
let mut review = PendingReview::get_by_id(db, review_id)
.await
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
.ok_or_else(|| cot::Error::internal("review not found"))?;
let _ = review.set_rejected(db).await;
Ok(auth::redirect(&format!("/admin/reviews/{review_id}")))
}
pub async fn reviews_clear(
_admin: AuthenticatedUser,
db: &Database,
status: Option<&str>,
) -> cot::Result<cot::http::Response<Body>> {
match status {
Some(s) if !s.is_empty() => {
PendingReview::delete_by_status(db, s)
.await
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?;
}
_ => {
PendingReview::delete_all(db)
.await
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?;
}
}
let redirect_url = match status {
Some(s) if !s.is_empty() => format!("/admin/reviews?status={s}"),
_ => "/admin/reviews".to_owned(),
};
Ok(auth::redirect(&redirect_url))
}
pub async fn review_requeue(
_admin: AuthenticatedUser,
db: &Database,
review_id: i64,
) -> cot::Result<cot::http::Response<Body>> {
let mut review = PendingReview::get_by_id(db, review_id)
.await
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
.ok_or_else(|| cot::Error::internal("review not found"))?;
let _ = review.set_queued(db).await;
Ok(auth::redirect(&format!("/admin/reviews/{review_id}")))
}