This commit is contained in:
@@ -10,8 +10,5 @@ fn main() {
|
|||||||
.output()
|
.output()
|
||||||
.expect("failed to run rustc --version");
|
.expect("failed to run rustc --version");
|
||||||
let version = String::from_utf8_lossy(&output.stdout);
|
let version = String::from_utf8_lossy(&output.stdout);
|
||||||
println!(
|
println!("cargo::rustc-env=FURU_RUSTC_VERSION={}", version.trim());
|
||||||
"cargo::rustc-env=FURU_RUSTC_VERSION={}",
|
|
||||||
version.trim()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-3
@@ -2,6 +2,7 @@ pub mod views;
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use cot::App;
|
||||||
use cot::db::Database;
|
use cot::db::Database;
|
||||||
use cot::db::migrations::SyncDynMigration;
|
use cot::db::migrations::SyncDynMigration;
|
||||||
use cot::json::Json;
|
use cot::json::Json;
|
||||||
@@ -10,7 +11,6 @@ use cot::response::IntoResponse;
|
|||||||
use cot::router::method::get;
|
use cot::router::method::get;
|
||||||
use cot::router::{Route, Router};
|
use cot::router::{Route, Router};
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
use cot::App;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::auth;
|
use crate::auth;
|
||||||
@@ -18,7 +18,10 @@ use crate::config::AppConfig;
|
|||||||
use crate::i18n::I18n;
|
use crate::i18n::I18n;
|
||||||
use crate::scheduler::{JobRegistry, SchedulerHandle};
|
use crate::scheduler::{JobRegistry, SchedulerHandle};
|
||||||
use crate::user::User;
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ReviewsQuery {
|
struct ReviewsQuery {
|
||||||
@@ -59,7 +62,11 @@ impl AdminApp {
|
|||||||
registry: Arc<JobRegistry>,
|
registry: Arc<JobRegistry>,
|
||||||
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
|
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self { config, registry, scheduler_handle }
|
Self {
|
||||||
|
config,
|
||||||
|
registry,
|
||||||
|
scheduler_handle,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,6 +543,33 @@ impl App for AdminApp {
|
|||||||
},
|
},
|
||||||
"admin_jobs",
|
"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(
|
Route::with_handler_and_name(
|
||||||
"/jobs/{name}/run",
|
"/jobs/{name}/run",
|
||||||
cot::router::method::post({
|
cot::router::method::post({
|
||||||
@@ -651,6 +685,21 @@ impl App for AdminApp {
|
|||||||
),
|
),
|
||||||
"admin_reviews_clear",
|
"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 ------------------------------------------------------
|
// -- Reviews ------------------------------------------------------
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/reviews",
|
"/reviews",
|
||||||
|
|||||||
+547
-98
@@ -9,14 +9,14 @@ use cot::{Body, Template};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::auth::{self, AuthenticatedUser};
|
use super::BUILD_INFO;
|
||||||
use crate::agent;
|
use crate::agent;
|
||||||
|
use crate::auth::{self, AuthenticatedUser};
|
||||||
use crate::config::{AppConfig, ConfigEntry, ConfigSources};
|
use crate::config::{AppConfig, ConfigEntry, ConfigSources};
|
||||||
use crate::i18n::{I18n, Translations};
|
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::scheduler::{self, JobRegistry, JobRun, PendingReview, ScheduledJob};
|
||||||
use crate::user::User;
|
use crate::user::User;
|
||||||
use super::BUILD_INFO;
|
|
||||||
|
|
||||||
use crate::agent::AgentProbeResult;
|
use crate::agent::AgentProbeResult;
|
||||||
|
|
||||||
@@ -31,10 +31,7 @@ pub struct ConfigDisplayEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Secret field names that should be redacted in the debug view.
|
/// Secret field names that should be redacted in the debug view.
|
||||||
const SECRET_FIELDS: &[&str] = &[
|
const SECRET_FIELDS: &[&str] = &["database_url", "oidc_client_secret"];
|
||||||
"database_url",
|
|
||||||
"oidc_client_secret",
|
|
||||||
];
|
|
||||||
|
|
||||||
fn is_secret(name: &str) -> bool {
|
fn is_secret(name: &str) -> bool {
|
||||||
let lower = name.to_ascii_lowercase();
|
let lower = name.to_ascii_lowercase();
|
||||||
@@ -66,44 +63,122 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<Co
|
|||||||
let defaults = AppConfig::default();
|
let defaults = AppConfig::default();
|
||||||
|
|
||||||
macro_rules! entry {
|
macro_rules! entry {
|
||||||
($field:ident, $value:expr, $default:expr) => {
|
($field:ident, $value:expr, $default:expr) => {{
|
||||||
{
|
let raw = $value;
|
||||||
let raw = $value;
|
let default_raw = $default;
|
||||||
let default_raw = $default;
|
let secret = is_secret(stringify!($field));
|
||||||
let secret = is_secret(stringify!($field));
|
let display = if secret { redact(&raw) } else { raw };
|
||||||
let display = if secret { redact(&raw) } else { raw };
|
let default_display = if secret {
|
||||||
let default_display = if secret { redact(&default_raw) } else { default_raw };
|
redact(&default_raw)
|
||||||
ConfigDisplayEntry {
|
} else {
|
||||||
key: stringify!($field).into(),
|
default_raw
|
||||||
env_var: format!("FURU_{}", stringify!($field).to_ascii_uppercase()),
|
};
|
||||||
value: display,
|
ConfigDisplayEntry {
|
||||||
default_value: default_display,
|
key: stringify!($field).into(),
|
||||||
source: sources.$field.code(),
|
env_var: format!("FURU_{}", stringify!($field).to_ascii_uppercase()),
|
||||||
}
|
value: display,
|
||||||
|
default_value: default_display,
|
||||||
|
source: sources.$field.code(),
|
||||||
}
|
}
|
||||||
};
|
}};
|
||||||
}
|
}
|
||||||
|
|
||||||
vec![
|
vec![
|
||||||
entry!(database_url, config.database_url.clone(), defaults.database_url.clone()),
|
entry!(
|
||||||
entry!(oidc_issuer, config.oidc_issuer.clone(), defaults.oidc_issuer.clone()),
|
database_url,
|
||||||
entry!(oidc_client_id, config.oidc_client_id.clone(), defaults.oidc_client_id.clone()),
|
config.database_url.clone(),
|
||||||
entry!(oidc_client_secret, config.oidc_client_secret.clone(), defaults.oidc_client_secret.clone()),
|
defaults.database_url.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!(
|
||||||
entry!(auth_sso_enabled, config.auth_sso_enabled.to_string(), defaults.auth_sso_enabled.to_string()),
|
oidc_issuer,
|
||||||
entry!(oidc_button_text, config.oidc_button_text.clone(), defaults.oidc_button_text.clone()),
|
config.oidc_issuer.clone(),
|
||||||
entry!(oidc_admin_groups, config.oidc_admin_groups.clone(), defaults.oidc_admin_groups.clone()),
|
defaults.oidc_issuer.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!(
|
||||||
entry!(agent_inbox_dir, config.agent_inbox_dir.clone(), defaults.agent_inbox_dir.clone()),
|
oidc_client_id,
|
||||||
entry!(agent_storage_dir, config.agent_storage_dir.clone(), defaults.agent_storage_dir.clone()),
|
config.oidc_client_id.clone(),
|
||||||
entry!(agent_llm_url, config.agent_llm_url.clone(), defaults.agent_llm_url.clone()),
|
defaults.oidc_client_id.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!(
|
||||||
entry!(agent_confidence_threshold, config.agent_confidence_threshold.to_string(), defaults.agent_confidence_threshold.to_string()),
|
oidc_client_secret,
|
||||||
entry!(agent_context_limit, config.agent_context_limit.to_string(), defaults.agent_context_limit.to_string()),
|
config.oidc_client_secret.clone(),
|
||||||
entry!(agent_concurrency, config.agent_concurrency.to_string(), defaults.agent_concurrency.to_string()),
|
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;
|
let RequestForm(result) = form;
|
||||||
match result {
|
match result {
|
||||||
FormResult::Ok(data) => {
|
FormResult::Ok(data) => {
|
||||||
let pw_enabled = if data.auth_password_enabled.is_some() { "true" } else { "false" };
|
let pw_enabled = if data.auth_password_enabled.is_some() {
|
||||||
let sso_enabled = if data.auth_sso_enabled.is_some() { "true" } else { "false" };
|
"true"
|
||||||
let swagger = if data.swagger_enabled.is_some() { "true" } else { "false" };
|
} else {
|
||||||
let agent_en = if data.agent_enabled.is_some() { "true" } else { "false" };
|
"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_button_text = data.oidc_button_text.unwrap_or_default();
|
||||||
let oidc_issuer = data.oidc_issuer.unwrap_or_default();
|
let oidc_issuer = data.oidc_issuer.unwrap_or_default();
|
||||||
let oidc_client_id = data.oidc_client_id.unwrap_or_default();
|
let oidc_client_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 (config, _sources) = AppConfig::load_with_db(db).await;
|
||||||
|
|
||||||
let probe = if config.agent_enabled && !config.agent_llm_url.is_empty() {
|
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 {
|
} else {
|
||||||
AgentProbeResult::default()
|
AgentProbeResult::default()
|
||||||
};
|
};
|
||||||
@@ -437,15 +533,29 @@ pub async fn users_create(
|
|||||||
let RequestForm(result) = form;
|
let RequestForm(result) = form;
|
||||||
match result {
|
match result {
|
||||||
FormResult::Ok(data) => {
|
FormResult::Ok(data) => {
|
||||||
let email = if data.email.is_empty() { None } else { Some(data.email.as_str()) };
|
let email = if data.email.is_empty() {
|
||||||
let display_name = if data.display_name.is_empty() { None } else { Some(data.display_name.as_str()) };
|
None
|
||||||
User::create(db, &data.username, email, display_name, &data.password, &data.role).await
|
} else {
|
||||||
.map_err(|e| cot::Error::internal(format!("failed to create user: {e}")))?;
|
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"))
|
Ok(auth::redirect("/admin/users"))
|
||||||
}
|
}
|
||||||
FormResult::ValidationError(_) => {
|
FormResult::ValidationError(_) => Ok(auth::redirect("/admin/users/new")),
|
||||||
Ok(auth::redirect("/admin/users/new"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,7 +565,8 @@ pub async fn users_edit(
|
|||||||
db: &Database,
|
db: &Database,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
) -> cot::Result<Html> {
|
) -> 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}")))?
|
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
|
||||||
.ok_or_else(|| cot::Error::internal("user not found"))?;
|
.ok_or_else(|| cot::Error::internal("user not found"))?;
|
||||||
let template = UserFormTemplate {
|
let template = UserFormTemplate {
|
||||||
@@ -481,13 +592,35 @@ pub async fn users_update(
|
|||||||
let RequestForm(result) = form;
|
let RequestForm(result) = form;
|
||||||
match result {
|
match result {
|
||||||
FormResult::Ok(data) => {
|
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}")))?
|
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
|
||||||
.ok_or_else(|| cot::Error::internal("user not found"))?;
|
.ok_or_else(|| cot::Error::internal("user not found"))?;
|
||||||
let email = if data.email.is_empty() { None } else { Some(data.email.as_str()) };
|
let email = if data.email.is_empty() {
|
||||||
let display_name = if data.display_name.is_empty() { None } else { Some(data.display_name.as_str()) };
|
None
|
||||||
let new_password = if data.password.is_empty() { None } else { Some(data.password.as_str()) };
|
} else {
|
||||||
target.update_fields(db, &data.username, email, display_name, new_password, &data.role).await
|
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}")))?;
|
.map_err(|e| cot::Error::internal(format!("failed to update user: {e}")))?;
|
||||||
Ok(auth::redirect("/admin/users"))
|
Ok(auth::redirect("/admin/users"))
|
||||||
}
|
}
|
||||||
@@ -502,7 +635,8 @@ pub async fn users_delete(
|
|||||||
db: &Database,
|
db: &Database,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
) -> cot::Result<cot::http::Response<Body>> {
|
) -> 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}")))?;
|
.map_err(|e| cot::Error::internal(format!("failed to delete user: {e}")))?;
|
||||||
Ok(auth::redirect("/admin/users"))
|
Ok(auth::redirect("/admin/users"))
|
||||||
}
|
}
|
||||||
@@ -519,10 +653,7 @@ struct SetupTemplate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn setup_page(i18n: I18n, message: String) -> cot::Result<Html> {
|
pub async fn setup_page(i18n: I18n, message: String) -> cot::Result<Html> {
|
||||||
let template = SetupTemplate {
|
let template = SetupTemplate { t: i18n.t, message };
|
||||||
t: i18n.t,
|
|
||||||
message,
|
|
||||||
};
|
|
||||||
Ok(Html::new(template.render()?))
|
Ok(Html::new(template.render()?))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,13 +712,25 @@ struct ArtistsTemplate {
|
|||||||
rows: Vec<ArtistRow>,
|
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 artists = Artist::list_all(db).await.unwrap_or_default();
|
||||||
let mut rows = Vec::with_capacity(artists.len());
|
let mut rows = Vec::with_capacity(artists.len());
|
||||||
for artist in artists {
|
for artist in artists {
|
||||||
let release_count = ReleaseArtist::count_by_artist(db, artist.id_val()).await.unwrap_or(0);
|
let release_count = ReleaseArtist::count_by_artist(db, artist.id_val())
|
||||||
let track_count = TrackArtist::count_by_artist(db, artist.id_val()).await.unwrap_or(0);
|
.await
|
||||||
rows.push(ArtistRow { artist, release_count, track_count });
|
.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 {
|
let template = ArtistsTemplate {
|
||||||
t: i18n.t,
|
t: i18n.t,
|
||||||
@@ -657,13 +800,11 @@ pub async fn artists_edit(
|
|||||||
.ok_or_else(|| cot::Error::internal("artist not found"))?;
|
.ok_or_else(|| cot::Error::internal("artist not found"))?;
|
||||||
|
|
||||||
let current_image_url = match artist.image_file_id {
|
let current_image_url = match artist.image_file_id {
|
||||||
Some(fid) => {
|
Some(fid) => MediaFile::get_by_id(db, fid)
|
||||||
MediaFile::get_by_id(db, fid)
|
.await
|
||||||
.await
|
.ok()
|
||||||
.ok()
|
.flatten()
|
||||||
.flatten()
|
.map(|mf| format!("/api/player/cover/{}", mf.id_val())),
|
||||||
.map(|mf| format!("/api/player/cover/{}", mf.id_val()))
|
|
||||||
}
|
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -830,9 +971,9 @@ pub async fn artists_upload_image(
|
|||||||
let cover = crate::agent::cover_art::CoverImage {
|
let cover = crate::agent::cover_art::CoverImage {
|
||||||
data: image_data,
|
data: image_data,
|
||||||
mime_type: parsed.mime_type.clone(),
|
mime_type: parsed.mime_type.clone(),
|
||||||
source: crate::agent::cover_art::CoverSource::FolderFile(
|
source: crate::agent::cover_art::CoverSource::FolderFile(std::path::PathBuf::from(
|
||||||
std::path::PathBuf::from(&parsed.filename),
|
&parsed.filename,
|
||||||
),
|
)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let cover_file_id = crate::agent::cover_art::save_cover_to_storage(
|
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
|
// If filtering by artist, find the set of release_ids for that artist
|
||||||
let filtered_release_ids: Option<Vec<i64>> = match filter_artist_id {
|
let filtered_release_ids: Option<Vec<i64>> = match filter_artist_id {
|
||||||
Some(aid) => {
|
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())
|
Some(links.iter().map(|l| l.release_id()).collect())
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
@@ -936,7 +1079,10 @@ pub async fn releases_list(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let artist_names = resolve_artist_names(db, release.id_val(), &names).await;
|
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 {
|
let template = ReleasesTemplate {
|
||||||
@@ -967,7 +1113,11 @@ struct ReleaseFormTemplate {
|
|||||||
lang_code: &'static str,
|
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 artists = Artist::list_all(db).await.unwrap_or_default();
|
||||||
let template = ReleaseFormTemplate {
|
let template = ReleaseFormTemplate {
|
||||||
t: i18n.t,
|
t: i18n.t,
|
||||||
@@ -1084,9 +1234,9 @@ pub async fn releases_update(
|
|||||||
.map_err(|e| cot::Error::internal(format!("failed to update artists: {e}")))?;
|
.map_err(|e| cot::Error::internal(format!("failed to update artists: {e}")))?;
|
||||||
Ok(auth::redirect("/admin/releases"))
|
Ok(auth::redirect("/admin/releases"))
|
||||||
}
|
}
|
||||||
FormResult::ValidationError(_) => {
|
FormResult::ValidationError(_) => Ok(auth::redirect(&format!(
|
||||||
Ok(auth::redirect(&format!("/admin/releases/{release_id}/edit")))
|
"/admin/releases/{release_id}/edit"
|
||||||
}
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1137,10 +1287,7 @@ pub async fn media_files_list(
|
|||||||
let rows: Vec<MediaFileRow> = files
|
let rows: Vec<MediaFileRow> = files
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|mf| {
|
.map(|mf| {
|
||||||
let track_title = track_map
|
let track_title = track_map.get(&mf.id_val()).cloned().unwrap_or_default();
|
||||||
.get(&mf.id_val())
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
MediaFileRow {
|
MediaFileRow {
|
||||||
media_file: mf,
|
media_file: mf,
|
||||||
track_title,
|
track_title,
|
||||||
@@ -1204,17 +1351,16 @@ pub async fn jobs_list(
|
|||||||
/// rows for jobs that are no longer registered.
|
/// rows for jobs that are no longer registered.
|
||||||
async fn sync_registered_jobs(db: &Database, registry: &JobRegistry) {
|
async fn sync_registered_jobs(db: &Database, registry: &JobRegistry) {
|
||||||
for job in registry.all_jobs() {
|
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());
|
tracing::error!("failed to upsert scheduled job {}: {e}", job.name());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Ok(all) = ScheduledJob::list_all(db).await {
|
if let Ok(all) = ScheduledJob::list_all(db).await {
|
||||||
for sched_job in all {
|
for sched_job in all {
|
||||||
if registry.get(sched_job.name_str()).is_none() {
|
if registry.get(sched_job.name_str()).is_none() {
|
||||||
tracing::warn!(
|
tracing::warn!("Removing orphaned scheduled job '{}'", sched_job.name_str());
|
||||||
"Removing orphaned scheduled job '{}'",
|
|
||||||
sched_job.name_str()
|
|
||||||
);
|
|
||||||
let _ = ScheduledJob::delete_by_name(db, sched_job.name_str()).await;
|
let _ = ScheduledJob::delete_by_name(db, sched_job.name_str()).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1231,6 +1377,15 @@ struct JobDetailTemplate {
|
|||||||
runs: Vec<JobRun>,
|
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(
|
pub async fn job_detail(
|
||||||
admin: AuthenticatedUser,
|
admin: AuthenticatedUser,
|
||||||
i18n: I18n,
|
i18n: I18n,
|
||||||
@@ -1275,12 +1430,76 @@ pub async fn job_run_now(
|
|||||||
Ok(auth::redirect(&format!("/admin/jobs/{job_name}")))
|
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(
|
pub async fn job_toggle_enabled(
|
||||||
_admin: AuthenticatedUser,
|
_admin: AuthenticatedUser,
|
||||||
db: &Database,
|
db: &Database,
|
||||||
handle_cell: &Arc<tokio::sync::OnceCell<Arc<scheduler::SchedulerHandle>>>,
|
handle_cell: &Arc<tokio::sync::OnceCell<Arc<scheduler::SchedulerHandle>>>,
|
||||||
job_name: &str,
|
job_name: &str,
|
||||||
) -> cot::Result<cot::http::Response<Body>> {
|
) -> 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)
|
let job = ScheduledJob::get_by_name(db, job_name)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
|
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
|
||||||
@@ -1311,6 +1530,10 @@ pub async fn job_update_cron(
|
|||||||
job_name: &str,
|
job_name: &str,
|
||||||
form: RequestForm<CronForm>,
|
form: RequestForm<CronForm>,
|
||||||
) -> cot::Result<cot::http::Response<Body>> {
|
) -> cot::Result<cot::http::Response<Body>> {
|
||||||
|
if job_name == "metadata_backfill" {
|
||||||
|
return Ok(auth::redirect("/admin/jobs/metadata_backfill"));
|
||||||
|
}
|
||||||
|
|
||||||
let RequestForm(result) = form;
|
let RequestForm(result) = form;
|
||||||
if let FormResult::Ok(data) = result {
|
if let FormResult::Ok(data) = result {
|
||||||
if let Some(handle) = handle_cell.get() {
|
if let Some(handle) = handle_cell.get() {
|
||||||
@@ -1366,11 +1589,164 @@ struct ReviewsTemplate {
|
|||||||
t: &'static Translations,
|
t: &'static Translations,
|
||||||
user_name: String,
|
user_name: String,
|
||||||
user_role: String,
|
user_role: String,
|
||||||
reviews: Vec<PendingReview>,
|
rows: Vec<ReviewListRow>,
|
||||||
stats_map: HashMap<i64, scheduler::ProcessingStatsRow>,
|
stats_map: HashMap<i64, scheduler::ProcessingStatsRow>,
|
||||||
status_filter: String,
|
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(
|
pub async fn reviews_list(
|
||||||
admin: AuthenticatedUser,
|
admin: AuthenticatedUser,
|
||||||
i18n: I18n,
|
i18n: I18n,
|
||||||
@@ -1389,12 +1765,27 @@ pub async fn reviews_list(
|
|||||||
let stats_map = scheduler::ProcessingStats::list_by_review_ids(pool, &review_ids)
|
let stats_map = scheduler::ProcessingStats::list_by_review_ids(pool, &review_ids)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.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 {
|
let template = ReviewsTemplate {
|
||||||
t: i18n.t,
|
t: i18n.t,
|
||||||
user_name: admin.name,
|
user_name: admin.name,
|
||||||
user_role: admin.role.code().to_owned(),
|
user_role: admin.role.code().to_owned(),
|
||||||
reviews,
|
rows,
|
||||||
stats_map,
|
stats_map,
|
||||||
status_filter: status.unwrap_or("").to_owned(),
|
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}")));
|
return Ok(auth::redirect(&format!("/admin/reviews/{review_id}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let normalized: crate::agent::dto::NormalizedFields =
|
let normalized: crate::agent::dto::NormalizedFields = serde_json::from_str(&result_str)
|
||||||
serde_json::from_str(&result_str)
|
.map_err(|e| cot::Error::internal(format!("invalid result_json: {e}")))?;
|
||||||
.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();
|
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}")))
|
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(
|
pub async fn review_reject(
|
||||||
_admin: AuthenticatedUser,
|
_admin: AuthenticatedUser,
|
||||||
db: &Database,
|
db: &Database,
|
||||||
|
|||||||
@@ -118,10 +118,7 @@ fn cover_name_priority(path: &Path) -> usize {
|
|||||||
/// 2. Try to extract embedded cover art from each audio file.
|
/// 2. Try to extract embedded cover art from each audio file.
|
||||||
///
|
///
|
||||||
/// Returns the first usable image found, or None.
|
/// Returns the first usable image found, or None.
|
||||||
pub async fn find_best_cover(
|
pub async fn find_best_cover(folder: &Path, audio_files: &[PathBuf]) -> Option<CoverImage> {
|
||||||
folder: &Path,
|
|
||||||
audio_files: &[PathBuf],
|
|
||||||
) -> Option<CoverImage> {
|
|
||||||
// Strategy 1: folder images
|
// Strategy 1: folder images
|
||||||
let folder_images = find_folder_images(folder);
|
let folder_images = find_folder_images(folder);
|
||||||
for img_path in &folder_images {
|
for img_path in &folder_images {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ pub struct RawMetadata {
|
|||||||
pub year: Option<u32>,
|
pub year: Option<u32>,
|
||||||
pub genre: Option<String>,
|
pub genre: Option<String>,
|
||||||
pub duration_secs: Option<f64>,
|
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).
|
/// Hints parsed from the file path (directory structure + filename).
|
||||||
|
|||||||
+36
-8
@@ -18,7 +18,10 @@ use super::dto::RawMetadata;
|
|||||||
/// Must be called from a blocking context (`spawn_blocking`).
|
/// Must be called from a blocking context (`spawn_blocking`).
|
||||||
pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
|
pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||||
match extract_via_symphonia(path) {
|
match extract_via_symphonia(path) {
|
||||||
Ok(meta) => Ok(meta),
|
Ok(mut meta) => {
|
||||||
|
fill_average_bitrate(path, &mut meta);
|
||||||
|
Ok(meta)
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let is_mp3 = path
|
let is_mp3 = path
|
||||||
.extension()
|
.extension()
|
||||||
@@ -27,7 +30,9 @@ pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if is_mp3 {
|
if is_mp3 {
|
||||||
tracing::debug!(error = %e, "Symphonia failed on MP3, falling back to id3 crate");
|
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 {
|
} else {
|
||||||
Err(e)
|
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> {
|
fn extract_via_symphonia(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||||
let file = std::fs::File::open(path)?;
|
let file = std::fs::File::open(path)?;
|
||||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||||
@@ -68,17 +89,24 @@ fn extract_via_symphonia(path: &Path) -> anyhow::Result<RawMetadata> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Duration
|
let audio_track = probed
|
||||||
meta.duration_secs = probed
|
|
||||||
.format
|
.format
|
||||||
.tracks()
|
.tracks()
|
||||||
.iter()
|
.iter()
|
||||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL);
|
||||||
.and_then(|t| {
|
|
||||||
let n_frames = t.codec_params.n_frames?;
|
if let Some(track) = audio_track {
|
||||||
let tb = t.codec_params.time_base?;
|
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)
|
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)
|
Ok(meta)
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-6
@@ -27,11 +27,7 @@ pub struct AgentProbeResult {
|
|||||||
|
|
||||||
/// Send a lightweight "introduce yourself" prompt to the LLM and return the
|
/// Send a lightweight "introduce yourself" prompt to the LLM and return the
|
||||||
/// response together with timing / usage statistics when available.
|
/// response together with timing / usage statistics when available.
|
||||||
pub async fn probe_llm(
|
pub async fn probe_llm(llm_url: &str, llm_model: &str, llm_auth: &str) -> AgentProbeResult {
|
||||||
llm_url: &str,
|
|
||||||
llm_model: &str,
|
|
||||||
llm_auth: &str,
|
|
||||||
) -> AgentProbeResult {
|
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let client = match reqwest::Client::builder()
|
let client = match reqwest::Client::builder()
|
||||||
@@ -85,7 +81,10 @@ pub async fn probe_llm(
|
|||||||
let body_text = resp.text().await.unwrap_or_default();
|
let body_text = resp.text().await.unwrap_or_default();
|
||||||
return AgentProbeResult {
|
return AgentProbeResult {
|
||||||
latency_ms,
|
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()
|
..Default::default()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+149
-50
@@ -1,6 +1,8 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata, SimilarArtist, SimilarRelease};
|
use super::dto::{
|
||||||
|
FolderContext, NormalizedFields, PathHints, RawMetadata, SimilarArtist, SimilarRelease,
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -171,18 +173,40 @@ fn estimate_batch_tokens(
|
|||||||
let mut per_file_tokens: u64 = 0;
|
let mut per_file_tokens: u64 = 0;
|
||||||
for f in files {
|
for f in files {
|
||||||
let mut chars: u64 = 40 + f.filename.len() as u64; // header
|
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.title {
|
||||||
if let Some(v) = &f.raw.artist { chars += 12 + v.len() as u64; }
|
chars += 10 + 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 let Some(v) = &f.raw.artist {
|
||||||
if f.raw.track_number.is_some() { chars += 18; }
|
chars += 12 + v.len() as u64;
|
||||||
if let Some(v) = &f.raw.genre { chars += 10 + 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
|
// hints
|
||||||
if let Some(v) = &f.hints.artist { chars += 16 + v.len() as u64; }
|
if let Some(v) = &f.hints.artist {
|
||||||
if let Some(v) = &f.hints.album { chars += 16 + v.len() as u64; }
|
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 let Some(v) = &f.hints.album {
|
||||||
if f.hints.track_number.is_some() { chars += 20; }
|
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;
|
per_file_tokens += chars / 4;
|
||||||
// Expected response per file (~150 tokens)
|
// Expected response per file (~150 tokens)
|
||||||
per_file_tokens += 150;
|
per_file_tokens += 150;
|
||||||
@@ -210,7 +234,10 @@ fn build_batch_user_message(
|
|||||||
if !similar_artists.is_empty() {
|
if !similar_artists.is_empty() {
|
||||||
msg.push_str("## Existing artists in database\n");
|
msg.push_str("## Existing artists in database\n");
|
||||||
for a in similar_artists {
|
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');
|
msg.push('\n');
|
||||||
}
|
}
|
||||||
@@ -219,7 +246,10 @@ fn build_batch_user_message(
|
|||||||
msg.push_str("## Existing releases in database\n");
|
msg.push_str("## Existing releases in database\n");
|
||||||
for r in similar_releases {
|
for r in similar_releases {
|
||||||
let year_str = r.year.map(|y| format!(", year: {y}")).unwrap_or_default();
|
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');
|
msg.push('\n');
|
||||||
}
|
}
|
||||||
@@ -230,12 +260,24 @@ fn build_batch_user_message(
|
|||||||
for f in files {
|
for f in files {
|
||||||
msg.push_str(&format!("### {}\n", f.filename));
|
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.title {
|
||||||
if let Some(v) = &f.raw.artist { msg.push_str(&format!("Artist: \"{v}\"\n")); }
|
msg.push_str(&format!("Title: \"{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.artist {
|
||||||
if let Some(v) = f.raw.track_number { msg.push_str(&format!("Track: {v}\n")); }
|
msg.push_str(&format!("Artist: \"{v}\"\n"));
|
||||||
if let Some(v) = &f.raw.genre { msg.push_str(&format!("Genre: \"{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)
|
// Path hints (only if different from tag metadata)
|
||||||
let has_hints = f.hints.artist.is_some()
|
let has_hints = f.hints.artist.is_some()
|
||||||
@@ -244,11 +286,21 @@ fn build_batch_user_message(
|
|||||||
|| f.hints.year.is_some()
|
|| f.hints.year.is_some()
|
||||||
|| f.hints.track_number.is_some();
|
|| f.hints.track_number.is_some();
|
||||||
if has_hints {
|
if has_hints {
|
||||||
if let Some(v) = &f.hints.artist { msg.push_str(&format!("Path artist: \"{v}\"\n")); }
|
if let Some(v) = &f.hints.artist {
|
||||||
if let Some(v) = &f.hints.album { msg.push_str(&format!("Path release: \"{v}\"\n")); }
|
msg.push_str(&format!("Path artist: \"{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.album {
|
||||||
if let Some(v) = f.hints.track_number { msg.push_str(&format!("Path track: {v}\n")); }
|
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');
|
msg.push('\n');
|
||||||
}
|
}
|
||||||
@@ -272,7 +324,11 @@ pub async fn normalize_batch(
|
|||||||
) -> anyhow::Result<BatchNormalizeResult> {
|
) -> anyhow::Result<BatchNormalizeResult> {
|
||||||
// Estimate tokens
|
// Estimate tokens
|
||||||
let estimated = estimate_batch_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
|
// 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 = files_vec;
|
||||||
|
|
||||||
let left_result = Box::pin(normalize_batch(
|
let left_result = Box::pin(normalize_batch(
|
||||||
llm_url, llm_model, llm_auth, system_prompt, context_limit,
|
llm_url,
|
||||||
left, similar_artists, similar_releases, folder_ctx,
|
llm_model,
|
||||||
)).await?;
|
llm_auth,
|
||||||
|
system_prompt,
|
||||||
|
context_limit,
|
||||||
|
left,
|
||||||
|
similar_artists,
|
||||||
|
similar_releases,
|
||||||
|
folder_ctx,
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
|
||||||
let right_result = Box::pin(normalize_batch(
|
let right_result = Box::pin(normalize_batch(
|
||||||
llm_url, llm_model, llm_auth, system_prompt, context_limit,
|
llm_url,
|
||||||
right, similar_artists, similar_releases, folder_ctx,
|
llm_model,
|
||||||
)).await?;
|
llm_auth,
|
||||||
|
system_prompt,
|
||||||
|
context_limit,
|
||||||
|
right,
|
||||||
|
similar_artists,
|
||||||
|
similar_releases,
|
||||||
|
folder_ctx,
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Merge results
|
// Merge results
|
||||||
let mut results = left_result.results;
|
let mut results = left_result.results;
|
||||||
@@ -312,20 +384,32 @@ pub async fn normalize_batch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build and send
|
// Build and send
|
||||||
let user_message = build_batch_user_message(
|
let user_message =
|
||||||
&files, similar_artists, similar_releases, folder_ctx,
|
build_batch_user_message(&files, similar_artists, similar_releases, folder_ctx);
|
||||||
);
|
|
||||||
|
|
||||||
let messages = vec![
|
let messages = vec![
|
||||||
ChatMessage { role: "system".into(), content: system_prompt.to_owned() },
|
ChatMessage {
|
||||||
ChatMessage { role: "user".into(), content: user_message },
|
role: "system".into(),
|
||||||
|
content: system_prompt.to_owned(),
|
||||||
|
},
|
||||||
|
ChatMessage {
|
||||||
|
role: "user".into(),
|
||||||
|
content: user_message,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
let call_result = call_llm_chat(
|
let call_result = call_llm_chat(
|
||||||
llm_url, llm_model, &messages,
|
llm_url,
|
||||||
if llm_auth.is_empty() { None } else { Some(llm_auth) },
|
llm_model,
|
||||||
).await;
|
&messages,
|
||||||
|
if llm_auth.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(llm_auth)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let duration_ms = start.elapsed().as_millis() as u64;
|
let duration_ms = start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
// If LLM error and batch > 1, try splitting (handles context overflow errors)
|
// 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 = files_vec;
|
||||||
|
|
||||||
let left_result = Box::pin(normalize_batch(
|
let left_result = Box::pin(normalize_batch(
|
||||||
llm_url, llm_model, llm_auth, system_prompt, context_limit,
|
llm_url,
|
||||||
left, similar_artists, similar_releases, folder_ctx,
|
llm_model,
|
||||||
)).await?;
|
llm_auth,
|
||||||
|
system_prompt,
|
||||||
|
context_limit,
|
||||||
|
left,
|
||||||
|
similar_artists,
|
||||||
|
similar_releases,
|
||||||
|
folder_ctx,
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
let right_result = Box::pin(normalize_batch(
|
let right_result = Box::pin(normalize_batch(
|
||||||
llm_url, llm_model, llm_auth, system_prompt, context_limit,
|
llm_url,
|
||||||
right, similar_artists, similar_releases, folder_ctx,
|
llm_model,
|
||||||
)).await?;
|
llm_auth,
|
||||||
|
system_prompt,
|
||||||
|
context_limit,
|
||||||
|
right,
|
||||||
|
similar_artists,
|
||||||
|
similar_releases,
|
||||||
|
folder_ctx,
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
|
||||||
let mut results = left_result.results;
|
let mut results = left_result.results;
|
||||||
results.extend(right_result.results);
|
results.extend(right_result.results);
|
||||||
@@ -363,7 +463,8 @@ pub async fn normalize_batch(
|
|||||||
results,
|
results,
|
||||||
model: left_result.model,
|
model: left_result.model,
|
||||||
prompt_tokens: left_result.prompt_tokens + right_result.prompt_tokens,
|
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,
|
duration_ms: left_result.duration_ms + right_result.duration_ms,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -398,9 +499,7 @@ fn parse_batch_response(
|
|||||||
|
|
||||||
// Strip markdown code fences if present
|
// Strip markdown code fences if present
|
||||||
let json_str = if cleaned.starts_with("```") {
|
let json_str = if cleaned.starts_with("```") {
|
||||||
let start = cleaned.find('[')
|
let start = cleaned.find('[').or_else(|| cleaned.find('{')).unwrap_or(0);
|
||||||
.or_else(|| cleaned.find('{'))
|
|
||||||
.unwrap_or(0);
|
|
||||||
let end_bracket = cleaned.rfind(']').map(|i| i + 1);
|
let end_bracket = cleaned.rfind(']').map(|i| i + 1);
|
||||||
let end_brace = 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());
|
let end = end_bracket.or(end_brace).unwrap_or(cleaned.len());
|
||||||
|
|||||||
@@ -113,11 +113,8 @@ fn parse_album_with_year(dir: &str) -> (String, Option<i32>) {
|
|||||||
let inside = &dir[start + 1..start + end];
|
let inside = &dir[start + 1..start + end];
|
||||||
if let Ok(year) = inside.trim().parse::<i32>() {
|
if let Ok(year) = inside.trim().parse::<i32>() {
|
||||||
if (1900..=2100).contains(&year) {
|
if (1900..=2100).contains(&year) {
|
||||||
let album = format!(
|
let album =
|
||||||
"{}{}",
|
format!("{}{}", &dir[..start].trim(), &dir[start + end + 1..].trim());
|
||||||
&dir[..start].trim(),
|
|
||||||
&dir[start + end + 1..].trim()
|
|
||||||
);
|
|
||||||
let album = album.trim().to_owned();
|
let album = album.trim().to_owned();
|
||||||
return (album, Some(year));
|
return (album, Some(year));
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-7
@@ -34,10 +34,7 @@ struct MeResponse {
|
|||||||
role: String,
|
role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn me_handler(
|
async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response::Response> {
|
||||||
session: Session,
|
|
||||||
db: Database,
|
|
||||||
) -> cot::Result<cot::response::Response> {
|
|
||||||
let Some(user) = auth::get_session_user(&session, &db).await else {
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||||
return Ok(json_error(
|
return Ok(json_error(
|
||||||
cot::http::StatusCode::UNAUTHORIZED,
|
cot::http::StatusCode::UNAUTHORIZED,
|
||||||
@@ -65,8 +62,10 @@ impl App for ApiApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn router(&self) -> Router {
|
fn router(&self) -> Router {
|
||||||
Router::with_urls([
|
Router::with_urls([Route::with_api_handler_and_name(
|
||||||
Route::with_api_handler_and_name("/me", api_get(me_handler), "api_me"),
|
"/me",
|
||||||
])
|
api_get(me_handler),
|
||||||
|
"api_me",
|
||||||
|
)])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-7
@@ -1,7 +1,7 @@
|
|||||||
|
use cot::Body;
|
||||||
use cot::db::Database;
|
use cot::db::Database;
|
||||||
use cot::response::IntoResponse;
|
use cot::response::IntoResponse;
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
use cot::Body;
|
|
||||||
|
|
||||||
use crate::user::User;
|
use crate::user::User;
|
||||||
|
|
||||||
@@ -78,12 +78,10 @@ pub async fn require_admin_or_redirect(
|
|||||||
return Err(redirect("/login"));
|
return Err(redirect("/login"));
|
||||||
};
|
};
|
||||||
if user.role != Role::Admin {
|
if user.role != Role::Admin {
|
||||||
return Err(
|
return Err("Forbidden"
|
||||||
"Forbidden"
|
.with_status(cot::http::StatusCode::FORBIDDEN)
|
||||||
.with_status(cot::http::StatusCode::FORBIDDEN)
|
.into_response()
|
||||||
.into_response()
|
.expect("valid response"));
|
||||||
.expect("valid response"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Ok(user)
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-31
@@ -66,24 +66,19 @@ pub mod db_migrations {
|
|||||||
const APP_NAME: &'static str = "furumusic";
|
const APP_NAME: &'static str = "furumusic";
|
||||||
const MIGRATION_NAME: &'static str = "m_0001_create_config";
|
const MIGRATION_NAME: &'static str = "m_0001_create_config";
|
||||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[];
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[];
|
||||||
const OPERATIONS: &'static [Operation] = &[
|
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||||
Operation::create_model()
|
.table_name(Identifier::new("furu__config"))
|
||||||
.table_name(Identifier::new("furu__config"))
|
.fields(&[
|
||||||
.fields(&[
|
Field::new(
|
||||||
Field::new(
|
Identifier::new("key"),
|
||||||
Identifier::new("key"),
|
<LimitedString<255> as DatabaseField>::TYPE,
|
||||||
<LimitedString<255> as DatabaseField>::TYPE,
|
)
|
||||||
)
|
.primary_key()
|
||||||
.primary_key()
|
.set_null(<LimitedString<255> as DatabaseField>::NULLABLE),
|
||||||
.set_null(<LimitedString<255> as DatabaseField>::NULLABLE),
|
Field::new(Identifier::new("value"), <String as DatabaseField>::TYPE)
|
||||||
Field::new(
|
|
||||||
Identifier::new("value"),
|
|
||||||
<String as DatabaseField>::TYPE,
|
|
||||||
)
|
|
||||||
.set_null(<String as DatabaseField>::NULLABLE),
|
.set_null(<String as DatabaseField>::NULLABLE),
|
||||||
])
|
])
|
||||||
.build(),
|
.build()];
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- M0002: rename furu__config → furumusic__config_entry ---------------
|
// -- M0002: rename furu__config → furumusic__config_entry ---------------
|
||||||
@@ -102,12 +97,12 @@ pub mod db_migrations {
|
|||||||
impl migrations::Migration for M0002RenameConfigTable {
|
impl migrations::Migration for M0002RenameConfigTable {
|
||||||
const APP_NAME: &'static str = "furumusic";
|
const APP_NAME: &'static str = "furumusic";
|
||||||
const MIGRATION_NAME: &'static str = "m_0002_rename_config_table";
|
const MIGRATION_NAME: &'static str = "m_0002_rename_config_table";
|
||||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
migrations::MigrationDependency::migration("furumusic", "m_0001_create_config"),
|
&[migrations::MigrationDependency::migration(
|
||||||
];
|
"furumusic",
|
||||||
const OPERATIONS: &'static [Operation] = &[
|
"m_0001_create_config",
|
||||||
Operation::custom(rename_config_table).build(),
|
)];
|
||||||
];
|
const OPERATIONS: &'static [Operation] = &[Operation::custom(rename_config_table).build()];
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[&M0001CreateConfig, &M0002RenameConfigTable];
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[&M0001CreateConfig, &M0002RenameConfigTable];
|
||||||
@@ -402,35 +397,51 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: tests run with --test-threads=1 so no concurrent env access.
|
// 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 set(k: &str, v: &str) {
|
||||||
unsafe fn unset(k: &str) { unsafe { std::env::remove_var(k) }; }
|
unsafe { std::env::set_var(k, v) };
|
||||||
|
}
|
||||||
|
unsafe fn unset(k: &str) {
|
||||||
|
unsafe { std::env::remove_var(k) };
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn env_override_string_field() {
|
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();
|
let cfg = AppConfig::load();
|
||||||
assert_eq!(cfg.oidc_issuer, "https://example.com");
|
assert_eq!(cfg.oidc_issuer, "https://example.com");
|
||||||
unsafe { unset("FURU_OIDC_ISSUER"); }
|
unsafe {
|
||||||
|
unset("FURU_OIDC_ISSUER");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn env_override_bool_field() {
|
fn env_override_bool_field() {
|
||||||
unsafe { set("FURU_AUTH_SSO_ENABLED", "true"); }
|
unsafe {
|
||||||
|
set("FURU_AUTH_SSO_ENABLED", "true");
|
||||||
|
}
|
||||||
let cfg = AppConfig::load();
|
let cfg = AppConfig::load();
|
||||||
assert!(cfg.auth_sso_enabled);
|
assert!(cfg.auth_sso_enabled);
|
||||||
unsafe { unset("FURU_AUTH_SSO_ENABLED"); }
|
unsafe {
|
||||||
|
unset("FURU_AUTH_SSO_ENABLED");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn source_tracking_env() {
|
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 cfg = AppConfig::default();
|
||||||
let mut sources = ConfigSources::default();
|
let mut sources = ConfigSources::default();
|
||||||
cfg.apply_env_overrides_tracked(&mut sources);
|
cfg.apply_env_overrides_tracked(&mut sources);
|
||||||
assert_eq!(cfg.oidc_issuer, "https://tracked.example.com");
|
assert_eq!(cfg.oidc_issuer, "https://tracked.example.com");
|
||||||
assert_eq!(sources.oidc_issuer, ConfigSource::Env);
|
assert_eq!(sources.oidc_issuer, ConfigSource::Env);
|
||||||
assert_eq!(sources.database_url, ConfigSource::Default);
|
assert_eq!(sources.database_url, ConfigSource::Default);
|
||||||
unsafe { unset("FURU_OIDC_ISSUER"); }
|
unsafe {
|
||||||
|
unset("FURU_OIDC_ISSUER");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+12
-6
@@ -2,10 +2,16 @@ mod phrases;
|
|||||||
|
|
||||||
pub use phrases::Translations;
|
pub use phrases::Translations;
|
||||||
|
|
||||||
use cot::request::extractors::FromRequestHead;
|
|
||||||
use cot::request::RequestHead;
|
use cot::request::RequestHead;
|
||||||
|
use cot::request::extractors::FromRequestHead;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
impl Translations {
|
||||||
|
pub fn app_version(&self) -> &'static str {
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Lang enum
|
// 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.
|
/// Build a `Set-Cookie` header value that persists the language choice for 1 year.
|
||||||
pub fn lang_cookie(lang: Lang) -> String {
|
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.
|
/// Parse `furu_lang` from the `Cookie` request header.
|
||||||
@@ -203,10 +212,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_unknown_falls_through() {
|
fn parse_unknown_falls_through() {
|
||||||
assert_eq!(
|
assert_eq!(parse_accept_language("de;q=1.0,ru;q=0.5"), Some(Lang::Ru));
|
||||||
parse_accept_language("de;q=1.0,ru;q=0.5"),
|
|
||||||
Some(Lang::Ru)
|
|
||||||
);
|
|
||||||
assert_eq!(parse_accept_language("de,fr,ja"), None);
|
assert_eq!(parse_accept_language("de,fr,ja"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,11 @@ translations! {
|
|||||||
jobs_back_to_list: "Back to jobs" , "Назад к заданиям";
|
jobs_back_to_list: "Back to jobs" , "Назад к заданиям";
|
||||||
jobs_run_detail: "Run detail" , "Детали запуска";
|
jobs_run_detail: "Run detail" , "Детали запуска";
|
||||||
jobs_back_to_job: "Back to job" , "Назад к заданию";
|
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
|
// Review management
|
||||||
reviews_heading: "Pending Reviews" , "Ожидающие проверки";
|
reviews_heading: "Pending Reviews" , "Ожидающие проверки";
|
||||||
@@ -194,6 +199,7 @@ translations! {
|
|||||||
reviews_status: "Status" , "Статус";
|
reviews_status: "Status" , "Статус";
|
||||||
reviews_type: "Type" , "Тип";
|
reviews_type: "Type" , "Тип";
|
||||||
reviews_input_path: "Input" , "Файл";
|
reviews_input_path: "Input" , "Файл";
|
||||||
|
reviews_tags: "Tags" , "Теги";
|
||||||
reviews_confidence: "Confidence" , "Уверенность";
|
reviews_confidence: "Confidence" , "Уверенность";
|
||||||
reviews_approve: "Approve" , "Подтвердить";
|
reviews_approve: "Approve" , "Подтвердить";
|
||||||
reviews_reject: "Reject" , "Отклонить";
|
reviews_reject: "Reject" , "Отклонить";
|
||||||
@@ -204,6 +210,15 @@ translations! {
|
|||||||
reviews_clear_all: "Clear all" , "Очистить все";
|
reviews_clear_all: "Clear all" , "Очистить все";
|
||||||
reviews_clear_filtered: "Clear shown" , "Очистить показанные";
|
reviews_clear_filtered: "Clear shown" , "Очистить показанные";
|
||||||
reviews_clear_confirm: "Are you sure? This will delete the selected reviews." , "Вы уверены? Выбранные проверки будут удалены.";
|
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_back_to_list: "Back to reviews" , "Назад к проверкам";
|
||||||
reviews_filter_all: "All" , "Все";
|
reviews_filter_all: "All" , "Все";
|
||||||
reviews_filter_pending: "Pending" , "Ожидают";
|
reviews_filter_pending: "Pending" , "Ожидают";
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ impl Job for ArtistImageBackfillJob {
|
|||||||
|
|
||||||
let count = result.rows_affected();
|
let count = result.rows_affected();
|
||||||
if count > 0 {
|
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 {
|
} else {
|
||||||
log.info("All artists already have images (or no covers available)");
|
log.info("All artists already have images (or no covers available)");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,10 +87,8 @@ impl Job for CoverBackfillJob {
|
|||||||
let folder = first_path.parent().unwrap_or(Path::new("."));
|
let folder = first_path.parent().unwrap_or(Path::new("."));
|
||||||
|
|
||||||
// Collect all audio file paths as PathBuf
|
// Collect all audio file paths as PathBuf
|
||||||
let audio_files: Vec<PathBuf> = audio_paths
|
let audio_files: Vec<PathBuf> =
|
||||||
.iter()
|
audio_paths.iter().map(|(p,)| PathBuf::from(p)).collect();
|
||||||
.map(|(p,)| PathBuf::from(p))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Try to find cover art
|
// Try to find cover art
|
||||||
let cover = match cover_art::find_best_cover(folder, &audio_files).await {
|
let cover = match cover_art::find_best_cover(folder, &audio_files).await {
|
||||||
@@ -135,12 +133,9 @@ impl Job for CoverBackfillJob {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(cover_file_id) => {
|
Ok(cover_file_id) => {
|
||||||
if let Err(e) = cover_art::assign_cover_to_release(
|
if let Err(e) =
|
||||||
&ctx.pool,
|
cover_art::assign_cover_to_release(&ctx.pool, *release_id, cover_file_id)
|
||||||
*release_id,
|
.await
|
||||||
cover_file_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
log.warn(&format!(
|
log.warn(&format!(
|
||||||
"Release {release_id} \"{release_title}\": saved cover but failed to assign: {e}"
|
"Release {release_id} \"{release_title}\": saved cover but failed to assign: {e}"
|
||||||
|
|||||||
+45
-26
@@ -30,7 +30,10 @@ impl Job for InboxDiscoverJob {
|
|||||||
|
|
||||||
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
||||||
// Prevent overlapping discover runs
|
// 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");
|
log.info("Another inbox_discover is already running, skipping");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -82,31 +85,38 @@ impl Job for InboxDiscoverJob {
|
|||||||
}
|
}
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
Err(e) => {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute SHA-256 hash
|
// Compute SHA-256 hash
|
||||||
let path_clone = file_path.to_path_buf();
|
let path_clone = file_path.to_path_buf();
|
||||||
let (hash, file_size) = match tokio::task::spawn_blocking(move || -> anyhow::Result<(String, i64)> {
|
let (hash, file_size) =
|
||||||
let data = std::fs::read(&path_clone)?;
|
match tokio::task::spawn_blocking(move || -> anyhow::Result<(String, i64)> {
|
||||||
let digest = Sha256::digest(&data);
|
let data = std::fs::read(&path_clone)?;
|
||||||
let hash = format!("{:x}", digest);
|
let digest = Sha256::digest(&data);
|
||||||
let size = data.len() as i64;
|
let hash = format!("{:x}", digest);
|
||||||
Ok((hash, size))
|
let size = data.len() as i64;
|
||||||
})
|
Ok((hash, size))
|
||||||
.await?
|
})
|
||||||
{
|
.await?
|
||||||
Ok(v) => v,
|
{
|
||||||
Err(e) => {
|
Ok(v) => v,
|
||||||
log.warn(&format!("Failed to hash {}: {e}", file_path.display()));
|
Err(e) => {
|
||||||
continue;
|
log.warn(&format!("Failed to hash {}: {e}", file_path.display()));
|
||||||
}
|
continue;
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Skip if hash already in media_files
|
// 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;
|
skipped_hash += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -120,7 +130,10 @@ impl Job for InboxDiscoverJob {
|
|||||||
{
|
{
|
||||||
Ok(m) => m,
|
Ok(m) => m,
|
||||||
Err(e) => {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -140,6 +153,9 @@ impl Job for InboxDiscoverJob {
|
|||||||
"raw_year": raw_meta.year,
|
"raw_year": raw_meta.year,
|
||||||
"raw_genre": raw_meta.genre,
|
"raw_genre": raw_meta.genre,
|
||||||
"duration_secs": raw_meta.duration_secs,
|
"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_title": hints.title,
|
||||||
"path_artist": hints.artist,
|
"path_artist": hints.artist,
|
||||||
"path_album": hints.album,
|
"path_album": hints.album,
|
||||||
@@ -172,7 +188,9 @@ impl Job for InboxDiscoverJob {
|
|||||||
// and no orchestrator is already running
|
// and no orchestrator is already running
|
||||||
if discovered > 0 {
|
if discovered > 0 {
|
||||||
if crate::jobs::inbox_process::is_orchestrator_running() {
|
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 {
|
} else {
|
||||||
log.info("Spawning inbox_process in background...");
|
log.info("Spawning inbox_process in background...");
|
||||||
let config = ctx.config.clone();
|
let config = ctx.config.clone();
|
||||||
@@ -181,11 +199,15 @@ impl Job for InboxDiscoverJob {
|
|||||||
let registry = ctx.registry.clone();
|
let registry = ctx.registry.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = crate::scheduler::trigger_job_now(
|
if let Err(e) = crate::scheduler::trigger_job_now(
|
||||||
&config, &db, &pool, ®istry, "inbox_process",
|
&config,
|
||||||
|
&db,
|
||||||
|
&pool,
|
||||||
|
®istry,
|
||||||
|
"inbox_process",
|
||||||
)
|
)
|
||||||
.await
|
.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
|
groups
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn collect_audio_files(
|
pub async fn collect_audio_files(dir: &Path, audio: &mut Vec<PathBuf>) -> anyhow::Result<()> {
|
||||||
dir: &Path,
|
|
||||||
audio: &mut Vec<PathBuf>,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let mut entries = tokio::fs::read_dir(dir).await?;
|
let mut entries = tokio::fs::read_dir(dir).await?;
|
||||||
while let Some(entry) = entries.next_entry().await? {
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
let name = entry.file_name().to_string_lossy().into_owned();
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
|||||||
+136
-85
@@ -20,12 +20,10 @@ pub fn is_orchestrator_running() -> bool {
|
|||||||
/// Try to acquire the PostgreSQL advisory lock for the orchestrator.
|
/// Try to acquire the PostgreSQL advisory lock for the orchestrator.
|
||||||
/// Returns true if the lock was acquired (no other orchestrator is running).
|
/// Returns true if the lock was acquired (no other orchestrator is running).
|
||||||
async fn try_acquire_orchestrator_lock(pool: &sqlx::PgPool) -> bool {
|
async fn try_acquire_orchestrator_lock(pool: &sqlx::PgPool) -> bool {
|
||||||
match sqlx::query_scalar::<_, bool>(
|
match sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)")
|
||||||
"SELECT pg_try_advisory_lock($1)"
|
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
||||||
)
|
.fetch_one(pool)
|
||||||
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
.await
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
Ok(acquired) => acquired,
|
Ok(acquired) => acquired,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -43,14 +41,12 @@ async fn release_orchestrator_lock(pool: &sqlx::PgPool) {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
use crate::agent::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata};
|
||||||
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::mover;
|
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] = &[
|
const AUDIO_EXTENSIONS: &[&str] = &[
|
||||||
"mp3", "flac", "ogg", "opus", "aac", "m4a", "wav", "ape", "wv", "wma", "tta", "aiff", "aif",
|
"mp3", "flac", "ogg", "opus", "aac", "m4a", "wav", "ape", "wv", "wma", "tta", "aiff", "aif",
|
||||||
@@ -83,8 +79,13 @@ impl Job for InboxProcessJob {
|
|||||||
previous_value = prev,
|
previous_value = prev,
|
||||||
"inbox_process: checking ORCHESTRATOR_RUNNING AtomicBool"
|
"inbox_process: checking ORCHESTRATOR_RUNNING AtomicBool"
|
||||||
);
|
);
|
||||||
if ORCHESTRATOR_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
|
if ORCHESTRATOR_RUNNING
|
||||||
log.info("Another inbox_process orchestrator is already running (AtomicBool), skipping");
|
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
log.info(
|
||||||
|
"Another inbox_process orchestrator is already running (AtomicBool), skipping",
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
struct AtomicGuard;
|
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 config = Arc::clone(&ctx.config);
|
||||||
let mut total_ok = 0u64;
|
let mut total_ok = 0u64;
|
||||||
@@ -151,9 +154,9 @@ impl Job for InboxProcessJob {
|
|||||||
folder_rel, file_count,
|
folder_rel, file_count,
|
||||||
));
|
));
|
||||||
|
|
||||||
let (ok, fail) = process_folder_batch(
|
let (ok, fail) =
|
||||||
&ctx.db, &config, &ctx.pool, &folder_rel, reviews, log,
|
process_folder_batch(&ctx.db, &config, &ctx.pool, &folder_rel, reviews, log)
|
||||||
).await;
|
.await;
|
||||||
|
|
||||||
total_ok += ok;
|
total_ok += ok;
|
||||||
total_fail += fail;
|
total_fail += fail;
|
||||||
@@ -296,7 +299,7 @@ async fn process_folder_batch(
|
|||||||
let _ = review.set_processing(db).await;
|
let _ = review.set_processing(db).await;
|
||||||
|
|
||||||
// Parse context_json
|
// Parse context_json
|
||||||
let context: serde_json::Value = review
|
let mut context: serde_json::Value = review
|
||||||
.context_json
|
.context_json
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|s| serde_json::from_str(s).ok())
|
.and_then(|s| serde_json::from_str(s).ok())
|
||||||
@@ -304,40 +307,51 @@ async fn process_folder_batch(
|
|||||||
|
|
||||||
// Extract metadata (with 60s timeout)
|
// Extract metadata (with 60s timeout)
|
||||||
let path_for_meta = file_path.to_path_buf();
|
let path_for_meta = file_path.to_path_buf();
|
||||||
let meta_future = tokio::task::spawn_blocking(move || {
|
let meta_future =
|
||||||
crate::agent::metadata::extract(&path_for_meta)
|
tokio::task::spawn_blocking(move || crate::agent::metadata::extract(&path_for_meta));
|
||||||
});
|
let raw_meta =
|
||||||
let raw_meta = match tokio::time::timeout(
|
match tokio::time::timeout(std::time::Duration::from_secs(60), meta_future).await {
|
||||||
std::time::Duration::from_secs(60),
|
Ok(Ok(Ok(m))) => m,
|
||||||
meta_future,
|
Ok(Ok(Err(e))) => {
|
||||||
).await {
|
let msg = format!("{filename}: metadata error: {e}");
|
||||||
Ok(Ok(Ok(m))) => m,
|
log.error(&msg);
|
||||||
Ok(Ok(Err(e))) => {
|
let _ = review.set_failed(db, &msg).await;
|
||||||
let msg = format!("{filename}: metadata error: {e}");
|
failed_reviews.push(review);
|
||||||
log.error(&msg);
|
continue;
|
||||||
let _ = review.set_failed(db, &msg).await;
|
}
|
||||||
failed_reviews.push(review);
|
Ok(Err(e)) => {
|
||||||
continue;
|
let msg = format!("{filename}: metadata panic: {e}");
|
||||||
}
|
log.error(&msg);
|
||||||
Ok(Err(e)) => {
|
let _ = review.set_failed(db, &msg).await;
|
||||||
let msg = format!("{filename}: metadata panic: {e}");
|
failed_reviews.push(review);
|
||||||
log.error(&msg);
|
continue;
|
||||||
let _ = review.set_failed(db, &msg).await;
|
}
|
||||||
failed_reviews.push(review);
|
Err(_) => {
|
||||||
continue;
|
let msg = format!("{filename}: metadata timeout (60s)");
|
||||||
}
|
log.error(&msg);
|
||||||
Err(_) => {
|
let _ = review.set_failed(db, &msg).await;
|
||||||
let msg = format!("{filename}: metadata timeout (60s)");
|
failed_reviews.push(review);
|
||||||
log.error(&msg);
|
continue;
|
||||||
let _ = review.set_failed(db, &msg).await;
|
}
|
||||||
failed_reviews.push(review);
|
};
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse path hints
|
// Parse path hints
|
||||||
let relative = file_path.strip_prefix(inbox_path).unwrap_or(file_path);
|
let relative = file_path.strip_prefix(inbox_path).unwrap_or(file_path);
|
||||||
let hints = crate::agent::path_hints::parse(relative);
|
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 {
|
prepared.push(PreparedFile {
|
||||||
review,
|
review,
|
||||||
@@ -366,14 +380,20 @@ async fn process_folder_batch(
|
|||||||
let mut album_queries: Vec<String> = Vec::new();
|
let mut album_queries: Vec<String> = Vec::new();
|
||||||
|
|
||||||
for p in &prepared {
|
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())
|
.or(p.hints.artist.as_deref())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_owned();
|
.to_owned();
|
||||||
if !artist_q.is_empty() && !artist_queries.contains(&artist_q) {
|
if !artist_q.is_empty() && !artist_queries.contains(&artist_q) {
|
||||||
artist_queries.push(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())
|
.or(p.hints.album.as_deref())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_owned();
|
.to_owned();
|
||||||
@@ -388,10 +408,15 @@ async fn process_folder_batch(
|
|||||||
match tokio::time::timeout(
|
match tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(30),
|
std::time::Duration::from_secs(30),
|
||||||
crate::agent::rag::find_similar_artists(pool, q, 5),
|
crate::agent::rag::find_similar_artists(pool, q, 5),
|
||||||
).await {
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(Ok(results)) => {
|
Ok(Ok(results)) => {
|
||||||
for a in 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);
|
all_similar_artists.push(a);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -406,10 +431,15 @@ async fn process_folder_batch(
|
|||||||
match tokio::time::timeout(
|
match tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(30),
|
std::time::Duration::from_secs(30),
|
||||||
crate::agent::rag::find_similar_releases(pool, q, 5),
|
crate::agent::rag::find_similar_releases(pool, q, 5),
|
||||||
).await {
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(Ok(results)) => {
|
Ok(Ok(results)) => {
|
||||||
for r in 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);
|
all_similar_releases.push(r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,8 +488,9 @@ async fn process_folder_batch(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build batch input
|
// Build batch input
|
||||||
let batch_files: Vec<BatchFileInput> = prepared.iter().map(|p| {
|
let batch_files: Vec<BatchFileInput> = prepared
|
||||||
BatchFileInput {
|
.iter()
|
||||||
|
.map(|p| BatchFileInput {
|
||||||
filename: p.filename.clone(),
|
filename: p.filename.clone(),
|
||||||
raw: RawMetadata {
|
raw: RawMetadata {
|
||||||
title: p.raw_meta.title.clone(),
|
title: p.raw_meta.title.clone(),
|
||||||
@@ -469,6 +500,9 @@ async fn process_folder_batch(
|
|||||||
year: p.raw_meta.year,
|
year: p.raw_meta.year,
|
||||||
genre: p.raw_meta.genre.clone(),
|
genre: p.raw_meta.genre.clone(),
|
||||||
duration_secs: p.raw_meta.duration_secs,
|
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 {
|
hints: PathHints {
|
||||||
title: p.hints.title.clone(),
|
title: p.hints.title.clone(),
|
||||||
@@ -477,8 +511,8 @@ async fn process_folder_batch(
|
|||||||
year: p.hints.year,
|
year: p.hints.year,
|
||||||
track_number: p.hints.track_number,
|
track_number: p.hints.track_number,
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
|
|
||||||
let system_prompt = include_str!("../../prompts/normalize_batch.txt");
|
let system_prompt = include_str!("../../prompts/normalize_batch.txt");
|
||||||
let context_limit = config.agent_context_limit;
|
let context_limit = config.agent_context_limit;
|
||||||
@@ -493,7 +527,8 @@ async fn process_folder_batch(
|
|||||||
&all_similar_artists,
|
&all_similar_artists,
|
||||||
&all_similar_releases,
|
&all_similar_releases,
|
||||||
Some(&folder_ctx),
|
Some(&folder_ctx),
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let batch_result = match llm_result {
|
let batch_result = match llm_result {
|
||||||
Ok(r) => r,
|
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 total_fail_count = failed_reviews.len() as u64 + file_count as u64;
|
||||||
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
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);
|
return (0, total_fail_count);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -524,9 +561,7 @@ async fn process_folder_batch(
|
|||||||
log.info("Phase 4: finalizing...");
|
log.info("Phase 4: finalizing...");
|
||||||
|
|
||||||
// Build lookup map: filename → NormalizedFields
|
// Build lookup map: filename → NormalizedFields
|
||||||
let result_map: HashMap<String, NormalizedFields> = batch_result.results
|
let result_map: HashMap<String, NormalizedFields> = batch_result.results.into_iter().collect();
|
||||||
.into_iter()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let llm_model = &batch_result.model;
|
let llm_model = &batch_result.model;
|
||||||
let prompt_per_file = batch_result.prompt_tokens / prepared.len().max(1) as u64;
|
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,
|
duration_per_file,
|
||||||
prompt_per_file as i64,
|
prompt_per_file as i64,
|
||||||
completion_per_file as i64,
|
completion_per_file as i64,
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let result_json = serde_json::to_string(normalized).unwrap_or_default();
|
let result_json = serde_json::to_string(normalized).unwrap_or_default();
|
||||||
let confidence = normalized.confidence.unwrap_or(0.0);
|
let confidence = normalized.confidence.unwrap_or(0.0);
|
||||||
@@ -573,7 +609,9 @@ async fn process_folder_batch(
|
|||||||
normalized.artist.as_deref().unwrap_or("-"),
|
normalized.artist.as_deref().unwrap_or("-"),
|
||||||
normalized.album.as_deref().unwrap_or("-"),
|
normalized.album.as_deref().unwrap_or("-"),
|
||||||
normalized.title.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()),
|
normalized.year.map_or("-".into(), |y| y.to_string()),
|
||||||
confidence,
|
confidence,
|
||||||
feat,
|
feat,
|
||||||
@@ -586,9 +624,17 @@ async fn process_folder_batch(
|
|||||||
|
|
||||||
if confidence >= config.agent_confidence_threshold {
|
if confidence >= config.agent_confidence_threshold {
|
||||||
match finalize_approved(
|
match finalize_approved(
|
||||||
db, pool, config, &input_path_str, normalized, &p.context,
|
db,
|
||||||
&config.agent_storage_dir, Some(llm_model),
|
pool,
|
||||||
).await {
|
config,
|
||||||
|
&input_path_str,
|
||||||
|
normalized,
|
||||||
|
&p.context,
|
||||||
|
&config.agent_storage_dir,
|
||||||
|
Some(llm_model),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let _ = p.review.set_auto_approved(db).await;
|
let _ = p.review.set_auto_approved(db).await;
|
||||||
ok_count += 1;
|
ok_count += 1;
|
||||||
@@ -604,7 +650,8 @@ async fn process_folder_batch(
|
|||||||
p.review.status = cot::db::LimitedString::new("pending").unwrap();
|
p.review.status = cot::db::LimitedString::new("pending").unwrap();
|
||||||
p.review.updated_at = cot::db::LimitedString::new(
|
p.review.updated_at = cot::db::LimitedString::new(
|
||||||
&chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
&chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
let _ = p.review.save(db).await;
|
let _ = p.review.save(db).await;
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
"{filename}: manual review (confidence {confidence} < {})",
|
"{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}"))?;
|
.map_err(|e| anyhow::anyhow!("failed to link release-artist: {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let sha256 = context
|
let sha256 = context.get("sha256").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
.get("sha256")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
let file_size = context
|
let file_size = context
|
||||||
.get("file_size")
|
.get("file_size")
|
||||||
.and_then(|v| v.as_i64())
|
.and_then(|v| v.as_i64())
|
||||||
@@ -681,6 +725,18 @@ pub async fn finalize_approved(
|
|||||||
.get("duration_secs")
|
.get("duration_secs")
|
||||||
.and_then(|v| v.as_f64())
|
.and_then(|v| v.as_f64())
|
||||||
.unwrap_or(0.0);
|
.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 source_path = Path::new(input_path_str);
|
||||||
let original_filename = source_path
|
let original_filename = source_path
|
||||||
@@ -746,9 +802,9 @@ pub async fn finalize_approved(
|
|||||||
file_size,
|
file_size,
|
||||||
sha256,
|
sha256,
|
||||||
Some(ext),
|
Some(ext),
|
||||||
None,
|
audio_bitrate,
|
||||||
None,
|
audio_sample_rate,
|
||||||
None,
|
audio_bit_depth,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("failed to create media file: {e}"))?;
|
.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
|
// Cover art: if the release has no cover yet, try to find one
|
||||||
if release.cover_file_id.is_none() {
|
if release.cover_file_id.is_none() {
|
||||||
let source_folder = Path::new(input_path_str)
|
let source_folder = Path::new(input_path_str).parent().unwrap_or(Path::new("."));
|
||||||
.parent()
|
|
||||||
.unwrap_or(Path::new("."));
|
|
||||||
|
|
||||||
// Collect audio files in the same folder to try embedded extraction
|
// 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)
|
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 {
|
} else if max_len <= 3 {
|
||||||
".".repeat(max_len)
|
".".repeat(max_len)
|
||||||
} else {
|
} else {
|
||||||
let suffix: String = path
|
let suffix: String = path.chars().skip(char_count - (max_len - 3)).collect();
|
||||||
.chars()
|
|
||||||
.skip(char_count - (max_len - 3))
|
|
||||||
.collect();
|
|
||||||
format!("...{suffix}")
|
format!("...{suffix}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@ pub mod artist_track_image_backfill;
|
|||||||
pub mod cover_backfill;
|
pub mod cover_backfill;
|
||||||
pub mod inbox_discover;
|
pub mod inbox_discover;
|
||||||
pub mod inbox_process;
|
pub mod inbox_process;
|
||||||
|
pub mod metadata_backfill;
|
||||||
|
|||||||
+14
-17
@@ -24,13 +24,13 @@ use cot::db::Database;
|
|||||||
use cot::form::{Form, FormResult};
|
use cot::form::{Form, FormResult};
|
||||||
use cot::html::Html;
|
use cot::html::Html;
|
||||||
use cot::middleware::SessionMiddleware;
|
use cot::middleware::SessionMiddleware;
|
||||||
use cot::static_files::StaticFilesMiddleware;
|
|
||||||
use cot::project::RegisterAppsContext;
|
use cot::project::RegisterAppsContext;
|
||||||
use cot::request::extractors::{RequestForm, UrlQuery};
|
use cot::request::extractors::{RequestForm, UrlQuery};
|
||||||
use cot::response::IntoResponse;
|
use cot::response::IntoResponse;
|
||||||
use cot::router::method::get;
|
use cot::router::method::get;
|
||||||
use cot::router::{Route, Router};
|
use cot::router::{Route, Router};
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
|
use cot::static_files::StaticFilesMiddleware;
|
||||||
use cot::{App, AppBuilder, Body, Project, Template};
|
use cot::{App, AppBuilder, Body, Project, Template};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@ fn build_registry() -> Arc<JobRegistry> {
|
|||||||
registry.register(jobs::cover_backfill::CoverBackfillJob);
|
registry.register(jobs::cover_backfill::CoverBackfillJob);
|
||||||
registry.register(jobs::artist_image_backfill::ArtistImageBackfillJob);
|
registry.register(jobs::artist_image_backfill::ArtistImageBackfillJob);
|
||||||
registry.register(jobs::artist_track_image_backfill::ArtistTrackImageBackfillJob);
|
registry.register(jobs::artist_track_image_backfill::ArtistTrackImageBackfillJob);
|
||||||
|
registry.register(jobs::metadata_backfill::MetadataBackfillJob);
|
||||||
Arc::new(registry)
|
Arc::new(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,11 +59,7 @@ fn build_registry() -> Arc<JobRegistry> {
|
|||||||
// Handlers
|
// Handlers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async fn index(
|
async fn index(session: Session, db: Database, i18n: I18n) -> cot::Result<cot::response::Response> {
|
||||||
session: Session,
|
|
||||||
db: Database,
|
|
||||||
i18n: I18n,
|
|
||||||
) -> cot::Result<cot::response::Response> {
|
|
||||||
let _user = match auth::get_session_user(&session, &db).await {
|
let _user = match auth::get_session_user(&session, &db).await {
|
||||||
Some(u) => u,
|
Some(u) => u,
|
||||||
None => return Ok(auth::redirect("/login")),
|
None => return Ok(auth::redirect("/login")),
|
||||||
@@ -164,7 +161,8 @@ impl App for FuruApp {
|
|||||||
get(|| async { Ok::<_, cot::Error>(auth::redirect("/swagger/")) }),
|
get(|| async { Ok::<_, cot::Error>(auth::redirect("/swagger/")) }),
|
||||||
"swagger_redirect",
|
"swagger_redirect",
|
||||||
),
|
),
|
||||||
Route::with_handler_and_name("/",
|
Route::with_handler_and_name(
|
||||||
|
"/",
|
||||||
|session: Session, db: Database, i18n: I18n| async move {
|
|session: Session, db: Database, i18n: I18n| async move {
|
||||||
index(session, db, i18n).await
|
index(session, db, i18n).await
|
||||||
},
|
},
|
||||||
@@ -186,9 +184,12 @@ impl App for FuruApp {
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).post({
|
})
|
||||||
|
.post({
|
||||||
let config = Arc::clone(&self.config);
|
let config = Arc::clone(&self.config);
|
||||||
move |i18n: I18n, db: Database, session: Session,
|
move |i18n: I18n,
|
||||||
|
db: Database,
|
||||||
|
session: Session,
|
||||||
form: RequestForm<LoginForm>| {
|
form: RequestForm<LoginForm>| {
|
||||||
let config = Arc::clone(&config);
|
let config = Arc::clone(&config);
|
||||||
async move {
|
async move {
|
||||||
@@ -204,8 +205,7 @@ impl App for FuruApp {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Try to authenticate
|
// Try to authenticate
|
||||||
if let Ok(Some(user)) =
|
if let Ok(Some(user)) = User::get_by_username(&db, &data.username).await
|
||||||
User::get_by_username(&db, &data.username).await
|
|
||||||
{
|
{
|
||||||
if let Some(hash) = user.password_ref() {
|
if let Some(hash) = user.password_ref() {
|
||||||
let password = Password::new(&data.password);
|
let password = Password::new(&data.password);
|
||||||
@@ -374,10 +374,7 @@ impl Project for FuruProject {
|
|||||||
"/api/player",
|
"/api/player",
|
||||||
);
|
);
|
||||||
if self.app_config.swagger_enabled {
|
if self.app_config.swagger_enabled {
|
||||||
apps.register_with_views(
|
apps.register_with_views(cot::openapi::swagger_ui::SwaggerUi::new(), "/swagger");
|
||||||
cot::openapi::swagger_ui::SwaggerUi::new(),
|
|
||||||
"/swagger",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,8 +390,8 @@ fn main() -> impl Project {
|
|||||||
// Initialise tracing subscriber with the configured log level.
|
// Initialise tracing subscriber with the configured log level.
|
||||||
// FURU_LOG_LEVEL (or the default "info") is parsed as an EnvFilter
|
// FURU_LOG_LEVEL (or the default "info") is parsed as an EnvFilter
|
||||||
// directive, so values like "debug", "warn,furumusic=trace" all work.
|
// directive, so values like "debug", "warn,furumusic=trace" all work.
|
||||||
let filter = tracing_subscriber::EnvFilter::try_new(&app_config.log_level)
|
let filter =
|
||||||
.unwrap_or_else(|e| {
|
tracing_subscriber::EnvFilter::try_new(&app_config.log_level).unwrap_or_else(|e| {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"WARNING: invalid FURU_LOG_LEVEL {:?}: {e}; falling back to \"info\"",
|
"WARNING: invalid FURU_LOG_LEVEL {:?}: {e}; falling back to \"info\"",
|
||||||
app_config.log_level,
|
app_config.log_level,
|
||||||
|
|||||||
+455
-341
File diff suppressed because it is too large
Load Diff
+5
-6
@@ -131,8 +131,7 @@ async fn get_or_refresh_provider(
|
|||||||
.unwrap_or(config.oidc_issuer.trim_end_matches('/'))
|
.unwrap_or(config.oidc_issuer.trim_end_matches('/'))
|
||||||
.to_owned();
|
.to_owned();
|
||||||
|
|
||||||
let issuer_url = IssuerUrl::new(issuer)
|
let issuer_url = IssuerUrl::new(issuer).map_err(|e| format!("invalid issuer URL: {e}"))?;
|
||||||
.map_err(|e| format!("invalid issuer URL: {e}"))?;
|
|
||||||
|
|
||||||
let metadata = CoreProviderMetadata::discover_async(issuer_url, http)
|
let metadata = CoreProviderMetadata::discover_async(issuer_url, http)
|
||||||
.await
|
.await
|
||||||
@@ -250,7 +249,9 @@ pub async fn oidc_callback_handler(
|
|||||||
i18n: I18n,
|
i18n: I18n,
|
||||||
db: Database,
|
db: Database,
|
||||||
session: Session,
|
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> {
|
) -> cot::Result<cot::response::Response> {
|
||||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
|
||||||
@@ -313,9 +314,7 @@ pub async fn oidc_callback_handler(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Exchange code for tokens.
|
// Exchange code for tokens.
|
||||||
let token_request = match client
|
let token_request = match client.exchange_code(AuthorizationCode::new(query.code.clone())) {
|
||||||
.exchange_code(AuthorizationCode::new(query.code.clone()))
|
|
||||||
{
|
|
||||||
Ok(req) => req,
|
Ok(req) => req,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("OIDC token endpoint not configured: {e}");
|
tracing::error!("OIDC token endpoint not configured: {e}");
|
||||||
|
|||||||
+156
-143
@@ -1,8 +1,8 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use cot::db::Database;
|
use cot::db::Database;
|
||||||
use cot::http::header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, RANGE};
|
|
||||||
use cot::http::StatusCode;
|
use cot::http::StatusCode;
|
||||||
|
use cot::http::header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, RANGE};
|
||||||
use cot::json::Json;
|
use cot::json::Json;
|
||||||
use cot::request::extractors::Path;
|
use cot::request::extractors::Path;
|
||||||
use cot::response::IntoResponse;
|
use cot::response::IntoResponse;
|
||||||
@@ -65,6 +65,8 @@ struct ArtistDetail {
|
|||||||
id: i64,
|
id: i64,
|
||||||
name: String,
|
name: String,
|
||||||
image_url: Option<String>,
|
image_url: Option<String>,
|
||||||
|
total_track_count: i64,
|
||||||
|
total_play_count: i64,
|
||||||
releases: Vec<ReleaseCard>,
|
releases: Vec<ReleaseCard>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +180,6 @@ struct LikedIds {
|
|||||||
track_ids: Vec<i64>,
|
track_ids: Vec<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Query helpers
|
// Query helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -455,13 +456,12 @@ async fn artist_detail_handler(
|
|||||||
return Ok(json_error(StatusCode::NOT_FOUND, "artist not found"));
|
return Ok(json_error(StatusCode::NOT_FOUND, "artist not found"));
|
||||||
};
|
};
|
||||||
|
|
||||||
let image_file_id: Option<i64> = sqlx::query_scalar(
|
let image_file_id: Option<i64> =
|
||||||
"SELECT image_file_id FROM furumusic__artist WHERE id = $1",
|
sqlx::query_scalar("SELECT image_file_id FROM furumusic__artist WHERE id = $1")
|
||||||
)
|
.bind(artist_id)
|
||||||
.bind(artist_id)
|
.fetch_one(pool)
|
||||||
.fetch_one(pool)
|
.await
|
||||||
.await
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
|
|
||||||
let releases = sqlx::query_as::<_, ReleaseRow>(
|
let releases = sqlx::query_as::<_, ReleaseRow>(
|
||||||
r#"SELECT r.id, r.title::text as title, r.release_type::text as release_type,
|
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();
|
.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 {
|
Json(ArtistDetail {
|
||||||
id: artist.id,
|
id: artist.id,
|
||||||
name: artist.name,
|
name: artist.name,
|
||||||
image_url: cover_url(image_file_id),
|
image_url: cover_url(image_file_id),
|
||||||
|
total_track_count,
|
||||||
|
total_play_count,
|
||||||
releases: release_cards,
|
releases: release_cards,
|
||||||
})
|
})
|
||||||
.into_response()
|
.into_response()
|
||||||
@@ -648,13 +664,12 @@ async fn playlists_handler(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Count liked tracks for the virtual Likes playlist
|
// Count liked tracks for the virtual Likes playlist
|
||||||
let likes_count: (i64,) = sqlx::query_as(
|
let likes_count: (i64,) =
|
||||||
"SELECT COUNT(*) FROM furumusic__user_liked_track WHERE user_id = $1",
|
sqlx::query_as("SELECT COUNT(*) FROM furumusic__user_liked_track WHERE user_id = $1")
|
||||||
)
|
.bind(user.id)
|
||||||
.bind(user.id)
|
.fetch_one(pool)
|
||||||
.fetch_one(pool)
|
.await
|
||||||
.await
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut cards = vec![PlaylistCard {
|
let mut cards = vec![PlaylistCard {
|
||||||
id: -1,
|
id: -1,
|
||||||
@@ -909,10 +924,7 @@ async fn stream_handler(
|
|||||||
.status(StatusCode::PARTIAL_CONTENT)
|
.status(StatusCode::PARTIAL_CONTENT)
|
||||||
.header(CONTENT_TYPE, media.mime_type.as_str())
|
.header(CONTENT_TYPE, media.mime_type.as_str())
|
||||||
.header(ACCEPT_RANGES, "bytes")
|
.header(ACCEPT_RANGES, "bytes")
|
||||||
.header(
|
.header(CONTENT_RANGE, format!("bytes {start}-{end}/{file_size}"))
|
||||||
CONTENT_RANGE,
|
|
||||||
format!("bytes {start}-{end}/{file_size}"),
|
|
||||||
)
|
|
||||||
.header(CONTENT_LENGTH, chunk_size.to_string())
|
.header(CONTENT_LENGTH, chunk_size.to_string())
|
||||||
.body(Body::fixed(data))
|
.body(Body::fixed(data))
|
||||||
.expect("valid response");
|
.expect("valid response");
|
||||||
@@ -969,11 +981,7 @@ fn parse_range(header: &str, file_size: u64) -> Option<(u64, u64)> {
|
|||||||
Some((start, end))
|
Some((start, end))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_file_range(
|
async fn read_file_range(path: &std::path::Path, start: u64, length: u64) -> cot::Result<Vec<u8>> {
|
||||||
path: &std::path::Path,
|
|
||||||
start: u64,
|
|
||||||
length: u64,
|
|
||||||
) -> cot::Result<Vec<u8>> {
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
|
|
||||||
let mut file = tokio::fs::File::open(path)
|
let mut file = tokio::fs::File::open(path)
|
||||||
@@ -1066,8 +1074,7 @@ async fn get_state_handler(
|
|||||||
|
|
||||||
let dto = match state {
|
let dto = match state {
|
||||||
Some(s) => {
|
Some(s) => {
|
||||||
let queue: Vec<i64> =
|
let queue: Vec<i64> = serde_json::from_str(&s.queue_json).unwrap_or_default();
|
||||||
serde_json::from_str(&s.queue_json).unwrap_or_default();
|
|
||||||
PlaybackStateDto {
|
PlaybackStateDto {
|
||||||
current_track_id: s.current_track_id,
|
current_track_id: s.current_track_id,
|
||||||
position_ms: s.position_ms,
|
position_ms: s.position_ms,
|
||||||
@@ -1106,8 +1113,8 @@ async fn put_state_handler(
|
|||||||
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
};
|
};
|
||||||
|
|
||||||
let queue_json = serde_json::to_string(&dto.queue)
|
let queue_json =
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
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();
|
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;
|
let playlist_id = path.0.id;
|
||||||
// Verify ownership
|
// Verify ownership
|
||||||
let owner: Option<(i64,)> = sqlx::query_as(
|
let owner: Option<(i64,)> =
|
||||||
"SELECT owner_id FROM furumusic__playlist WHERE id = $1",
|
sqlx::query_as("SELECT owner_id FROM furumusic__playlist WHERE id = $1")
|
||||||
)
|
.bind(playlist_id)
|
||||||
.bind(playlist_id)
|
.fetch_optional(pool)
|
||||||
.fetch_optional(pool)
|
.await
|
||||||
.await
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
let Some(owner) = owner else {
|
let Some(owner) = owner else {
|
||||||
return Ok(json_error(StatusCode::NOT_FOUND, "playlist not found"));
|
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 {
|
if let Some(desc) = &body.description {
|
||||||
sqlx::query("UPDATE furumusic__playlist SET description = $1, updated_at = $2 WHERE id = $3")
|
sqlx::query(
|
||||||
.bind(desc)
|
"UPDATE furumusic__playlist SET description = $1, updated_at = $2 WHERE id = $3",
|
||||||
.bind(&now)
|
)
|
||||||
.bind(playlist_id)
|
.bind(desc)
|
||||||
.execute(pool)
|
.bind(&now)
|
||||||
.await
|
.bind(playlist_id)
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
}
|
}
|
||||||
Json(serde_json::json!({"ok": true})).into_response()
|
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"));
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
};
|
};
|
||||||
let playlist_id = path.0.id;
|
let playlist_id = path.0.id;
|
||||||
let owner: Option<(i64,)> = sqlx::query_as(
|
let owner: Option<(i64,)> =
|
||||||
"SELECT owner_id FROM furumusic__playlist WHERE id = $1",
|
sqlx::query_as("SELECT owner_id FROM furumusic__playlist WHERE id = $1")
|
||||||
)
|
.bind(playlist_id)
|
||||||
.bind(playlist_id)
|
.fetch_optional(pool)
|
||||||
.fetch_optional(pool)
|
.await
|
||||||
.await
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
let Some(owner) = owner else {
|
let Some(owner) = owner else {
|
||||||
return Ok(json_error(StatusCode::NOT_FOUND, "playlist not found"));
|
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"));
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
};
|
};
|
||||||
let playlist_id = path.0.id;
|
let playlist_id = path.0.id;
|
||||||
let owner: Option<(i64,)> = sqlx::query_as(
|
let owner: Option<(i64,)> =
|
||||||
"SELECT owner_id FROM furumusic__playlist WHERE id = $1",
|
sqlx::query_as("SELECT owner_id FROM furumusic__playlist WHERE id = $1")
|
||||||
)
|
.bind(playlist_id)
|
||||||
.bind(playlist_id)
|
.fetch_optional(pool)
|
||||||
.fetch_optional(pool)
|
.await
|
||||||
.await
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
let Some(owner) = owner else {
|
let Some(owner) = owner else {
|
||||||
return Ok(json_error(StatusCode::NOT_FOUND, "playlist not found"));
|
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"));
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
};
|
};
|
||||||
let playlist_id = path.0.id;
|
let playlist_id = path.0.id;
|
||||||
let owner: Option<(i64,)> = sqlx::query_as(
|
let owner: Option<(i64,)> =
|
||||||
"SELECT owner_id FROM furumusic__playlist WHERE id = $1",
|
sqlx::query_as("SELECT owner_id FROM furumusic__playlist WHERE id = $1")
|
||||||
)
|
.bind(playlist_id)
|
||||||
.bind(playlist_id)
|
.fetch_optional(pool)
|
||||||
.fetch_optional(pool)
|
.await
|
||||||
.await
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
let Some(owner) = owner else {
|
let Some(owner) = owner else {
|
||||||
return Ok(json_error(StatusCode::NOT_FOUND, "playlist not found"));
|
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"));
|
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query("DELETE FROM furumusic__playlist_track WHERE playlist_id = $1 AND track_id = $2")
|
||||||
"DELETE FROM furumusic__playlist_track WHERE playlist_id = $1 AND track_id = $2",
|
.bind(playlist_id)
|
||||||
)
|
.bind(body.track_id)
|
||||||
.bind(playlist_id)
|
.execute(pool)
|
||||||
.bind(body.track_id)
|
.await
|
||||||
.execute(pool)
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
|
|
||||||
// Re-number positions
|
// Re-number positions
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -1682,14 +1685,12 @@ async fn toggle_like_track_handler(
|
|||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
if existing.is_some() {
|
if existing.is_some() {
|
||||||
sqlx::query(
|
sqlx::query("DELETE FROM furumusic__user_liked_track WHERE user_id = $1 AND track_id = $2")
|
||||||
"DELETE FROM furumusic__user_liked_track WHERE user_id = $1 AND track_id = $2",
|
.bind(user.id)
|
||||||
)
|
.bind(track_id)
|
||||||
.bind(user.id)
|
.execute(pool)
|
||||||
.bind(track_id)
|
.await
|
||||||
.execute(pool)
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
Json(LikeStatus { liked: false }).into_response()
|
Json(LikeStatus { liked: false }).into_response()
|
||||||
} else {
|
} else {
|
||||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
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 {
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||||
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
};
|
};
|
||||||
let rows: Vec<(i64,)> = sqlx::query_as(
|
let rows: Vec<(i64,)> =
|
||||||
"SELECT track_id FROM furumusic__user_liked_track WHERE user_id = $1",
|
sqlx::query_as("SELECT track_id FROM furumusic__user_liked_track WHERE user_id = $1")
|
||||||
)
|
.bind(user.id)
|
||||||
.bind(user.id)
|
.fetch_all(pool)
|
||||||
.fetch_all(pool)
|
.await
|
||||||
.await
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Json(LikedIds {
|
Json(LikedIds {
|
||||||
track_ids: rows.into_iter().map(|r| r.0).collect(),
|
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();
|
let mut track_map: std::collections::HashMap<i64, TrackItem> = std::collections::HashMap::new();
|
||||||
for t in tracks {
|
for t in tracks {
|
||||||
let tid = t.id;
|
let tid = t.id;
|
||||||
track_map.insert(tid, TrackItem {
|
track_map.insert(
|
||||||
id: t.id,
|
tid,
|
||||||
title: t.title,
|
TrackItem {
|
||||||
track_number: t.track_number,
|
id: t.id,
|
||||||
disc_number: t.disc_number,
|
title: t.title,
|
||||||
duration_seconds: t.duration_seconds,
|
track_number: t.track_number,
|
||||||
artists: track_main_artists.remove(&tid).unwrap_or_default(),
|
disc_number: t.disc_number,
|
||||||
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
duration_seconds: t.duration_seconds,
|
||||||
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
artists: track_main_artists.remove(&tid).unwrap_or_default(),
|
||||||
stream_url: format!("/api/player/stream/{tid}"),
|
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
|
// Reorder results to match input order
|
||||||
let result: Vec<TrackItem> = ids
|
let result: Vec<TrackItem> = ids.iter().filter_map(|id| track_map.remove(id)).collect();
|
||||||
.iter()
|
|
||||||
.filter_map(|id| track_map.remove(id))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Json(result).into_response()
|
Json(result).into_response()
|
||||||
}
|
}
|
||||||
@@ -1926,8 +1926,7 @@ impl App for PlayerApp {
|
|||||||
|
|
||||||
fn router(&self) -> Router {
|
fn router(&self) -> Router {
|
||||||
let pool_config = Arc::clone(&self.config);
|
let pool_config = Arc::clone(&self.config);
|
||||||
let pool: Arc<tokio::sync::OnceCell<sqlx::PgPool>> =
|
let pool: Arc<tokio::sync::OnceCell<sqlx::PgPool>> = Arc::new(tokio::sync::OnceCell::new());
|
||||||
Arc::new(tokio::sync::OnceCell::new());
|
|
||||||
|
|
||||||
Router::with_urls([
|
Router::with_urls([
|
||||||
// -- Artists (paginated) --
|
// -- Artists (paginated) --
|
||||||
@@ -2077,7 +2076,10 @@ impl App for PlayerApp {
|
|||||||
.put({
|
.put({
|
||||||
let pool = Arc::clone(&pool);
|
let pool = Arc::clone(&pool);
|
||||||
let pool_config = Arc::clone(&pool_config);
|
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 = Arc::clone(&pool);
|
||||||
let pool_config = Arc::clone(&pool_config);
|
let pool_config = Arc::clone(&pool_config);
|
||||||
async move {
|
async move {
|
||||||
@@ -2122,7 +2124,10 @@ impl App for PlayerApp {
|
|||||||
post({
|
post({
|
||||||
let pool = Arc::clone(&pool);
|
let pool = Arc::clone(&pool);
|
||||||
let pool_config = Arc::clone(&pool_config);
|
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 = Arc::clone(&pool);
|
||||||
let pool_config = Arc::clone(&pool_config);
|
let pool_config = Arc::clone(&pool_config);
|
||||||
async move {
|
async move {
|
||||||
@@ -2142,7 +2147,10 @@ impl App for PlayerApp {
|
|||||||
.delete({
|
.delete({
|
||||||
let pool = Arc::clone(&pool);
|
let pool = Arc::clone(&pool);
|
||||||
let pool_config = Arc::clone(&pool_config);
|
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 = Arc::clone(&pool);
|
||||||
let pool_config = Arc::clone(&pool_config);
|
let pool_config = Arc::clone(&pool_config);
|
||||||
async move {
|
async move {
|
||||||
@@ -2155,7 +2163,8 @@ impl App for PlayerApp {
|
|||||||
.expect("player pool")
|
.expect("player pool")
|
||||||
})
|
})
|
||||||
.await;
|
.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 = Arc::clone(&pool);
|
||||||
let pool_config = Arc::clone(&pool_config);
|
let pool_config = Arc::clone(&pool_config);
|
||||||
let config = Arc::clone(&self.config);
|
let config = Arc::clone(&self.config);
|
||||||
get(move |session: Session, db: Database,
|
get(
|
||||||
|
move |session: Session,
|
||||||
|
db: Database,
|
||||||
path: Path<PathTrackId>,
|
path: Path<PathTrackId>,
|
||||||
request: cot::request::Request| {
|
request: cot::request::Request| {
|
||||||
let pool = Arc::clone(&pool);
|
let pool = Arc::clone(&pool);
|
||||||
let pool_config = Arc::clone(&pool_config);
|
let pool_config = Arc::clone(&pool_config);
|
||||||
let config = Arc::clone(&config);
|
let config = Arc::clone(&config);
|
||||||
async move {
|
async move {
|
||||||
let pg_pool = pool
|
let pg_pool = pool
|
||||||
.get_or_init(|| async {
|
.get_or_init(|| async {
|
||||||
sqlx::postgres::PgPoolOptions::new()
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
.max_connections(5)
|
.max_connections(5)
|
||||||
.connect(&pool_config.database_url)
|
.connect(&pool_config.database_url)
|
||||||
.await
|
.await
|
||||||
.expect("player pool")
|
.expect("player pool")
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
stream_handler(session, db, pg_pool, &config, &request, path).await
|
stream_handler(session, db, pg_pool, &config, &request, path).await
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
"player_stream",
|
"player_stream",
|
||||||
),
|
),
|
||||||
@@ -2272,24 +2284,25 @@ impl App for PlayerApp {
|
|||||||
let pool = Arc::clone(&pool);
|
let pool = Arc::clone(&pool);
|
||||||
let pool_config = Arc::clone(&pool_config);
|
let pool_config = Arc::clone(&pool_config);
|
||||||
let config = Arc::clone(&self.config);
|
let config = Arc::clone(&self.config);
|
||||||
get(move |session: Session, db: Database,
|
get(
|
||||||
path: Path<PathMediaFileId>| {
|
move |session: Session, db: Database, path: Path<PathMediaFileId>| {
|
||||||
let pool = Arc::clone(&pool);
|
let pool = Arc::clone(&pool);
|
||||||
let pool_config = Arc::clone(&pool_config);
|
let pool_config = Arc::clone(&pool_config);
|
||||||
let config = Arc::clone(&config);
|
let config = Arc::clone(&config);
|
||||||
async move {
|
async move {
|
||||||
let pg_pool = pool
|
let pg_pool = pool
|
||||||
.get_or_init(|| async {
|
.get_or_init(|| async {
|
||||||
sqlx::postgres::PgPoolOptions::new()
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
.max_connections(5)
|
.max_connections(5)
|
||||||
.connect(&pool_config.database_url)
|
.connect(&pool_config.database_url)
|
||||||
.await
|
.await
|
||||||
.expect("player pool")
|
.expect("player pool")
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
cover_handler(session, db, pg_pool, &config, path).await
|
cover_handler(session, db, pg_pool, &config, path).await
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
"player_cover",
|
"player_cover",
|
||||||
),
|
),
|
||||||
|
|||||||
+303
-133
@@ -1,5 +1,4 @@
|
|||||||
/// Job scheduler: models, migrations, Job trait, JobRegistry, and scheduler loop.
|
/// Job scheduler: models, migrations, Job trait, JobRegistry, and scheduler loop.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -74,7 +73,12 @@ impl ScheduledJob {
|
|||||||
Self::get_by_primary_key(db, name.to_owned()).await
|
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? {
|
if let Some(mut existing) = Self::get_by_name(db, name).await? {
|
||||||
// Update cron expression and description if they changed
|
// Update cron expression and description if they changed
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
@@ -170,7 +174,11 @@ pub struct JobRun {
|
|||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
impl JobRun {
|
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 {
|
let mut run = Self {
|
||||||
id: Auto::auto(),
|
id: Auto::auto(),
|
||||||
job_name: limited_string(job_name),
|
job_name: limited_string(job_name),
|
||||||
@@ -186,7 +194,12 @@ impl JobRun {
|
|||||||
Ok(run)
|
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.status = LimitedString::new("completed").unwrap();
|
||||||
self.finished_at = Some(now_iso().to_string());
|
self.finished_at = Some(now_iso().to_string());
|
||||||
self.duration_ms = Some(duration_ms);
|
self.duration_ms = Some(duration_ms);
|
||||||
@@ -194,7 +207,13 @@ impl JobRun {
|
|||||||
self.save(db).await
|
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.status = LimitedString::new("failed").unwrap();
|
||||||
self.finished_at = Some(now_iso().to_string());
|
self.finished_at = Some(now_iso().to_string());
|
||||||
self.duration_ms = Some(duration_ms);
|
self.duration_ms = Some(duration_ms);
|
||||||
@@ -207,7 +226,11 @@ impl JobRun {
|
|||||||
Self::get_by_primary_key(db, Auto::Fixed(id)).await
|
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>(
|
let rows = sqlx::query_as::<_, JobRunRow>(
|
||||||
"SELECT id, job_name, status, started_at, finished_at, duration_ms, log_output, error_message, trigger \
|
"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"
|
FROM furumusic__job_run WHERE job_name = $1 ORDER BY id DESC LIMIT $2"
|
||||||
@@ -229,7 +252,7 @@ impl JobRun {
|
|||||||
SET status = 'failed', \
|
SET status = 'failed', \
|
||||||
finished_at = $1, \
|
finished_at = $1, \
|
||||||
error_message = 'Process restarted while job was running' \
|
error_message = 'Process restarted while job was running' \
|
||||||
WHERE status = 'running'"
|
WHERE status = 'running'",
|
||||||
)
|
)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@@ -472,7 +495,7 @@ impl PendingReview {
|
|||||||
SET status = 'failed', \
|
SET status = 'failed', \
|
||||||
error_message = 'Process restarted while review was being processed', \
|
error_message = 'Process restarted while review was being processed', \
|
||||||
updated_at = $1 \
|
updated_at = $1 \
|
||||||
WHERE status = 'processing'"
|
WHERE status = 'processing'",
|
||||||
)
|
)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@@ -497,6 +520,46 @@ impl PendingReview {
|
|||||||
Ok(())
|
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 {
|
pub fn id_val(&self) -> i64 {
|
||||||
self.id.unwrap()
|
self.id.unwrap()
|
||||||
}
|
}
|
||||||
@@ -589,12 +652,19 @@ impl ProcessingStats {
|
|||||||
Ok(all.into_iter().next())
|
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() {
|
if ids.is_empty() {
|
||||||
return Ok(HashMap::new());
|
return Ok(HashMap::new());
|
||||||
}
|
}
|
||||||
// Build comma-separated ID list
|
// 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!(
|
let query = format!(
|
||||||
"SELECT pending_review_id, model_name, llm_duration_ms, prompt_tokens, completion_tokens \
|
"SELECT pending_review_id, model_name, llm_duration_ms, prompt_tokens, completion_tokens \
|
||||||
FROM furumusic__processing_stats WHERE pending_review_id IN ({id_list})"
|
FROM furumusic__processing_stats WHERE pending_review_id IN ({id_list})"
|
||||||
@@ -659,28 +729,46 @@ pub mod db_migrations {
|
|||||||
impl migrations::Migration for M0022CreateScheduledJob {
|
impl migrations::Migration for M0022CreateScheduledJob {
|
||||||
const APP_NAME: &'static str = "furumusic";
|
const APP_NAME: &'static str = "furumusic";
|
||||||
const MIGRATION_NAME: &'static str = "m_0022_create_scheduled_job";
|
const MIGRATION_NAME: &'static str = "m_0022_create_scheduled_job";
|
||||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
migrations::MigrationDependency::migration("furumusic", "m_0021_create_trgm_indexes"),
|
&[migrations::MigrationDependency::migration(
|
||||||
];
|
"furumusic",
|
||||||
const OPERATIONS: &'static [Operation] = &[
|
"m_0021_create_trgm_indexes",
|
||||||
Operation::create_model()
|
)];
|
||||||
.table_name(Identifier::new("furumusic__scheduled_job"))
|
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||||
.fields(&[
|
.table_name(Identifier::new("furumusic__scheduled_job"))
|
||||||
Field::new(Identifier::new("name"), <String as DatabaseField>::TYPE)
|
.fields(&[
|
||||||
.primary_key()
|
Field::new(Identifier::new("name"), <String as DatabaseField>::TYPE)
|
||||||
.set_null(<String as DatabaseField>::NULLABLE),
|
.primary_key()
|
||||||
Field::new(Identifier::new("description"), <String as DatabaseField>::TYPE),
|
.set_null(<String as DatabaseField>::NULLABLE),
|
||||||
Field::new(Identifier::new("cron_expression"), <LimitedString<100> as DatabaseField>::TYPE),
|
Field::new(
|
||||||
Field::new(Identifier::new("enabled"), <bool as DatabaseField>::TYPE),
|
Identifier::new("description"),
|
||||||
Field::new(Identifier::new("last_run_at"), <LimitedString<32> as DatabaseField>::TYPE)
|
<String as DatabaseField>::TYPE,
|
||||||
.set_null(true),
|
),
|
||||||
Field::new(Identifier::new("next_run_at"), <LimitedString<32> as DatabaseField>::TYPE)
|
Field::new(
|
||||||
.set_null(true),
|
Identifier::new("cron_expression"),
|
||||||
Field::new(Identifier::new("created_at"), <LimitedString<32> as DatabaseField>::TYPE),
|
<LimitedString<100> as DatabaseField>::TYPE,
|
||||||
Field::new(Identifier::new("updated_at"), <LimitedString<32> as DatabaseField>::TYPE),
|
),
|
||||||
])
|
Field::new(Identifier::new("enabled"), <bool as DatabaseField>::TYPE),
|
||||||
.build(),
|
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)]
|
#[derive(Debug, Copy, Clone)]
|
||||||
@@ -689,31 +777,52 @@ pub mod db_migrations {
|
|||||||
impl migrations::Migration for M0023CreateJobRun {
|
impl migrations::Migration for M0023CreateJobRun {
|
||||||
const APP_NAME: &'static str = "furumusic";
|
const APP_NAME: &'static str = "furumusic";
|
||||||
const MIGRATION_NAME: &'static str = "m_0023_create_job_run";
|
const MIGRATION_NAME: &'static str = "m_0023_create_job_run";
|
||||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
migrations::MigrationDependency::migration("furumusic", "m_0022_create_scheduled_job"),
|
&[migrations::MigrationDependency::migration(
|
||||||
];
|
"furumusic",
|
||||||
const OPERATIONS: &'static [Operation] = &[
|
"m_0022_create_scheduled_job",
|
||||||
Operation::create_model()
|
)];
|
||||||
.table_name(Identifier::new("furumusic__job_run"))
|
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||||
.fields(&[
|
.table_name(Identifier::new("furumusic__job_run"))
|
||||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
.fields(&[
|
||||||
.primary_key()
|
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||||
.auto(),
|
.primary_key()
|
||||||
Field::new(Identifier::new("job_name"), <LimitedString<100> as DatabaseField>::TYPE),
|
.auto(),
|
||||||
Field::new(Identifier::new("status"), <LimitedString<32> as DatabaseField>::TYPE),
|
Field::new(
|
||||||
Field::new(Identifier::new("started_at"), <LimitedString<32> as DatabaseField>::TYPE),
|
Identifier::new("job_name"),
|
||||||
Field::new(Identifier::new("finished_at"), <LimitedString<32> as DatabaseField>::TYPE)
|
<LimitedString<100> as DatabaseField>::TYPE,
|
||||||
.set_null(true),
|
),
|
||||||
Field::new(Identifier::new("duration_ms"), <i64 as DatabaseField>::TYPE)
|
Field::new(
|
||||||
.set_null(true),
|
Identifier::new("status"),
|
||||||
Field::new(Identifier::new("log_output"), <String as DatabaseField>::TYPE)
|
<LimitedString<32> as DatabaseField>::TYPE,
|
||||||
.set_null(true),
|
),
|
||||||
Field::new(Identifier::new("error_message"), <String as DatabaseField>::TYPE)
|
Field::new(
|
||||||
.set_null(true),
|
Identifier::new("started_at"),
|
||||||
Field::new(Identifier::new("trigger"), <LimitedString<32> as DatabaseField>::TYPE),
|
<LimitedString<32> as DatabaseField>::TYPE,
|
||||||
])
|
),
|
||||||
.build(),
|
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)]
|
#[derive(Debug, Copy, Clone)]
|
||||||
@@ -722,34 +831,57 @@ pub mod db_migrations {
|
|||||||
impl migrations::Migration for M0024CreatePendingReview {
|
impl migrations::Migration for M0024CreatePendingReview {
|
||||||
const APP_NAME: &'static str = "furumusic";
|
const APP_NAME: &'static str = "furumusic";
|
||||||
const MIGRATION_NAME: &'static str = "m_0024_create_pending_review";
|
const MIGRATION_NAME: &'static str = "m_0024_create_pending_review";
|
||||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
migrations::MigrationDependency::migration("furumusic", "m_0023_create_job_run"),
|
&[migrations::MigrationDependency::migration(
|
||||||
];
|
"furumusic",
|
||||||
const OPERATIONS: &'static [Operation] = &[
|
"m_0023_create_job_run",
|
||||||
Operation::create_model()
|
)];
|
||||||
.table_name(Identifier::new("furumusic__pending_review"))
|
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||||
.fields(&[
|
.table_name(Identifier::new("furumusic__pending_review"))
|
||||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
.fields(&[
|
||||||
.primary_key()
|
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||||
.auto(),
|
.primary_key()
|
||||||
Field::new(Identifier::new("job_run_id"), <i64 as DatabaseField>::TYPE),
|
.auto(),
|
||||||
Field::new(Identifier::new("review_type"), <LimitedString<64> as DatabaseField>::TYPE),
|
Field::new(Identifier::new("job_run_id"), <i64 as DatabaseField>::TYPE),
|
||||||
Field::new(Identifier::new("input_path"), <String as DatabaseField>::TYPE)
|
Field::new(
|
||||||
.set_null(true),
|
Identifier::new("review_type"),
|
||||||
Field::new(Identifier::new("context_json"), <String as DatabaseField>::TYPE)
|
<LimitedString<64> as DatabaseField>::TYPE,
|
||||||
.set_null(true),
|
),
|
||||||
Field::new(Identifier::new("result_json"), <String as DatabaseField>::TYPE)
|
Field::new(
|
||||||
.set_null(true),
|
Identifier::new("input_path"),
|
||||||
Field::new(Identifier::new("status"), <LimitedString<32> as DatabaseField>::TYPE),
|
<String 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),
|
.set_null(true),
|
||||||
])
|
Field::new(
|
||||||
.build(),
|
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]
|
#[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 = [
|
let stmts = [
|
||||||
"CREATE INDEX idx_job_run_job_name ON furumusic__job_run (job_name, id DESC)",
|
"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)",
|
"CREATE INDEX idx_job_run_status ON furumusic__job_run (status)",
|
||||||
@@ -768,16 +900,19 @@ pub mod db_migrations {
|
|||||||
impl migrations::Migration for M0025CreateSchedulerIndexes {
|
impl migrations::Migration for M0025CreateSchedulerIndexes {
|
||||||
const APP_NAME: &'static str = "furumusic";
|
const APP_NAME: &'static str = "furumusic";
|
||||||
const MIGRATION_NAME: &'static str = "m_0025_create_scheduler_indexes";
|
const MIGRATION_NAME: &'static str = "m_0025_create_scheduler_indexes";
|
||||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
migrations::MigrationDependency::migration("furumusic", "m_0024_create_pending_review"),
|
&[migrations::MigrationDependency::migration(
|
||||||
];
|
"furumusic",
|
||||||
const OPERATIONS: &'static [Operation] = &[
|
"m_0024_create_pending_review",
|
||||||
Operation::custom(create_scheduler_indexes).build(),
|
)];
|
||||||
];
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_scheduler_indexes).build()];
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cot::db::migrations::migration_op]
|
#[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
|
ctx.db
|
||||||
.raw("ALTER TABLE furumusic__pending_review ADD COLUMN error_message TEXT")
|
.raw("ALTER TABLE furumusic__pending_review ADD COLUMN error_message TEXT")
|
||||||
.await?;
|
.await?;
|
||||||
@@ -790,12 +925,13 @@ pub mod db_migrations {
|
|||||||
impl migrations::Migration for M0026AddPendingReviewErrorMessage {
|
impl migrations::Migration for M0026AddPendingReviewErrorMessage {
|
||||||
const APP_NAME: &'static str = "furumusic";
|
const APP_NAME: &'static str = "furumusic";
|
||||||
const MIGRATION_NAME: &'static str = "m_0026_add_pending_review_error_message";
|
const MIGRATION_NAME: &'static str = "m_0026_add_pending_review_error_message";
|
||||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
migrations::MigrationDependency::migration("furumusic", "m_0025_create_scheduler_indexes"),
|
&[migrations::MigrationDependency::migration(
|
||||||
];
|
"furumusic",
|
||||||
const OPERATIONS: &'static [Operation] = &[
|
"m_0025_create_scheduler_indexes",
|
||||||
Operation::custom(add_pending_review_error_message).build(),
|
)];
|
||||||
];
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(add_pending_review_error_message).build()];
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Copy, Clone)]
|
#[derive(Debug, Copy, Clone)]
|
||||||
@@ -804,25 +940,43 @@ pub mod db_migrations {
|
|||||||
impl migrations::Migration for M0027CreateProcessingStats {
|
impl migrations::Migration for M0027CreateProcessingStats {
|
||||||
const APP_NAME: &'static str = "furumusic";
|
const APP_NAME: &'static str = "furumusic";
|
||||||
const MIGRATION_NAME: &'static str = "m_0027_create_processing_stats";
|
const MIGRATION_NAME: &'static str = "m_0027_create_processing_stats";
|
||||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
migrations::MigrationDependency::migration("furumusic", "m_0026_add_pending_review_error_message"),
|
&[migrations::MigrationDependency::migration(
|
||||||
];
|
"furumusic",
|
||||||
const OPERATIONS: &'static [Operation] = &[
|
"m_0026_add_pending_review_error_message",
|
||||||
Operation::create_model()
|
)];
|
||||||
.table_name(Identifier::new("furumusic__processing_stats"))
|
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||||
.fields(&[
|
.table_name(Identifier::new("furumusic__processing_stats"))
|
||||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
.fields(&[
|
||||||
.primary_key()
|
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||||
.auto(),
|
.primary_key()
|
||||||
Field::new(Identifier::new("pending_review_id"), <i64 as DatabaseField>::TYPE),
|
.auto(),
|
||||||
Field::new(Identifier::new("model_name"), <LimitedString<128> as DatabaseField>::TYPE),
|
Field::new(
|
||||||
Field::new(Identifier::new("llm_duration_ms"), <i64 as DatabaseField>::TYPE),
|
Identifier::new("pending_review_id"),
|
||||||
Field::new(Identifier::new("prompt_tokens"), <i64 as DatabaseField>::TYPE),
|
<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),
|
Field::new(
|
||||||
])
|
Identifier::new("model_name"),
|
||||||
.build(),
|
<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] = &[
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
@@ -856,11 +1010,19 @@ pub struct JobLog {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
impl JobLog {
|
impl JobLog {
|
||||||
pub fn new() -> Self {
|
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 {
|
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) {
|
pub fn info(&mut self, msg: &str) {
|
||||||
@@ -894,13 +1056,11 @@ impl JobLog {
|
|||||||
let run_id = self.run_id;
|
let run_id = self.run_id;
|
||||||
let pool = pool.clone();
|
let pool = pool.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query("UPDATE furumusic__job_run SET log_output = $1 WHERE id = $2")
|
||||||
"UPDATE furumusic__job_run SET log_output = $1 WHERE id = $2"
|
.bind(&output)
|
||||||
)
|
.bind(run_id)
|
||||||
.bind(&output)
|
.execute(&pool)
|
||||||
.bind(run_id)
|
.await;
|
||||||
.execute(&pool)
|
|
||||||
.await;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -997,7 +1157,9 @@ impl SchedulerHandle {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let duration_ms = start.elapsed().as_millis() as i64;
|
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?;
|
self.add_cron_job(job_name, new_cron).await?;
|
||||||
|
|
||||||
// Update DB
|
// 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.cron_expression = LimitedString::new(new_cron).unwrap();
|
||||||
sched_job.next_run_at = compute_next_run(new_cron);
|
sched_job.next_run_at = compute_next_run(new_cron);
|
||||||
sched_job.updated_at = now_iso();
|
sched_job.updated_at = now_iso();
|
||||||
@@ -1083,7 +1246,10 @@ impl SchedulerHandle {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let uuid = self.scheduler.add(cron_job).await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1161,7 +1327,9 @@ async fn run_scheduled_job(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
let duration_ms = start.elapsed().as_millis() as i64;
|
let duration_ms = start.elapsed().as_millis() as i64;
|
||||||
tracing::error!(job = job_name, duration_ms, "Job failed: {e}");
|
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
|
// Update next_run_at in DB
|
||||||
if let Some(next) = compute_next_run(cron_expr) {
|
if let Some(next) = compute_next_run(cron_expr) {
|
||||||
let _ = sqlx::query(
|
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(&next)
|
||||||
.bind(sched_job.name_str())
|
.bind(sched_job.name_str())
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1339,7 +1507,9 @@ pub async fn trigger_job_now(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let duration_ms = start.elapsed().as_millis() as i64;
|
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
@@ -108,7 +108,9 @@ impl User {
|
|||||||
|
|
||||||
/// Delete this user by primary key.
|
/// Delete this user by primary key.
|
||||||
pub async fn delete_by_id(db: &Database, user_id: i64) -> cot::db::Result<()> {
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,10 +122,16 @@ impl User {
|
|||||||
&self.username
|
&self.username
|
||||||
}
|
}
|
||||||
pub fn email_str(&self) -> String {
|
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 {
|
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 {
|
pub fn role_str(&self) -> &str {
|
||||||
&self.role
|
&self.role
|
||||||
@@ -162,7 +170,9 @@ impl User {
|
|||||||
|
|
||||||
/// Find a user by email address.
|
/// Find a user by email address.
|
||||||
pub async fn get_by_email(db: &Database, email: &str) -> cot::db::Result<Option<Self>> {
|
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 {
|
pub mod db_migrations {
|
||||||
|
use cot::auth::PasswordHash;
|
||||||
use cot::db::migrations::{self, Field, Operation, SyncDynMigration};
|
use cot::db::migrations::{self, Field, Operation, SyncDynMigration};
|
||||||
use cot::db::{DatabaseField, Identifier, LimitedString};
|
use cot::db::{DatabaseField, Identifier, LimitedString};
|
||||||
use cot::auth::PasswordHash;
|
|
||||||
|
|
||||||
// -- M0003: create furumusic__user -------------------------------------
|
// -- M0003: create furumusic__user -------------------------------------
|
||||||
|
|
||||||
@@ -269,58 +279,49 @@ pub mod db_migrations {
|
|||||||
impl migrations::Migration for M0003CreateUser {
|
impl migrations::Migration for M0003CreateUser {
|
||||||
const APP_NAME: &'static str = "furumusic";
|
const APP_NAME: &'static str = "furumusic";
|
||||||
const MIGRATION_NAME: &'static str = "m_0003_create_user";
|
const MIGRATION_NAME: &'static str = "m_0003_create_user";
|
||||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
migrations::MigrationDependency::migration(
|
&[migrations::MigrationDependency::migration(
|
||||||
"furumusic",
|
"furumusic",
|
||||||
"m_0002_rename_config_table",
|
"m_0002_rename_config_table",
|
||||||
),
|
)];
|
||||||
];
|
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||||
const OPERATIONS: &'static [Operation] = &[
|
.table_name(Identifier::new("furumusic__user"))
|
||||||
Operation::create_model()
|
.fields(&[
|
||||||
.table_name(Identifier::new("furumusic__user"))
|
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||||
.fields(&[
|
|
||||||
Field::new(
|
|
||||||
Identifier::new("id"),
|
|
||||||
<i64 as DatabaseField>::TYPE,
|
|
||||||
)
|
|
||||||
.primary_key()
|
.primary_key()
|
||||||
.auto(),
|
.auto(),
|
||||||
Field::new(
|
Field::new(
|
||||||
Identifier::new("username"),
|
Identifier::new("username"),
|
||||||
<LimitedString<255> as DatabaseField>::TYPE,
|
<LimitedString<255> as DatabaseField>::TYPE,
|
||||||
)
|
)
|
||||||
.unique(),
|
.unique(),
|
||||||
Field::new(
|
Field::new(
|
||||||
Identifier::new("password"),
|
Identifier::new("password"),
|
||||||
<PasswordHash as DatabaseField>::TYPE,
|
<PasswordHash as DatabaseField>::TYPE,
|
||||||
)
|
)
|
||||||
.set_null(true),
|
.set_null(true),
|
||||||
Field::new(
|
Field::new(
|
||||||
Identifier::new("email"),
|
Identifier::new("email"),
|
||||||
<LimitedString<255> as DatabaseField>::TYPE,
|
<LimitedString<255> as DatabaseField>::TYPE,
|
||||||
)
|
)
|
||||||
.set_null(true),
|
.set_null(true),
|
||||||
Field::new(
|
Field::new(
|
||||||
Identifier::new("display_name"),
|
Identifier::new("display_name"),
|
||||||
<LimitedString<255> as DatabaseField>::TYPE,
|
<LimitedString<255> as DatabaseField>::TYPE,
|
||||||
)
|
)
|
||||||
.set_null(true),
|
.set_null(true),
|
||||||
Field::new(
|
Field::new(
|
||||||
Identifier::new("avatar_url"),
|
Identifier::new("avatar_url"),
|
||||||
<String as DatabaseField>::TYPE,
|
<String as DatabaseField>::TYPE,
|
||||||
)
|
)
|
||||||
.set_null(true),
|
.set_null(true),
|
||||||
Field::new(
|
Field::new(
|
||||||
Identifier::new("role"),
|
Identifier::new("role"),
|
||||||
<LimitedString<32> as DatabaseField>::TYPE,
|
<LimitedString<32> as DatabaseField>::TYPE,
|
||||||
),
|
),
|
||||||
Field::new(
|
Field::new(Identifier::new("is_active"), <bool as DatabaseField>::TYPE),
|
||||||
Identifier::new("is_active"),
|
])
|
||||||
<bool as DatabaseField>::TYPE,
|
.build()];
|
||||||
),
|
|
||||||
])
|
|
||||||
.build(),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- M0004: create furumusic__oidc_link --------------------------------
|
// -- M0004: create furumusic__oidc_link --------------------------------
|
||||||
@@ -331,52 +332,43 @@ pub mod db_migrations {
|
|||||||
impl migrations::Migration for M0004CreateOidcLink {
|
impl migrations::Migration for M0004CreateOidcLink {
|
||||||
const APP_NAME: &'static str = "furumusic";
|
const APP_NAME: &'static str = "furumusic";
|
||||||
const MIGRATION_NAME: &'static str = "m_0004_create_oidc_link";
|
const MIGRATION_NAME: &'static str = "m_0004_create_oidc_link";
|
||||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
migrations::MigrationDependency::migration(
|
&[migrations::MigrationDependency::migration(
|
||||||
"furumusic",
|
"furumusic",
|
||||||
"m_0003_create_user",
|
"m_0003_create_user",
|
||||||
),
|
)];
|
||||||
];
|
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||||
const OPERATIONS: &'static [Operation] = &[
|
.table_name(Identifier::new("furumusic__oidc_link"))
|
||||||
Operation::create_model()
|
.fields(&[
|
||||||
.table_name(Identifier::new("furumusic__oidc_link"))
|
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||||
.fields(&[
|
|
||||||
Field::new(
|
|
||||||
Identifier::new("id"),
|
|
||||||
<i64 as DatabaseField>::TYPE,
|
|
||||||
)
|
|
||||||
.primary_key()
|
.primary_key()
|
||||||
.auto(),
|
.auto(),
|
||||||
Field::new(
|
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
|
||||||
Identifier::new("user_id"),
|
Field::new(
|
||||||
<i64 as DatabaseField>::TYPE,
|
Identifier::new("issuer"),
|
||||||
),
|
<LimitedString<255> as DatabaseField>::TYPE,
|
||||||
Field::new(
|
),
|
||||||
Identifier::new("issuer"),
|
Field::new(
|
||||||
<LimitedString<255> as DatabaseField>::TYPE,
|
Identifier::new("sub"),
|
||||||
),
|
<LimitedString<255> as DatabaseField>::TYPE,
|
||||||
Field::new(
|
),
|
||||||
Identifier::new("sub"),
|
Field::new(
|
||||||
<LimitedString<255> as DatabaseField>::TYPE,
|
Identifier::new("email"),
|
||||||
),
|
<LimitedString<255> as DatabaseField>::TYPE,
|
||||||
Field::new(
|
)
|
||||||
Identifier::new("email"),
|
.set_null(true),
|
||||||
<LimitedString<255> as DatabaseField>::TYPE,
|
Field::new(
|
||||||
)
|
Identifier::new("name"),
|
||||||
.set_null(true),
|
<LimitedString<255> as DatabaseField>::TYPE,
|
||||||
Field::new(
|
)
|
||||||
Identifier::new("name"),
|
.set_null(true),
|
||||||
<LimitedString<255> as DatabaseField>::TYPE,
|
Field::new(
|
||||||
)
|
Identifier::new("avatar_url"),
|
||||||
.set_null(true),
|
<String as DatabaseField>::TYPE,
|
||||||
Field::new(
|
)
|
||||||
Identifier::new("avatar_url"),
|
.set_null(true),
|
||||||
<String as DatabaseField>::TYPE,
|
])
|
||||||
)
|
.build()];
|
||||||
.set_null(true),
|
|
||||||
])
|
|
||||||
.build(),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- M0005: indexes on furumusic__oidc_link ----------------------------
|
// -- M0005: indexes on furumusic__oidc_link ----------------------------
|
||||||
@@ -406,15 +398,13 @@ pub mod db_migrations {
|
|||||||
impl migrations::Migration for M0005OidcLinkIndexes {
|
impl migrations::Migration for M0005OidcLinkIndexes {
|
||||||
const APP_NAME: &'static str = "furumusic";
|
const APP_NAME: &'static str = "furumusic";
|
||||||
const MIGRATION_NAME: &'static str = "m_0005_oidc_link_indexes";
|
const MIGRATION_NAME: &'static str = "m_0005_oidc_link_indexes";
|
||||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
migrations::MigrationDependency::migration(
|
&[migrations::MigrationDependency::migration(
|
||||||
"furumusic",
|
"furumusic",
|
||||||
"m_0004_create_oidc_link",
|
"m_0004_create_oidc_link",
|
||||||
),
|
)];
|
||||||
];
|
const OPERATIONS: &'static [Operation] =
|
||||||
const OPERATIONS: &'static [Operation] = &[
|
&[Operation::custom(create_oidc_link_indexes).build()];
|
||||||
Operation::custom(create_oidc_link_indexes).build(),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
|
|||||||
@@ -13,9 +13,12 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div style="margin: 1rem 0; display: flex; gap: .5rem; align-items: flex-end;">
|
<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;">
|
<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>
|
<button type="submit" style="padding:.4rem 1rem; background:#007bff; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_run_now }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if job.name_str() != "metadata_backfill" %}
|
||||||
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
|
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
|
||||||
{% if job.enabled() %}
|
{% 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>
|
<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>
|
<button type="submit" style="padding:.4rem 1rem; background:#28a745; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_enable }}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>{{ t.jobs_cron }}</h2>
|
{% if job.name_str() == "metadata_backfill" %}
|
||||||
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.jobs_cron_help }}</p>
|
<h2>{{ t.jobs_metadata_backfill_options }}</h2>
|
||||||
<form method="post" action="/admin/jobs/{{ job.name_str() }}/cron" style="display:flex;gap:.5rem;align-items:center;margin-bottom:1.5rem;">
|
<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;">
|
||||||
<input name="cron_expression" value="{{ job.cron_expression_str() }}" style="width:20em;font-family:monospace;">
|
<fieldset style="border:0; margin:0 0 .75rem; padding:0;">
|
||||||
<button type="submit" style="padding:.4rem 1rem; background:#6c757d; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_cron_update }}</button>
|
<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>
|
</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>
|
<h2>{{ t.jobs_run_history }}</h2>
|
||||||
{% if runs.is_empty() %}
|
{% if runs.is_empty() %}
|
||||||
|
|||||||
@@ -23,9 +23,14 @@
|
|||||||
<td>{{ job.last_run_at_str() }}</td>
|
<td>{{ job.last_run_at_str() }}</td>
|
||||||
<td>{{ job.next_run_at_str() }}</td>
|
<td>{{ job.next_run_at_str() }}</td>
|
||||||
<td style="display:flex;gap:.3rem;">
|
<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;">
|
<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>
|
<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>
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if job.name_str() != "metadata_backfill" %}
|
||||||
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
|
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
|
||||||
{% if job.enabled() %}
|
{% 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>
|
<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>
|
<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 %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; min-height: 100vh; background: #f5f5f5; color: #333; }
|
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 { width: 220px; background: #1a1a2e; color: #eee; padding: 1rem; }
|
||||||
nav.sidebar h2 { font-size: 1.1rem; margin-bottom: 1.5rem; }
|
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 { color: #ccc; text-decoration: none; display: block; padding: .4rem .6rem; border-radius: 4px; }
|
||||||
nav.sidebar a:hover { background: #16213e; color: #fff; }
|
nav.sidebar a:hover { background: #16213e; color: #fff; }
|
||||||
.main-wrap { flex: 1; display: flex; flex-direction: column; }
|
.main-wrap { flex: 1; display: flex; flex-direction: column; }
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<nav class="sidebar">
|
<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/">{{ t.nav_dashboard }}</a>
|
||||||
<a href="/admin/artists">{{ t.nav_artists }}</a>
|
<a href="/admin/artists">{{ t.nav_artists }}</a>
|
||||||
<a href="/admin/releases">{{ t.nav_releases }}</a>
|
<a href="/admin/releases">{{ t.nav_releases }}</a>
|
||||||
|
|||||||
+215
-12
@@ -4,7 +4,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>{{ t.reviews_heading }}</h1>
|
<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" 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=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>
|
<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=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=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>
|
<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>
|
<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 }}');">
|
<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>
|
<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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if reviews.is_empty() %}
|
{% if rows.is_empty() %}
|
||||||
<p>{{ t.reviews_empty }}</p>
|
<p>{{ t.reviews_empty }}</p>
|
||||||
{% else %}
|
{% 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>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="review-select-cell"></th>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>{{ t.reviews_status }}</th>
|
<th>{{ t.reviews_status }}</th>
|
||||||
<th>{{ t.reviews_type }}</th>
|
<th>{{ t.reviews_type }}</th>
|
||||||
<th>{{ t.reviews_input_path }}</th>
|
<th>{{ t.reviews_input_path }}</th>
|
||||||
|
<th>{{ t.reviews_tags }}</th>
|
||||||
<th>{{ t.reviews_confidence }}</th>
|
<th>{{ t.reviews_confidence }}</th>
|
||||||
<th>{{ t.reviews_model }}</th>
|
<th>{{ t.reviews_model }}</th>
|
||||||
<th>{{ t.reviews_llm_duration }}</th>
|
<th>{{ t.reviews_llm_duration }}</th>
|
||||||
@@ -37,14 +49,22 @@
|
|||||||
<th>{{ t.reviews_created }}</th>
|
<th>{{ t.reviews_created }}</th>
|
||||||
<th>{{ t.jobs_actions }}</th>
|
<th>{{ t.jobs_actions }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
{% for review in reviews %}
|
{% for row in rows %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/admin/reviews/{{ review.id_val() }}">{{ review.id_val() }}</a></td>
|
<td class="review-select-cell">
|
||||||
<td><span class="badge {{ review.status_badge_class() }}">{{ review.status_str() }}</span></td>
|
<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>{{ review.review_type_str() }}</td>
|
</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><a href="/admin/reviews/{{ row.review.id_val() }}">{{ row.review.id_val() }}</a></td>
|
||||||
<td>{% match review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}</td>
|
<td><span class="badge {{ row.review.status_badge_class() }}">{{ row.review.status_str() }}</span></td>
|
||||||
{% match stats_map.get(&review.id_val()) %}
|
<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) %}
|
{% when Some with (s) %}
|
||||||
<td>{{ s.model_name }}</td>
|
<td>{{ s.model_name }}</td>
|
||||||
<td>{{ s.duration_display() }}</td>
|
<td>{{ s.duration_display() }}</td>
|
||||||
@@ -54,20 +74,203 @@
|
|||||||
<td>-</td>
|
<td>-</td>
|
||||||
<td>-</td>
|
<td>-</td>
|
||||||
{% endmatch %}
|
{% endmatch %}
|
||||||
<td>{{ review.created_at_str() }}</td>
|
<td>{{ row.review.created_at_str() }}</td>
|
||||||
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<style>
|
<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-completed { background: #d4edda; color: #155724; }
|
||||||
.badge-failed { background: #f8d7da; color: #721c24; }
|
.badge-failed { background: #f8d7da; color: #721c24; }
|
||||||
.badge-pending { background: #fff3cd; color: #856404; }
|
.badge-pending { background: #fff3cd; color: #856404; }
|
||||||
.badge-queued { background: #d1ecf1; color: #0c5460; }
|
.badge-queued { background: #d1ecf1; color: #0c5460; }
|
||||||
.badge-processing { background: #cce5ff; color: #004085; }
|
.badge-processing { background: #cce5ff; color: #004085; }
|
||||||
</style>
|
</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 %}
|
{% endblock content %}
|
||||||
|
|||||||
+85
-26
@@ -273,6 +273,22 @@ body {
|
|||||||
.artist-header .artist-img svg { width: 80px; height: 80px; color: var(--text-subdued); }
|
.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-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 detail header */
|
||||||
.release-header {
|
.release-header {
|
||||||
@@ -1355,35 +1371,45 @@ body {
|
|||||||
</div>
|
</div>
|
||||||
<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 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>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="section-title" style="font-size:20px">Releases</h2>
|
<template x-for="group in $store.library.artistReleaseGroups()" :key="group.type">
|
||||||
<div class="card-grid">
|
<section class="artist-release-group">
|
||||||
<template x-for="release in $store.library.currentArtist.releases" :key="release.id">
|
<h2 class="artist-release-group-title" x-text="group.label"></h2>
|
||||||
<div class="card" @click="$store.library.openRelease(release.id)">
|
<div class="card-grid">
|
||||||
<div class="card-img">
|
<template x-for="release in group.releases" :key="release.id">
|
||||||
<template x-if="release.cover_url">
|
<div class="card" @click="$store.library.openRelease(release.id)">
|
||||||
<img :src="release.cover_url" :alt="release.title" loading="lazy">
|
<div class="card-img">
|
||||||
</template>
|
<template x-if="release.cover_url">
|
||||||
<template x-if="!release.cover_url">
|
<img :src="release.cover_url" :alt="release.title" loading="lazy">
|
||||||
<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>
|
||||||
</template>
|
<template x-if="!release.cover_url">
|
||||||
<button class="card-enqueue-btn" @click.stop="$store.library.enqueueRelease(release.id)" title="Add to queue">
|
<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>
|
||||||
<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>
|
</template>
|
||||||
</button>
|
<button class="card-enqueue-btn" @click.stop="$store.library.enqueueRelease(release.id)" title="Add to queue">
|
||||||
<button class="card-play-btn" @click.stop="$store.library.playRelease(release.id)">
|
<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>
|
||||||
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
</button>
|
||||||
</button>
|
<button class="card-play-btn" @click.stop="$store.library.playRelease(release.id)">
|
||||||
</div>
|
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||||
<div class="card-title" x-text="release.title"></div>
|
</button>
|
||||||
<div class="card-subtitle">
|
</div>
|
||||||
<span x-text="release.year || ''"></span>
|
<div class="card-title" x-text="release.title"></div>
|
||||||
<span x-text="release.release_type"></span>
|
<div class="card-subtitle">
|
||||||
</div>
|
<span x-text="release.year || ''"></span>
|
||||||
|
<span x-text="release.track_count + ' tracks'"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</section>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -2179,6 +2205,39 @@ document.addEventListener('alpine:init', () => {
|
|||||||
} catch {}
|
} 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) {
|
async openRelease(id) {
|
||||||
this.searchQuery = '';
|
this.searchQuery = '';
|
||||||
this.searchResults = null;
|
this.searchResults = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user