Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3a3f5368d | |||
| 5f925be29b | |||
| 8530016d35 | |||
| cae77e9401 | |||
| 709f319bc5 | |||
| bf0a2a553c | |||
| 3fc9b16e2c | |||
| 29f6d04d12 | |||
| c34485b521 | |||
| bc9f9605d8 | |||
| 2f0ed2ee09 | |||
| dcc665563a |
Generated
+1030
-14
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "furumusic"
|
||||
version = "0.1.1"
|
||||
version = "0.1.8"
|
||||
edition = "2024"
|
||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||
|
||||
@@ -26,3 +26,4 @@ tokio-cron-scheduler = "0.15"
|
||||
croner = "3"
|
||||
async-trait = "0.1"
|
||||
uuid = "1"
|
||||
librqbit = { version = "8.1.1", features = ["disable-upload"] }
|
||||
|
||||
@@ -7,7 +7,7 @@ Built with Rust ([cot](https://cot.rs) framework).
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
export FURU_DATABASE_URL=postgres://user:pass@localhost/furumusic
|
||||
export FURU_DATABASE_URL=postgresql://user:pass@localhost/furumusic
|
||||
cargo run
|
||||
# Open http://localhost:8000/admin/setup to create the first admin account
|
||||
```
|
||||
@@ -87,7 +87,7 @@ Full OpenID Connect authorization code flow with PKCE:
|
||||
|
||||
Provider metadata is cached for 1 hour and invalidated when OIDC config changes.
|
||||
|
||||
**Group-to-role mapping:** The `oidc_admin_groups` config field lists OIDC group names (comma-separated) that grant the admin role. Groups are extracted from the `groups` claim in the ID token JWT payload.
|
||||
**Group access and role mapping:** The `oidc_user_groups` config field lists OIDC group names (comma-separated) allowed to access the service. When it is set, users outside both `oidc_user_groups` and `oidc_admin_groups` are denied before provisioning/login. The `oidc_admin_groups` config field lists OIDC group names that grant the admin role. Groups are extracted from the `groups` claim in the ID token JWT payload.
|
||||
|
||||
**User provisioning order:**
|
||||
1. Find existing `OidcLink` by issuer+sub → update claims, update role
|
||||
@@ -197,4 +197,5 @@ All prefixed with `FURU_`. Priority: env var > DB override > compiled default.
|
||||
| `FURU_OIDC_CLIENT_SECRET` | OIDC client secret | *(empty)* |
|
||||
| `FURU_OIDC_BUTTON_TEXT` | SSO button label | `Sign in with SSO` |
|
||||
| `FURU_OIDC_ADMIN_GROUPS` | Comma-separated OIDC groups that grant admin | *(empty)* |
|
||||
| `FURU_OIDC_USER_GROUPS` | Comma-separated OIDC groups allowed to access the service. Empty means any authenticated SSO user is allowed. | *(empty)* |
|
||||
| `FURU_SWAGGER_ENABLED` | Serve Swagger UI at `/swagger/` | `false` |
|
||||
|
||||
@@ -10,8 +10,5 @@ fn main() {
|
||||
.output()
|
||||
.expect("failed to run rustc --version");
|
||||
let version = String::from_utf8_lossy(&output.stdout);
|
||||
println!(
|
||||
"cargo::rustc-env=FURU_RUSTC_VERSION={}",
|
||||
version.trim()
|
||||
);
|
||||
println!("cargo::rustc-env=FURU_RUSTC_VERSION={}", version.trim());
|
||||
}
|
||||
|
||||
+52
-3
@@ -2,6 +2,7 @@ pub mod views;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cot::App;
|
||||
use cot::db::Database;
|
||||
use cot::db::migrations::SyncDynMigration;
|
||||
use cot::json::Json;
|
||||
@@ -10,7 +11,6 @@ use cot::response::IntoResponse;
|
||||
use cot::router::method::get;
|
||||
use cot::router::{Route, Router};
|
||||
use cot::session::Session;
|
||||
use cot::App;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth;
|
||||
@@ -18,7 +18,10 @@ use crate::config::AppConfig;
|
||||
use crate::i18n::I18n;
|
||||
use crate::scheduler::{JobRegistry, SchedulerHandle};
|
||||
use crate::user::User;
|
||||
use views::{ArtistForm, CronForm, OidcSettingsForm, ReleaseForm, SetImageBody, SetupForm, UploadImageBody, UserForm};
|
||||
use views::{
|
||||
ArtistForm, CronForm, MetadataBackfillForm, OidcSettingsForm, ReleaseForm, ReviewsBulkForm,
|
||||
SetImageBody, SetupForm, UploadImageBody, UserForm,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReviewsQuery {
|
||||
@@ -59,7 +62,11 @@ impl AdminApp {
|
||||
registry: Arc<JobRegistry>,
|
||||
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
|
||||
) -> Self {
|
||||
Self { config, registry, scheduler_handle }
|
||||
Self {
|
||||
config,
|
||||
registry,
|
||||
scheduler_handle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,6 +543,33 @@ impl App for AdminApp {
|
||||
},
|
||||
"admin_jobs",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/jobs/metadata_backfill/run-options",
|
||||
cot::router::method::post({
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
move |session: Session, db: Database,
|
||||
form: RequestForm<MetadataBackfillForm>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
let pg_pool = pool.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(3)
|
||||
.connect(&pool_config.database_url)
|
||||
.await
|
||||
.expect("admin pool")
|
||||
}).await;
|
||||
views::metadata_backfill_run(admin, &db, pg_pool, form).await
|
||||
}
|
||||
}
|
||||
}),
|
||||
"admin_metadata_backfill_run",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/jobs/{name}/run",
|
||||
cot::router::method::post({
|
||||
@@ -651,6 +685,21 @@ impl App for AdminApp {
|
||||
),
|
||||
"admin_reviews_clear",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/reviews/bulk",
|
||||
cot::router::method::post(
|
||||
|session: Session, db: Database,
|
||||
form: RequestForm<ReviewsBulkForm>| async move {
|
||||
let admin =
|
||||
match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::reviews_bulk(admin, &db, form).await
|
||||
},
|
||||
),
|
||||
"admin_reviews_bulk",
|
||||
),
|
||||
// -- Reviews ------------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/reviews",
|
||||
|
||||
+545
-84
@@ -9,14 +9,14 @@ use cot::{Body, Template};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::auth::{self, AuthenticatedUser};
|
||||
use super::BUILD_INFO;
|
||||
use crate::agent;
|
||||
use crate::auth::{self, AuthenticatedUser};
|
||||
use crate::config::{AppConfig, ConfigEntry, ConfigSources};
|
||||
use crate::i18n::{I18n, Translations};
|
||||
use crate::music::{Artist, MediaFile, Release, ReleaseArtist, Track, TrackArtist, RELEASE_TYPES};
|
||||
use crate::music::{Artist, MediaFile, RELEASE_TYPES, Release, ReleaseArtist, Track, TrackArtist};
|
||||
use crate::scheduler::{self, JobRegistry, JobRun, PendingReview, ScheduledJob};
|
||||
use crate::user::User;
|
||||
use super::BUILD_INFO;
|
||||
|
||||
use crate::agent::AgentProbeResult;
|
||||
|
||||
@@ -31,10 +31,7 @@ pub struct ConfigDisplayEntry {
|
||||
}
|
||||
|
||||
/// Secret field names that should be redacted in the debug view.
|
||||
const SECRET_FIELDS: &[&str] = &[
|
||||
"database_url",
|
||||
"oidc_client_secret",
|
||||
];
|
||||
const SECRET_FIELDS: &[&str] = &["database_url", "oidc_client_secret"];
|
||||
|
||||
fn is_secret(name: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
@@ -66,13 +63,16 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<Co
|
||||
let defaults = AppConfig::default();
|
||||
|
||||
macro_rules! entry {
|
||||
($field:ident, $value:expr, $default:expr) => {
|
||||
{
|
||||
($field:ident, $value:expr, $default:expr) => {{
|
||||
let raw = $value;
|
||||
let default_raw = $default;
|
||||
let secret = is_secret(stringify!($field));
|
||||
let display = if secret { redact(&raw) } else { raw };
|
||||
let default_display = if secret { redact(&default_raw) } else { default_raw };
|
||||
let default_display = if secret {
|
||||
redact(&default_raw)
|
||||
} else {
|
||||
default_raw
|
||||
};
|
||||
ConfigDisplayEntry {
|
||||
key: stringify!($field).into(),
|
||||
env_var: format!("FURU_{}", stringify!($field).to_ascii_uppercase()),
|
||||
@@ -80,30 +80,110 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<Co
|
||||
default_value: default_display,
|
||||
source: sources.$field.code(),
|
||||
}
|
||||
}
|
||||
};
|
||||
}};
|
||||
}
|
||||
|
||||
vec![
|
||||
entry!(database_url, config.database_url.clone(), defaults.database_url.clone()),
|
||||
entry!(oidc_issuer, config.oidc_issuer.clone(), defaults.oidc_issuer.clone()),
|
||||
entry!(oidc_client_id, config.oidc_client_id.clone(), defaults.oidc_client_id.clone()),
|
||||
entry!(oidc_client_secret, config.oidc_client_secret.clone(), defaults.oidc_client_secret.clone()),
|
||||
entry!(log_level, config.log_level.clone(), defaults.log_level.clone()),
|
||||
entry!(auth_password_enabled, config.auth_password_enabled.to_string(), defaults.auth_password_enabled.to_string()),
|
||||
entry!(auth_sso_enabled, config.auth_sso_enabled.to_string(), defaults.auth_sso_enabled.to_string()),
|
||||
entry!(oidc_button_text, config.oidc_button_text.clone(), defaults.oidc_button_text.clone()),
|
||||
entry!(oidc_admin_groups, config.oidc_admin_groups.clone(), defaults.oidc_admin_groups.clone()),
|
||||
entry!(swagger_enabled, config.swagger_enabled.to_string(), defaults.swagger_enabled.to_string()),
|
||||
entry!(agent_enabled, config.agent_enabled.to_string(), defaults.agent_enabled.to_string()),
|
||||
entry!(agent_inbox_dir, config.agent_inbox_dir.clone(), defaults.agent_inbox_dir.clone()),
|
||||
entry!(agent_storage_dir, config.agent_storage_dir.clone(), defaults.agent_storage_dir.clone()),
|
||||
entry!(agent_llm_url, config.agent_llm_url.clone(), defaults.agent_llm_url.clone()),
|
||||
entry!(agent_llm_model, config.agent_llm_model.clone(), defaults.agent_llm_model.clone()),
|
||||
entry!(agent_llm_auth, config.agent_llm_auth.clone(), defaults.agent_llm_auth.clone()),
|
||||
entry!(agent_confidence_threshold, config.agent_confidence_threshold.to_string(), defaults.agent_confidence_threshold.to_string()),
|
||||
entry!(agent_context_limit, config.agent_context_limit.to_string(), defaults.agent_context_limit.to_string()),
|
||||
entry!(agent_concurrency, config.agent_concurrency.to_string(), defaults.agent_concurrency.to_string()),
|
||||
entry!(
|
||||
database_url,
|
||||
config.database_url.clone(),
|
||||
defaults.database_url.clone()
|
||||
),
|
||||
entry!(
|
||||
oidc_issuer,
|
||||
config.oidc_issuer.clone(),
|
||||
defaults.oidc_issuer.clone()
|
||||
),
|
||||
entry!(
|
||||
oidc_client_id,
|
||||
config.oidc_client_id.clone(),
|
||||
defaults.oidc_client_id.clone()
|
||||
),
|
||||
entry!(
|
||||
oidc_client_secret,
|
||||
config.oidc_client_secret.clone(),
|
||||
defaults.oidc_client_secret.clone()
|
||||
),
|
||||
entry!(
|
||||
log_level,
|
||||
config.log_level.clone(),
|
||||
defaults.log_level.clone()
|
||||
),
|
||||
entry!(
|
||||
auth_password_enabled,
|
||||
config.auth_password_enabled.to_string(),
|
||||
defaults.auth_password_enabled.to_string()
|
||||
),
|
||||
entry!(
|
||||
auth_sso_enabled,
|
||||
config.auth_sso_enabled.to_string(),
|
||||
defaults.auth_sso_enabled.to_string()
|
||||
),
|
||||
entry!(
|
||||
oidc_button_text,
|
||||
config.oidc_button_text.clone(),
|
||||
defaults.oidc_button_text.clone()
|
||||
),
|
||||
entry!(
|
||||
oidc_admin_groups,
|
||||
config.oidc_admin_groups.clone(),
|
||||
defaults.oidc_admin_groups.clone()
|
||||
),
|
||||
entry!(
|
||||
oidc_user_groups,
|
||||
config.oidc_user_groups.clone(),
|
||||
defaults.oidc_user_groups.clone()
|
||||
),
|
||||
entry!(
|
||||
swagger_enabled,
|
||||
config.swagger_enabled.to_string(),
|
||||
defaults.swagger_enabled.to_string()
|
||||
),
|
||||
entry!(
|
||||
agent_enabled,
|
||||
config.agent_enabled.to_string(),
|
||||
defaults.agent_enabled.to_string()
|
||||
),
|
||||
entry!(
|
||||
agent_inbox_dir,
|
||||
config.agent_inbox_dir.clone(),
|
||||
defaults.agent_inbox_dir.clone()
|
||||
),
|
||||
entry!(
|
||||
agent_storage_dir,
|
||||
config.agent_storage_dir.clone(),
|
||||
defaults.agent_storage_dir.clone()
|
||||
),
|
||||
entry!(
|
||||
agent_llm_url,
|
||||
config.agent_llm_url.clone(),
|
||||
defaults.agent_llm_url.clone()
|
||||
),
|
||||
entry!(
|
||||
agent_llm_model,
|
||||
config.agent_llm_model.clone(),
|
||||
defaults.agent_llm_model.clone()
|
||||
),
|
||||
entry!(
|
||||
agent_llm_auth,
|
||||
config.agent_llm_auth.clone(),
|
||||
defaults.agent_llm_auth.clone()
|
||||
),
|
||||
entry!(
|
||||
agent_confidence_threshold,
|
||||
config.agent_confidence_threshold.to_string(),
|
||||
defaults.agent_confidence_threshold.to_string()
|
||||
),
|
||||
entry!(
|
||||
agent_context_limit,
|
||||
config.agent_context_limit.to_string(),
|
||||
defaults.agent_context_limit.to_string()
|
||||
),
|
||||
entry!(
|
||||
agent_concurrency,
|
||||
config.agent_concurrency.to_string(),
|
||||
defaults.agent_concurrency.to_string()
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -173,6 +253,8 @@ struct SettingsTemplate {
|
||||
oidc_client_secret_source: &'static str,
|
||||
oidc_admin_groups: String,
|
||||
oidc_admin_groups_source: &'static str,
|
||||
oidc_user_groups: String,
|
||||
oidc_user_groups_source: &'static str,
|
||||
swagger_enabled: bool,
|
||||
swagger_enabled_source: &'static str,
|
||||
agent_enabled: bool,
|
||||
@@ -223,6 +305,8 @@ pub async fn settings_handler(
|
||||
oidc_client_secret_source: sources.oidc_client_secret.code(),
|
||||
oidc_admin_groups: config.oidc_admin_groups,
|
||||
oidc_admin_groups_source: sources.oidc_admin_groups.code(),
|
||||
oidc_user_groups: config.oidc_user_groups,
|
||||
oidc_user_groups_source: sources.oidc_user_groups.code(),
|
||||
swagger_enabled: config.swagger_enabled,
|
||||
swagger_enabled_source: sources.swagger_enabled.code(),
|
||||
agent_enabled: config.agent_enabled,
|
||||
@@ -256,6 +340,7 @@ pub struct OidcSettingsForm {
|
||||
oidc_client_id: Option<String>,
|
||||
oidc_client_secret: Option<String>,
|
||||
oidc_admin_groups: Option<String>,
|
||||
oidc_user_groups: Option<String>,
|
||||
swagger_enabled: Option<String>,
|
||||
agent_enabled: Option<String>,
|
||||
agent_inbox_dir: Option<String>,
|
||||
@@ -278,15 +363,32 @@ pub async fn settings_submit(
|
||||
let RequestForm(result) = form;
|
||||
match result {
|
||||
FormResult::Ok(data) => {
|
||||
let pw_enabled = if data.auth_password_enabled.is_some() { "true" } else { "false" };
|
||||
let sso_enabled = if data.auth_sso_enabled.is_some() { "true" } else { "false" };
|
||||
let swagger = if data.swagger_enabled.is_some() { "true" } else { "false" };
|
||||
let agent_en = if data.agent_enabled.is_some() { "true" } else { "false" };
|
||||
let pw_enabled = if data.auth_password_enabled.is_some() {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
};
|
||||
let sso_enabled = if data.auth_sso_enabled.is_some() {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
};
|
||||
let swagger = if data.swagger_enabled.is_some() {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
};
|
||||
let agent_en = if data.agent_enabled.is_some() {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
};
|
||||
let oidc_button_text = data.oidc_button_text.unwrap_or_default();
|
||||
let oidc_issuer = data.oidc_issuer.unwrap_or_default();
|
||||
let oidc_client_id = data.oidc_client_id.unwrap_or_default();
|
||||
let oidc_client_secret = data.oidc_client_secret.unwrap_or_default();
|
||||
let oidc_admin_groups = data.oidc_admin_groups.unwrap_or_default();
|
||||
let oidc_user_groups = data.oidc_user_groups.unwrap_or_default();
|
||||
let agent_inbox_dir = data.agent_inbox_dir.unwrap_or_default();
|
||||
let agent_storage_dir = data.agent_storage_dir.unwrap_or_default();
|
||||
let agent_llm_url = data.agent_llm_url.unwrap_or_default();
|
||||
@@ -295,7 +397,7 @@ pub async fn settings_submit(
|
||||
let agent_confidence_threshold = data.agent_confidence_threshold.unwrap_or_default();
|
||||
let agent_context_limit = data.agent_context_limit.unwrap_or_default();
|
||||
let agent_concurrency = data.agent_concurrency.unwrap_or_default();
|
||||
let fields: [(&str, &str); 17] = [
|
||||
let fields: [(&str, &str); 18] = [
|
||||
("auth_password_enabled", pw_enabled),
|
||||
("auth_sso_enabled", sso_enabled),
|
||||
("oidc_button_text", &oidc_button_text),
|
||||
@@ -303,6 +405,7 @@ pub async fn settings_submit(
|
||||
("oidc_client_id", &oidc_client_id),
|
||||
("oidc_client_secret", &oidc_client_secret),
|
||||
("oidc_admin_groups", &oidc_admin_groups),
|
||||
("oidc_user_groups", &oidc_user_groups),
|
||||
("swagger_enabled", swagger),
|
||||
("agent_enabled", agent_en),
|
||||
("agent_inbox_dir", &agent_inbox_dir),
|
||||
@@ -353,7 +456,12 @@ pub async fn settings_probe_handler(
|
||||
let (config, _sources) = AppConfig::load_with_db(db).await;
|
||||
|
||||
let probe = if config.agent_enabled && !config.agent_llm_url.is_empty() {
|
||||
agent::probe_llm(&config.agent_llm_url, &config.agent_llm_model, &config.agent_llm_auth).await
|
||||
agent::probe_llm(
|
||||
&config.agent_llm_url,
|
||||
&config.agent_llm_model,
|
||||
&config.agent_llm_auth,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
AgentProbeResult::default()
|
||||
};
|
||||
@@ -437,15 +545,29 @@ pub async fn users_create(
|
||||
let RequestForm(result) = form;
|
||||
match result {
|
||||
FormResult::Ok(data) => {
|
||||
let email = if data.email.is_empty() { None } else { Some(data.email.as_str()) };
|
||||
let display_name = if data.display_name.is_empty() { None } else { Some(data.display_name.as_str()) };
|
||||
User::create(db, &data.username, email, display_name, &data.password, &data.role).await
|
||||
let email = if data.email.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(data.email.as_str())
|
||||
};
|
||||
let display_name = if data.display_name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(data.display_name.as_str())
|
||||
};
|
||||
User::create(
|
||||
db,
|
||||
&data.username,
|
||||
email,
|
||||
display_name,
|
||||
&data.password,
|
||||
&data.role,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to create user: {e}")))?;
|
||||
Ok(auth::redirect("/admin/users"))
|
||||
}
|
||||
FormResult::ValidationError(_) => {
|
||||
Ok(auth::redirect("/admin/users/new"))
|
||||
}
|
||||
FormResult::ValidationError(_) => Ok(auth::redirect("/admin/users/new")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,7 +577,8 @@ pub async fn users_edit(
|
||||
db: &Database,
|
||||
user_id: i64,
|
||||
) -> cot::Result<Html> {
|
||||
let target = User::get_by_id(db, user_id).await
|
||||
let target = User::get_by_id(db, user_id)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
|
||||
.ok_or_else(|| cot::Error::internal("user not found"))?;
|
||||
let template = UserFormTemplate {
|
||||
@@ -481,13 +604,35 @@ pub async fn users_update(
|
||||
let RequestForm(result) = form;
|
||||
match result {
|
||||
FormResult::Ok(data) => {
|
||||
let mut target = User::get_by_id(db, user_id).await
|
||||
let mut target = User::get_by_id(db, user_id)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
|
||||
.ok_or_else(|| cot::Error::internal("user not found"))?;
|
||||
let email = if data.email.is_empty() { None } else { Some(data.email.as_str()) };
|
||||
let display_name = if data.display_name.is_empty() { None } else { Some(data.display_name.as_str()) };
|
||||
let new_password = if data.password.is_empty() { None } else { Some(data.password.as_str()) };
|
||||
target.update_fields(db, &data.username, email, display_name, new_password, &data.role).await
|
||||
let email = if data.email.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(data.email.as_str())
|
||||
};
|
||||
let display_name = if data.display_name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(data.display_name.as_str())
|
||||
};
|
||||
let new_password = if data.password.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(data.password.as_str())
|
||||
};
|
||||
target
|
||||
.update_fields(
|
||||
db,
|
||||
&data.username,
|
||||
email,
|
||||
display_name,
|
||||
new_password,
|
||||
&data.role,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to update user: {e}")))?;
|
||||
Ok(auth::redirect("/admin/users"))
|
||||
}
|
||||
@@ -502,7 +647,8 @@ pub async fn users_delete(
|
||||
db: &Database,
|
||||
user_id: i64,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
User::delete_by_id(db, user_id).await
|
||||
User::delete_by_id(db, user_id)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to delete user: {e}")))?;
|
||||
Ok(auth::redirect("/admin/users"))
|
||||
}
|
||||
@@ -519,10 +665,7 @@ struct SetupTemplate {
|
||||
}
|
||||
|
||||
pub async fn setup_page(i18n: I18n, message: String) -> cot::Result<Html> {
|
||||
let template = SetupTemplate {
|
||||
t: i18n.t,
|
||||
message,
|
||||
};
|
||||
let template = SetupTemplate { t: i18n.t, message };
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
@@ -581,13 +724,25 @@ struct ArtistsTemplate {
|
||||
rows: Vec<ArtistRow>,
|
||||
}
|
||||
|
||||
pub async fn artists_list(admin: AuthenticatedUser, i18n: I18n, db: &Database) -> cot::Result<Html> {
|
||||
pub async fn artists_list(
|
||||
admin: AuthenticatedUser,
|
||||
i18n: I18n,
|
||||
db: &Database,
|
||||
) -> cot::Result<Html> {
|
||||
let artists = Artist::list_all(db).await.unwrap_or_default();
|
||||
let mut rows = Vec::with_capacity(artists.len());
|
||||
for artist in artists {
|
||||
let release_count = ReleaseArtist::count_by_artist(db, artist.id_val()).await.unwrap_or(0);
|
||||
let track_count = TrackArtist::count_by_artist(db, artist.id_val()).await.unwrap_or(0);
|
||||
rows.push(ArtistRow { artist, release_count, track_count });
|
||||
let release_count = ReleaseArtist::count_by_artist(db, artist.id_val())
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let track_count = TrackArtist::count_by_artist(db, artist.id_val())
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
rows.push(ArtistRow {
|
||||
artist,
|
||||
release_count,
|
||||
track_count,
|
||||
});
|
||||
}
|
||||
let template = ArtistsTemplate {
|
||||
t: i18n.t,
|
||||
@@ -657,13 +812,11 @@ pub async fn artists_edit(
|
||||
.ok_or_else(|| cot::Error::internal("artist not found"))?;
|
||||
|
||||
let current_image_url = match artist.image_file_id {
|
||||
Some(fid) => {
|
||||
MediaFile::get_by_id(db, fid)
|
||||
Some(fid) => MediaFile::get_by_id(db, fid)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|mf| format!("/api/player/cover/{}", mf.id_val()))
|
||||
}
|
||||
.map(|mf| format!("/api/player/cover/{}", mf.id_val())),
|
||||
None => None,
|
||||
};
|
||||
|
||||
@@ -830,9 +983,9 @@ pub async fn artists_upload_image(
|
||||
let cover = crate::agent::cover_art::CoverImage {
|
||||
data: image_data,
|
||||
mime_type: parsed.mime_type.clone(),
|
||||
source: crate::agent::cover_art::CoverSource::FolderFile(
|
||||
std::path::PathBuf::from(&parsed.filename),
|
||||
),
|
||||
source: crate::agent::cover_art::CoverSource::FolderFile(std::path::PathBuf::from(
|
||||
&parsed.filename,
|
||||
)),
|
||||
};
|
||||
|
||||
let cover_file_id = crate::agent::cover_art::save_cover_to_storage(
|
||||
@@ -922,7 +1075,9 @@ pub async fn releases_list(
|
||||
// If filtering by artist, find the set of release_ids for that artist
|
||||
let filtered_release_ids: Option<Vec<i64>> = match filter_artist_id {
|
||||
Some(aid) => {
|
||||
let links = ReleaseArtist::find_by_artist(db, aid).await.unwrap_or_default();
|
||||
let links = ReleaseArtist::find_by_artist(db, aid)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
Some(links.iter().map(|l| l.release_id()).collect())
|
||||
}
|
||||
None => None,
|
||||
@@ -936,7 +1091,10 @@ pub async fn releases_list(
|
||||
}
|
||||
}
|
||||
let artist_names = resolve_artist_names(db, release.id_val(), &names).await;
|
||||
rows.push(ReleaseRow { release, artist_names });
|
||||
rows.push(ReleaseRow {
|
||||
release,
|
||||
artist_names,
|
||||
});
|
||||
}
|
||||
|
||||
let template = ReleasesTemplate {
|
||||
@@ -967,7 +1125,11 @@ struct ReleaseFormTemplate {
|
||||
lang_code: &'static str,
|
||||
}
|
||||
|
||||
pub async fn releases_new(admin: AuthenticatedUser, i18n: I18n, db: &Database) -> cot::Result<Html> {
|
||||
pub async fn releases_new(
|
||||
admin: AuthenticatedUser,
|
||||
i18n: I18n,
|
||||
db: &Database,
|
||||
) -> cot::Result<Html> {
|
||||
let artists = Artist::list_all(db).await.unwrap_or_default();
|
||||
let template = ReleaseFormTemplate {
|
||||
t: i18n.t,
|
||||
@@ -1084,9 +1246,9 @@ pub async fn releases_update(
|
||||
.map_err(|e| cot::Error::internal(format!("failed to update artists: {e}")))?;
|
||||
Ok(auth::redirect("/admin/releases"))
|
||||
}
|
||||
FormResult::ValidationError(_) => {
|
||||
Ok(auth::redirect(&format!("/admin/releases/{release_id}/edit")))
|
||||
}
|
||||
FormResult::ValidationError(_) => Ok(auth::redirect(&format!(
|
||||
"/admin/releases/{release_id}/edit"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1137,10 +1299,7 @@ pub async fn media_files_list(
|
||||
let rows: Vec<MediaFileRow> = files
|
||||
.into_iter()
|
||||
.map(|mf| {
|
||||
let track_title = track_map
|
||||
.get(&mf.id_val())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let track_title = track_map.get(&mf.id_val()).cloned().unwrap_or_default();
|
||||
MediaFileRow {
|
||||
media_file: mf,
|
||||
track_title,
|
||||
@@ -1204,17 +1363,16 @@ pub async fn jobs_list(
|
||||
/// rows for jobs that are no longer registered.
|
||||
async fn sync_registered_jobs(db: &Database, registry: &JobRegistry) {
|
||||
for job in registry.all_jobs() {
|
||||
if let Err(e) = ScheduledJob::upsert(db, job.name(), job.description(), job.default_cron()).await {
|
||||
if let Err(e) =
|
||||
ScheduledJob::upsert(db, job.name(), job.description(), job.default_cron()).await
|
||||
{
|
||||
tracing::error!("failed to upsert scheduled job {}: {e}", job.name());
|
||||
}
|
||||
}
|
||||
if let Ok(all) = ScheduledJob::list_all(db).await {
|
||||
for sched_job in all {
|
||||
if registry.get(sched_job.name_str()).is_none() {
|
||||
tracing::warn!(
|
||||
"Removing orphaned scheduled job '{}'",
|
||||
sched_job.name_str()
|
||||
);
|
||||
tracing::warn!("Removing orphaned scheduled job '{}'", sched_job.name_str());
|
||||
let _ = ScheduledJob::delete_by_name(db, sched_job.name_str()).await;
|
||||
}
|
||||
}
|
||||
@@ -1231,6 +1389,15 @@ struct JobDetailTemplate {
|
||||
runs: Vec<JobRun>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Form)]
|
||||
pub struct MetadataBackfillForm {
|
||||
audio_bitrate: Option<String>,
|
||||
audio_sample_rate: Option<String>,
|
||||
audio_bit_depth: Option<String>,
|
||||
duration_seconds: Option<String>,
|
||||
mode: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn job_detail(
|
||||
admin: AuthenticatedUser,
|
||||
i18n: I18n,
|
||||
@@ -1275,12 +1442,76 @@ pub async fn job_run_now(
|
||||
Ok(auth::redirect(&format!("/admin/jobs/{job_name}")))
|
||||
}
|
||||
|
||||
pub async fn metadata_backfill_run(
|
||||
_admin: AuthenticatedUser,
|
||||
db: &Database,
|
||||
pool: &sqlx::PgPool,
|
||||
form: RequestForm<MetadataBackfillForm>,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
let RequestForm(result) = form;
|
||||
let data = match result {
|
||||
FormResult::Ok(data) => data,
|
||||
FormResult::ValidationError(_) => {
|
||||
return Ok(auth::redirect("/admin/jobs/metadata_backfill"));
|
||||
}
|
||||
};
|
||||
|
||||
let options = crate::jobs::metadata_backfill::MetadataBackfillOptions {
|
||||
audio_bitrate: data.audio_bitrate.is_some(),
|
||||
audio_sample_rate: data.audio_sample_rate.is_some(),
|
||||
audio_bit_depth: data.audio_bit_depth.is_some(),
|
||||
duration_seconds: data.duration_seconds.is_some(),
|
||||
overwrite: data.mode.as_deref() == Some("overwrite"),
|
||||
};
|
||||
|
||||
let mut run = JobRun::create_running(db, "metadata_backfill", "manual")
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to create job run: {e}")))?;
|
||||
let run_id = run.id_val();
|
||||
let db = db.clone();
|
||||
let pool = pool.clone();
|
||||
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let start = std::time::Instant::now();
|
||||
let ctx = scheduler::JobContext {
|
||||
config: Arc::new(live_config),
|
||||
db: db.clone(),
|
||||
pool: pool.clone(),
|
||||
run_id,
|
||||
registry: Arc::new(JobRegistry::new()),
|
||||
};
|
||||
let mut log = scheduler::JobLog::with_live_flush(pool.clone(), run_id);
|
||||
let result =
|
||||
crate::jobs::metadata_backfill::run_with_options(&ctx, &mut log, options).await;
|
||||
let duration_ms = start.elapsed().as_millis() as i64;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let _ = run.set_completed(&db, duration_ms, &log.output()).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = run
|
||||
.set_failed(&db, duration_ms, &log.output(), &e.to_string())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(auth::redirect(&format!(
|
||||
"/admin/jobs/metadata_backfill/runs/{run_id}"
|
||||
)))
|
||||
}
|
||||
|
||||
pub async fn job_toggle_enabled(
|
||||
_admin: AuthenticatedUser,
|
||||
db: &Database,
|
||||
handle_cell: &Arc<tokio::sync::OnceCell<Arc<scheduler::SchedulerHandle>>>,
|
||||
job_name: &str,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
if job_name == "metadata_backfill" {
|
||||
return Ok(auth::redirect("/admin/jobs/metadata_backfill"));
|
||||
}
|
||||
|
||||
let job = ScheduledJob::get_by_name(db, job_name)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
|
||||
@@ -1311,6 +1542,10 @@ pub async fn job_update_cron(
|
||||
job_name: &str,
|
||||
form: RequestForm<CronForm>,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
if job_name == "metadata_backfill" {
|
||||
return Ok(auth::redirect("/admin/jobs/metadata_backfill"));
|
||||
}
|
||||
|
||||
let RequestForm(result) = form;
|
||||
if let FormResult::Ok(data) = result {
|
||||
if let Some(handle) = handle_cell.get() {
|
||||
@@ -1366,11 +1601,164 @@ struct ReviewsTemplate {
|
||||
t: &'static Translations,
|
||||
user_name: String,
|
||||
user_role: String,
|
||||
reviews: Vec<PendingReview>,
|
||||
rows: Vec<ReviewListRow>,
|
||||
stats_map: HashMap<i64, scheduler::ProcessingStatsRow>,
|
||||
status_filter: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ReviewListRow {
|
||||
review: PendingReview,
|
||||
display_input_path: String,
|
||||
media_tags: Vec<ReviewMediaTag>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ReviewMediaTag {
|
||||
label: String,
|
||||
kind: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct ReviewMediaTagRow {
|
||||
sha256_hash: String,
|
||||
original_filename: String,
|
||||
file_size_bytes: i64,
|
||||
audio_format: Option<String>,
|
||||
audio_bitrate: Option<i32>,
|
||||
audio_sample_rate: Option<i32>,
|
||||
audio_bit_depth: Option<i32>,
|
||||
}
|
||||
|
||||
fn compact_path_tail(path: &str, max_chars: usize) -> String {
|
||||
let normalized = path.replace('\\', "/");
|
||||
if normalized.chars().count() <= max_chars {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
let segments = normalized.split('/').collect::<Vec<_>>();
|
||||
let filename = segments.last().copied().unwrap_or(normalized.as_str());
|
||||
let filename_len = filename.chars().count();
|
||||
if filename_len + 4 <= max_chars {
|
||||
return format!(".../{filename}");
|
||||
}
|
||||
|
||||
if filename_len > max_chars {
|
||||
let suffix_len = max_chars.saturating_sub(3);
|
||||
let suffix = filename
|
||||
.chars()
|
||||
.skip(filename_len.saturating_sub(suffix_len))
|
||||
.collect::<String>();
|
||||
return format!("...{suffix}");
|
||||
}
|
||||
format!(".../{filename}")
|
||||
}
|
||||
|
||||
fn context_sha256(review: &PendingReview) -> Option<String> {
|
||||
let value = serde_json::from_str::<serde_json::Value>(review.context_json_str()).ok()?;
|
||||
let sha = value.get("sha256")?.as_str()?.trim();
|
||||
let is_sha256 = sha.len() == 64 && sha.chars().all(|ch| ch.is_ascii_hexdigit());
|
||||
is_sha256.then(|| sha.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn file_extension(filename: &str) -> Option<String> {
|
||||
std::path::Path::new(filename)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.trim().to_ascii_lowercase())
|
||||
.filter(|ext| !ext.is_empty())
|
||||
}
|
||||
|
||||
fn size_display(bytes: i64) -> String {
|
||||
if bytes >= 1_073_741_824 {
|
||||
format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
|
||||
} else if bytes >= 1_048_576 {
|
||||
format!("{:.1} MB", bytes as f64 / 1_048_576.0)
|
||||
} else if bytes >= 1024 {
|
||||
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
fn review_tag(label: impl Into<String>, kind: &'static str) -> ReviewMediaTag {
|
||||
ReviewMediaTag {
|
||||
label: label.into(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
fn media_tags(row: &ReviewMediaTagRow) -> Vec<ReviewMediaTag> {
|
||||
let mut tags = Vec::new();
|
||||
if let Some(format) = row.audio_format.as_deref().filter(|s| !s.is_empty()) {
|
||||
tags.push(review_tag(format.to_ascii_lowercase(), "format"));
|
||||
} else if let Some(ext) = file_extension(&row.original_filename) {
|
||||
tags.push(review_tag(ext, "format"));
|
||||
}
|
||||
if let Some(bitrate) = row.audio_bitrate {
|
||||
tags.push(review_tag(format!("{bitrate} kbps"), "bitrate"));
|
||||
}
|
||||
if let Some(sample_rate) = row.audio_sample_rate {
|
||||
if sample_rate % 1000 == 0 {
|
||||
tags.push(review_tag(format!("{} kHz", sample_rate / 1000), "sample"));
|
||||
} else {
|
||||
tags.push(review_tag(
|
||||
format!("{:.1} kHz", sample_rate as f64 / 1000.0),
|
||||
"sample",
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(bit_depth) = row.audio_bit_depth {
|
||||
tags.push(review_tag(format!("{bit_depth}-bit"), "depth"));
|
||||
}
|
||||
tags.push(review_tag(size_display(row.file_size_bytes), "size"));
|
||||
tags
|
||||
}
|
||||
|
||||
async fn review_media_tags(
|
||||
pool: &sqlx::PgPool,
|
||||
reviews: &[PendingReview],
|
||||
) -> HashMap<String, Vec<ReviewMediaTag>> {
|
||||
let mut hashes = reviews
|
||||
.iter()
|
||||
.filter_map(context_sha256)
|
||||
.collect::<Vec<_>>();
|
||||
hashes.sort();
|
||||
hashes.dedup();
|
||||
if hashes.is_empty() {
|
||||
return HashMap::new();
|
||||
}
|
||||
|
||||
let quoted = hashes
|
||||
.iter()
|
||||
.map(|hash| format!("'{hash}'"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let query = format!(
|
||||
"SELECT sha256_hash::text AS sha256_hash, \
|
||||
original_filename::text AS original_filename, \
|
||||
file_size_bytes, \
|
||||
audio_format::text AS audio_format, \
|
||||
audio_bitrate, audio_sample_rate, audio_bit_depth \
|
||||
FROM furumusic__media_file \
|
||||
WHERE file_type = 'audio' AND sha256_hash IN ({quoted})"
|
||||
);
|
||||
|
||||
match sqlx::query_as::<_, ReviewMediaTagRow>(&query)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows
|
||||
.into_iter()
|
||||
.map(|row| (row.sha256_hash.to_ascii_lowercase(), media_tags(&row)))
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to load review media tags");
|
||||
HashMap::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reviews_list(
|
||||
admin: AuthenticatedUser,
|
||||
i18n: I18n,
|
||||
@@ -1389,12 +1777,27 @@ pub async fn reviews_list(
|
||||
let stats_map = scheduler::ProcessingStats::list_by_review_ids(pool, &review_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let media_tags = review_media_tags(pool, &reviews).await;
|
||||
let rows = reviews
|
||||
.into_iter()
|
||||
.map(|review| {
|
||||
let media_tags = context_sha256(&review)
|
||||
.and_then(|sha| media_tags.get(&sha).cloned())
|
||||
.unwrap_or_default();
|
||||
let display_input_path = compact_path_tail(review.input_path_str(), 80);
|
||||
ReviewListRow {
|
||||
review,
|
||||
display_input_path,
|
||||
media_tags,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let template = ReviewsTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
reviews,
|
||||
rows,
|
||||
stats_map,
|
||||
status_filter: status.unwrap_or("").to_owned(),
|
||||
};
|
||||
@@ -1479,8 +1882,7 @@ pub async fn review_approve(
|
||||
return Ok(auth::redirect(&format!("/admin/reviews/{review_id}")));
|
||||
}
|
||||
|
||||
let normalized: crate::agent::dto::NormalizedFields =
|
||||
serde_json::from_str(&result_str)
|
||||
let normalized: crate::agent::dto::NormalizedFields = serde_json::from_str(&result_str)
|
||||
.map_err(|e| cot::Error::internal(format!("invalid result_json: {e}")))?;
|
||||
|
||||
let context: serde_json::Value = serde_json::from_str(&context_str).unwrap_or_default();
|
||||
@@ -1518,6 +1920,65 @@ pub async fn review_approve(
|
||||
Ok(auth::redirect(&format!("/admin/reviews/{review_id}")))
|
||||
}
|
||||
|
||||
#[derive(Debug, Form)]
|
||||
pub struct ReviewsBulkForm {
|
||||
selected_ids: Option<String>,
|
||||
action: Option<String>,
|
||||
status_filter: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_review_ids(raw: &str) -> Vec<i64> {
|
||||
let mut ids = raw
|
||||
.split(',')
|
||||
.filter_map(|part| part.trim().parse::<i64>().ok())
|
||||
.filter(|id| *id > 0)
|
||||
.collect::<Vec<_>>();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
ids
|
||||
}
|
||||
|
||||
fn reviews_redirect(status: Option<&str>) -> String {
|
||||
match status {
|
||||
Some(s) if !s.is_empty() => format!("/admin/reviews?status={s}"),
|
||||
_ => "/admin/reviews".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reviews_bulk(
|
||||
_admin: AuthenticatedUser,
|
||||
db: &Database,
|
||||
form: RequestForm<ReviewsBulkForm>,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
let RequestForm(result) = form;
|
||||
let data = match result {
|
||||
FormResult::Ok(data) => data,
|
||||
FormResult::ValidationError(_) => return Ok(auth::redirect("/admin/reviews")),
|
||||
};
|
||||
|
||||
let redirect_url = reviews_redirect(data.status_filter.as_deref());
|
||||
let ids = parse_review_ids(data.selected_ids.as_deref().unwrap_or_default());
|
||||
if ids.is_empty() {
|
||||
return Ok(auth::redirect(&redirect_url));
|
||||
}
|
||||
|
||||
match data.action.as_deref() {
|
||||
Some("delete") => {
|
||||
PendingReview::delete_by_ids(db, &ids)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?;
|
||||
}
|
||||
Some("requeue") => {
|
||||
PendingReview::requeue_by_ids(db, &ids)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(auth::redirect(&redirect_url))
|
||||
}
|
||||
|
||||
pub async fn review_reject(
|
||||
_admin: AuthenticatedUser,
|
||||
db: &Database,
|
||||
|
||||
@@ -118,10 +118,7 @@ fn cover_name_priority(path: &Path) -> usize {
|
||||
/// 2. Try to extract embedded cover art from each audio file.
|
||||
///
|
||||
/// Returns the first usable image found, or None.
|
||||
pub async fn find_best_cover(
|
||||
folder: &Path,
|
||||
audio_files: &[PathBuf],
|
||||
) -> Option<CoverImage> {
|
||||
pub async fn find_best_cover(folder: &Path, audio_files: &[PathBuf]) -> Option<CoverImage> {
|
||||
// Strategy 1: folder images
|
||||
let folder_images = find_folder_images(folder);
|
||||
for img_path in &folder_images {
|
||||
@@ -363,6 +360,8 @@ pub async fn save_cover_to_storage(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("UFO"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("failed to create cover MediaFile: {e}"))?;
|
||||
|
||||
@@ -10,6 +10,9 @@ pub struct RawMetadata {
|
||||
pub year: Option<u32>,
|
||||
pub genre: Option<String>,
|
||||
pub duration_secs: Option<f64>,
|
||||
pub audio_bitrate: Option<i32>,
|
||||
pub audio_sample_rate: Option<i32>,
|
||||
pub audio_bit_depth: Option<i32>,
|
||||
}
|
||||
|
||||
/// Hints parsed from the file path (directory structure + filename).
|
||||
|
||||
+36
-8
@@ -18,7 +18,10 @@ use super::dto::RawMetadata;
|
||||
/// Must be called from a blocking context (`spawn_blocking`).
|
||||
pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||
match extract_via_symphonia(path) {
|
||||
Ok(meta) => Ok(meta),
|
||||
Ok(mut meta) => {
|
||||
fill_average_bitrate(path, &mut meta);
|
||||
Ok(meta)
|
||||
}
|
||||
Err(e) => {
|
||||
let is_mp3 = path
|
||||
.extension()
|
||||
@@ -27,7 +30,9 @@ pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||
.unwrap_or(false);
|
||||
if is_mp3 {
|
||||
tracing::debug!(error = %e, "Symphonia failed on MP3, falling back to id3 crate");
|
||||
extract_mp3_via_id3(path)
|
||||
let mut meta = extract_mp3_via_id3(path)?;
|
||||
fill_average_bitrate(path, &mut meta);
|
||||
Ok(meta)
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
@@ -35,6 +40,22 @@ pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_average_bitrate(path: &Path, meta: &mut RawMetadata) {
|
||||
if meta.audio_bitrate.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(duration_secs) = meta.duration_secs.filter(|duration| *duration > 0.0) else {
|
||||
return;
|
||||
};
|
||||
let Ok(metadata) = std::fs::metadata(path) else {
|
||||
return;
|
||||
};
|
||||
let kbps = ((metadata.len() as f64 * 8.0) / duration_secs / 1000.0).round();
|
||||
if kbps.is_finite() && kbps > 0.0 && kbps <= i32::MAX as f64 {
|
||||
meta.audio_bitrate = Some(kbps as i32);
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_via_symphonia(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||
let file = std::fs::File::open(path)?;
|
||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
@@ -68,17 +89,24 @@ fn extract_via_symphonia(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||
}
|
||||
}
|
||||
|
||||
// Duration
|
||||
meta.duration_secs = probed
|
||||
let audio_track = probed
|
||||
.format
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.and_then(|t| {
|
||||
let n_frames = t.codec_params.n_frames?;
|
||||
let tb = t.codec_params.time_base?;
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL);
|
||||
|
||||
if let Some(track) = audio_track {
|
||||
let params = &track.codec_params;
|
||||
meta.duration_secs = params.n_frames.and_then(|n_frames| {
|
||||
let tb = params.time_base?;
|
||||
Some(n_frames as f64 * tb.numer as f64 / tb.denom as f64)
|
||||
});
|
||||
meta.audio_sample_rate = params.sample_rate.and_then(|rate| i32::try_from(rate).ok());
|
||||
meta.audio_bit_depth = params
|
||||
.bits_per_sample
|
||||
.or(params.bits_per_coded_sample)
|
||||
.and_then(|bits| i32::try_from(bits).ok());
|
||||
}
|
||||
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
+5
-6
@@ -27,11 +27,7 @@ pub struct AgentProbeResult {
|
||||
|
||||
/// Send a lightweight "introduce yourself" prompt to the LLM and return the
|
||||
/// response together with timing / usage statistics when available.
|
||||
pub async fn probe_llm(
|
||||
llm_url: &str,
|
||||
llm_model: &str,
|
||||
llm_auth: &str,
|
||||
) -> AgentProbeResult {
|
||||
pub async fn probe_llm(llm_url: &str, llm_model: &str, llm_auth: &str) -> AgentProbeResult {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
@@ -85,7 +81,10 @@ pub async fn probe_llm(
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
return AgentProbeResult {
|
||||
latency_ms,
|
||||
error: format!("HTTP {status}: {}", body_text.chars().take(300).collect::<String>()),
|
||||
error: format!(
|
||||
"HTTP {status}: {}",
|
||||
body_text.chars().take(300).collect::<String>()
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
+149
-50
@@ -1,6 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata, SimilarArtist, SimilarRelease};
|
||||
use super::dto::{
|
||||
FolderContext, NormalizedFields, PathHints, RawMetadata, SimilarArtist, SimilarRelease,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -171,18 +173,40 @@ fn estimate_batch_tokens(
|
||||
let mut per_file_tokens: u64 = 0;
|
||||
for f in files {
|
||||
let mut chars: u64 = 40 + f.filename.len() as u64; // header
|
||||
if let Some(v) = &f.raw.title { chars += 10 + v.len() as u64; }
|
||||
if let Some(v) = &f.raw.artist { chars += 12 + v.len() as u64; }
|
||||
if let Some(v) = &f.raw.album { chars += 12 + v.len() as u64; }
|
||||
if f.raw.year.is_some() { chars += 12; }
|
||||
if f.raw.track_number.is_some() { chars += 18; }
|
||||
if let Some(v) = &f.raw.genre { chars += 10 + v.len() as u64; }
|
||||
if let Some(v) = &f.raw.title {
|
||||
chars += 10 + v.len() as u64;
|
||||
}
|
||||
if let Some(v) = &f.raw.artist {
|
||||
chars += 12 + v.len() as u64;
|
||||
}
|
||||
if let Some(v) = &f.raw.album {
|
||||
chars += 12 + v.len() as u64;
|
||||
}
|
||||
if f.raw.year.is_some() {
|
||||
chars += 12;
|
||||
}
|
||||
if f.raw.track_number.is_some() {
|
||||
chars += 18;
|
||||
}
|
||||
if let Some(v) = &f.raw.genre {
|
||||
chars += 10 + v.len() as u64;
|
||||
}
|
||||
// hints
|
||||
if let Some(v) = &f.hints.artist { chars += 16 + v.len() as u64; }
|
||||
if let Some(v) = &f.hints.album { chars += 16 + v.len() as u64; }
|
||||
if let Some(v) = &f.hints.title { chars += 15 + v.len() as u64; }
|
||||
if f.hints.year.is_some() { chars += 14; }
|
||||
if f.hints.track_number.is_some() { chars += 20; }
|
||||
if let Some(v) = &f.hints.artist {
|
||||
chars += 16 + v.len() as u64;
|
||||
}
|
||||
if let Some(v) = &f.hints.album {
|
||||
chars += 16 + v.len() as u64;
|
||||
}
|
||||
if let Some(v) = &f.hints.title {
|
||||
chars += 15 + v.len() as u64;
|
||||
}
|
||||
if f.hints.year.is_some() {
|
||||
chars += 14;
|
||||
}
|
||||
if f.hints.track_number.is_some() {
|
||||
chars += 20;
|
||||
}
|
||||
per_file_tokens += chars / 4;
|
||||
// Expected response per file (~150 tokens)
|
||||
per_file_tokens += 150;
|
||||
@@ -210,7 +234,10 @@ fn build_batch_user_message(
|
||||
if !similar_artists.is_empty() {
|
||||
msg.push_str("## Existing artists in database\n");
|
||||
for a in similar_artists {
|
||||
msg.push_str(&format!("- \"{}\" (similarity: {:.2})\n", a.name, a.similarity));
|
||||
msg.push_str(&format!(
|
||||
"- \"{}\" (similarity: {:.2})\n",
|
||||
a.name, a.similarity
|
||||
));
|
||||
}
|
||||
msg.push('\n');
|
||||
}
|
||||
@@ -219,7 +246,10 @@ fn build_batch_user_message(
|
||||
msg.push_str("## Existing releases in database\n");
|
||||
for r in similar_releases {
|
||||
let year_str = r.year.map(|y| format!(", year: {y}")).unwrap_or_default();
|
||||
msg.push_str(&format!("- \"{}\" (similarity: {:.2}{})\n", r.title, r.similarity, year_str));
|
||||
msg.push_str(&format!(
|
||||
"- \"{}\" (similarity: {:.2}{})\n",
|
||||
r.title, r.similarity, year_str
|
||||
));
|
||||
}
|
||||
msg.push('\n');
|
||||
}
|
||||
@@ -230,12 +260,24 @@ fn build_batch_user_message(
|
||||
for f in files {
|
||||
msg.push_str(&format!("### {}\n", f.filename));
|
||||
|
||||
if let Some(v) = &f.raw.title { msg.push_str(&format!("Title: \"{v}\"\n")); }
|
||||
if let Some(v) = &f.raw.artist { msg.push_str(&format!("Artist: \"{v}\"\n")); }
|
||||
if let Some(v) = &f.raw.album { msg.push_str(&format!("Release: \"{v}\"\n")); }
|
||||
if let Some(v) = f.raw.year { msg.push_str(&format!("Year: {v}\n")); }
|
||||
if let Some(v) = f.raw.track_number { msg.push_str(&format!("Track: {v}\n")); }
|
||||
if let Some(v) = &f.raw.genre { msg.push_str(&format!("Genre: \"{v}\"\n")); }
|
||||
if let Some(v) = &f.raw.title {
|
||||
msg.push_str(&format!("Title: \"{v}\"\n"));
|
||||
}
|
||||
if let Some(v) = &f.raw.artist {
|
||||
msg.push_str(&format!("Artist: \"{v}\"\n"));
|
||||
}
|
||||
if let Some(v) = &f.raw.album {
|
||||
msg.push_str(&format!("Release: \"{v}\"\n"));
|
||||
}
|
||||
if let Some(v) = f.raw.year {
|
||||
msg.push_str(&format!("Year: {v}\n"));
|
||||
}
|
||||
if let Some(v) = f.raw.track_number {
|
||||
msg.push_str(&format!("Track: {v}\n"));
|
||||
}
|
||||
if let Some(v) = &f.raw.genre {
|
||||
msg.push_str(&format!("Genre: \"{v}\"\n"));
|
||||
}
|
||||
|
||||
// Path hints (only if different from tag metadata)
|
||||
let has_hints = f.hints.artist.is_some()
|
||||
@@ -244,11 +286,21 @@ fn build_batch_user_message(
|
||||
|| f.hints.year.is_some()
|
||||
|| f.hints.track_number.is_some();
|
||||
if has_hints {
|
||||
if let Some(v) = &f.hints.artist { msg.push_str(&format!("Path artist: \"{v}\"\n")); }
|
||||
if let Some(v) = &f.hints.album { msg.push_str(&format!("Path release: \"{v}\"\n")); }
|
||||
if let Some(v) = &f.hints.title { msg.push_str(&format!("Path title: \"{v}\"\n")); }
|
||||
if let Some(v) = f.hints.year { msg.push_str(&format!("Path year: {v}\n")); }
|
||||
if let Some(v) = f.hints.track_number { msg.push_str(&format!("Path track: {v}\n")); }
|
||||
if let Some(v) = &f.hints.artist {
|
||||
msg.push_str(&format!("Path artist: \"{v}\"\n"));
|
||||
}
|
||||
if let Some(v) = &f.hints.album {
|
||||
msg.push_str(&format!("Path release: \"{v}\"\n"));
|
||||
}
|
||||
if let Some(v) = &f.hints.title {
|
||||
msg.push_str(&format!("Path title: \"{v}\"\n"));
|
||||
}
|
||||
if let Some(v) = f.hints.year {
|
||||
msg.push_str(&format!("Path year: {v}\n"));
|
||||
}
|
||||
if let Some(v) = f.hints.track_number {
|
||||
msg.push_str(&format!("Path track: {v}\n"));
|
||||
}
|
||||
}
|
||||
msg.push('\n');
|
||||
}
|
||||
@@ -272,7 +324,11 @@ pub async fn normalize_batch(
|
||||
) -> anyhow::Result<BatchNormalizeResult> {
|
||||
// Estimate tokens
|
||||
let estimated = estimate_batch_tokens(
|
||||
system_prompt, &files, similar_artists, similar_releases, folder_ctx,
|
||||
system_prompt,
|
||||
&files,
|
||||
similar_artists,
|
||||
similar_releases,
|
||||
folder_ctx,
|
||||
);
|
||||
|
||||
// If over 80% of context limit and more than 1 file, split
|
||||
@@ -290,14 +346,30 @@ pub async fn normalize_batch(
|
||||
let left = files_vec;
|
||||
|
||||
let left_result = Box::pin(normalize_batch(
|
||||
llm_url, llm_model, llm_auth, system_prompt, context_limit,
|
||||
left, similar_artists, similar_releases, folder_ctx,
|
||||
)).await?;
|
||||
llm_url,
|
||||
llm_model,
|
||||
llm_auth,
|
||||
system_prompt,
|
||||
context_limit,
|
||||
left,
|
||||
similar_artists,
|
||||
similar_releases,
|
||||
folder_ctx,
|
||||
))
|
||||
.await?;
|
||||
|
||||
let right_result = Box::pin(normalize_batch(
|
||||
llm_url, llm_model, llm_auth, system_prompt, context_limit,
|
||||
right, similar_artists, similar_releases, folder_ctx,
|
||||
)).await?;
|
||||
llm_url,
|
||||
llm_model,
|
||||
llm_auth,
|
||||
system_prompt,
|
||||
context_limit,
|
||||
right,
|
||||
similar_artists,
|
||||
similar_releases,
|
||||
folder_ctx,
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Merge results
|
||||
let mut results = left_result.results;
|
||||
@@ -312,20 +384,32 @@ pub async fn normalize_batch(
|
||||
}
|
||||
|
||||
// Build and send
|
||||
let user_message = build_batch_user_message(
|
||||
&files, similar_artists, similar_releases, folder_ctx,
|
||||
);
|
||||
let user_message =
|
||||
build_batch_user_message(&files, similar_artists, similar_releases, folder_ctx);
|
||||
|
||||
let messages = vec![
|
||||
ChatMessage { role: "system".into(), content: system_prompt.to_owned() },
|
||||
ChatMessage { role: "user".into(), content: user_message },
|
||||
ChatMessage {
|
||||
role: "system".into(),
|
||||
content: system_prompt.to_owned(),
|
||||
},
|
||||
ChatMessage {
|
||||
role: "user".into(),
|
||||
content: user_message,
|
||||
},
|
||||
];
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let call_result = call_llm_chat(
|
||||
llm_url, llm_model, &messages,
|
||||
if llm_auth.is_empty() { None } else { Some(llm_auth) },
|
||||
).await;
|
||||
llm_url,
|
||||
llm_model,
|
||||
&messages,
|
||||
if llm_auth.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(llm_auth)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let duration_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
// If LLM error and batch > 1, try splitting (handles context overflow errors)
|
||||
@@ -349,13 +433,29 @@ pub async fn normalize_batch(
|
||||
let left = files_vec;
|
||||
|
||||
let left_result = Box::pin(normalize_batch(
|
||||
llm_url, llm_model, llm_auth, system_prompt, context_limit,
|
||||
left, similar_artists, similar_releases, folder_ctx,
|
||||
)).await?;
|
||||
llm_url,
|
||||
llm_model,
|
||||
llm_auth,
|
||||
system_prompt,
|
||||
context_limit,
|
||||
left,
|
||||
similar_artists,
|
||||
similar_releases,
|
||||
folder_ctx,
|
||||
))
|
||||
.await?;
|
||||
let right_result = Box::pin(normalize_batch(
|
||||
llm_url, llm_model, llm_auth, system_prompt, context_limit,
|
||||
right, similar_artists, similar_releases, folder_ctx,
|
||||
)).await?;
|
||||
llm_url,
|
||||
llm_model,
|
||||
llm_auth,
|
||||
system_prompt,
|
||||
context_limit,
|
||||
right,
|
||||
similar_artists,
|
||||
similar_releases,
|
||||
folder_ctx,
|
||||
))
|
||||
.await?;
|
||||
|
||||
let mut results = left_result.results;
|
||||
results.extend(right_result.results);
|
||||
@@ -363,7 +463,8 @@ pub async fn normalize_batch(
|
||||
results,
|
||||
model: left_result.model,
|
||||
prompt_tokens: left_result.prompt_tokens + right_result.prompt_tokens,
|
||||
completion_tokens: left_result.completion_tokens + right_result.completion_tokens,
|
||||
completion_tokens: left_result.completion_tokens
|
||||
+ right_result.completion_tokens,
|
||||
duration_ms: left_result.duration_ms + right_result.duration_ms,
|
||||
});
|
||||
}
|
||||
@@ -398,9 +499,7 @@ fn parse_batch_response(
|
||||
|
||||
// Strip markdown code fences if present
|
||||
let json_str = if cleaned.starts_with("```") {
|
||||
let start = cleaned.find('[')
|
||||
.or_else(|| cleaned.find('{'))
|
||||
.unwrap_or(0);
|
||||
let start = cleaned.find('[').or_else(|| cleaned.find('{')).unwrap_or(0);
|
||||
let end_bracket = cleaned.rfind(']').map(|i| i + 1);
|
||||
let end_brace = cleaned.rfind('}').map(|i| i + 1);
|
||||
let end = end_bracket.or(end_brace).unwrap_or(cleaned.len());
|
||||
|
||||
@@ -113,11 +113,8 @@ fn parse_album_with_year(dir: &str) -> (String, Option<i32>) {
|
||||
let inside = &dir[start + 1..start + end];
|
||||
if let Ok(year) = inside.trim().parse::<i32>() {
|
||||
if (1900..=2100).contains(&year) {
|
||||
let album = format!(
|
||||
"{}{}",
|
||||
&dir[..start].trim(),
|
||||
&dir[start + end + 1..].trim()
|
||||
);
|
||||
let album =
|
||||
format!("{}{}", &dir[..start].trim(), &dir[start + end + 1..].trim());
|
||||
let album = album.trim().to_owned();
|
||||
return (album, Some(year));
|
||||
}
|
||||
|
||||
+6
-7
@@ -34,10 +34,7 @@ struct MeResponse {
|
||||
role: String,
|
||||
}
|
||||
|
||||
async fn me_handler(
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response::Response> {
|
||||
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||
return Ok(json_error(
|
||||
cot::http::StatusCode::UNAUTHORIZED,
|
||||
@@ -65,8 +62,10 @@ impl App for ApiApp {
|
||||
}
|
||||
|
||||
fn router(&self) -> Router {
|
||||
Router::with_urls([
|
||||
Route::with_api_handler_and_name("/me", api_get(me_handler), "api_me"),
|
||||
])
|
||||
Router::with_urls([Route::with_api_handler_and_name(
|
||||
"/me",
|
||||
api_get(me_handler),
|
||||
"api_me",
|
||||
)])
|
||||
}
|
||||
}
|
||||
|
||||
+3
-5
@@ -1,7 +1,7 @@
|
||||
use cot::Body;
|
||||
use cot::db::Database;
|
||||
use cot::response::IntoResponse;
|
||||
use cot::session::Session;
|
||||
use cot::Body;
|
||||
|
||||
use crate::user::User;
|
||||
|
||||
@@ -78,12 +78,10 @@ pub async fn require_admin_or_redirect(
|
||||
return Err(redirect("/login"));
|
||||
};
|
||||
if user.role != Role::Admin {
|
||||
return Err(
|
||||
"Forbidden"
|
||||
return Err("Forbidden"
|
||||
.with_status(cot::http::StatusCode::FORBIDDEN)
|
||||
.into_response()
|
||||
.expect("valid response"),
|
||||
);
|
||||
.expect("valid response"));
|
||||
}
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
+40
-22
@@ -66,8 +66,7 @@ pub mod db_migrations {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0001_create_config";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||
.table_name(Identifier::new("furu__config"))
|
||||
.fields(&[
|
||||
Field::new(
|
||||
@@ -76,14 +75,10 @@ pub mod db_migrations {
|
||||
)
|
||||
.primary_key()
|
||||
.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),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
.build()];
|
||||
}
|
||||
|
||||
// -- M0002: rename furu__config → furumusic__config_entry ---------------
|
||||
@@ -102,12 +97,12 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0002RenameConfigTable {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0002_rename_config_table";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration("furumusic", "m_0001_create_config"),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::custom(rename_config_table).build(),
|
||||
];
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0001_create_config",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::custom(rename_config_table).build()];
|
||||
}
|
||||
|
||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[&M0001CreateConfig, &M0002RenameConfigTable];
|
||||
@@ -127,6 +122,7 @@ pub struct ConfigSources {
|
||||
pub auth_sso_enabled: ConfigSource,
|
||||
pub oidc_button_text: ConfigSource,
|
||||
pub oidc_admin_groups: ConfigSource,
|
||||
pub oidc_user_groups: ConfigSource,
|
||||
pub swagger_enabled: ConfigSource,
|
||||
pub agent_enabled: ConfigSource,
|
||||
pub agent_inbox_dir: ConfigSource,
|
||||
@@ -151,6 +147,7 @@ impl Default for ConfigSources {
|
||||
auth_sso_enabled: ConfigSource::Default,
|
||||
oidc_button_text: ConfigSource::Default,
|
||||
oidc_admin_groups: ConfigSource::Default,
|
||||
oidc_user_groups: ConfigSource::Default,
|
||||
swagger_enabled: ConfigSource::Default,
|
||||
agent_enabled: ConfigSource::Default,
|
||||
agent_inbox_dir: ConfigSource::Default,
|
||||
@@ -243,6 +240,8 @@ pub struct AppConfig {
|
||||
pub oidc_button_text: String,
|
||||
/// Comma-separated list of OIDC group names that grant admin role.
|
||||
pub oidc_admin_groups: String,
|
||||
/// Comma-separated list of OIDC group names that are allowed to use the service.
|
||||
pub oidc_user_groups: String,
|
||||
/// Whether the Swagger UI is served at /swagger/.
|
||||
pub swagger_enabled: bool,
|
||||
/// Whether the AI agent background loop is enabled.
|
||||
@@ -277,6 +276,7 @@ impl Default for AppConfig {
|
||||
auth_sso_enabled: false,
|
||||
oidc_button_text: "Sign in with SSO".into(),
|
||||
oidc_admin_groups: String::new(),
|
||||
oidc_user_groups: String::new(),
|
||||
swagger_enabled: false,
|
||||
agent_enabled: false,
|
||||
agent_inbox_dir: String::new(),
|
||||
@@ -302,6 +302,7 @@ impl_env_overrides!(
|
||||
auth_sso_enabled,
|
||||
oidc_button_text,
|
||||
oidc_admin_groups,
|
||||
oidc_user_groups,
|
||||
swagger_enabled,
|
||||
agent_enabled,
|
||||
agent_inbox_dir,
|
||||
@@ -377,6 +378,7 @@ impl AppConfig {
|
||||
apply_db_field!(auth_sso_enabled);
|
||||
apply_db_field!(oidc_button_text);
|
||||
apply_db_field!(oidc_admin_groups);
|
||||
apply_db_field!(oidc_user_groups);
|
||||
apply_db_field!(swagger_enabled);
|
||||
apply_db_field!(agent_enabled);
|
||||
apply_db_field!(agent_inbox_dir);
|
||||
@@ -402,35 +404,51 @@ mod tests {
|
||||
}
|
||||
|
||||
// SAFETY: tests run with --test-threads=1 so no concurrent env access.
|
||||
unsafe fn set(k: &str, v: &str) { unsafe { std::env::set_var(k, v) }; }
|
||||
unsafe fn unset(k: &str) { unsafe { std::env::remove_var(k) }; }
|
||||
unsafe fn set(k: &str, v: &str) {
|
||||
unsafe { std::env::set_var(k, v) };
|
||||
}
|
||||
unsafe fn unset(k: &str) {
|
||||
unsafe { std::env::remove_var(k) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_string_field() {
|
||||
unsafe { set("FURU_OIDC_ISSUER", "https://example.com"); }
|
||||
unsafe {
|
||||
set("FURU_OIDC_ISSUER", "https://example.com");
|
||||
}
|
||||
let cfg = AppConfig::load();
|
||||
assert_eq!(cfg.oidc_issuer, "https://example.com");
|
||||
unsafe { unset("FURU_OIDC_ISSUER"); }
|
||||
unsafe {
|
||||
unset("FURU_OIDC_ISSUER");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_bool_field() {
|
||||
unsafe { set("FURU_AUTH_SSO_ENABLED", "true"); }
|
||||
unsafe {
|
||||
set("FURU_AUTH_SSO_ENABLED", "true");
|
||||
}
|
||||
let cfg = AppConfig::load();
|
||||
assert!(cfg.auth_sso_enabled);
|
||||
unsafe { unset("FURU_AUTH_SSO_ENABLED"); }
|
||||
unsafe {
|
||||
unset("FURU_AUTH_SSO_ENABLED");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_tracking_env() {
|
||||
unsafe { set("FURU_OIDC_ISSUER", "https://tracked.example.com"); }
|
||||
unsafe {
|
||||
set("FURU_OIDC_ISSUER", "https://tracked.example.com");
|
||||
}
|
||||
let mut cfg = AppConfig::default();
|
||||
let mut sources = ConfigSources::default();
|
||||
cfg.apply_env_overrides_tracked(&mut sources);
|
||||
assert_eq!(cfg.oidc_issuer, "https://tracked.example.com");
|
||||
assert_eq!(sources.oidc_issuer, ConfigSource::Env);
|
||||
assert_eq!(sources.database_url, ConfigSource::Default);
|
||||
unsafe { unset("FURU_OIDC_ISSUER"); }
|
||||
unsafe {
|
||||
unset("FURU_OIDC_ISSUER");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+12
-6
@@ -2,10 +2,16 @@ mod phrases;
|
||||
|
||||
pub use phrases::Translations;
|
||||
|
||||
use cot::request::extractors::FromRequestHead;
|
||||
use cot::request::RequestHead;
|
||||
use cot::request::extractors::FromRequestHead;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
impl Translations {
|
||||
pub fn app_version(&self) -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lang enum
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -77,7 +83,10 @@ const COOKIE_NAME: &str = "furu_lang";
|
||||
|
||||
/// Build a `Set-Cookie` header value that persists the language choice for 1 year.
|
||||
pub fn lang_cookie(lang: Lang) -> String {
|
||||
format!("{COOKIE_NAME}={}; Path=/; SameSite=Lax; Max-Age=31536000", lang.code())
|
||||
format!(
|
||||
"{COOKIE_NAME}={}; Path=/; SameSite=Lax; Max-Age=31536000",
|
||||
lang.code()
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse `furu_lang` from the `Cookie` request header.
|
||||
@@ -203,10 +212,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_unknown_falls_through() {
|
||||
assert_eq!(
|
||||
parse_accept_language("de;q=1.0,ru;q=0.5"),
|
||||
Some(Lang::Ru)
|
||||
);
|
||||
assert_eq!(parse_accept_language("de;q=1.0,ru;q=0.5"), Some(Lang::Ru));
|
||||
assert_eq!(parse_accept_language("de,fr,ja"), None);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ translations! {
|
||||
settings_oidc_issuer_help: "Base URL of the OIDC provider (e.g. https://accounts.google.com)" , "Базовый URL провайдера OIDC (напр. https://accounts.google.com)";
|
||||
settings_oidc_admin_groups: "Admin groups" , "Группы администраторов";
|
||||
settings_oidc_admin_groups_help: "Comma-separated OIDC group names that grant admin role (e.g. /admin,/furumusic-admins)" , "OIDC группы через запятую, дающие роль администратора (напр. /admin,/furumusic-admins)";
|
||||
settings_oidc_user_groups: "User groups" , "Группы пользователей";
|
||||
settings_oidc_user_groups_help: "Comma-separated OIDC group names allowed to access the service. If empty, any authenticated SSO user is allowed." , "OIDC группы через запятую, которым разрешён доступ к сервису. Если пусто, разрешён любой SSO пользователь.";
|
||||
|
||||
// User management
|
||||
nav_users: "Users" , "Пользователи";
|
||||
@@ -97,6 +99,7 @@ translations! {
|
||||
// OIDC login errors
|
||||
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
|
||||
login_sso_disabled: "SSO login is not configured." , "Вход через SSO не настроен.";
|
||||
login_access_denied: "Access denied. Contact your administrator." , "Доступ запрещён. Обратитесь к администратору.";
|
||||
|
||||
// Artist management
|
||||
nav_artists: "Artists" , "Артисты";
|
||||
@@ -187,6 +190,11 @@ translations! {
|
||||
jobs_back_to_list: "Back to jobs" , "Назад к заданиям";
|
||||
jobs_run_detail: "Run detail" , "Детали запуска";
|
||||
jobs_back_to_job: "Back to job" , "Назад к заданию";
|
||||
jobs_metadata_backfill_options: "Metadata backfill options" , "Параметры обновления метадаты";
|
||||
jobs_metadata_backfill_fields: "Fields to update" , "Поля для обновления";
|
||||
jobs_metadata_backfill_fill_missing: "Fill missing only" , "Заполнить только пустые";
|
||||
jobs_metadata_backfill_overwrite: "Overwrite existing values" , "Перезаписать существующие";
|
||||
jobs_metadata_backfill_run: "Run metadata backfill" , "Запустить обновление метадаты";
|
||||
|
||||
// Review management
|
||||
reviews_heading: "Pending Reviews" , "Ожидающие проверки";
|
||||
@@ -194,6 +202,7 @@ translations! {
|
||||
reviews_status: "Status" , "Статус";
|
||||
reviews_type: "Type" , "Тип";
|
||||
reviews_input_path: "Input" , "Файл";
|
||||
reviews_tags: "Tags" , "Теги";
|
||||
reviews_confidence: "Confidence" , "Уверенность";
|
||||
reviews_approve: "Approve" , "Подтвердить";
|
||||
reviews_reject: "Reject" , "Отклонить";
|
||||
@@ -204,6 +213,15 @@ translations! {
|
||||
reviews_clear_all: "Clear all" , "Очистить все";
|
||||
reviews_clear_filtered: "Clear shown" , "Очистить показанные";
|
||||
reviews_clear_confirm: "Are you sure? This will delete the selected reviews." , "Вы уверены? Выбранные проверки будут удалены.";
|
||||
reviews_select_all: "Select shown" , "Выбрать показанные";
|
||||
reviews_clear_selection: "Clear selection" , "Снять выбор";
|
||||
reviews_delete_selected: "Delete selected" , "Удалить выбранные";
|
||||
reviews_requeue_selected: "Re-queue selected" , "В очередь выбранные";
|
||||
reviews_selected_none: "Selected: 0" , "Выбрано: 0";
|
||||
reviews_selected_prefix: "Selected" , "Выбрано";
|
||||
reviews_none_selected_confirm: "Select at least one review." , "Выберите хотя бы одну проверку.";
|
||||
reviews_delete_selected_confirm: "Delete selected reviews?" , "Удалить выбранные проверки?";
|
||||
reviews_requeue_selected_confirm: "Re-queue selected reviews?" , "Поставить выбранные проверки в очередь?";
|
||||
reviews_back_to_list: "Back to reviews" , "Назад к проверкам";
|
||||
reviews_filter_all: "All" , "Все";
|
||||
reviews_filter_pending: "Pending" , "Ожидают";
|
||||
|
||||
@@ -48,7 +48,9 @@ impl Job for ArtistImageBackfillJob {
|
||||
|
||||
let count = result.rows_affected();
|
||||
if count > 0 {
|
||||
log.info(&format!("Assigned images to {count} artists from release covers"));
|
||||
log.info(&format!(
|
||||
"Assigned images to {count} artists from release covers"
|
||||
));
|
||||
} else {
|
||||
log.info("All artists already have images (or no covers available)");
|
||||
}
|
||||
|
||||
@@ -87,10 +87,8 @@ impl Job for CoverBackfillJob {
|
||||
let folder = first_path.parent().unwrap_or(Path::new("."));
|
||||
|
||||
// Collect all audio file paths as PathBuf
|
||||
let audio_files: Vec<PathBuf> = audio_paths
|
||||
.iter()
|
||||
.map(|(p,)| PathBuf::from(p))
|
||||
.collect();
|
||||
let audio_files: Vec<PathBuf> =
|
||||
audio_paths.iter().map(|(p,)| PathBuf::from(p)).collect();
|
||||
|
||||
// Try to find cover art
|
||||
let cover = match cover_art::find_best_cover(folder, &audio_files).await {
|
||||
@@ -135,11 +133,8 @@ impl Job for CoverBackfillJob {
|
||||
.await
|
||||
{
|
||||
Ok(cover_file_id) => {
|
||||
if let Err(e) = cover_art::assign_cover_to_release(
|
||||
&ctx.pool,
|
||||
*release_id,
|
||||
cover_file_id,
|
||||
)
|
||||
if let Err(e) =
|
||||
cover_art::assign_cover_to_release(&ctx.pool, *release_id, cover_file_id)
|
||||
.await
|
||||
{
|
||||
log.warn(&format!(
|
||||
|
||||
+35
-12
@@ -30,7 +30,10 @@ impl Job for InboxDiscoverJob {
|
||||
|
||||
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
||||
// Prevent overlapping discover runs
|
||||
if DISCOVER_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
|
||||
if DISCOVER_RUNNING
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
log.info("Another inbox_discover is already running, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
@@ -82,14 +85,18 @@ impl Job for InboxDiscoverJob {
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
log.warn(&format!("Error checking existing review for {}: {e}", input_path_str));
|
||||
log.warn(&format!(
|
||||
"Error checking existing review for {}: {e}",
|
||||
input_path_str
|
||||
));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute SHA-256 hash
|
||||
let path_clone = file_path.to_path_buf();
|
||||
let (hash, file_size) = match tokio::task::spawn_blocking(move || -> anyhow::Result<(String, i64)> {
|
||||
let (hash, file_size) =
|
||||
match tokio::task::spawn_blocking(move || -> anyhow::Result<(String, i64)> {
|
||||
let data = std::fs::read(&path_clone)?;
|
||||
let digest = Sha256::digest(&data);
|
||||
let hash = format!("{:x}", digest);
|
||||
@@ -106,7 +113,10 @@ impl Job for InboxDiscoverJob {
|
||||
};
|
||||
|
||||
// Skip if hash already in media_files
|
||||
if crate::agent::rag::file_hash_exists(&ctx.pool, &hash).await.unwrap_or(false) {
|
||||
if crate::agent::rag::file_hash_exists(&ctx.pool, &hash)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
skipped_hash += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -120,14 +130,19 @@ impl Job for InboxDiscoverJob {
|
||||
{
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
log.warn(&format!("Failed to extract metadata from {}: {e}", file_path.display()));
|
||||
log.warn(&format!(
|
||||
"Failed to extract metadata from {}: {e}",
|
||||
file_path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse path hints
|
||||
let relative = file_path.strip_prefix(inbox).unwrap_or(file_path);
|
||||
let hints = crate::agent::path_hints::parse(relative);
|
||||
let uploader = crate::jobs::uploader_from_relative_path(&ctx.pool, relative).await;
|
||||
let hinted_relative = crate::jobs::strip_user_upload_prefix(relative);
|
||||
let hints = crate::agent::path_hints::parse(&hinted_relative);
|
||||
|
||||
// Build context JSON
|
||||
let context = serde_json::json!({
|
||||
@@ -140,6 +155,11 @@ impl Job for InboxDiscoverJob {
|
||||
"raw_year": raw_meta.year,
|
||||
"raw_genre": raw_meta.genre,
|
||||
"duration_secs": raw_meta.duration_secs,
|
||||
"audio_bitrate": raw_meta.audio_bitrate,
|
||||
"audio_sample_rate": raw_meta.audio_sample_rate,
|
||||
"audio_bit_depth": raw_meta.audio_bit_depth,
|
||||
"uploaded_by_user_id": uploader.user_id,
|
||||
"uploader_name": uploader.name,
|
||||
"path_title": hints.title,
|
||||
"path_artist": hints.artist,
|
||||
"path_album": hints.album,
|
||||
@@ -172,7 +192,9 @@ impl Job for InboxDiscoverJob {
|
||||
// and no orchestrator is already running
|
||||
if discovered > 0 {
|
||||
if crate::jobs::inbox_process::is_orchestrator_running() {
|
||||
log.info("New files discovered but inbox_process already running, it will pick them up");
|
||||
log.info(
|
||||
"New files discovered but inbox_process already running, it will pick them up",
|
||||
);
|
||||
} else {
|
||||
log.info("Spawning inbox_process in background...");
|
||||
let config = ctx.config.clone();
|
||||
@@ -181,7 +203,11 @@ impl Job for InboxDiscoverJob {
|
||||
let registry = ctx.registry.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = crate::scheduler::trigger_job_now(
|
||||
&config, &db, &pool, ®istry, "inbox_process",
|
||||
&config,
|
||||
&db,
|
||||
&pool,
|
||||
®istry,
|
||||
"inbox_process",
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -214,10 +240,7 @@ pub fn group_by_folder(files: &[PathBuf]) -> Vec<(PathBuf, Vec<PathBuf>)> {
|
||||
groups
|
||||
}
|
||||
|
||||
pub async fn collect_audio_files(
|
||||
dir: &Path,
|
||||
audio: &mut Vec<PathBuf>,
|
||||
) -> anyhow::Result<()> {
|
||||
pub async fn collect_audio_files(dir: &Path, audio: &mut Vec<PathBuf>) -> anyhow::Result<()> {
|
||||
let mut entries = tokio::fs::read_dir(dir).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
|
||||
+130
-60
@@ -20,9 +20,7 @@ pub fn is_orchestrator_running() -> bool {
|
||||
/// Try to acquire the PostgreSQL advisory lock for the orchestrator.
|
||||
/// Returns true if the lock was acquired (no other orchestrator is running).
|
||||
async fn try_acquire_orchestrator_lock(pool: &sqlx::PgPool) -> bool {
|
||||
match sqlx::query_scalar::<_, bool>(
|
||||
"SELECT pg_try_advisory_lock($1)"
|
||||
)
|
||||
match sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)")
|
||||
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
@@ -43,14 +41,12 @@ async fn release_orchestrator_lock(pool: &sqlx::PgPool) {
|
||||
.await;
|
||||
}
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::music::{
|
||||
Artist, MediaFile, Release, ReleaseArtist, Track, TrackArtist,
|
||||
};
|
||||
use crate::scheduler::{Job, JobContext, JobLog, JobRun, PendingReview, ProcessingStats};
|
||||
use crate::agent::dto::{FolderContext, NormalizedFields, RawMetadata, PathHints};
|
||||
use crate::agent::normalize::BatchFileInput;
|
||||
use crate::agent::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata};
|
||||
use crate::agent::mover;
|
||||
use crate::agent::normalize::BatchFileInput;
|
||||
use crate::config::AppConfig;
|
||||
use crate::music::{Artist, MediaFile, Release, ReleaseArtist, Track, TrackArtist};
|
||||
use crate::scheduler::{Job, JobContext, JobLog, JobRun, PendingReview, ProcessingStats};
|
||||
|
||||
const AUDIO_EXTENSIONS: &[&str] = &[
|
||||
"mp3", "flac", "ogg", "opus", "aac", "m4a", "wav", "ape", "wv", "wma", "tta", "aiff", "aif",
|
||||
@@ -83,8 +79,13 @@ impl Job for InboxProcessJob {
|
||||
previous_value = prev,
|
||||
"inbox_process: checking ORCHESTRATOR_RUNNING AtomicBool"
|
||||
);
|
||||
if ORCHESTRATOR_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
|
||||
log.info("Another inbox_process orchestrator is already running (AtomicBool), skipping");
|
||||
if ORCHESTRATOR_RUNNING
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
log.info(
|
||||
"Another inbox_process orchestrator is already running (AtomicBool), skipping",
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
struct AtomicGuard;
|
||||
@@ -115,7 +116,9 @@ impl Job for InboxProcessJob {
|
||||
});
|
||||
}
|
||||
}
|
||||
let _advisory_guard = AdvisoryGuard { pool: pool_for_unlock };
|
||||
let _advisory_guard = AdvisoryGuard {
|
||||
pool: pool_for_unlock,
|
||||
};
|
||||
|
||||
let config = Arc::clone(&ctx.config);
|
||||
let mut total_ok = 0u64;
|
||||
@@ -151,9 +154,9 @@ impl Job for InboxProcessJob {
|
||||
folder_rel, file_count,
|
||||
));
|
||||
|
||||
let (ok, fail) = process_folder_batch(
|
||||
&ctx.db, &config, &ctx.pool, &folder_rel, reviews, log,
|
||||
).await;
|
||||
let (ok, fail) =
|
||||
process_folder_batch(&ctx.db, &config, &ctx.pool, &folder_rel, reviews, log)
|
||||
.await;
|
||||
|
||||
total_ok += ok;
|
||||
total_fail += fail;
|
||||
@@ -296,7 +299,7 @@ async fn process_folder_batch(
|
||||
let _ = review.set_processing(db).await;
|
||||
|
||||
// Parse context_json
|
||||
let context: serde_json::Value = review
|
||||
let mut context: serde_json::Value = review
|
||||
.context_json
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
@@ -304,13 +307,10 @@ async fn process_folder_batch(
|
||||
|
||||
// Extract metadata (with 60s timeout)
|
||||
let path_for_meta = file_path.to_path_buf();
|
||||
let meta_future = tokio::task::spawn_blocking(move || {
|
||||
crate::agent::metadata::extract(&path_for_meta)
|
||||
});
|
||||
let raw_meta = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(60),
|
||||
meta_future,
|
||||
).await {
|
||||
let meta_future =
|
||||
tokio::task::spawn_blocking(move || crate::agent::metadata::extract(&path_for_meta));
|
||||
let raw_meta =
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(60), meta_future).await {
|
||||
Ok(Ok(Ok(m))) => m,
|
||||
Ok(Ok(Err(e))) => {
|
||||
let msg = format!("{filename}: metadata error: {e}");
|
||||
@@ -337,7 +337,32 @@ async fn process_folder_batch(
|
||||
|
||||
// Parse path hints
|
||||
let relative = file_path.strip_prefix(inbox_path).unwrap_or(file_path);
|
||||
let hints = crate::agent::path_hints::parse(relative);
|
||||
let uploader = crate::jobs::uploader_from_relative_path(pool, relative).await;
|
||||
let hinted_relative = crate::jobs::strip_user_upload_prefix(relative);
|
||||
let hints = crate::agent::path_hints::parse(&hinted_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),
|
||||
);
|
||||
if !context_obj.contains_key("uploaded_by_user_id") {
|
||||
context_obj.insert(
|
||||
"uploaded_by_user_id".to_owned(),
|
||||
serde_json::json!(uploader.user_id),
|
||||
);
|
||||
}
|
||||
if !context_obj.contains_key("uploader_name") {
|
||||
context_obj.insert("uploader_name".to_owned(), serde_json::json!(uploader.name));
|
||||
}
|
||||
}
|
||||
|
||||
prepared.push(PreparedFile {
|
||||
review,
|
||||
@@ -366,14 +391,20 @@ async fn process_folder_batch(
|
||||
let mut album_queries: Vec<String> = Vec::new();
|
||||
|
||||
for p in &prepared {
|
||||
let artist_q = p.raw_meta.artist.as_deref()
|
||||
let artist_q = p
|
||||
.raw_meta
|
||||
.artist
|
||||
.as_deref()
|
||||
.or(p.hints.artist.as_deref())
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
if !artist_q.is_empty() && !artist_queries.contains(&artist_q) {
|
||||
artist_queries.push(artist_q);
|
||||
}
|
||||
let album_q = p.raw_meta.album.as_deref()
|
||||
let album_q = p
|
||||
.raw_meta
|
||||
.album
|
||||
.as_deref()
|
||||
.or(p.hints.album.as_deref())
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
@@ -388,10 +419,15 @@ async fn process_folder_batch(
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
crate::agent::rag::find_similar_artists(pool, q, 5),
|
||||
).await {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(results)) => {
|
||||
for a in results {
|
||||
if !all_similar_artists.iter().any(|x: &crate::agent::dto::SimilarArtist| x.id == a.id) {
|
||||
if !all_similar_artists
|
||||
.iter()
|
||||
.any(|x: &crate::agent::dto::SimilarArtist| x.id == a.id)
|
||||
{
|
||||
all_similar_artists.push(a);
|
||||
}
|
||||
}
|
||||
@@ -406,10 +442,15 @@ async fn process_folder_batch(
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
crate::agent::rag::find_similar_releases(pool, q, 5),
|
||||
).await {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(results)) => {
|
||||
for r in results {
|
||||
if !all_similar_releases.iter().any(|x: &crate::agent::dto::SimilarRelease| x.id == r.id) {
|
||||
if !all_similar_releases
|
||||
.iter()
|
||||
.any(|x: &crate::agent::dto::SimilarRelease| x.id == r.id)
|
||||
{
|
||||
all_similar_releases.push(r);
|
||||
}
|
||||
}
|
||||
@@ -458,8 +499,9 @@ async fn process_folder_batch(
|
||||
};
|
||||
|
||||
// Build batch input
|
||||
let batch_files: Vec<BatchFileInput> = prepared.iter().map(|p| {
|
||||
BatchFileInput {
|
||||
let batch_files: Vec<BatchFileInput> = prepared
|
||||
.iter()
|
||||
.map(|p| BatchFileInput {
|
||||
filename: p.filename.clone(),
|
||||
raw: RawMetadata {
|
||||
title: p.raw_meta.title.clone(),
|
||||
@@ -469,6 +511,9 @@ async fn process_folder_batch(
|
||||
year: p.raw_meta.year,
|
||||
genre: p.raw_meta.genre.clone(),
|
||||
duration_secs: p.raw_meta.duration_secs,
|
||||
audio_bitrate: p.raw_meta.audio_bitrate,
|
||||
audio_sample_rate: p.raw_meta.audio_sample_rate,
|
||||
audio_bit_depth: p.raw_meta.audio_bit_depth,
|
||||
},
|
||||
hints: PathHints {
|
||||
title: p.hints.title.clone(),
|
||||
@@ -477,8 +522,8 @@ async fn process_folder_batch(
|
||||
year: p.hints.year,
|
||||
track_number: p.hints.track_number,
|
||||
},
|
||||
}
|
||||
}).collect();
|
||||
})
|
||||
.collect();
|
||||
|
||||
let system_prompt = include_str!("../../prompts/normalize_batch.txt");
|
||||
let context_limit = config.agent_context_limit;
|
||||
@@ -493,7 +538,8 @@ async fn process_folder_batch(
|
||||
&all_similar_artists,
|
||||
&all_similar_releases,
|
||||
Some(&folder_ctx),
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
|
||||
let batch_result = match llm_result {
|
||||
Ok(r) => r,
|
||||
@@ -506,7 +552,9 @@ async fn process_folder_batch(
|
||||
}
|
||||
let total_fail_count = failed_reviews.len() as u64 + file_count as u64;
|
||||
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
||||
let _ = run.set_failed(db, duration_ms, &log.output(), &err_msg).await;
|
||||
let _ = run
|
||||
.set_failed(db, duration_ms, &log.output(), &err_msg)
|
||||
.await;
|
||||
return (0, total_fail_count);
|
||||
}
|
||||
};
|
||||
@@ -524,9 +572,7 @@ async fn process_folder_batch(
|
||||
log.info("Phase 4: finalizing...");
|
||||
|
||||
// Build lookup map: filename → NormalizedFields
|
||||
let result_map: HashMap<String, NormalizedFields> = batch_result.results
|
||||
.into_iter()
|
||||
.collect();
|
||||
let result_map: HashMap<String, NormalizedFields> = batch_result.results.into_iter().collect();
|
||||
|
||||
let llm_model = &batch_result.model;
|
||||
let prompt_per_file = batch_result.prompt_tokens / prepared.len().max(1) as u64;
|
||||
@@ -558,7 +604,8 @@ async fn process_folder_batch(
|
||||
duration_per_file,
|
||||
prompt_per_file as i64,
|
||||
completion_per_file as i64,
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
|
||||
let result_json = serde_json::to_string(normalized).unwrap_or_default();
|
||||
let confidence = normalized.confidence.unwrap_or(0.0);
|
||||
@@ -573,7 +620,9 @@ async fn process_folder_batch(
|
||||
normalized.artist.as_deref().unwrap_or("-"),
|
||||
normalized.album.as_deref().unwrap_or("-"),
|
||||
normalized.title.as_deref().unwrap_or("-"),
|
||||
normalized.track_number.map_or("-".into(), |n| n.to_string()),
|
||||
normalized
|
||||
.track_number
|
||||
.map_or("-".into(), |n| n.to_string()),
|
||||
normalized.year.map_or("-".into(), |y| y.to_string()),
|
||||
confidence,
|
||||
feat,
|
||||
@@ -586,9 +635,17 @@ async fn process_folder_batch(
|
||||
|
||||
if confidence >= config.agent_confidence_threshold {
|
||||
match finalize_approved(
|
||||
db, pool, config, &input_path_str, normalized, &p.context,
|
||||
&config.agent_storage_dir, Some(llm_model),
|
||||
).await {
|
||||
db,
|
||||
pool,
|
||||
config,
|
||||
&input_path_str,
|
||||
normalized,
|
||||
&p.context,
|
||||
&config.agent_storage_dir,
|
||||
Some(llm_model),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
let _ = p.review.set_auto_approved(db).await;
|
||||
ok_count += 1;
|
||||
@@ -604,7 +661,8 @@ async fn process_folder_batch(
|
||||
p.review.status = cot::db::LimitedString::new("pending").unwrap();
|
||||
p.review.updated_at = cot::db::LimitedString::new(
|
||||
&chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
let _ = p.review.save(db).await;
|
||||
log.info(&format!(
|
||||
"{filename}: manual review (confidence {confidence} < {})",
|
||||
@@ -669,10 +727,7 @@ pub async fn finalize_approved(
|
||||
.map_err(|e| anyhow::anyhow!("failed to link release-artist: {e}"))?;
|
||||
}
|
||||
|
||||
let sha256 = context
|
||||
.get("sha256")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let sha256 = context.get("sha256").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let file_size = context
|
||||
.get("file_size")
|
||||
.and_then(|v| v.as_i64())
|
||||
@@ -681,6 +736,24 @@ pub async fn finalize_approved(
|
||||
.get("duration_secs")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let audio_bitrate = context
|
||||
.get("audio_bitrate")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(|v| i32::try_from(v).ok());
|
||||
let audio_sample_rate = context
|
||||
.get("audio_sample_rate")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(|v| i32::try_from(v).ok());
|
||||
let audio_bit_depth = context
|
||||
.get("audio_bit_depth")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(|v| i32::try_from(v).ok());
|
||||
let uploaded_by_user_id = context.get("uploaded_by_user_id").and_then(|v| v.as_i64());
|
||||
let uploader_name = context
|
||||
.get("uploader_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("UFO");
|
||||
|
||||
let source_path = Path::new(input_path_str);
|
||||
let original_filename = source_path
|
||||
@@ -746,9 +819,11 @@ pub async fn finalize_approved(
|
||||
file_size,
|
||||
sha256,
|
||||
Some(ext),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
audio_bitrate,
|
||||
audio_sample_rate,
|
||||
audio_bit_depth,
|
||||
uploaded_by_user_id,
|
||||
Some(uploader_name),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("failed to create media file: {e}"))?;
|
||||
@@ -785,9 +860,7 @@ pub async fn finalize_approved(
|
||||
|
||||
// Cover art: if the release has no cover yet, try to find one
|
||||
if release.cover_file_id.is_none() {
|
||||
let source_folder = Path::new(input_path_str)
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."));
|
||||
let source_folder = Path::new(input_path_str).parent().unwrap_or(Path::new("."));
|
||||
|
||||
// Collect audio files in the same folder to try embedded extraction
|
||||
let audio_files_in_folder: Vec<std::path::PathBuf> = std::fs::read_dir(source_folder)
|
||||
@@ -955,10 +1028,7 @@ fn truncate_path(path: &str, max_len: usize) -> String {
|
||||
} else if max_len <= 3 {
|
||||
".".repeat(max_len)
|
||||
} else {
|
||||
let suffix: String = path
|
||||
.chars()
|
||||
.skip(char_count - (max_len - 3))
|
||||
.collect();
|
||||
let suffix: String = path.chars().skip(char_count - (max_len - 3)).collect();
|
||||
format!("...{suffix}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,74 @@ pub mod artist_track_image_backfill;
|
||||
pub mod cover_backfill;
|
||||
pub mod inbox_discover;
|
||||
pub mod inbox_process;
|
||||
pub mod metadata_backfill;
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UploaderAttribution {
|
||||
pub user_id: Option<i64>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl UploaderAttribution {
|
||||
pub fn unknown() -> Self {
|
||||
Self {
|
||||
user_id: None,
|
||||
name: "UFO".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn strip_user_upload_prefix(relative_path: &Path) -> PathBuf {
|
||||
let components: Vec<_> = relative_path.components().collect();
|
||||
if components.len() >= 3
|
||||
&& matches!(components[0], Component::Normal(value) if value == "user_uploads")
|
||||
{
|
||||
components[2..].iter().collect()
|
||||
} else {
|
||||
relative_path.to_path_buf()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn uploader_from_relative_path(
|
||||
pool: &sqlx::PgPool,
|
||||
relative_path: &Path,
|
||||
) -> UploaderAttribution {
|
||||
let components: Vec<_> = relative_path.components().collect();
|
||||
let Some(Component::Normal(root)) = components.first() else {
|
||||
return UploaderAttribution::unknown();
|
||||
};
|
||||
if *root != "user_uploads" {
|
||||
return UploaderAttribution::unknown();
|
||||
}
|
||||
|
||||
let Some(Component::Normal(user_id_os)) = components.get(1) else {
|
||||
return UploaderAttribution::unknown();
|
||||
};
|
||||
let Some(user_id_str) = user_id_os.to_str() else {
|
||||
return UploaderAttribution::unknown();
|
||||
};
|
||||
let Ok(user_id) = user_id_str.parse::<i64>() else {
|
||||
return UploaderAttribution::unknown();
|
||||
};
|
||||
|
||||
let name: Option<String> = sqlx::query_scalar(
|
||||
r#"SELECT COALESCE(NULLIF(display_name, ''), username)::text
|
||||
FROM furumusic__user
|
||||
WHERE id = $1 AND is_active = true"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
match name {
|
||||
Some(name) if !name.trim().is_empty() => UploaderAttribution {
|
||||
user_id: Some(user_id),
|
||||
name,
|
||||
},
|
||||
_ => UploaderAttribution::unknown(),
|
||||
}
|
||||
}
|
||||
|
||||
+20
-18
@@ -9,6 +9,7 @@ mod music;
|
||||
mod oidc;
|
||||
mod player;
|
||||
mod scheduler;
|
||||
mod torrents;
|
||||
mod user;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -24,13 +25,13 @@ use cot::db::Database;
|
||||
use cot::form::{Form, FormResult};
|
||||
use cot::html::Html;
|
||||
use cot::middleware::SessionMiddleware;
|
||||
use cot::static_files::StaticFilesMiddleware;
|
||||
use cot::project::RegisterAppsContext;
|
||||
use cot::request::extractors::{RequestForm, UrlQuery};
|
||||
use cot::response::IntoResponse;
|
||||
use cot::router::method::get;
|
||||
use cot::router::{Route, Router};
|
||||
use cot::session::Session;
|
||||
use cot::static_files::StaticFilesMiddleware;
|
||||
use cot::{App, AppBuilder, Body, Project, Template};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -51,6 +52,7 @@ fn build_registry() -> Arc<JobRegistry> {
|
||||
registry.register(jobs::cover_backfill::CoverBackfillJob);
|
||||
registry.register(jobs::artist_image_backfill::ArtistImageBackfillJob);
|
||||
registry.register(jobs::artist_track_image_backfill::ArtistTrackImageBackfillJob);
|
||||
registry.register(jobs::metadata_backfill::MetadataBackfillJob);
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
@@ -58,11 +60,7 @@ fn build_registry() -> Arc<JobRegistry> {
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn index(
|
||||
session: Session,
|
||||
db: Database,
|
||||
i18n: I18n,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
async fn index(session: Session, db: Database, i18n: I18n) -> cot::Result<cot::response::Response> {
|
||||
let _user = match auth::get_session_user(&session, &db).await {
|
||||
Some(u) => u,
|
||||
None => return Ok(auth::redirect("/login")),
|
||||
@@ -164,7 +162,8 @@ impl App for FuruApp {
|
||||
get(|| async { Ok::<_, cot::Error>(auth::redirect("/swagger/")) }),
|
||||
"swagger_redirect",
|
||||
),
|
||||
Route::with_handler_and_name("/",
|
||||
Route::with_handler_and_name(
|
||||
"/",
|
||||
|session: Session, db: Database, i18n: I18n| async move {
|
||||
index(session, db, i18n).await
|
||||
},
|
||||
@@ -186,9 +185,12 @@ impl App for FuruApp {
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}).post({
|
||||
})
|
||||
.post({
|
||||
let config = Arc::clone(&self.config);
|
||||
move |i18n: I18n, db: Database, session: Session,
|
||||
move |i18n: I18n,
|
||||
db: Database,
|
||||
session: Session,
|
||||
form: RequestForm<LoginForm>| {
|
||||
let config = Arc::clone(&config);
|
||||
async move {
|
||||
@@ -204,8 +206,7 @@ impl App for FuruApp {
|
||||
};
|
||||
|
||||
// Try to authenticate
|
||||
if let Ok(Some(user)) =
|
||||
User::get_by_username(&db, &data.username).await
|
||||
if let Ok(Some(user)) = User::get_by_username(&db, &data.username).await
|
||||
{
|
||||
if let Some(hash) = user.password_ref() {
|
||||
let password = Password::new(&data.password);
|
||||
@@ -280,6 +281,7 @@ impl Project for FuruProject {
|
||||
" FURU_OIDC_CLIENT_SECRET OIDC client secret\n",
|
||||
" FURU_OIDC_BUTTON_TEXT SSO button label (default: Sign in with SSO)\n",
|
||||
" FURU_OIDC_ADMIN_GROUPS OIDC groups that grant admin role\n",
|
||||
" FURU_OIDC_USER_GROUPS OIDC groups allowed to access the service\n",
|
||||
"\n",
|
||||
" API:\n",
|
||||
" FURU_SWAGGER_ENABLED Enable Swagger UI at /swagger/ (default: false)\n",
|
||||
@@ -370,14 +372,14 @@ impl Project for FuruProject {
|
||||
);
|
||||
apps.register_with_views(api::ApiApp, "/api");
|
||||
apps.register_with_views(
|
||||
player::PlayerApp::new(Arc::clone(&self.app_config)),
|
||||
player::PlayerApp::new(
|
||||
Arc::clone(&self.app_config),
|
||||
Arc::clone(&self.scheduler_handle),
|
||||
),
|
||||
"/api/player",
|
||||
);
|
||||
if self.app_config.swagger_enabled {
|
||||
apps.register_with_views(
|
||||
cot::openapi::swagger_ui::SwaggerUi::new(),
|
||||
"/swagger",
|
||||
);
|
||||
apps.register_with_views(cot::openapi::swagger_ui::SwaggerUi::new(), "/swagger");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,8 +395,8 @@ fn main() -> impl Project {
|
||||
// Initialise tracing subscriber with the configured log level.
|
||||
// FURU_LOG_LEVEL (or the default "info") is parsed as an EnvFilter
|
||||
// directive, so values like "debug", "warn,furumusic=trace" all work.
|
||||
let filter = tracing_subscriber::EnvFilter::try_new(&app_config.log_level)
|
||||
.unwrap_or_else(|e| {
|
||||
let filter =
|
||||
tracing_subscriber::EnvFilter::try_new(&app_config.log_level).unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"WARNING: invalid FURU_LOG_LEVEL {:?}: {e}; falling back to \"info\"",
|
||||
app_config.log_level,
|
||||
|
||||
+389
-229
@@ -4,7 +4,6 @@
|
||||
/// content (files, artists, releases, tracks, genres), user interactions
|
||||
/// (likes, follows, playlists, play history, playback state), and the
|
||||
/// AI-agent processing queue.
|
||||
|
||||
use cot::db::{Auto, Database, LimitedString, Model};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -37,6 +36,10 @@ pub struct MediaFile {
|
||||
pub audio_sample_rate: Option<i32>,
|
||||
/// Bit depth (16, 24, 32)
|
||||
pub audio_bit_depth: Option<i32>,
|
||||
/// FK -> user who imported/uploaded the source, NULL when unknown.
|
||||
pub uploaded_by_user_id: Option<i64>,
|
||||
/// Stable display label for the uploader. Unknown uploads are stored as "UFO".
|
||||
pub uploader_name: LimitedString<255>,
|
||||
pub created_at: LimitedString<32>,
|
||||
}
|
||||
|
||||
@@ -99,11 +102,7 @@ impl Artist {
|
||||
Ok(artist)
|
||||
}
|
||||
|
||||
pub async fn update_name(
|
||||
&mut self,
|
||||
db: &Database,
|
||||
name: &str,
|
||||
) -> cot::db::Result<()> {
|
||||
pub async fn update_name(&mut self, db: &Database, name: &str) -> cot::db::Result<()> {
|
||||
self.name = LimitedString::new(name).unwrap();
|
||||
self.name_sort = LimitedString::new(&normalize_name(name)).unwrap();
|
||||
self.updated_at = now_iso();
|
||||
@@ -612,8 +611,13 @@ impl MediaFile {
|
||||
audio_bitrate: Option<i32>,
|
||||
audio_sample_rate: Option<i32>,
|
||||
audio_bit_depth: Option<i32>,
|
||||
uploaded_by_user_id: Option<i64>,
|
||||
uploader_name: Option<&str>,
|
||||
) -> cot::db::Result<Self> {
|
||||
let now = now_iso();
|
||||
let uploader_name = uploader_name
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or("UFO");
|
||||
let mut mf = Self {
|
||||
id: Auto::auto(),
|
||||
file_type: LimitedString::new(file_type).unwrap(),
|
||||
@@ -626,6 +630,8 @@ impl MediaFile {
|
||||
audio_bitrate,
|
||||
audio_sample_rate,
|
||||
audio_bit_depth,
|
||||
uploaded_by_user_id,
|
||||
uploader_name: LimitedString::new(uploader_name).unwrap(),
|
||||
created_at: now,
|
||||
};
|
||||
mf.insert(db).await?;
|
||||
@@ -711,37 +717,67 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0006CreateMediaFile {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0006_create_media_file";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0005_oidc_link_indexes",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__media_file"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||
.primary_key()
|
||||
.auto(),
|
||||
Field::new(Identifier::new("file_type"), <LimitedString<32> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("file_path"), <String as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("original_filename"), <LimitedString<255> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("mime_type"), <LimitedString<100> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("file_size_bytes"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("sha256_hash"), <LimitedString<64> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("audio_format"), <LimitedString<32> as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("file_type"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("file_path"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("original_filename"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("mime_type"),
|
||||
<LimitedString<100> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("file_size_bytes"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("sha256_hash"),
|
||||
<LimitedString<64> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("audio_format"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("audio_bitrate"), <i32 as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("audio_bitrate"),
|
||||
<i32 as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("audio_sample_rate"), <i32 as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("audio_sample_rate"),
|
||||
<i32 as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("audio_bit_depth"), <i32 as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("audio_bit_depth"),
|
||||
<i32 as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("created_at"), <LimitedString<32> as DatabaseField>::TYPE),
|
||||
Field::new(
|
||||
Identifier::new("created_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
.build()];
|
||||
}
|
||||
|
||||
// -- M0007: create furumusic__artist --------------------------------------
|
||||
@@ -752,29 +788,41 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0007CreateArtist {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0007_create_artist";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0006_create_media_file",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__artist"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||
.primary_key()
|
||||
.auto(),
|
||||
Field::new(Identifier::new("name"), <LimitedString<255> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("name_sort"), <LimitedString<255> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("image_file_id"), <i64 as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("name"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("name_sort"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("image_file_id"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("is_hidden"), <bool 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),
|
||||
Field::new(
|
||||
Identifier::new("created_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("updated_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
.build()];
|
||||
}
|
||||
|
||||
// -- M0008: create furumusic__release -------------------------------------
|
||||
@@ -785,36 +833,53 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0008CreateRelease {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0008_create_release";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0007_create_artist",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__release"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||
.primary_key()
|
||||
.auto(),
|
||||
Field::new(Identifier::new("title"), <LimitedString<255> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("title_sort"), <LimitedString<255> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("release_type"), <LimitedString<32> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("year"), <i32 as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("title"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("title_sort"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("release_type"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(Identifier::new("year"), <i32 as DatabaseField>::TYPE).set_null(true),
|
||||
Field::new(
|
||||
Identifier::new("cover_file_id"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("cover_file_id"), <i64 as DatabaseField>::TYPE)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("total_tracks"), <i32 as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("total_tracks"),
|
||||
<i32 as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("total_discs"), <i32 as DatabaseField>::TYPE)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("is_hidden"), <bool 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),
|
||||
Field::new(
|
||||
Identifier::new("created_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("updated_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
.build()];
|
||||
}
|
||||
|
||||
// -- M0009: create furumusic__release_artist ------------------------------
|
||||
@@ -825,14 +890,12 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0009CreateReleaseArtist {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0009_create_release_artist";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0008_create_release",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__release_artist"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||
@@ -842,8 +905,7 @@ pub mod db_migrations {
|
||||
Field::new(Identifier::new("artist_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("position"), <i32 as DatabaseField>::TYPE),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
.build()];
|
||||
}
|
||||
|
||||
// -- M0010: create furumusic__track ---------------------------------------
|
||||
@@ -854,38 +916,58 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0010CreateTrack {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0010_create_track";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0009_create_release_artist",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__track"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||
.primary_key()
|
||||
.auto(),
|
||||
Field::new(Identifier::new("title"), <LimitedString<255> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("title_sort"), <LimitedString<255> as DatabaseField>::TYPE),
|
||||
Field::new(
|
||||
Identifier::new("title"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("title_sort"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(Identifier::new("release_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("track_number"), <i32 as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("track_number"),
|
||||
<i32 as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("disc_number"), <i32 as DatabaseField>::TYPE)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("duration_seconds"), <f64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("audio_file_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("cover_file_id"), <i64 as DatabaseField>::TYPE)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("year"), <i32 as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("duration_seconds"),
|
||||
<f64 as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("audio_file_id"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("cover_file_id"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("year"), <i32 as DatabaseField>::TYPE).set_null(true),
|
||||
Field::new(Identifier::new("is_hidden"), <bool 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),
|
||||
Field::new(
|
||||
Identifier::new("created_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("updated_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
.build()];
|
||||
}
|
||||
|
||||
// -- M0011: create furumusic__track_artist --------------------------------
|
||||
@@ -896,14 +978,12 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0011CreateTrackArtist {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0011_create_track_artist";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0010_create_track",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__track_artist"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||
@@ -911,11 +991,13 @@ pub mod db_migrations {
|
||||
.auto(),
|
||||
Field::new(Identifier::new("track_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("artist_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("role"), <LimitedString<32> as DatabaseField>::TYPE),
|
||||
Field::new(
|
||||
Identifier::new("role"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(Identifier::new("position"), <i32 as DatabaseField>::TYPE),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
.build()];
|
||||
}
|
||||
|
||||
// -- M0012: create furumusic__genre + furumusic__track_genre ---------------
|
||||
@@ -926,12 +1008,11 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0012CreateGenreTables {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0012_create_genre_tables";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0011_create_track_artist",
|
||||
),
|
||||
];
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__genre"))
|
||||
@@ -939,9 +1020,15 @@ pub mod db_migrations {
|
||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||
.primary_key()
|
||||
.auto(),
|
||||
Field::new(Identifier::new("name"), <LimitedString<100> as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("name"),
|
||||
<LimitedString<100> as DatabaseField>::TYPE,
|
||||
)
|
||||
.unique(),
|
||||
Field::new(Identifier::new("name_normalized"), <LimitedString<100> as DatabaseField>::TYPE),
|
||||
Field::new(
|
||||
Identifier::new("name_normalized"),
|
||||
<LimitedString<100> as DatabaseField>::TYPE,
|
||||
),
|
||||
])
|
||||
.build(),
|
||||
Operation::create_model()
|
||||
@@ -965,14 +1052,12 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0013CreateUserLikedTrack {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0013_create_user_liked_track";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0012_create_genre_tables",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__user_liked_track"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||
@@ -980,10 +1065,12 @@ pub mod db_migrations {
|
||||
.auto(),
|
||||
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("track_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("created_at"), <LimitedString<32> as DatabaseField>::TYPE),
|
||||
Field::new(
|
||||
Identifier::new("created_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
.build()];
|
||||
}
|
||||
|
||||
// -- M0014: create furumusic__user_followed_artist ------------------------
|
||||
@@ -994,14 +1081,12 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0014CreateUserFollowedArtist {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0014_create_user_followed_artist";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0013_create_user_liked_track",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__user_followed_artist"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||
@@ -1009,10 +1094,12 @@ pub mod db_migrations {
|
||||
.auto(),
|
||||
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("artist_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("created_at"), <LimitedString<32> as DatabaseField>::TYPE),
|
||||
Field::new(
|
||||
Identifier::new("created_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
.build()];
|
||||
}
|
||||
|
||||
// -- M0015: create playlist tables ----------------------------------------
|
||||
@@ -1023,12 +1110,11 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0015CreatePlaylistTables {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0015_create_playlist_tables";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0014_create_user_followed_artist",
|
||||
),
|
||||
];
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__playlist"))
|
||||
@@ -1037,16 +1123,34 @@ pub mod db_migrations {
|
||||
.primary_key()
|
||||
.auto(),
|
||||
Field::new(Identifier::new("owner_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("title"), <LimitedString<255> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("description"), <String as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("title"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("description"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("is_public"), <bool as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("cover_file_id"), <i64 as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("cover_file_id"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("forked_from_id"), <i64 as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("forked_from_id"),
|
||||
<i64 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),
|
||||
Field::new(
|
||||
Identifier::new("created_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("updated_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
])
|
||||
.build(),
|
||||
Operation::create_model()
|
||||
@@ -1058,8 +1162,14 @@ pub mod db_migrations {
|
||||
Field::new(Identifier::new("playlist_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("track_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("position"), <i32 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("added_at"), <LimitedString<32> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("added_by_user_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(
|
||||
Identifier::new("added_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("added_by_user_id"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
),
|
||||
])
|
||||
.build(),
|
||||
Operation::create_model()
|
||||
@@ -1070,7 +1180,10 @@ pub mod db_migrations {
|
||||
.auto(),
|
||||
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("playlist_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("saved_at"), <LimitedString<32> as DatabaseField>::TYPE),
|
||||
Field::new(
|
||||
Identifier::new("saved_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
@@ -1084,14 +1197,12 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0016CreatePlayHistory {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0016_create_play_history";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0015_create_playlist_tables",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__play_history"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||
@@ -1099,13 +1210,18 @@ pub mod db_migrations {
|
||||
.auto(),
|
||||
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("track_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("played_at"), <LimitedString<32> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("duration_listened"), <i32 as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("played_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("duration_listened"),
|
||||
<i32 as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("completed"), <bool as DatabaseField>::TYPE),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
.build()];
|
||||
}
|
||||
|
||||
// -- M0017: create furumusic__playback_state ------------------------------
|
||||
@@ -1116,31 +1232,43 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0017CreatePlaybackState {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0017_create_playback_state";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0016_create_play_history",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__playback_state"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||
.primary_key()
|
||||
.auto(),
|
||||
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("current_track_id"), <i64 as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("current_track_id"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("position_ms"), <i32 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("queue_json"), <String as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("queue_position"), <i32 as DatabaseField>::TYPE),
|
||||
Field::new(
|
||||
Identifier::new("queue_json"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("queue_position"),
|
||||
<i32 as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(Identifier::new("shuffle"), <bool as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("repeat_mode"), <LimitedString<16> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("updated_at"), <LimitedString<32> as DatabaseField>::TYPE),
|
||||
Field::new(
|
||||
Identifier::new("repeat_mode"),
|
||||
<LimitedString<16> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("updated_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
.build()];
|
||||
}
|
||||
|
||||
// -- M0018: create furumusic__processing_task -----------------------------
|
||||
@@ -1151,110 +1279,123 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0018CreateProcessingTask {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0018_create_processing_task";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0017_create_playback_state",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__processing_task"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||
.primary_key()
|
||||
.auto(),
|
||||
Field::new(Identifier::new("status"), <LimitedString<32> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("task_type"), <LimitedString<64> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("input_path"), <String as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("status"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("task_type"),
|
||||
<LimitedString<64> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("input_path"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("context_json"), <String as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("context_json"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("result_json"), <String as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("result_json"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("error_message"), <String as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("error_message"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("attempts"), <i32 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("max_attempts"), <i32 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),
|
||||
Field::new(Identifier::new("started_at"), <LimitedString<32> as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("max_attempts"),
|
||||
<i32 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,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("started_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("completed_at"), <LimitedString<32> as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("completed_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
.build()];
|
||||
}
|
||||
|
||||
// -- M0019: indexes for all music tables ----------------------------------
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn create_music_indexes(
|
||||
ctx: migrations::MigrationContext<'_>,
|
||||
) -> cot::db::Result<()> {
|
||||
async fn create_music_indexes(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
let stmts = [
|
||||
// media_file: lookup by hash for dedup
|
||||
"CREATE INDEX idx_media_file_sha256 ON furumusic__media_file (sha256_hash)",
|
||||
// media_file: filter by type
|
||||
"CREATE INDEX idx_media_file_type ON furumusic__media_file (file_type)",
|
||||
|
||||
// artist: search by normalized name
|
||||
"CREATE INDEX idx_artist_name_sort ON furumusic__artist (name_sort)",
|
||||
|
||||
// release: search by normalized title
|
||||
"CREATE INDEX idx_release_title_sort ON furumusic__release (title_sort)",
|
||||
// release: filter by type
|
||||
"CREATE INDEX idx_release_type ON furumusic__release (release_type)",
|
||||
|
||||
// release_artist: unique pair + lookup
|
||||
"CREATE UNIQUE INDEX idx_release_artist_uniq ON furumusic__release_artist (release_id, artist_id)",
|
||||
"CREATE INDEX idx_release_artist_artist ON furumusic__release_artist (artist_id)",
|
||||
|
||||
// track: search by normalized title
|
||||
"CREATE INDEX idx_track_title_sort ON furumusic__track (title_sort)",
|
||||
// track: FK to release
|
||||
"CREATE INDEX idx_track_release ON furumusic__track (release_id)",
|
||||
// track: FK to audio file
|
||||
"CREATE INDEX idx_track_audio_file ON furumusic__track (audio_file_id)",
|
||||
|
||||
// track_artist: unique triple + lookups
|
||||
"CREATE UNIQUE INDEX idx_track_artist_uniq ON furumusic__track_artist (track_id, artist_id, role)",
|
||||
"CREATE INDEX idx_track_artist_artist ON furumusic__track_artist (artist_id)",
|
||||
|
||||
// track_genre: unique pair + lookup
|
||||
"CREATE UNIQUE INDEX idx_track_genre_uniq ON furumusic__track_genre (track_id, genre_id)",
|
||||
"CREATE INDEX idx_track_genre_genre ON furumusic__track_genre (genre_id)",
|
||||
|
||||
// genre: lookup by normalized name
|
||||
"CREATE INDEX idx_genre_normalized ON furumusic__genre (name_normalized)",
|
||||
|
||||
// user_liked_track: unique pair + lookup by track
|
||||
"CREATE UNIQUE INDEX idx_user_liked_track_uniq ON furumusic__user_liked_track (user_id, track_id)",
|
||||
"CREATE INDEX idx_user_liked_track_track ON furumusic__user_liked_track (track_id)",
|
||||
|
||||
// user_followed_artist: unique pair + lookup by artist
|
||||
"CREATE UNIQUE INDEX idx_user_followed_artist_uniq ON furumusic__user_followed_artist (user_id, artist_id)",
|
||||
"CREATE INDEX idx_user_followed_artist_artist ON furumusic__user_followed_artist (artist_id)",
|
||||
|
||||
// playlist: owner lookup
|
||||
"CREATE INDEX idx_playlist_owner ON furumusic__playlist (owner_id)",
|
||||
|
||||
// playlist_track: ordered tracks in playlist + lookup by track
|
||||
"CREATE INDEX idx_playlist_track_playlist ON furumusic__playlist_track (playlist_id, position)",
|
||||
"CREATE INDEX idx_playlist_track_track ON furumusic__playlist_track (track_id)",
|
||||
|
||||
// saved_playlist: unique pair + lookup by playlist
|
||||
"CREATE UNIQUE INDEX idx_saved_playlist_uniq ON furumusic__saved_playlist (user_id, playlist_id)",
|
||||
"CREATE INDEX idx_saved_playlist_playlist ON furumusic__saved_playlist (playlist_id)",
|
||||
|
||||
// play_history: user timeline + lookup by track
|
||||
"CREATE INDEX idx_play_history_user ON furumusic__play_history (user_id, played_at)",
|
||||
"CREATE INDEX idx_play_history_track ON furumusic__play_history (track_id)",
|
||||
|
||||
// playback_state: one per user
|
||||
"CREATE UNIQUE INDEX idx_playback_state_user ON furumusic__playback_state (user_id)",
|
||||
|
||||
// processing_task: queue polling (status + created_at)
|
||||
"CREATE INDEX idx_processing_task_status ON furumusic__processing_task (status, created_at)",
|
||||
];
|
||||
@@ -1272,15 +1413,12 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0019CreateMusicIndexes {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0019_create_music_indexes";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0018_create_processing_task",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::custom(create_music_indexes).build(),
|
||||
];
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::custom(create_music_indexes).build()];
|
||||
}
|
||||
|
||||
// -- M0020: enable pg_trgm extension --------------------------------------
|
||||
@@ -1297,15 +1435,12 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0020EnablePgTrgm {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0020_enable_pg_trgm";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0019_create_music_indexes",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::custom(enable_pg_trgm).build(),
|
||||
];
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::custom(enable_pg_trgm).build()];
|
||||
}
|
||||
|
||||
// -- M0021: GIN trigram indexes for fuzzy search --------------------------
|
||||
@@ -1323,15 +1458,12 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0021CreateTrgmIndexes {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0021_create_trgm_indexes";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0020_enable_pg_trgm",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::custom(create_trgm_indexes).build(),
|
||||
];
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::custom(create_trgm_indexes).build()];
|
||||
}
|
||||
|
||||
// -- M0022: GIN trigram index on track.title_sort ---------------------------
|
||||
@@ -1348,15 +1480,13 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0022CreateTrackTrgmIndex {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0022_create_track_trgm_index";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0021_create_trgm_indexes",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::custom(create_track_trgm_index).build(),
|
||||
];
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[Operation::custom(create_track_trgm_index).build()];
|
||||
}
|
||||
|
||||
// -- M0028: add model_name to artist, release, track -----------------------
|
||||
@@ -1381,15 +1511,13 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0028AddModelNameColumns {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0028_add_model_name_columns";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0027_create_processing_stats",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::custom(add_model_name_columns).build(),
|
||||
];
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[Operation::custom(add_model_name_columns).build()];
|
||||
}
|
||||
|
||||
// -- M0029: add volume column to playback_state ----------------------------
|
||||
@@ -1408,15 +1536,46 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0029AddPlaybackVolume {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0029_add_playback_volume";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0028_add_model_name_columns",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::custom(add_playback_volume).build(),
|
||||
];
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::custom(add_playback_volume).build()];
|
||||
}
|
||||
|
||||
// -- M0030: add uploader attribution to media_file ------------------------
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn add_media_file_uploader(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
ctx.db
|
||||
.raw("ALTER TABLE furumusic__media_file ADD COLUMN uploaded_by_user_id BIGINT DEFAULT NULL")
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw("ALTER TABLE furumusic__media_file ADD COLUMN uploader_name VARCHAR(255) NOT NULL DEFAULT 'UFO'")
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw("CREATE INDEX IF NOT EXISTS idx_media_file_uploaded_by_user ON furumusic__media_file (uploaded_by_user_id)")
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw("CREATE INDEX IF NOT EXISTS idx_media_file_uploader_name ON furumusic__media_file (uploader_name)")
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0030AddMediaFileUploader;
|
||||
|
||||
impl migrations::Migration for M0030AddMediaFileUploader {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0030_add_media_file_uploader";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0029_add_playback_volume",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[Operation::custom(add_media_file_uploader).build()];
|
||||
}
|
||||
|
||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||
@@ -1439,5 +1598,6 @@ pub mod db_migrations {
|
||||
&M0022CreateTrackTrgmIndex,
|
||||
&M0028AddModelNameColumns,
|
||||
&M0029AddPlaybackVolume,
|
||||
&M0030AddMediaFileUploader,
|
||||
];
|
||||
}
|
||||
|
||||
+41
-7
@@ -131,8 +131,7 @@ async fn get_or_refresh_provider(
|
||||
.unwrap_or(config.oidc_issuer.trim_end_matches('/'))
|
||||
.to_owned();
|
||||
|
||||
let issuer_url = IssuerUrl::new(issuer)
|
||||
.map_err(|e| format!("invalid issuer URL: {e}"))?;
|
||||
let issuer_url = IssuerUrl::new(issuer).map_err(|e| format!("invalid issuer URL: {e}"))?;
|
||||
|
||||
let metadata = CoreProviderMetadata::discover_async(issuer_url, http)
|
||||
.await
|
||||
@@ -250,7 +249,9 @@ pub async fn oidc_callback_handler(
|
||||
i18n: I18n,
|
||||
db: Database,
|
||||
session: Session,
|
||||
cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery<OidcCallbackQuery>,
|
||||
cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery<
|
||||
OidcCallbackQuery,
|
||||
>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||
|
||||
@@ -313,9 +314,7 @@ pub async fn oidc_callback_handler(
|
||||
};
|
||||
|
||||
// Exchange code for tokens.
|
||||
let token_request = match client
|
||||
.exchange_code(AuthorizationCode::new(query.code.clone()))
|
||||
{
|
||||
let token_request = match client.exchange_code(AuthorizationCode::new(query.code.clone())) {
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC token endpoint not configured: {e}");
|
||||
@@ -385,9 +384,23 @@ pub async fn oidc_callback_handler(
|
||||
.unwrap_or_default();
|
||||
|
||||
tracing::info!(
|
||||
"OIDC login: sub={sub}, groups={groups:?}, admin_groups={:?}",
|
||||
"OIDC login: sub={sub}, groups={groups:?}, admin_groups={:?}, user_groups={:?}",
|
||||
config.oidc_admin_groups,
|
||||
config.oidc_user_groups,
|
||||
);
|
||||
|
||||
if !is_allowed_by_groups(
|
||||
&groups,
|
||||
&config.oidc_user_groups,
|
||||
&config.oidc_admin_groups,
|
||||
) {
|
||||
tracing::warn!(
|
||||
"OIDC login denied by group allowlist: sub={sub}, groups={groups:?}, user_groups={:?}, admin_groups={:?}",
|
||||
config.oidc_user_groups,
|
||||
config.oidc_admin_groups,
|
||||
);
|
||||
return redirect_login_with_error(i18n.t.login_access_denied);
|
||||
}
|
||||
|
||||
// User provisioning logic.
|
||||
let user = match provision_user(
|
||||
@@ -459,6 +472,27 @@ fn resolve_role(groups: &[String], admin_groups: &str) -> &'static str {
|
||||
auth::Role::User.code()
|
||||
}
|
||||
|
||||
fn parse_group_set(groups: &str) -> std::collections::HashSet<&str> {
|
||||
groups
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn has_any_group(groups: &[String], allowed: &std::collections::HashSet<&str>) -> bool {
|
||||
groups.iter().any(|g| allowed.contains(g.as_str()))
|
||||
}
|
||||
|
||||
fn is_allowed_by_groups(groups: &[String], user_groups: &str, admin_groups: &str) -> bool {
|
||||
let user_set = parse_group_set(user_groups);
|
||||
if user_set.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let admin_set = parse_group_set(admin_groups);
|
||||
has_any_group(groups, &user_set) || has_any_group(groups, &admin_set)
|
||||
}
|
||||
|
||||
async fn provision_user(
|
||||
db: &Database,
|
||||
issuer: &str,
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct ArtistCard {
|
||||
pub(super) id: i64,
|
||||
pub(super) name: String,
|
||||
pub(super) image_url: Option<String>,
|
||||
pub(super) release_count: i64,
|
||||
pub(super) track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct Paginated<T: Serialize> {
|
||||
pub(super) items: Vec<T>,
|
||||
pub(super) total: i64,
|
||||
pub(super) page: i32,
|
||||
pub(super) per_page: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct ReleaseCard {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) release_type: String,
|
||||
pub(super) year: Option<i32>,
|
||||
pub(super) cover_url: Option<String>,
|
||||
pub(super) track_count: i64,
|
||||
pub(super) uploaders: Vec<UploaderSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct ArtistDetail {
|
||||
pub(super) id: i64,
|
||||
pub(super) name: String,
|
||||
pub(super) image_url: Option<String>,
|
||||
pub(super) total_track_count: i64,
|
||||
pub(super) total_play_count: i64,
|
||||
pub(super) releases: Vec<ReleaseCard>,
|
||||
pub(super) featured_tracks: Vec<ArtistAppearanceTrack>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct ArtistRef {
|
||||
pub(super) id: i64,
|
||||
pub(super) name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct TrackItem {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) track_number: Option<i32>,
|
||||
pub(super) disc_number: Option<i32>,
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) artists: Vec<ArtistRef>,
|
||||
pub(super) featured_artists: Vec<ArtistRef>,
|
||||
pub(super) cover_url: Option<String>,
|
||||
pub(super) stream_url: String,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) audio_format: Option<String>,
|
||||
pub(super) audio_bitrate: Option<i32>,
|
||||
pub(super) audio_sample_rate: Option<i32>,
|
||||
pub(super) audio_bit_depth: Option<i32>,
|
||||
pub(super) file_size_bytes: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct ArtistAppearanceTrack {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) release_id: i64,
|
||||
pub(super) release_title: String,
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) artists: Vec<ArtistRef>,
|
||||
pub(super) featured_artists: Vec<ArtistRef>,
|
||||
pub(super) cover_url: Option<String>,
|
||||
pub(super) stream_url: String,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) audio_format: Option<String>,
|
||||
pub(super) audio_bitrate: Option<i32>,
|
||||
pub(super) audio_sample_rate: Option<i32>,
|
||||
pub(super) audio_bit_depth: Option<i32>,
|
||||
pub(super) file_size_bytes: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct ReleaseDetail {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) release_type: String,
|
||||
pub(super) year: Option<i32>,
|
||||
pub(super) cover_url: Option<String>,
|
||||
pub(super) artists: Vec<ArtistRef>,
|
||||
pub(super) tracks: Vec<TrackItem>,
|
||||
pub(super) uploaders: Vec<UploaderSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub(super) struct UploaderSummary {
|
||||
pub(super) name: String,
|
||||
pub(super) track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct PlaylistCard {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) track_count: i64,
|
||||
pub(super) is_own: bool,
|
||||
pub(super) kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub(super) struct PlaybackStateDto {
|
||||
pub(super) current_track_id: Option<i64>,
|
||||
pub(super) position_ms: i32,
|
||||
pub(super) queue: Vec<i64>,
|
||||
pub(super) queue_position: i32,
|
||||
pub(super) shuffle: bool,
|
||||
pub(super) repeat_mode: String,
|
||||
pub(super) volume: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct PlaylistDetail {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) description: Option<String>,
|
||||
pub(super) is_own: bool,
|
||||
pub(super) kind: String,
|
||||
pub(super) tracks: Vec<TrackItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct SearchResults {
|
||||
pub(super) artists: Vec<ArtistCard>,
|
||||
pub(super) releases: Vec<ReleaseCard>,
|
||||
pub(super) tracks: Vec<TrackItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct UserStats {
|
||||
pub(super) liked_tracks: i64,
|
||||
pub(super) playlists: i64,
|
||||
pub(super) plays: i64,
|
||||
pub(super) listened_minutes: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct UserProfile {
|
||||
pub(super) name: String,
|
||||
pub(super) role: String,
|
||||
pub(super) stats: UserStats,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct PlayHistoryItem {
|
||||
pub(super) id: i64,
|
||||
pub(super) track_id: i64,
|
||||
pub(super) track_title: String,
|
||||
pub(super) release_title: Option<String>,
|
||||
pub(super) played_at: String,
|
||||
pub(super) duration_listened: Option<i32>,
|
||||
pub(super) completed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct PlayHistoryPage {
|
||||
pub(super) items: Vec<PlayHistoryItem>,
|
||||
pub(super) total: i64,
|
||||
pub(super) page: i32,
|
||||
pub(super) per_page: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct LikeStatus {
|
||||
pub(super) liked: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct LikedIds {
|
||||
pub(super) track_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct FollowStatus {
|
||||
pub(super) followed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct FollowedArtists {
|
||||
pub(super) artist_ids: Vec<i64>,
|
||||
pub(super) artists: Vec<ArtistCard>,
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use crate::player::dto::UploaderSummary;
|
||||
use crate::player::rows::ReleaseUploaderRow;
|
||||
|
||||
pub(super) fn cover_url(file_id: Option<i64>) -> Option<String> {
|
||||
file_id.map(|id| format!("/api/player/cover/{id}"))
|
||||
}
|
||||
|
||||
pub(super) fn track_cover_url(
|
||||
track_cover: Option<i64>,
|
||||
release_cover: Option<i64>,
|
||||
) -> Option<String> {
|
||||
cover_url(track_cover.or(release_cover))
|
||||
}
|
||||
|
||||
pub(super) async fn load_release_uploaders(
|
||||
pool: &sqlx::PgPool,
|
||||
release_ids: &[i64],
|
||||
) -> Result<std::collections::HashMap<i64, Vec<UploaderSummary>>, sqlx::Error> {
|
||||
if release_ids.is_empty() {
|
||||
return Ok(std::collections::HashMap::new());
|
||||
}
|
||||
|
||||
let rows = sqlx::query_as::<_, ReleaseUploaderRow>(
|
||||
r#"SELECT t.release_id,
|
||||
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
||||
COUNT(*)::bigint AS track_count
|
||||
FROM furumusic__track t
|
||||
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||
WHERE t.release_id = ANY($1) AND t.is_hidden = false
|
||||
GROUP BY t.release_id, COALESCE(mf.uploader_name, 'UFO')
|
||||
ORDER BY t.release_id, track_count DESC, uploader_name"#,
|
||||
)
|
||||
.bind(release_ids)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut map: std::collections::HashMap<i64, Vec<UploaderSummary>> =
|
||||
std::collections::HashMap::new();
|
||||
for row in rows {
|
||||
map.entry(row.release_id)
|
||||
.or_default()
|
||||
.push(UploaderSummary {
|
||||
name: row.uploader_name,
|
||||
track_count: row.track_count,
|
||||
});
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
+761
-406
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct HistoryEntry {
|
||||
pub(super) track_id: i64,
|
||||
pub(super) duration_listened: Option<i32>,
|
||||
pub(super) completed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct HistoryQuery {
|
||||
pub(super) page: Option<i32>,
|
||||
pub(super) limit: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct TracksByIdsRequest {
|
||||
pub(super) ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct CreatePlaylistRequest {
|
||||
pub(super) title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct UpdatePlaylistRequest {
|
||||
pub(super) title: Option<String>,
|
||||
pub(super) description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct AddTracksRequest {
|
||||
pub(super) track_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct RemoveTrackRequest {
|
||||
pub(super) track_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct PaginationQuery {
|
||||
pub(super) page: Option<i32>,
|
||||
pub(super) limit: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct PathId {
|
||||
pub(super) id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct PathStringId {
|
||||
pub(super) id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct SearchQuery {
|
||||
pub(super) q: String,
|
||||
pub(super) limit: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct PathTrackId {
|
||||
pub(super) track_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct PathMediaFileId {
|
||||
pub(super) media_file_id: i64,
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct ArtistRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) name: String,
|
||||
pub(super) image_file_id: Option<i64>,
|
||||
pub(super) release_count: i64,
|
||||
pub(super) track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct CountRow {
|
||||
pub(super) count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct ReleaseRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) release_type: String,
|
||||
pub(super) year: Option<i32>,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct ArtistBriefRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) name: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct TrackRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) track_number: Option<i32>,
|
||||
pub(super) disc_number: Option<i32>,
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) release_cover_file_id: Option<i64>,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) audio_format: Option<String>,
|
||||
pub(super) audio_bitrate: Option<i32>,
|
||||
pub(super) audio_sample_rate: Option<i32>,
|
||||
pub(super) audio_bit_depth: Option<i32>,
|
||||
pub(super) file_size_bytes: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct TrackArtistRow {
|
||||
pub(super) track_id: i64,
|
||||
pub(super) artist_id: i64,
|
||||
pub(super) artist_name: String,
|
||||
pub(super) role: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct MediaFileRow {
|
||||
pub(super) file_path: String,
|
||||
pub(super) mime_type: String,
|
||||
pub(super) file_size_bytes: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct PlaybackStateRow {
|
||||
pub(super) current_track_id: Option<i64>,
|
||||
pub(super) position_ms: i32,
|
||||
pub(super) queue_json: String,
|
||||
pub(super) queue_position: i32,
|
||||
pub(super) shuffle: bool,
|
||||
pub(super) repeat_mode: String,
|
||||
pub(super) volume: f64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct PlaylistRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) track_count: i64,
|
||||
pub(super) is_own: bool,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct PlaylistInfoRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) description: Option<String>,
|
||||
pub(super) owner_id: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct PlaylistTrackRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) track_number: Option<i32>,
|
||||
pub(super) disc_number: Option<i32>,
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) release_cover_file_id: Option<i64>,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) audio_format: Option<String>,
|
||||
pub(super) audio_bitrate: Option<i32>,
|
||||
pub(super) audio_sample_rate: Option<i32>,
|
||||
pub(super) audio_bit_depth: Option<i32>,
|
||||
pub(super) file_size_bytes: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct AppearanceTrackRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) release_id: i64,
|
||||
pub(super) release_title: String,
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) release_cover_file_id: Option<i64>,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) audio_format: Option<String>,
|
||||
pub(super) audio_bitrate: Option<i32>,
|
||||
pub(super) audio_sample_rate: Option<i32>,
|
||||
pub(super) audio_bit_depth: Option<i32>,
|
||||
pub(super) file_size_bytes: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct SearchArtistRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) name: String,
|
||||
pub(super) image_file_id: Option<i64>,
|
||||
pub(super) release_count: i64,
|
||||
pub(super) track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct SearchReleaseRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) release_type: String,
|
||||
pub(super) year: Option<i32>,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct SearchTrackRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) track_number: Option<i32>,
|
||||
pub(super) disc_number: Option<i32>,
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) release_cover_file_id: Option<i64>,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) audio_format: Option<String>,
|
||||
pub(super) audio_bitrate: Option<i32>,
|
||||
pub(super) audio_sample_rate: Option<i32>,
|
||||
pub(super) audio_bit_depth: Option<i32>,
|
||||
pub(super) file_size_bytes: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct ReleaseUploaderRow {
|
||||
pub(super) release_id: i64,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct PlayHistoryRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) track_id: i64,
|
||||
pub(super) track_title: String,
|
||||
pub(super) release_title: Option<String>,
|
||||
pub(super) played_at: String,
|
||||
pub(super) duration_listened: Option<i32>,
|
||||
pub(super) completed: bool,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct ReleaseInfoRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) release_type: String,
|
||||
pub(super) year: Option<i32>,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
}
|
||||
+259
-89
@@ -1,5 +1,4 @@
|
||||
/// Job scheduler: models, migrations, Job trait, JobRegistry, and scheduler loop.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -74,7 +73,12 @@ impl ScheduledJob {
|
||||
Self::get_by_primary_key(db, name.to_owned()).await
|
||||
}
|
||||
|
||||
pub async fn upsert(db: &Database, name: &str, description: &str, cron_expression: &str) -> cot::db::Result<Self> {
|
||||
pub async fn upsert(
|
||||
db: &Database,
|
||||
name: &str,
|
||||
description: &str,
|
||||
cron_expression: &str,
|
||||
) -> cot::db::Result<Self> {
|
||||
if let Some(mut existing) = Self::get_by_name(db, name).await? {
|
||||
// Update cron expression and description if they changed
|
||||
let mut changed = false;
|
||||
@@ -170,7 +174,11 @@ pub struct JobRun {
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl JobRun {
|
||||
pub async fn create_running(db: &Database, job_name: &str, trigger: &str) -> cot::db::Result<Self> {
|
||||
pub async fn create_running(
|
||||
db: &Database,
|
||||
job_name: &str,
|
||||
trigger: &str,
|
||||
) -> cot::db::Result<Self> {
|
||||
let mut run = Self {
|
||||
id: Auto::auto(),
|
||||
job_name: limited_string(job_name),
|
||||
@@ -186,7 +194,12 @@ impl JobRun {
|
||||
Ok(run)
|
||||
}
|
||||
|
||||
pub async fn set_completed(&mut self, db: &Database, duration_ms: i64, log: &str) -> cot::db::Result<()> {
|
||||
pub async fn set_completed(
|
||||
&mut self,
|
||||
db: &Database,
|
||||
duration_ms: i64,
|
||||
log: &str,
|
||||
) -> cot::db::Result<()> {
|
||||
self.status = LimitedString::new("completed").unwrap();
|
||||
self.finished_at = Some(now_iso().to_string());
|
||||
self.duration_ms = Some(duration_ms);
|
||||
@@ -194,7 +207,13 @@ impl JobRun {
|
||||
self.save(db).await
|
||||
}
|
||||
|
||||
pub async fn set_failed(&mut self, db: &Database, duration_ms: i64, log: &str, error: &str) -> cot::db::Result<()> {
|
||||
pub async fn set_failed(
|
||||
&mut self,
|
||||
db: &Database,
|
||||
duration_ms: i64,
|
||||
log: &str,
|
||||
error: &str,
|
||||
) -> cot::db::Result<()> {
|
||||
self.status = LimitedString::new("failed").unwrap();
|
||||
self.finished_at = Some(now_iso().to_string());
|
||||
self.duration_ms = Some(duration_ms);
|
||||
@@ -207,7 +226,11 @@ impl JobRun {
|
||||
Self::get_by_primary_key(db, Auto::Fixed(id)).await
|
||||
}
|
||||
|
||||
pub async fn list_by_job(pool: &sqlx::PgPool, job_name: &str, limit: i64) -> anyhow::Result<Vec<Self>> {
|
||||
pub async fn list_by_job(
|
||||
pool: &sqlx::PgPool,
|
||||
job_name: &str,
|
||||
limit: i64,
|
||||
) -> anyhow::Result<Vec<Self>> {
|
||||
let rows = sqlx::query_as::<_, JobRunRow>(
|
||||
"SELECT id, job_name, status, started_at, finished_at, duration_ms, log_output, error_message, trigger \
|
||||
FROM furumusic__job_run WHERE job_name = $1 ORDER BY id DESC LIMIT $2"
|
||||
@@ -229,7 +252,7 @@ impl JobRun {
|
||||
SET status = 'failed', \
|
||||
finished_at = $1, \
|
||||
error_message = 'Process restarted while job was running' \
|
||||
WHERE status = 'running'"
|
||||
WHERE status = 'running'",
|
||||
)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
@@ -472,7 +495,7 @@ impl PendingReview {
|
||||
SET status = 'failed', \
|
||||
error_message = 'Process restarted while review was being processed', \
|
||||
updated_at = $1 \
|
||||
WHERE status = 'processing'"
|
||||
WHERE status = 'processing'",
|
||||
)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
@@ -497,6 +520,46 @@ impl PendingReview {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_by_ids(db: &Database, ids: &[i64]) -> cot::db::Result<()> {
|
||||
for chunk in ids.chunks(1000) {
|
||||
if chunk.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let id_list = chunk
|
||||
.iter()
|
||||
.map(i64::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
db.raw(&format!(
|
||||
"DELETE FROM furumusic__pending_review WHERE id IN ({id_list})"
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn requeue_by_ids(db: &Database, ids: &[i64]) -> cot::db::Result<()> {
|
||||
let now = now_iso().to_string();
|
||||
for chunk in ids.chunks(1000) {
|
||||
if chunk.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let id_list = chunk
|
||||
.iter()
|
||||
.map(i64::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
db.raw(&format!(
|
||||
"UPDATE furumusic__pending_review \
|
||||
SET status = 'queued', error_message = NULL, updated_at = '{}' \
|
||||
WHERE id IN ({id_list})",
|
||||
now.replace('\'', "''")
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn id_val(&self) -> i64 {
|
||||
self.id.unwrap()
|
||||
}
|
||||
@@ -589,12 +652,19 @@ impl ProcessingStats {
|
||||
Ok(all.into_iter().next())
|
||||
}
|
||||
|
||||
pub async fn list_by_review_ids(pool: &sqlx::PgPool, ids: &[i64]) -> anyhow::Result<HashMap<i64, ProcessingStatsRow>> {
|
||||
pub async fn list_by_review_ids(
|
||||
pool: &sqlx::PgPool,
|
||||
ids: &[i64],
|
||||
) -> anyhow::Result<HashMap<i64, ProcessingStatsRow>> {
|
||||
if ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
// Build comma-separated ID list
|
||||
let id_list: String = ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
|
||||
let id_list: String = ids
|
||||
.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let query = format!(
|
||||
"SELECT pending_review_id, model_name, llm_duration_ms, prompt_tokens, completion_tokens \
|
||||
FROM furumusic__processing_stats WHERE pending_review_id IN ({id_list})"
|
||||
@@ -659,28 +729,46 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0022CreateScheduledJob {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0022_create_scheduled_job";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration("furumusic", "m_0021_create_trgm_indexes"),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0021_create_trgm_indexes",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__scheduled_job"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("name"), <String as DatabaseField>::TYPE)
|
||||
.primary_key()
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(Identifier::new("description"), <String as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("cron_expression"), <LimitedString<100> as DatabaseField>::TYPE),
|
||||
Field::new(
|
||||
Identifier::new("description"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("cron_expression"),
|
||||
<LimitedString<100> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(Identifier::new("enabled"), <bool as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("last_run_at"), <LimitedString<32> as DatabaseField>::TYPE)
|
||||
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)
|
||||
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),
|
||||
Field::new(
|
||||
Identifier::new("created_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("updated_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
.build()];
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
@@ -689,31 +777,52 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0023CreateJobRun {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0023_create_job_run";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration("furumusic", "m_0022_create_scheduled_job"),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0022_create_scheduled_job",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__job_run"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||
.primary_key()
|
||||
.auto(),
|
||||
Field::new(Identifier::new("job_name"), <LimitedString<100> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("status"), <LimitedString<32> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("started_at"), <LimitedString<32> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("finished_at"), <LimitedString<32> as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("job_name"),
|
||||
<LimitedString<100> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("status"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("started_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("finished_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("duration_ms"), <i64 as DatabaseField>::TYPE)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("log_output"), <String as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("log_output"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("error_message"), <String as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("error_message"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("trigger"), <LimitedString<32> as DatabaseField>::TYPE),
|
||||
Field::new(
|
||||
Identifier::new("trigger"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
.build()];
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
@@ -722,34 +831,57 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0024CreatePendingReview {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0024_create_pending_review";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration("furumusic", "m_0023_create_job_run"),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0023_create_job_run",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__pending_review"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||
.primary_key()
|
||||
.auto(),
|
||||
Field::new(Identifier::new("job_run_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("review_type"), <LimitedString<64> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("input_path"), <String as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("review_type"),
|
||||
<LimitedString<64> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("input_path"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("context_json"), <String as DatabaseField>::TYPE)
|
||||
Field::new(
|
||||
Identifier::new("context_json"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(Identifier::new("result_json"), <String as DatabaseField>::TYPE)
|
||||
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),
|
||||
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(),
|
||||
];
|
||||
.build()];
|
||||
}
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn create_scheduler_indexes(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
async fn create_scheduler_indexes(
|
||||
ctx: migrations::MigrationContext<'_>,
|
||||
) -> cot::db::Result<()> {
|
||||
let stmts = [
|
||||
"CREATE INDEX idx_job_run_job_name ON furumusic__job_run (job_name, id DESC)",
|
||||
"CREATE INDEX idx_job_run_status ON furumusic__job_run (status)",
|
||||
@@ -768,16 +900,19 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0025CreateSchedulerIndexes {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0025_create_scheduler_indexes";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration("furumusic", "m_0024_create_pending_review"),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::custom(create_scheduler_indexes).build(),
|
||||
];
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0024_create_pending_review",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[Operation::custom(create_scheduler_indexes).build()];
|
||||
}
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn add_pending_review_error_message(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
async fn add_pending_review_error_message(
|
||||
ctx: migrations::MigrationContext<'_>,
|
||||
) -> cot::db::Result<()> {
|
||||
ctx.db
|
||||
.raw("ALTER TABLE furumusic__pending_review ADD COLUMN error_message TEXT")
|
||||
.await?;
|
||||
@@ -790,12 +925,13 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0026AddPendingReviewErrorMessage {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0026_add_pending_review_error_message";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration("furumusic", "m_0025_create_scheduler_indexes"),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::custom(add_pending_review_error_message).build(),
|
||||
];
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0025_create_scheduler_indexes",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[Operation::custom(add_pending_review_error_message).build()];
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
@@ -804,25 +940,43 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0027CreateProcessingStats {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0027_create_processing_stats";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration("furumusic", "m_0026_add_pending_review_error_message"),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0026_add_pending_review_error_message",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__processing_stats"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||
.primary_key()
|
||||
.auto(),
|
||||
Field::new(Identifier::new("pending_review_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("model_name"), <LimitedString<128> as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("llm_duration_ms"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("prompt_tokens"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("completion_tokens"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(Identifier::new("created_at"), <LimitedString<32> as DatabaseField>::TYPE),
|
||||
Field::new(
|
||||
Identifier::new("pending_review_id"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("model_name"),
|
||||
<LimitedString<128> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("llm_duration_ms"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("prompt_tokens"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("completion_tokens"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("created_at"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
.build()];
|
||||
}
|
||||
|
||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||
@@ -856,11 +1010,19 @@ pub struct JobLog {
|
||||
#[allow(dead_code)]
|
||||
impl JobLog {
|
||||
pub fn new() -> Self {
|
||||
Self { lines: Vec::new(), pool: None, run_id: 0 }
|
||||
Self {
|
||||
lines: Vec::new(),
|
||||
pool: None,
|
||||
run_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_live_flush(pool: sqlx::PgPool, run_id: i64) -> Self {
|
||||
Self { lines: Vec::new(), pool: Some(pool), run_id }
|
||||
Self {
|
||||
lines: Vec::new(),
|
||||
pool: Some(pool),
|
||||
run_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info(&mut self, msg: &str) {
|
||||
@@ -894,9 +1056,7 @@ impl JobLog {
|
||||
let run_id = self.run_id;
|
||||
let pool = pool.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE furumusic__job_run SET log_output = $1 WHERE id = $2"
|
||||
)
|
||||
let _ = sqlx::query("UPDATE furumusic__job_run SET log_output = $1 WHERE id = $2")
|
||||
.bind(&output)
|
||||
.bind(run_id)
|
||||
.execute(&pool)
|
||||
@@ -997,7 +1157,9 @@ impl SchedulerHandle {
|
||||
}
|
||||
Err(e) => {
|
||||
let duration_ms = start.elapsed().as_millis() as i64;
|
||||
let _ = run.set_failed(db, duration_ms, &log.output(), &e.to_string()).await;
|
||||
let _ = run
|
||||
.set_failed(db, duration_ms, &log.output(), &e.to_string())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1025,7 +1187,8 @@ impl SchedulerHandle {
|
||||
self.add_cron_job(job_name, new_cron).await?;
|
||||
|
||||
// Update DB
|
||||
if let Ok(Some(mut sched_job)) = ScheduledJob::get_by_name(&self.shared_db, job_name).await {
|
||||
if let Ok(Some(mut sched_job)) = ScheduledJob::get_by_name(&self.shared_db, job_name).await
|
||||
{
|
||||
sched_job.cron_expression = LimitedString::new(new_cron).unwrap();
|
||||
sched_job.next_run_at = compute_next_run(new_cron);
|
||||
sched_job.updated_at = now_iso();
|
||||
@@ -1083,7 +1246,10 @@ impl SchedulerHandle {
|
||||
})?;
|
||||
|
||||
let uuid = self.scheduler.add(cron_job).await?;
|
||||
self.job_uuids.write().await.insert(job_name.to_owned(), uuid);
|
||||
self.job_uuids
|
||||
.write()
|
||||
.await
|
||||
.insert(job_name.to_owned(), uuid);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1161,7 +1327,9 @@ async fn run_scheduled_job(
|
||||
Err(e) => {
|
||||
let duration_ms = start.elapsed().as_millis() as i64;
|
||||
tracing::error!(job = job_name, duration_ms, "Job failed: {e}");
|
||||
let _ = run.set_failed(db, duration_ms, &log.output(), &e.to_string()).await;
|
||||
let _ = run
|
||||
.set_failed(db, duration_ms, &log.output(), &e.to_string())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1264,7 +1432,7 @@ pub async fn start_scheduler(
|
||||
// Update next_run_at in DB
|
||||
if let Some(next) = compute_next_run(cron_expr) {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE furumusic__scheduled_job SET next_run_at = $1 WHERE name = $2"
|
||||
"UPDATE furumusic__scheduled_job SET next_run_at = $1 WHERE name = $2",
|
||||
)
|
||||
.bind(&next)
|
||||
.bind(sched_job.name_str())
|
||||
@@ -1339,7 +1507,9 @@ pub async fn trigger_job_now(
|
||||
}
|
||||
Err(e) => {
|
||||
let duration_ms = start.elapsed().as_millis() as i64;
|
||||
let _ = run.set_failed(db, duration_ms, &log.output(), &e.to_string()).await;
|
||||
let _ = run
|
||||
.set_failed(db, duration_ms, &log.output(), &e.to_string())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+547
@@ -0,0 +1,547 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, bail};
|
||||
use base64::Engine;
|
||||
use librqbit::{
|
||||
AddTorrent, AddTorrentOptions, AddTorrentResponse, ManagedTorrent, Session, SessionOptions,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{Mutex, OnceCell};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::scheduler::SchedulerHandle;
|
||||
|
||||
const METADATA_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TorrentFileDto {
|
||||
pub index: usize,
|
||||
pub name: String,
|
||||
pub components: Vec<String>,
|
||||
pub length: u64,
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TorrentPreviewDto {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub info_hash: String,
|
||||
pub total_size: u64,
|
||||
pub files: Vec<TorrentFileDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TorrentJobDto {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub info_hash: String,
|
||||
pub status: String,
|
||||
pub total_size: u64,
|
||||
pub selected_size: u64,
|
||||
pub downloaded_bytes: u64,
|
||||
pub progress_percent: f64,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TorrentPreviewKind {
|
||||
Magnet,
|
||||
TorrentFile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TorrentPreviewRequest {
|
||||
pub kind: TorrentPreviewKind,
|
||||
pub magnet: Option<String>,
|
||||
pub torrent_base64: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TorrentStartRequest {
|
||||
pub selected_files: Vec<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TorrentJobStatus {
|
||||
Preview,
|
||||
Downloading,
|
||||
Moving,
|
||||
Complete,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl TorrentJobStatus {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Preview => "preview",
|
||||
Self::Downloading => "downloading",
|
||||
Self::Moving => "moving",
|
||||
Self::Complete => "complete",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TorrentJob {
|
||||
id: String,
|
||||
name: String,
|
||||
info_hash: String,
|
||||
torrent_bytes: Vec<u8>,
|
||||
files: Vec<TorrentFileDto>,
|
||||
status: TorrentJobStatus,
|
||||
output_dir: PathBuf,
|
||||
selected_files: Vec<usize>,
|
||||
handle: Option<Arc<ManagedTorrent>>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl TorrentJob {
|
||||
fn total_size(&self) -> u64 {
|
||||
self.files.iter().map(|f| f.length).sum()
|
||||
}
|
||||
|
||||
fn selected_size(&self) -> u64 {
|
||||
if self.selected_files.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|f| self.selected_files.contains(&f.index))
|
||||
.map(|f| f.length)
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn dto(&self) -> TorrentJobDto {
|
||||
let stats = self.handle.as_ref().map(|h| h.stats());
|
||||
let downloaded_bytes = stats.as_ref().map(|s| s.progress_bytes).unwrap_or(0);
|
||||
let total_bytes = stats
|
||||
.as_ref()
|
||||
.map(|s| s.total_bytes)
|
||||
.filter(|v| *v > 0)
|
||||
.unwrap_or_else(|| self.selected_size());
|
||||
let progress_percent = if total_bytes == 0 {
|
||||
0.0
|
||||
} else {
|
||||
downloaded_bytes as f64 / total_bytes as f64 * 100.0
|
||||
};
|
||||
|
||||
Self::dto_from_parts(
|
||||
&self.id,
|
||||
&self.name,
|
||||
&self.info_hash,
|
||||
self.status,
|
||||
self.total_size(),
|
||||
self.selected_size(),
|
||||
downloaded_bytes,
|
||||
progress_percent,
|
||||
self.error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn dto_from_parts(
|
||||
id: &str,
|
||||
name: &str,
|
||||
info_hash: &str,
|
||||
status: TorrentJobStatus,
|
||||
total_size: u64,
|
||||
selected_size: u64,
|
||||
downloaded_bytes: u64,
|
||||
progress_percent: f64,
|
||||
error: Option<String>,
|
||||
) -> TorrentJobDto {
|
||||
TorrentJobDto {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
info_hash: info_hash.to_string(),
|
||||
status: status.as_str().to_string(),
|
||||
total_size,
|
||||
selected_size,
|
||||
downloaded_bytes,
|
||||
progress_percent: progress_percent.clamp(0.0, 100.0),
|
||||
error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TorrentService {
|
||||
temp_root: PathBuf,
|
||||
session: OnceCell<Arc<Session>>,
|
||||
jobs: Mutex<HashMap<String, TorrentJob>>,
|
||||
scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>,
|
||||
}
|
||||
|
||||
impl TorrentService {
|
||||
pub fn new(scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>) -> Self {
|
||||
Self {
|
||||
temp_root: std::env::temp_dir().join("furumusic").join("torrents"),
|
||||
session: OnceCell::new(),
|
||||
jobs: Mutex::new(HashMap::new()),
|
||||
scheduler_handle,
|
||||
}
|
||||
}
|
||||
|
||||
async fn session(&self) -> anyhow::Result<Arc<Session>> {
|
||||
let temp_root = self.temp_root.clone();
|
||||
self.session
|
||||
.get_or_try_init(|| async move {
|
||||
tokio::fs::create_dir_all(&temp_root).await?;
|
||||
Session::new_with_opts(
|
||||
temp_root,
|
||||
SessionOptions {
|
||||
disable_upload: true,
|
||||
enable_upnp_port_forwarding: false,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub async fn preview(
|
||||
&self,
|
||||
request: TorrentPreviewRequest,
|
||||
) -> anyhow::Result<TorrentPreviewDto> {
|
||||
let session = self.session().await?;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let output_dir = self.temp_root.join(&id).join("download");
|
||||
tokio::fs::create_dir_all(&output_dir).await?;
|
||||
|
||||
let add = match request.kind {
|
||||
TorrentPreviewKind::Magnet => {
|
||||
let magnet = request
|
||||
.magnet
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.context("magnet link is empty")?;
|
||||
AddTorrent::from_url(magnet.to_string())
|
||||
}
|
||||
TorrentPreviewKind::TorrentFile => {
|
||||
let encoded = request
|
||||
.torrent_base64
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.context("torrent file is empty")?;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.context("invalid torrent file encoding")?;
|
||||
AddTorrent::from_bytes(bytes)
|
||||
}
|
||||
};
|
||||
|
||||
let response = tokio::time::timeout(
|
||||
METADATA_TIMEOUT,
|
||||
session.add_torrent(
|
||||
add,
|
||||
Some(AddTorrentOptions {
|
||||
list_only: true,
|
||||
output_folder: Some(output_dir.to_string_lossy().to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.context("timed out while resolving torrent metadata")??;
|
||||
|
||||
let AddTorrentResponse::ListOnly(list) = response else {
|
||||
bail!("torrent was unexpectedly added instead of previewed");
|
||||
};
|
||||
|
||||
let name = list
|
||||
.info
|
||||
.name
|
||||
.as_ref()
|
||||
.map(|b| String::from_utf8_lossy(b.as_ref()).to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| list.info_hash.as_string());
|
||||
|
||||
let mut files = Vec::new();
|
||||
for (index, details) in list.info.iter_file_details()?.enumerate() {
|
||||
let name = details
|
||||
.filename
|
||||
.to_string()
|
||||
.unwrap_or_else(|_| "<invalid filename>".to_string());
|
||||
let selected = is_audio_path(&name);
|
||||
files.push(TorrentFileDto {
|
||||
index,
|
||||
name,
|
||||
components: details.filename.to_vec().unwrap_or_default(),
|
||||
length: details.len,
|
||||
selected,
|
||||
});
|
||||
}
|
||||
|
||||
let total_size = files.iter().map(|f| f.length).sum();
|
||||
let dto = TorrentPreviewDto {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
info_hash: list.info_hash.as_string(),
|
||||
total_size,
|
||||
files: files.clone(),
|
||||
};
|
||||
|
||||
let job = TorrentJob {
|
||||
id: id.clone(),
|
||||
name,
|
||||
info_hash: dto.info_hash.clone(),
|
||||
torrent_bytes: list.torrent_bytes.to_vec(),
|
||||
files,
|
||||
status: TorrentJobStatus::Preview,
|
||||
output_dir,
|
||||
selected_files: Vec::new(),
|
||||
handle: None,
|
||||
error: None,
|
||||
};
|
||||
self.jobs.lock().await.insert(id, job);
|
||||
|
||||
Ok(dto)
|
||||
}
|
||||
|
||||
pub async fn status(&self, id: &str) -> anyhow::Result<TorrentJobDto> {
|
||||
let jobs = self.jobs.lock().await;
|
||||
let job = jobs.get(id).context("torrent job not found")?;
|
||||
Ok(job.dto())
|
||||
}
|
||||
|
||||
pub async fn start(
|
||||
self: &Arc<Self>,
|
||||
id: &str,
|
||||
selected_files: Vec<usize>,
|
||||
inbox_dir: String,
|
||||
uploader_user_id: i64,
|
||||
) -> anyhow::Result<TorrentJobDto> {
|
||||
if selected_files.is_empty() {
|
||||
bail!("select at least one file");
|
||||
}
|
||||
if inbox_dir.trim().is_empty() {
|
||||
bail!("agent_inbox_dir is not configured");
|
||||
}
|
||||
let inbox_dir = validate_inbox_dir(&inbox_dir)?;
|
||||
|
||||
let (torrent_bytes, output_dir) = {
|
||||
let mut jobs = self.jobs.lock().await;
|
||||
let job = jobs.get_mut(id).context("torrent job not found")?;
|
||||
if job.status != TorrentJobStatus::Preview && job.status != TorrentJobStatus::Failed {
|
||||
bail!("torrent job is already started");
|
||||
}
|
||||
validate_selection(&job.files, &selected_files)?;
|
||||
job.status = TorrentJobStatus::Downloading;
|
||||
job.selected_files = selected_files.clone();
|
||||
job.error = None;
|
||||
(job.torrent_bytes.clone(), job.output_dir.clone())
|
||||
};
|
||||
|
||||
let session = self.session().await?;
|
||||
let response = session
|
||||
.add_torrent(
|
||||
AddTorrent::from_bytes(torrent_bytes),
|
||||
Some(AddTorrentOptions {
|
||||
only_files: Some(selected_files),
|
||||
output_folder: Some(output_dir.to_string_lossy().to_string()),
|
||||
overwrite: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let handle = response
|
||||
.into_handle()
|
||||
.context("torrent did not return a download handle")?;
|
||||
|
||||
let dto = {
|
||||
let mut jobs = self.jobs.lock().await;
|
||||
let job = jobs.get_mut(id).context("torrent job not found")?;
|
||||
job.handle = Some(handle.clone());
|
||||
job.dto()
|
||||
};
|
||||
|
||||
let service = Arc::clone(self);
|
||||
let id = id.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = handle.wait_until_completed().await {
|
||||
service.stop_torrent(&handle).await;
|
||||
service.fail_job(&id, err.to_string()).await;
|
||||
return;
|
||||
}
|
||||
service.stop_torrent(&handle).await;
|
||||
if let Err(err) = service
|
||||
.finalize_completed(&id, &inbox_dir, uploader_user_id)
|
||||
.await
|
||||
{
|
||||
service.fail_job(&id, err.to_string()).await;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(dto)
|
||||
}
|
||||
|
||||
async fn fail_job(&self, id: &str, error: String) {
|
||||
let mut jobs = self.jobs.lock().await;
|
||||
if let Some(job) = jobs.get_mut(id) {
|
||||
job.status = TorrentJobStatus::Failed;
|
||||
job.error = Some(error);
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop_torrent(&self, handle: &Arc<ManagedTorrent>) {
|
||||
match self.session().await {
|
||||
Ok(session) => {
|
||||
if let Err(err) = session.delete(handle.id().into(), false).await {
|
||||
tracing::warn!("failed to stop completed torrent: {err}");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to access torrent session for shutdown: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn finalize_completed(
|
||||
&self,
|
||||
id: &str,
|
||||
inbox_dir: &Path,
|
||||
uploader_user_id: i64,
|
||||
) -> anyhow::Result<()> {
|
||||
let (name, files, selected_files, output_dir) = {
|
||||
let mut jobs = self.jobs.lock().await;
|
||||
let job = jobs.get_mut(id).context("torrent job not found")?;
|
||||
job.status = TorrentJobStatus::Moving;
|
||||
(
|
||||
job.name.clone(),
|
||||
job.files.clone(),
|
||||
job.selected_files.clone(),
|
||||
job.output_dir.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
let destination_root = inbox_dir
|
||||
.join("user_uploads")
|
||||
.join(uploader_user_id.to_string())
|
||||
.join(sanitize_path_component(&name));
|
||||
tokio::fs::create_dir_all(&destination_root).await?;
|
||||
|
||||
for file in files.iter().filter(|f| selected_files.contains(&f.index)) {
|
||||
let source = safe_join(&output_dir, &file.components)?;
|
||||
if !tokio::fs::try_exists(&source).await? {
|
||||
continue;
|
||||
}
|
||||
let destination = safe_join(&destination_root, &file.components)?;
|
||||
if let Some(parent) = destination.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
move_file(&source, &destination).await?;
|
||||
}
|
||||
|
||||
let job_root = self.temp_root.join(id);
|
||||
let _ = tokio::fs::remove_dir_all(job_root).await;
|
||||
|
||||
{
|
||||
let mut jobs = self.jobs.lock().await;
|
||||
let job = jobs.get_mut(id).context("torrent job not found")?;
|
||||
job.status = TorrentJobStatus::Complete;
|
||||
}
|
||||
|
||||
if let Some(handle) = self.scheduler_handle.get() {
|
||||
let handle = Arc::clone(handle);
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = handle.trigger_job_now("inbox_discover").await {
|
||||
tracing::warn!("failed to trigger inbox_discover after torrent: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_selection(files: &[TorrentFileDto], selected_files: &[usize]) -> anyhow::Result<()> {
|
||||
for index in selected_files {
|
||||
if !files.iter().any(|file| file.index == *index) {
|
||||
bail!("selected file index {index} is not in this torrent");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_inbox_dir(inbox_dir: &str) -> anyhow::Result<PathBuf> {
|
||||
let trimmed = inbox_dir.trim();
|
||||
let path = PathBuf::from(trimmed);
|
||||
if !path.is_absolute() {
|
||||
bail!(
|
||||
"agent_inbox_dir must be an absolute path for this host, got `{}`",
|
||||
trimmed
|
||||
);
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn is_audio_path(path: &str) -> bool {
|
||||
let Some(ext) = Path::new(path).extension().and_then(|e| e.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
ext.to_ascii_lowercase().as_str(),
|
||||
"mp3"
|
||||
| "flac"
|
||||
| "ogg"
|
||||
| "opus"
|
||||
| "aac"
|
||||
| "m4a"
|
||||
| "wav"
|
||||
| "ape"
|
||||
| "wv"
|
||||
| "wma"
|
||||
| "tta"
|
||||
| "aiff"
|
||||
| "aif"
|
||||
)
|
||||
}
|
||||
|
||||
fn sanitize_path_component(value: &str) -> String {
|
||||
let sanitized: String = value
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||
c if c.is_control() => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
let trimmed = sanitized.trim().trim_matches('.').trim();
|
||||
if trimmed.is_empty() {
|
||||
"torrent".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn safe_join(root: &Path, components: &[String]) -> anyhow::Result<PathBuf> {
|
||||
let mut path = root.to_path_buf();
|
||||
for component in components {
|
||||
let sanitized = sanitize_path_component(component);
|
||||
if sanitized == "." || sanitized == ".." {
|
||||
bail!("unsafe torrent path component");
|
||||
}
|
||||
path.push(sanitized);
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
async fn move_file(source: &Path, destination: &Path) -> anyhow::Result<()> {
|
||||
match tokio::fs::rename(source, destination).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::CrossesDevices => {
|
||||
tokio::fs::copy(source, destination).await?;
|
||||
tokio::fs::remove_file(source).await?;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
+34
-44
@@ -108,7 +108,9 @@ impl User {
|
||||
|
||||
/// Delete this user by primary key.
|
||||
pub async fn delete_by_id(db: &Database, user_id: i64) -> cot::db::Result<()> {
|
||||
cot::db::query!(User, $id == Auto::Fixed(user_id)).delete(db).await?;
|
||||
cot::db::query!(User, $id == Auto::Fixed(user_id))
|
||||
.delete(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -120,10 +122,16 @@ impl User {
|
||||
&self.username
|
||||
}
|
||||
pub fn email_str(&self) -> String {
|
||||
self.email.as_ref().map(|e| e.to_string()).unwrap_or_default()
|
||||
self.email
|
||||
.as_ref()
|
||||
.map(|e| e.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn display_name_str(&self) -> String {
|
||||
self.display_name.as_ref().map(|d| d.to_string()).unwrap_or_default()
|
||||
self.display_name
|
||||
.as_ref()
|
||||
.map(|d| d.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn role_str(&self) -> &str {
|
||||
&self.role
|
||||
@@ -162,7 +170,9 @@ impl User {
|
||||
|
||||
/// Find a user by email address.
|
||||
pub async fn get_by_email(db: &Database, email: &str) -> cot::db::Result<Option<Self>> {
|
||||
cot::db::query!(User, $email == Some(email.to_owned())).get(db).await
|
||||
cot::db::query!(User, $email == Some(email.to_owned()))
|
||||
.get(db)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,9 +267,9 @@ impl OidcLink {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub mod db_migrations {
|
||||
use cot::auth::PasswordHash;
|
||||
use cot::db::migrations::{self, Field, Operation, SyncDynMigration};
|
||||
use cot::db::{DatabaseField, Identifier, LimitedString};
|
||||
use cot::auth::PasswordHash;
|
||||
|
||||
// -- M0003: create furumusic__user -------------------------------------
|
||||
|
||||
@@ -269,20 +279,15 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0003CreateUser {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0003_create_user";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0002_rename_config_table",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__user"))
|
||||
.fields(&[
|
||||
Field::new(
|
||||
Identifier::new("id"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
)
|
||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||
.primary_key()
|
||||
.auto(),
|
||||
Field::new(
|
||||
@@ -314,13 +319,9 @@ pub mod db_migrations {
|
||||
Identifier::new("role"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("is_active"),
|
||||
<bool as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(Identifier::new("is_active"), <bool as DatabaseField>::TYPE),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
.build()];
|
||||
}
|
||||
|
||||
// -- M0004: create furumusic__oidc_link --------------------------------
|
||||
@@ -331,26 +332,18 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0004CreateOidcLink {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0004_create_oidc_link";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0003_create_user",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__oidc_link"))
|
||||
.fields(&[
|
||||
Field::new(
|
||||
Identifier::new("id"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
)
|
||||
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||
.primary_key()
|
||||
.auto(),
|
||||
Field::new(
|
||||
Identifier::new("user_id"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
|
||||
Field::new(
|
||||
Identifier::new("issuer"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
@@ -375,8 +368,7 @@ pub mod db_migrations {
|
||||
)
|
||||
.set_null(true),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
.build()];
|
||||
}
|
||||
|
||||
// -- M0005: indexes on furumusic__oidc_link ----------------------------
|
||||
@@ -406,15 +398,13 @@ pub mod db_migrations {
|
||||
impl migrations::Migration for M0005OidcLinkIndexes {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0005_oidc_link_indexes";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0004_create_oidc_link",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::custom(create_oidc_link_indexes).build(),
|
||||
];
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[Operation::custom(create_oidc_link_indexes).build()];
|
||||
}
|
||||
|
||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||
|
||||
@@ -13,9 +13,12 @@
|
||||
</table>
|
||||
|
||||
<div style="margin: 1rem 0; display: flex; gap: .5rem; align-items: flex-end;">
|
||||
{% if job.name_str() != "metadata_backfill" %}
|
||||
<form method="post" action="/admin/jobs/{{ job.name_str() }}/run" style="margin:0;">
|
||||
<button type="submit" style="padding:.4rem 1rem; background:#007bff; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_run_now }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if job.name_str() != "metadata_backfill" %}
|
||||
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
|
||||
{% if job.enabled() %}
|
||||
<button type="submit" style="padding:.4rem 1rem; background:#dc3545; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_disable }}</button>
|
||||
@@ -23,14 +26,47 @@
|
||||
<button type="submit" style="padding:.4rem 1rem; background:#28a745; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_enable }}</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if job.name_str() == "metadata_backfill" %}
|
||||
<h2>{{ t.jobs_metadata_backfill_options }}</h2>
|
||||
<form method="post" action="/admin/jobs/metadata_backfill/run-options" style="margin:0 0 1.5rem; padding:1rem; background:#fff; border:1px solid #e0e0e0; border-radius:6px;">
|
||||
<fieldset style="border:0; margin:0 0 .75rem; padding:0;">
|
||||
<legend style="font-weight:600; margin-bottom:.5rem;">{{ t.jobs_metadata_backfill_fields }}</legend>
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||
<input type="checkbox" name="audio_bitrate" checked> audio_bitrate
|
||||
</label>
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||
<input type="checkbox" name="audio_sample_rate" checked> audio_sample_rate
|
||||
</label>
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||
<input type="checkbox" name="audio_bit_depth" checked> audio_bit_depth
|
||||
</label>
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||
<input type="checkbox" name="duration_seconds" checked> duration_seconds
|
||||
</label>
|
||||
</fieldset>
|
||||
<div style="display:flex; gap:1rem; align-items:center; margin-bottom:.9rem;">
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center;">
|
||||
<input type="radio" name="mode" value="fill_missing" checked> {{ t.jobs_metadata_backfill_fill_missing }}
|
||||
</label>
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center;">
|
||||
<input type="radio" name="mode" value="overwrite"> {{ t.jobs_metadata_backfill_overwrite }}
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" style="padding:.45rem 1rem; background:#007bff; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_metadata_backfill_run }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if job.name_str() != "metadata_backfill" %}
|
||||
<h2>{{ t.jobs_cron }}</h2>
|
||||
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.jobs_cron_help }}</p>
|
||||
<form method="post" action="/admin/jobs/{{ job.name_str() }}/cron" style="display:flex;gap:.5rem;align-items:center;margin-bottom:1.5rem;">
|
||||
<input name="cron_expression" value="{{ job.cron_expression_str() }}" style="width:20em;font-family:monospace;">
|
||||
<button type="submit" style="padding:.4rem 1rem; background:#6c757d; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_cron_update }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<h2>{{ t.jobs_run_history }}</h2>
|
||||
{% if runs.is_empty() %}
|
||||
|
||||
@@ -23,9 +23,14 @@
|
||||
<td>{{ job.last_run_at_str() }}</td>
|
||||
<td>{{ job.next_run_at_str() }}</td>
|
||||
<td style="display:flex;gap:.3rem;">
|
||||
{% if job.name_str() == "metadata_backfill" %}
|
||||
<a href="/admin/jobs/{{ job.name_str() }}" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #007bff; background:#007bff; color:#fff; cursor:pointer; text-decoration:none;">{{ t.jobs_metadata_backfill_options }}</a>
|
||||
{% else %}
|
||||
<form method="post" action="/admin/jobs/{{ job.name_str() }}/run" style="margin:0;">
|
||||
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #007bff; background:#007bff; color:#fff; cursor:pointer;">{{ t.jobs_run_now }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if job.name_str() != "metadata_backfill" %}
|
||||
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
|
||||
{% if job.enabled() %}
|
||||
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #dc3545; background:#fff; color:#dc3545; cursor:pointer;">{{ t.jobs_disable }}</button>
|
||||
@@ -33,6 +38,7 @@
|
||||
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #28a745; background:#fff; color:#28a745; cursor:pointer;">{{ t.jobs_enable }}</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; min-height: 100vh; background: #f5f5f5; color: #333; }
|
||||
nav.sidebar { width: 220px; background: #1a1a2e; color: #eee; padding: 1rem; }
|
||||
nav.sidebar h2 { font-size: 1.1rem; margin-bottom: 1.5rem; }
|
||||
.admin-version { display: inline-block; margin-left: .35rem; color: #999; font-size: .72rem; font-weight: 500; vertical-align: baseline; }
|
||||
nav.sidebar a { color: #ccc; text-decoration: none; display: block; padding: .4rem .6rem; border-radius: 4px; }
|
||||
nav.sidebar a:hover { background: #16213e; color: #fff; }
|
||||
.main-wrap { flex: 1; display: flex; flex-direction: column; }
|
||||
@@ -36,7 +37,7 @@
|
||||
|
||||
{% block body %}
|
||||
<nav class="sidebar">
|
||||
<h2>{{ t.site_name }} {{ t.nav_admin }}</h2>
|
||||
<h2>{{ t.site_name }} {{ t.nav_admin }} <span class="admin-version">v{{ t.app_version() }}</span></h2>
|
||||
<a href="/admin/">{{ t.nav_dashboard }}</a>
|
||||
<a href="/admin/artists">{{ t.nav_artists }}</a>
|
||||
<a href="/admin/releases">{{ t.nav_releases }}</a>
|
||||
|
||||
+215
-12
@@ -4,7 +4,7 @@
|
||||
{% block content %}
|
||||
<h1>{{ t.reviews_heading }}</h1>
|
||||
|
||||
<div style="margin-bottom: 1rem; display: flex; gap: .5rem; align-items: center;">
|
||||
<div style="margin-bottom: 1rem; display: flex; gap: .5rem; align-items: center; flex-wrap: wrap;">
|
||||
<a href="/admin/reviews" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "" %} #333; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_all }}</a>
|
||||
<a href="/admin/reviews?status=pending" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "pending" %} #ffc107; color: #000{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_pending }}</a>
|
||||
<a href="/admin/reviews?status=approved" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "approved" %} #28a745; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_approved }}</a>
|
||||
@@ -13,7 +13,7 @@
|
||||
<a href="/admin/reviews?status=processing" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "processing" %} #007bff; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_processing }}</a>
|
||||
<a href="/admin/reviews?status=auto_approved" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "auto_approved" %} #28a745; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_auto_approved }}</a>
|
||||
<a href="/admin/reviews?status=failed" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "failed" %} #dc3545; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_failed }}</a>
|
||||
{% if !reviews.is_empty() %}
|
||||
{% if !rows.is_empty() %}
|
||||
<span style="flex:1;"></span>
|
||||
<form method="post" action="/admin/reviews/clear{% if !status_filter.is_empty() %}?status={{ status_filter }}{% endif %}" style="margin:0;" onsubmit="return confirm('{{ t.reviews_clear_confirm }}');">
|
||||
<button type="submit" style="padding:.3rem .8rem; border-radius:4px; border:1px solid #dc3545; background:#fff; color:#dc3545; cursor:pointer;">{% if status_filter.is_empty() %}{{ t.reviews_clear_all }}{% else %}{{ t.reviews_clear_filtered }}{% endif %}</button>
|
||||
@@ -21,15 +21,27 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if reviews.is_empty() %}
|
||||
{% if rows.is_empty() %}
|
||||
<p>{{ t.reviews_empty }}</p>
|
||||
{% else %}
|
||||
<form id="reviews-bulk-form" method="post" action="/admin/reviews/bulk" style="margin:0;">
|
||||
<input type="hidden" name="selected_ids" id="selected-review-ids" value="">
|
||||
<input type="hidden" name="status_filter" value="{{ status_filter }}">
|
||||
<div class="review-bulk-toolbar">
|
||||
<button type="button" id="select-shown-reviews" class="review-toolbar-button">{{ t.reviews_select_all }}</button>
|
||||
<button type="button" id="clear-review-selection" class="review-toolbar-button">{{ t.reviews_clear_selection }}</button>
|
||||
<button type="submit" name="action" value="delete" class="review-danger-button" disabled>{{ t.reviews_delete_selected }}</button>
|
||||
<button type="submit" name="action" value="requeue" class="review-primary-button" disabled>{{ t.reviews_requeue_selected }}</button>
|
||||
<span id="review-selection-summary" class="review-selection-summary">{{ t.reviews_selected_none }}</span>
|
||||
</div>
|
||||
<table>
|
||||
<tr>
|
||||
<th class="review-select-cell"></th>
|
||||
<th>ID</th>
|
||||
<th>{{ t.reviews_status }}</th>
|
||||
<th>{{ t.reviews_type }}</th>
|
||||
<th>{{ t.reviews_input_path }}</th>
|
||||
<th>{{ t.reviews_tags }}</th>
|
||||
<th>{{ t.reviews_confidence }}</th>
|
||||
<th>{{ t.reviews_model }}</th>
|
||||
<th>{{ t.reviews_llm_duration }}</th>
|
||||
@@ -37,14 +49,22 @@
|
||||
<th>{{ t.reviews_created }}</th>
|
||||
<th>{{ t.jobs_actions }}</th>
|
||||
</tr>
|
||||
{% for review in reviews %}
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td><a href="/admin/reviews/{{ review.id_val() }}">{{ review.id_val() }}</a></td>
|
||||
<td><span class="badge {{ review.status_badge_class() }}">{{ review.status_str() }}</span></td>
|
||||
<td>{{ review.review_type_str() }}</td>
|
||||
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ review.input_path_str() }}">{{ review.input_path_str() }}</td>
|
||||
<td>{% match review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}</td>
|
||||
{% match stats_map.get(&review.id_val()) %}
|
||||
<td class="review-select-cell">
|
||||
<input type="checkbox" class="review-select" value="{{ row.review.id_val() }}" data-status="{{ row.review.status_str() }}" aria-label="Select review {{ row.review.id_val() }}">
|
||||
</td>
|
||||
<td><a href="/admin/reviews/{{ row.review.id_val() }}">{{ row.review.id_val() }}</a></td>
|
||||
<td><span class="badge {{ row.review.status_badge_class() }}">{{ row.review.status_str() }}</span></td>
|
||||
<td>{{ row.review.review_type_str() }}</td>
|
||||
<td class="review-input-path" title="{{ row.review.input_path_str() }}">{{ row.display_input_path }}</td>
|
||||
<td class="review-tag-cell">
|
||||
{% for tag in row.media_tags %}
|
||||
<span class="review-tag review-tag-{{ tag.kind }}">{{ tag.label }}</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>{% match row.review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}</td>
|
||||
{% match stats_map.get(&row.review.id_val()) %}
|
||||
{% when Some with (s) %}
|
||||
<td>{{ s.model_name }}</td>
|
||||
<td>{{ s.duration_display() }}</td>
|
||||
@@ -54,20 +74,203 @@
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
{% endmatch %}
|
||||
<td>{{ review.created_at_str() }}</td>
|
||||
<td>{{ row.review.created_at_str() }}</td>
|
||||
<td>
|
||||
<a href="/admin/reviews/{{ review.id_val() }}">{{ t.reviews_view }}</a>
|
||||
<a href="/admin/reviews/{{ row.review.id_val() }}">{{ t.reviews_view }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
.review-bulk-toolbar {
|
||||
margin-bottom: .75rem;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.review-toolbar-button,
|
||||
.review-danger-button,
|
||||
.review-primary-button {
|
||||
padding: .35rem .7rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ced4da;
|
||||
background: #fff;
|
||||
color: #212529;
|
||||
cursor: pointer;
|
||||
}
|
||||
.review-danger-button {
|
||||
border-color: #dc3545;
|
||||
color: #dc3545;
|
||||
}
|
||||
.review-primary-button {
|
||||
border-color: #17a2b8;
|
||||
color: #0c5460;
|
||||
}
|
||||
.review-danger-button:disabled,
|
||||
.review-primary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .45;
|
||||
}
|
||||
.review-selection-summary {
|
||||
min-height: 1.7rem;
|
||||
padding: .35rem .6rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
background: #f8f9fa;
|
||||
color: #495057;
|
||||
font-size: .9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.review-select-cell {
|
||||
width: 2.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
.review-select {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.review-input-path {
|
||||
max-width: 34rem;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.review-tag-cell {
|
||||
max-width: 18rem;
|
||||
}
|
||||
.review-tag {
|
||||
display: inline-block;
|
||||
margin: .1rem .15rem .1rem 0;
|
||||
padding: .12rem .35rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
background: #f8f9fa;
|
||||
color: #495057;
|
||||
font-size: .8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.review-tag-format { border-color: #9ec5fe; background: #e7f1ff; color: #084298; }
|
||||
.review-tag-bitrate { border-color: #a3cfbb; background: #d1e7dd; color: #0f5132; }
|
||||
.review-tag-sample { border-color: #ffda6a; background: #fff3cd; color: #664d03; }
|
||||
.review-tag-depth { border-color: #d0bfff; background: #f0e7ff; color: #3d246c; }
|
||||
.review-tag-size { border-color: #ced4da; background: #f8f9fa; color: #495057; }
|
||||
.badge-completed { background: #d4edda; color: #155724; }
|
||||
.badge-failed { background: #f8d7da; color: #721c24; }
|
||||
.badge-pending { background: #fff3cd; color: #856404; }
|
||||
.badge-queued { background: #d1ecf1; color: #0c5460; }
|
||||
.badge-processing { background: #cce5ff; color: #004085; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const form = document.getElementById("reviews-bulk-form");
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkboxes = Array.from(form.querySelectorAll(".review-select"));
|
||||
const selectedIdsInput = document.getElementById("selected-review-ids");
|
||||
const summary = document.getElementById("review-selection-summary");
|
||||
const selectShownButton = document.getElementById("select-shown-reviews");
|
||||
const clearSelectionButton = document.getElementById("clear-review-selection");
|
||||
const submitButtons = Array.from(form.querySelectorAll("button[type='submit']"));
|
||||
const selected = new Set();
|
||||
const statusCounts = new Map();
|
||||
|
||||
const labels = {
|
||||
pending: "{{ t.reviews_filter_pending }}",
|
||||
approved: "{{ t.reviews_filter_approved }}",
|
||||
rejected: "{{ t.reviews_filter_rejected }}",
|
||||
queued: "{{ t.reviews_filter_queued }}",
|
||||
processing: "{{ t.reviews_filter_processing }}",
|
||||
auto_approved: "{{ t.reviews_filter_auto_approved }}",
|
||||
failed: "{{ t.reviews_filter_failed }}"
|
||||
};
|
||||
|
||||
function setStatusCount(status, delta) {
|
||||
const next = (statusCounts.get(status) || 0) + delta;
|
||||
if (next > 0) {
|
||||
statusCounts.set(status, next);
|
||||
} else {
|
||||
statusCounts.delete(status);
|
||||
}
|
||||
}
|
||||
|
||||
function syncControls() {
|
||||
selectedIdsInput.value = Array.from(selected).join(",");
|
||||
const total = selected.size;
|
||||
for (const button of submitButtons) {
|
||||
button.disabled = total === 0;
|
||||
}
|
||||
if (total === 0) {
|
||||
summary.textContent = "{{ t.reviews_selected_none }}";
|
||||
return;
|
||||
}
|
||||
const parts = Array.from(statusCounts.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([status, count]) => `${labels[status] || status}: ${count}`);
|
||||
summary.textContent = `{{ t.reviews_selected_prefix }}: ${total} (${parts.join(", ")})`;
|
||||
}
|
||||
|
||||
function setChecked(checkbox, checked) {
|
||||
const id = checkbox.value;
|
||||
const isSelected = selected.has(id);
|
||||
checkbox.checked = checked;
|
||||
if (isSelected === checked) {
|
||||
return;
|
||||
}
|
||||
const status = checkbox.dataset.status || "unknown";
|
||||
if (checked) {
|
||||
selected.add(id);
|
||||
setStatusCount(status, 1);
|
||||
} else {
|
||||
selected.delete(id);
|
||||
setStatusCount(status, -1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const checkbox of checkboxes) {
|
||||
checkbox.addEventListener("change", () => {
|
||||
setChecked(checkbox, checkbox.checked);
|
||||
syncControls();
|
||||
});
|
||||
}
|
||||
|
||||
selectShownButton.addEventListener("click", () => {
|
||||
for (const checkbox of checkboxes) {
|
||||
setChecked(checkbox, true);
|
||||
}
|
||||
syncControls();
|
||||
});
|
||||
|
||||
clearSelectionButton.addEventListener("click", () => {
|
||||
for (const checkbox of checkboxes) {
|
||||
setChecked(checkbox, false);
|
||||
}
|
||||
syncControls();
|
||||
});
|
||||
|
||||
form.addEventListener("submit", (event) => {
|
||||
syncControls();
|
||||
if (selected.size === 0) {
|
||||
event.preventDefault();
|
||||
alert("{{ t.reviews_none_selected_confirm }}");
|
||||
return;
|
||||
}
|
||||
|
||||
const action = event.submitter ? event.submitter.value : "";
|
||||
const message = action === "requeue"
|
||||
? "{{ t.reviews_requeue_selected_confirm }}"
|
||||
: "{{ t.reviews_delete_selected_confirm }}";
|
||||
if (!confirm(message)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
syncControls();
|
||||
})();
|
||||
</script>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
<td><input name="oidc_admin_groups" id="oidc_admin_groups" value="{{ oidc_admin_groups }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_admin_groups_source }}">{{ oidc_admin_groups_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="oidc_user_groups">{{ t.settings_oidc_user_groups }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_oidc_user_groups_help }}</span></td>
|
||||
<td><input name="oidc_user_groups" id="oidc_user_groups" value="{{ oidc_user_groups }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_user_groups_source }}">{{ oidc_user_groups_source }}</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
<h2>{{ t.settings_api }}</h2>
|
||||
<table>
|
||||
|
||||
+2197
-20
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user