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
+1 -4
View File
@@ -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());
}
+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,
+1 -4
View File
@@ -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<CoverImage> {
pub async fn find_best_cover(folder: &Path, audio_files: &[PathBuf]) -> Option<CoverImage> {
// Strategy 1: folder images
let folder_images = find_folder_images(folder);
for img_path in &folder_images {
+3
View File
@@ -10,6 +10,9 @@ pub struct RawMetadata {
pub year: Option<u32>,
pub genre: Option<String>,
pub duration_secs: Option<f64>,
pub audio_bitrate: Option<i32>,
pub audio_sample_rate: Option<i32>,
pub audio_bit_depth: Option<i32>,
}
/// Hints parsed from the file path (directory structure + filename).
+36 -8
View File
@@ -18,7 +18,10 @@ use super::dto::RawMetadata;
/// Must be called from a blocking context (`spawn_blocking`).
pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
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<RawMetadata> {
.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<RawMetadata> {
}
}
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<RawMetadata> {
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<RawMetadata> {
}
}
// 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)
}
+5 -6
View File
@@ -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::<String>()),
error: format!(
"HTTP {status}: {}",
body_text.chars().take(300).collect::<String>()
),
..Default::default()
};
}
+149 -50
View File
@@ -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<BatchNormalizeResult> {
// 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());
+2 -5
View File
@@ -113,11 +113,8 @@ fn parse_album_with_year(dir: &str) -> (String, Option<i32>) {
let inside = &dir[start + 1..start + end];
if let Ok(year) = inside.trim().parse::<i32>() {
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));
}
+6 -7
View File
@@ -34,10 +34,7 @@ struct MeResponse {
role: String,
}
async fn me_handler(
session: Session,
db: Database,
) -> cot::Result<cot::response::Response> {
async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response::Response> {
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",
)])
}
}
+5 -7
View File
@@ -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)
}
+42 -31
View File
@@ -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"),
<LimitedString<255> as DatabaseField>::TYPE,
)
.primary_key()
.set_null(<LimitedString<255> as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("value"),
<String as DatabaseField>::TYPE,
)
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("furu__config"))
.fields(&[
Field::new(
Identifier::new("key"),
<LimitedString<255> as DatabaseField>::TYPE,
)
.primary_key()
.set_null(<LimitedString<255> as DatabaseField>::NULLABLE),
Field::new(Identifier::new("value"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::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]
+12 -6
View File
@@ -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);
}
+15
View File
@@ -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" , "Ожидают";
+3 -1
View File
@@ -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)");
}
+5 -10
View File
@@ -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<PathBuf> = audio_paths
.iter()
.map(|(p,)| PathBuf::from(p))
.collect();
let audio_files: Vec<PathBuf> =
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}"
+45 -26
View File
@@ -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, &registry, "inbox_process",
&config,
&db,
&pool,
&registry,
"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<PathBuf>)> {
groups
}
pub async fn collect_audio_files(
dir: &Path,
audio: &mut Vec<PathBuf>,
) -> anyhow::Result<()> {
pub async fn collect_audio_files(dir: &Path, audio: &mut Vec<PathBuf>) -> 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();
+136 -85
View File
@@ -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<String> = 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<BatchFileInput> = prepared.iter().map(|p| {
BatchFileInput {
let batch_files: Vec<BatchFileInput> = 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<String, NormalizedFields> = batch_result.results
.into_iter()
.collect();
let result_map: HashMap<String, NormalizedFields> = 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::path::PathBuf> = 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}")
}
}
+234
View File
@@ -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<i32>,
audio_sample_rate: Option<i32>,
audio_bit_depth: Option<i32>,
track_id: Option<i64>,
duration_seconds: Option<f64>,
}
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<T>(current: Option<T>, overwrite: bool) -> bool {
overwrite || current.is_none()
}
fn should_update_duration(current: Option<f64>, overwrite: bool) -> bool {
overwrite || current.unwrap_or(0.0) <= 0.0
}
fn resolve_media_path(file_path: &str, storage_dir: &str) -> Option<PathBuf> {
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
}
+1
View File
@@ -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;
+14 -17
View File
@@ -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<JobRegistry> {
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<JobRegistry> {
// Handlers
// ---------------------------------------------------------------------------
async fn index(
session: Session,
db: Database,
i18n: I18n,
) -> cot::Result<cot::response::Response> {
async fn index(session: Session, db: Database, i18n: I18n) -> cot::Result<cot::response::Response> {
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<LoginForm>| {
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,
+455 -341
View File
File diff suppressed because it is too large Load Diff
+5 -6
View File
@@ -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<OidcCallbackQuery>,
cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery<
OidcCallbackQuery,
>,
) -> cot::Result<cot::response::Response> {
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}");
+156 -143
View File
@@ -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<String>,
total_track_count: i64,
total_play_count: i64,
releases: Vec<ReleaseCard>,
}
@@ -178,7 +180,6 @@ struct LikedIds {
track_ids: Vec<i64>,
}
// ---------------------------------------------------------------------------
// 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<i64> = 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<i64> =
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<Vec<u8>> {
async fn read_file_range(path: &std::path::Path, start: u64, length: u64) -> cot::Result<Vec<u8>> {
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<i64> =
serde_json::from_str(&s.queue_json).unwrap_or_default();
let queue: Vec<i64> = 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<i64, TrackItem> = 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<TrackItem> = ids
.iter()
.filter_map(|id| track_map.remove(id))
.collect();
let result: Vec<TrackItem> = 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<tokio::sync::OnceCell<sqlx::PgPool>> =
Arc::new(tokio::sync::OnceCell::new());
let pool: Arc<tokio::sync::OnceCell<sqlx::PgPool>> = 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<PathId>, json: Json<UpdatePlaylistRequest>| {
move |session: Session,
db: Database,
path: Path<PathId>,
json: Json<UpdatePlaylistRequest>| {
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<PathId>, json: Json<AddTracksRequest>| {
move |session: Session,
db: Database,
path: Path<PathId>,
json: Json<AddTracksRequest>| {
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<PathId>, json: Json<RemoveTrackRequest>| {
move |session: Session,
db: Database,
path: Path<PathId>,
json: Json<RemoveTrackRequest>| {
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<PathTrackId>,
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<PathMediaFileId>| {
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<PathMediaFileId>| {
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",
),
+303 -133
View File
@@ -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<Self> {
pub async fn upsert(
db: &Database,
name: &str,
description: &str,
cron_expression: &str,
) -> cot::db::Result<Self> {
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<Self> {
pub async fn create_running(
db: &Database,
job_name: &str,
trigger: &str,
) -> cot::db::Result<Self> {
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<Vec<Self>> {
pub async fn list_by_job(
pool: &sqlx::PgPool,
job_name: &str,
limit: i64,
) -> anyhow::Result<Vec<Self>> {
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::<Vec<_>>()
.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::<Vec<_>>()
.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<HashMap<i64, ProcessingStatsRow>> {
pub async fn list_by_review_ids(
pool: &sqlx::PgPool,
ids: &[i64],
) -> anyhow::Result<HashMap<i64, ProcessingStatsRow>> {
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::<Vec<_>>().join(",");
let id_list: String = ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.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"), <String as DatabaseField>::TYPE)
.primary_key()
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(Identifier::new("description"), <String as DatabaseField>::TYPE),
Field::new(Identifier::new("cron_expression"), <LimitedString<100> as DatabaseField>::TYPE),
Field::new(Identifier::new("enabled"), <bool as DatabaseField>::TYPE),
Field::new(Identifier::new("last_run_at"), <LimitedString<32> as DatabaseField>::TYPE)
.set_null(true),
Field::new(Identifier::new("next_run_at"), <LimitedString<32> as DatabaseField>::TYPE)
.set_null(true),
Field::new(Identifier::new("created_at"), <LimitedString<32> as DatabaseField>::TYPE),
Field::new(Identifier::new("updated_at"), <LimitedString<32> 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"), <String as DatabaseField>::TYPE)
.primary_key()
.set_null(<String as DatabaseField>::NULLABLE),
Field::new(
Identifier::new("description"),
<String as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("cron_expression"),
<LimitedString<100> as DatabaseField>::TYPE,
),
Field::new(Identifier::new("enabled"), <bool as DatabaseField>::TYPE),
Field::new(
Identifier::new("last_run_at"),
<LimitedString<32> as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("next_run_at"),
<LimitedString<32> as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("created_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("updated_at"),
<LimitedString<32> 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"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(Identifier::new("job_name"), <LimitedString<100> as DatabaseField>::TYPE),
Field::new(Identifier::new("status"), <LimitedString<32> as DatabaseField>::TYPE),
Field::new(Identifier::new("started_at"), <LimitedString<32> as DatabaseField>::TYPE),
Field::new(Identifier::new("finished_at"), <LimitedString<32> as DatabaseField>::TYPE)
.set_null(true),
Field::new(Identifier::new("duration_ms"), <i64 as DatabaseField>::TYPE)
.set_null(true),
Field::new(Identifier::new("log_output"), <String as DatabaseField>::TYPE)
.set_null(true),
Field::new(Identifier::new("error_message"), <String as DatabaseField>::TYPE)
.set_null(true),
Field::new(Identifier::new("trigger"), <LimitedString<32> 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"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(
Identifier::new("job_name"),
<LimitedString<100> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("status"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("started_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("finished_at"),
<LimitedString<32> as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(Identifier::new("duration_ms"), <i64 as DatabaseField>::TYPE)
.set_null(true),
Field::new(
Identifier::new("log_output"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("error_message"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("trigger"),
<LimitedString<32> 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"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(Identifier::new("job_run_id"), <i64 as DatabaseField>::TYPE),
Field::new(Identifier::new("review_type"), <LimitedString<64> as DatabaseField>::TYPE),
Field::new(Identifier::new("input_path"), <String as DatabaseField>::TYPE)
.set_null(true),
Field::new(Identifier::new("context_json"), <String as DatabaseField>::TYPE)
.set_null(true),
Field::new(Identifier::new("result_json"), <String as DatabaseField>::TYPE)
.set_null(true),
Field::new(Identifier::new("status"), <LimitedString<32> as DatabaseField>::TYPE),
Field::new(Identifier::new("created_at"), <LimitedString<32> as DatabaseField>::TYPE),
Field::new(Identifier::new("updated_at"), <LimitedString<32> 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"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(Identifier::new("job_run_id"), <i64 as DatabaseField>::TYPE),
Field::new(
Identifier::new("review_type"),
<LimitedString<64> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("input_path"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("context_json"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("result_json"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("status"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("created_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("updated_at"),
<LimitedString<32> 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"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(Identifier::new("pending_review_id"), <i64 as DatabaseField>::TYPE),
Field::new(Identifier::new("model_name"), <LimitedString<128> as DatabaseField>::TYPE),
Field::new(Identifier::new("llm_duration_ms"), <i64 as DatabaseField>::TYPE),
Field::new(Identifier::new("prompt_tokens"), <i64 as DatabaseField>::TYPE),
Field::new(Identifier::new("completion_tokens"), <i64 as DatabaseField>::TYPE),
Field::new(Identifier::new("created_at"), <LimitedString<32> 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"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(
Identifier::new("pending_review_id"),
<i64 as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("model_name"),
<LimitedString<128> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("llm_duration_ms"),
<i64 as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("prompt_tokens"),
<i64 as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("completion_tokens"),
<i64 as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("created_at"),
<LimitedString<32> 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;
}
}
+92 -102
View File
@@ -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<Option<Self>> {
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"),
<i64 as DatabaseField>::TYPE,
)
)];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("furumusic__user"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(
Identifier::new("username"),
<LimitedString<255> as DatabaseField>::TYPE,
)
.unique(),
Field::new(
Identifier::new("password"),
<PasswordHash as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("email"),
<LimitedString<255> as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("display_name"),
<LimitedString<255> as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("avatar_url"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("role"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("is_active"),
<bool as DatabaseField>::TYPE,
),
])
.build(),
];
Field::new(
Identifier::new("username"),
<LimitedString<255> as DatabaseField>::TYPE,
)
.unique(),
Field::new(
Identifier::new("password"),
<PasswordHash as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("email"),
<LimitedString<255> as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("display_name"),
<LimitedString<255> as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("avatar_url"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("role"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(Identifier::new("is_active"), <bool as DatabaseField>::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"),
<i64 as DatabaseField>::TYPE,
)
)];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("furumusic__oidc_link"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(
Identifier::new("user_id"),
<i64 as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("issuer"),
<LimitedString<255> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("sub"),
<LimitedString<255> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("email"),
<LimitedString<255> as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("name"),
<LimitedString<255> as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("avatar_url"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
])
.build(),
];
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
Field::new(
Identifier::new("issuer"),
<LimitedString<255> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("sub"),
<LimitedString<255> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("email"),
<LimitedString<255> as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("name"),
<LimitedString<255> as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("avatar_url"),
<String as DatabaseField>::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] = &[
+41 -5
View File
@@ -13,9 +13,12 @@
</table>
<div style="margin: 1rem 0; display: flex; gap: .5rem; align-items: flex-end;">
{% if job.name_str() != "metadata_backfill" %}
<form method="post" action="/admin/jobs/{{ job.name_str() }}/run" style="margin:0;">
<button type="submit" style="padding:.4rem 1rem; background:#007bff; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_run_now }}</button>
</form>
{% endif %}
{% if job.name_str() != "metadata_backfill" %}
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
{% if job.enabled() %}
<button type="submit" style="padding:.4rem 1rem; background:#dc3545; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_disable }}</button>
@@ -23,14 +26,47 @@
<button type="submit" style="padding:.4rem 1rem; background:#28a745; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_enable }}</button>
{% endif %}
</form>
{% endif %}
</div>
<h2>{{ t.jobs_cron }}</h2>
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.jobs_cron_help }}</p>
<form method="post" action="/admin/jobs/{{ job.name_str() }}/cron" style="display:flex;gap:.5rem;align-items:center;margin-bottom:1.5rem;">
<input name="cron_expression" value="{{ job.cron_expression_str() }}" style="width:20em;font-family:monospace;">
<button type="submit" style="padding:.4rem 1rem; background:#6c757d; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_cron_update }}</button>
{% if job.name_str() == "metadata_backfill" %}
<h2>{{ t.jobs_metadata_backfill_options }}</h2>
<form method="post" action="/admin/jobs/metadata_backfill/run-options" style="margin:0 0 1.5rem; padding:1rem; background:#fff; border:1px solid #e0e0e0; border-radius:6px;">
<fieldset style="border:0; margin:0 0 .75rem; padding:0;">
<legend style="font-weight:600; margin-bottom:.5rem;">{{ t.jobs_metadata_backfill_fields }}</legend>
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
<input type="checkbox" name="audio_bitrate" checked> audio_bitrate
</label>
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
<input type="checkbox" name="audio_sample_rate" checked> audio_sample_rate
</label>
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
<input type="checkbox" name="audio_bit_depth" checked> audio_bit_depth
</label>
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
<input type="checkbox" name="duration_seconds" checked> duration_seconds
</label>
</fieldset>
<div style="display:flex; gap:1rem; align-items:center; margin-bottom:.9rem;">
<label style="display:inline-flex; gap:.35rem; align-items:center;">
<input type="radio" name="mode" value="fill_missing" checked> {{ t.jobs_metadata_backfill_fill_missing }}
</label>
<label style="display:inline-flex; gap:.35rem; align-items:center;">
<input type="radio" name="mode" value="overwrite"> {{ t.jobs_metadata_backfill_overwrite }}
</label>
</div>
<button type="submit" style="padding:.45rem 1rem; background:#007bff; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_metadata_backfill_run }}</button>
</form>
{% endif %}
{% if job.name_str() != "metadata_backfill" %}
<h2>{{ t.jobs_cron }}</h2>
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.jobs_cron_help }}</p>
<form method="post" action="/admin/jobs/{{ job.name_str() }}/cron" style="display:flex;gap:.5rem;align-items:center;margin-bottom:1.5rem;">
<input name="cron_expression" value="{{ job.cron_expression_str() }}" style="width:20em;font-family:monospace;">
<button type="submit" style="padding:.4rem 1rem; background:#6c757d; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_cron_update }}</button>
</form>
{% endif %}
<h2>{{ t.jobs_run_history }}</h2>
{% if runs.is_empty() %}
+6
View File
@@ -23,9 +23,14 @@
<td>{{ job.last_run_at_str() }}</td>
<td>{{ job.next_run_at_str() }}</td>
<td style="display:flex;gap:.3rem;">
{% if job.name_str() == "metadata_backfill" %}
<a href="/admin/jobs/{{ job.name_str() }}" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #007bff; background:#007bff; color:#fff; cursor:pointer; text-decoration:none;">{{ t.jobs_metadata_backfill_options }}</a>
{% else %}
<form method="post" action="/admin/jobs/{{ job.name_str() }}/run" style="margin:0;">
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #007bff; background:#007bff; color:#fff; cursor:pointer;">{{ t.jobs_run_now }}</button>
</form>
{% endif %}
{% if job.name_str() != "metadata_backfill" %}
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
{% if job.enabled() %}
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #dc3545; background:#fff; color:#dc3545; cursor:pointer;">{{ t.jobs_disable }}</button>
@@ -33,6 +38,7 @@
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #28a745; background:#fff; color:#28a745; cursor:pointer;">{{ t.jobs_enable }}</button>
{% endif %}
</form>
{% endif %}
</td>
</tr>
{% endfor %}
+2 -1
View File
@@ -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 %}
<nav class="sidebar">
<h2>{{ t.site_name }} {{ t.nav_admin }}</h2>
<h2>{{ t.site_name }} {{ t.nav_admin }} <span class="admin-version">v{{ t.app_version() }}</span></h2>
<a href="/admin/">{{ t.nav_dashboard }}</a>
<a href="/admin/artists">{{ t.nav_artists }}</a>
<a href="/admin/releases">{{ t.nav_releases }}</a>
+215 -12
View File
@@ -4,7 +4,7 @@
{% block content %}
<h1>{{ t.reviews_heading }}</h1>
<div style="margin-bottom: 1rem; display: flex; gap: .5rem; align-items: center;">
<div style="margin-bottom: 1rem; display: flex; gap: .5rem; align-items: center; flex-wrap: wrap;">
<a href="/admin/reviews" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "" %} #333; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_all }}</a>
<a href="/admin/reviews?status=pending" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "pending" %} #ffc107; color: #000{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_pending }}</a>
<a href="/admin/reviews?status=approved" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "approved" %} #28a745; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_approved }}</a>
@@ -13,7 +13,7 @@
<a href="/admin/reviews?status=processing" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "processing" %} #007bff; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_processing }}</a>
<a href="/admin/reviews?status=auto_approved" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "auto_approved" %} #28a745; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_auto_approved }}</a>
<a href="/admin/reviews?status=failed" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "failed" %} #dc3545; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_failed }}</a>
{% if !reviews.is_empty() %}
{% if !rows.is_empty() %}
<span style="flex:1;"></span>
<form method="post" action="/admin/reviews/clear{% if !status_filter.is_empty() %}?status={{ status_filter }}{% endif %}" style="margin:0;" onsubmit="return confirm('{{ t.reviews_clear_confirm }}');">
<button type="submit" style="padding:.3rem .8rem; border-radius:4px; border:1px solid #dc3545; background:#fff; color:#dc3545; cursor:pointer;">{% if status_filter.is_empty() %}{{ t.reviews_clear_all }}{% else %}{{ t.reviews_clear_filtered }}{% endif %}</button>
@@ -21,15 +21,27 @@
{% endif %}
</div>
{% if reviews.is_empty() %}
{% if rows.is_empty() %}
<p>{{ t.reviews_empty }}</p>
{% else %}
<form id="reviews-bulk-form" method="post" action="/admin/reviews/bulk" style="margin:0;">
<input type="hidden" name="selected_ids" id="selected-review-ids" value="">
<input type="hidden" name="status_filter" value="{{ status_filter }}">
<div class="review-bulk-toolbar">
<button type="button" id="select-shown-reviews" class="review-toolbar-button">{{ t.reviews_select_all }}</button>
<button type="button" id="clear-review-selection" class="review-toolbar-button">{{ t.reviews_clear_selection }}</button>
<button type="submit" name="action" value="delete" class="review-danger-button" disabled>{{ t.reviews_delete_selected }}</button>
<button type="submit" name="action" value="requeue" class="review-primary-button" disabled>{{ t.reviews_requeue_selected }}</button>
<span id="review-selection-summary" class="review-selection-summary">{{ t.reviews_selected_none }}</span>
</div>
<table>
<tr>
<th class="review-select-cell"></th>
<th>ID</th>
<th>{{ t.reviews_status }}</th>
<th>{{ t.reviews_type }}</th>
<th>{{ t.reviews_input_path }}</th>
<th>{{ t.reviews_tags }}</th>
<th>{{ t.reviews_confidence }}</th>
<th>{{ t.reviews_model }}</th>
<th>{{ t.reviews_llm_duration }}</th>
@@ -37,14 +49,22 @@
<th>{{ t.reviews_created }}</th>
<th>{{ t.jobs_actions }}</th>
</tr>
{% for review in reviews %}
{% for row in rows %}
<tr>
<td><a href="/admin/reviews/{{ review.id_val() }}">{{ review.id_val() }}</a></td>
<td><span class="badge {{ review.status_badge_class() }}">{{ review.status_str() }}</span></td>
<td>{{ review.review_type_str() }}</td>
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ review.input_path_str() }}">{{ review.input_path_str() }}</td>
<td>{% match review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}</td>
{% match stats_map.get(&review.id_val()) %}
<td class="review-select-cell">
<input type="checkbox" class="review-select" value="{{ row.review.id_val() }}" data-status="{{ row.review.status_str() }}" aria-label="Select review {{ row.review.id_val() }}">
</td>
<td><a href="/admin/reviews/{{ row.review.id_val() }}">{{ row.review.id_val() }}</a></td>
<td><span class="badge {{ row.review.status_badge_class() }}">{{ row.review.status_str() }}</span></td>
<td>{{ row.review.review_type_str() }}</td>
<td class="review-input-path" title="{{ row.review.input_path_str() }}">{{ row.display_input_path }}</td>
<td class="review-tag-cell">
{% for tag in row.media_tags %}
<span class="review-tag review-tag-{{ tag.kind }}">{{ tag.label }}</span>
{% endfor %}
</td>
<td>{% match row.review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}</td>
{% match stats_map.get(&row.review.id_val()) %}
{% when Some with (s) %}
<td>{{ s.model_name }}</td>
<td>{{ s.duration_display() }}</td>
@@ -54,20 +74,203 @@
<td>-</td>
<td>-</td>
{% endmatch %}
<td>{{ review.created_at_str() }}</td>
<td>{{ row.review.created_at_str() }}</td>
<td>
<a href="/admin/reviews/{{ review.id_val() }}">{{ t.reviews_view }}</a>
<a href="/admin/reviews/{{ row.review.id_val() }}">{{ t.reviews_view }}</a>
</td>
</tr>
{% endfor %}
</table>
</form>
{% endif %}
<style>
.review-bulk-toolbar {
margin-bottom: .75rem;
display: flex;
gap: .5rem;
align-items: center;
flex-wrap: wrap;
}
.review-toolbar-button,
.review-danger-button,
.review-primary-button {
padding: .35rem .7rem;
border-radius: 4px;
border: 1px solid #ced4da;
background: #fff;
color: #212529;
cursor: pointer;
}
.review-danger-button {
border-color: #dc3545;
color: #dc3545;
}
.review-primary-button {
border-color: #17a2b8;
color: #0c5460;
}
.review-danger-button:disabled,
.review-primary-button:disabled {
cursor: not-allowed;
opacity: .45;
}
.review-selection-summary {
min-height: 1.7rem;
padding: .35rem .6rem;
border: 1px solid #dee2e6;
border-radius: 4px;
background: #f8f9fa;
color: #495057;
font-size: .9rem;
white-space: nowrap;
}
.review-select-cell {
width: 2.25rem;
text-align: center;
}
.review-select {
width: 1rem;
height: 1rem;
}
.review-input-path {
max-width: 34rem;
white-space: normal;
overflow-wrap: anywhere;
}
.review-tag-cell {
max-width: 18rem;
}
.review-tag {
display: inline-block;
margin: .1rem .15rem .1rem 0;
padding: .12rem .35rem;
border: 1px solid #ced4da;
border-radius: 4px;
background: #f8f9fa;
color: #495057;
font-size: .8rem;
white-space: nowrap;
}
.review-tag-format { border-color: #9ec5fe; background: #e7f1ff; color: #084298; }
.review-tag-bitrate { border-color: #a3cfbb; background: #d1e7dd; color: #0f5132; }
.review-tag-sample { border-color: #ffda6a; background: #fff3cd; color: #664d03; }
.review-tag-depth { border-color: #d0bfff; background: #f0e7ff; color: #3d246c; }
.review-tag-size { border-color: #ced4da; background: #f8f9fa; color: #495057; }
.badge-completed { background: #d4edda; color: #155724; }
.badge-failed { background: #f8d7da; color: #721c24; }
.badge-pending { background: #fff3cd; color: #856404; }
.badge-queued { background: #d1ecf1; color: #0c5460; }
.badge-processing { background: #cce5ff; color: #004085; }
</style>
<script>
(() => {
const form = document.getElementById("reviews-bulk-form");
if (!form) {
return;
}
const checkboxes = Array.from(form.querySelectorAll(".review-select"));
const selectedIdsInput = document.getElementById("selected-review-ids");
const summary = document.getElementById("review-selection-summary");
const selectShownButton = document.getElementById("select-shown-reviews");
const clearSelectionButton = document.getElementById("clear-review-selection");
const submitButtons = Array.from(form.querySelectorAll("button[type='submit']"));
const selected = new Set();
const statusCounts = new Map();
const labels = {
pending: "{{ t.reviews_filter_pending }}",
approved: "{{ t.reviews_filter_approved }}",
rejected: "{{ t.reviews_filter_rejected }}",
queued: "{{ t.reviews_filter_queued }}",
processing: "{{ t.reviews_filter_processing }}",
auto_approved: "{{ t.reviews_filter_auto_approved }}",
failed: "{{ t.reviews_filter_failed }}"
};
function setStatusCount(status, delta) {
const next = (statusCounts.get(status) || 0) + delta;
if (next > 0) {
statusCounts.set(status, next);
} else {
statusCounts.delete(status);
}
}
function syncControls() {
selectedIdsInput.value = Array.from(selected).join(",");
const total = selected.size;
for (const button of submitButtons) {
button.disabled = total === 0;
}
if (total === 0) {
summary.textContent = "{{ t.reviews_selected_none }}";
return;
}
const parts = Array.from(statusCounts.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([status, count]) => `${labels[status] || status}: ${count}`);
summary.textContent = `{{ t.reviews_selected_prefix }}: ${total} (${parts.join(", ")})`;
}
function setChecked(checkbox, checked) {
const id = checkbox.value;
const isSelected = selected.has(id);
checkbox.checked = checked;
if (isSelected === checked) {
return;
}
const status = checkbox.dataset.status || "unknown";
if (checked) {
selected.add(id);
setStatusCount(status, 1);
} else {
selected.delete(id);
setStatusCount(status, -1);
}
}
for (const checkbox of checkboxes) {
checkbox.addEventListener("change", () => {
setChecked(checkbox, checkbox.checked);
syncControls();
});
}
selectShownButton.addEventListener("click", () => {
for (const checkbox of checkboxes) {
setChecked(checkbox, true);
}
syncControls();
});
clearSelectionButton.addEventListener("click", () => {
for (const checkbox of checkboxes) {
setChecked(checkbox, false);
}
syncControls();
});
form.addEventListener("submit", (event) => {
syncControls();
if (selected.size === 0) {
event.preventDefault();
alert("{{ t.reviews_none_selected_confirm }}");
return;
}
const action = event.submitter ? event.submitter.value : "";
const message = action === "requeue"
? "{{ t.reviews_requeue_selected_confirm }}"
: "{{ t.reviews_delete_selected_confirm }}";
if (!confirm(message)) {
event.preventDefault();
}
});
syncControls();
})();
</script>
{% endblock content %}
+85 -26
View File
@@ -273,6 +273,22 @@ body {
.artist-header .artist-img svg { width: 80px; height: 80px; color: var(--text-subdued); }
.artist-header .artist-name { font-size: 48px; font-weight: 900; line-height: 1.1; }
.artist-stats {
color: var(--text-subdued);
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 14px;
}
.artist-release-group { margin-top: 28px; }
.artist-release-group:first-of-type { margin-top: 0; }
.artist-release-group-title {
font-size: 20px;
font-weight: 700;
margin-bottom: 14px;
text-transform: capitalize;
}
/* Release detail header */
.release-header {
@@ -1355,35 +1371,45 @@ body {
</div>
<div>
<div class="artist-header .artist-name" x-text="$store.library.currentArtist.name" style="font-size:48px;font-weight:900;line-height:1.1"></div>
<div style="color:var(--text-subdued);margin-top:8px" x-text="$store.library.currentArtist.releases.length + ' releases'"></div>
<div class="artist-stats">
<span x-text="$store.library.currentArtist.releases.length + ' releases'"></span>
<span></span>
<span x-text="$store.library.currentArtist.total_track_count + ' tracks'"></span>
<span></span>
<span x-text="$store.library.currentArtist.total_play_count + ' plays'"></span>
</div>
</div>
</div>
<h2 class="section-title" style="font-size:20px">Releases</h2>
<div class="card-grid">
<template x-for="release in $store.library.currentArtist.releases" :key="release.id">
<div class="card" @click="$store.library.openRelease(release.id)">
<div class="card-img">
<template x-if="release.cover_url">
<img :src="release.cover_url" :alt="release.title" loading="lazy">
</template>
<template x-if="!release.cover_url">
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg></span>
</template>
<button class="card-enqueue-btn" @click.stop="$store.library.enqueueRelease(release.id)" title="Add to queue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="card-play-btn" @click.stop="$store.library.playRelease(release.id)">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
<div class="card-title" x-text="release.title"></div>
<div class="card-subtitle">
<span x-text="release.year || ''"></span>
<span x-text="release.release_type"></span>
</div>
<template x-for="group in $store.library.artistReleaseGroups()" :key="group.type">
<section class="artist-release-group">
<h2 class="artist-release-group-title" x-text="group.label"></h2>
<div class="card-grid">
<template x-for="release in group.releases" :key="release.id">
<div class="card" @click="$store.library.openRelease(release.id)">
<div class="card-img">
<template x-if="release.cover_url">
<img :src="release.cover_url" :alt="release.title" loading="lazy">
</template>
<template x-if="!release.cover_url">
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg></span>
</template>
<button class="card-enqueue-btn" @click.stop="$store.library.enqueueRelease(release.id)" title="Add to queue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="card-play-btn" @click.stop="$store.library.playRelease(release.id)">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
<div class="card-title" x-text="release.title"></div>
<div class="card-subtitle">
<span x-text="release.year || ''"></span>
<span x-text="release.track_count + ' tracks'"></span>
</div>
</div>
</template>
</div>
</template>
</div>
</section>
</template>
</div>
</template>
@@ -2179,6 +2205,39 @@ document.addEventListener('alpine:init', () => {
} catch {}
},
artistReleaseGroups() {
const releases = this.currentArtist?.releases || [];
const order = ['album', 'ep', 'single', 'compilation', 'mixtape', 'live', 'soundtrack'];
const labels = {
album: 'Albums',
ep: 'EPs',
single: 'Singles',
compilation: 'Compilations',
mixtape: 'Mixtapes',
live: 'Live releases',
soundtrack: 'Soundtracks',
};
const groups = new Map();
for (const release of releases) {
const type = (release.release_type || 'other').toLowerCase();
if (!groups.has(type)) {
groups.set(type, []);
}
groups.get(type).push(release);
}
return Array.from(groups.entries())
.sort(([a], [b]) => {
const ai = order.includes(a) ? order.indexOf(a) : order.length;
const bi = order.includes(b) ? order.indexOf(b) : order.length;
return ai === bi ? a.localeCompare(b) : ai - bi;
})
.map(([type, groupReleases]) => ({
type,
label: labels[type] || type,
releases: groupReleases,
}));
},
async openRelease(id) {
this.searchQuery = '';
this.searchResults = null;