Reworked Reviews page
Build and Publish / Build and Publish Docker Image (push) Successful in 2m47s

This commit is contained in:
2026-05-25 13:50:24 +03:00
parent e9e16dd807
commit dcc665563a
31 changed files with 2674 additions and 1137 deletions
+52 -3
View File
@@ -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<JobRegistry>,
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
) -> 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<MetadataBackfillForm>| {
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<ReviewsBulkForm>| 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",
+547 -98
View File
@@ -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<Co
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(),
}
($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<Html> {
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<cot::http::Response<Body>> {
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<Html> {
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<ArtistRow>,
}
pub async fn artists_list(admin: AuthenticatedUser, i18n: I18n, db: &Database) -> cot::Result<Html> {
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 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<Vec<i64>> = 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<Html> {
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,
@@ -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<MediaFileRow> = 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<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,
@@ -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<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}")))?
@@ -1311,6 +1530,10 @@ pub async fn job_update_cron(
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() {
@@ -1366,11 +1589,164 @@ struct ReviewsTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
reviews: Vec<PendingReview>,
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,
@@ -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<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,