Compare commits

...

12 Commits

Author SHA1 Message Date
ab a3a3f5368d Added user attribution
Build and Publish / Build and Publish Docker Image (push) Successful in 2m47s
2026-05-25 23:06:34 +03:00
ab 5f925be29b Added user attribution 2026-05-25 23:04:58 +03:00
ab 8530016d35 Reworked Artist page
Build and Publish / Build and Publish Docker Image (push) Successful in 2m47s
2026-05-25 17:41:00 +03:00
Ultradesu cae77e9401 Added OIDC users group filter
Build and Publish / Build and Publish Docker Image (push) Successful in 2m41s
2026-05-25 16:26:45 +03:00
Ultradesu 709f319bc5 Fixed UI
Build and Publish / Build and Publish Docker Image (push) Successful in 2m42s
2026-05-25 15:57:10 +03:00
Ultradesu bf0a2a553c Added torrent client and upload feature. reworked mobile UI.
Build and Publish / Build and Publish Docker Image (push) Successful in 2m47s
2026-05-25 15:40:07 +03:00
Ultradesu 3fc9b16e2c Reworked main page content 2026-05-25 14:59:15 +03:00
Ultradesu 29f6d04d12 Reworked main page content 2026-05-25 14:59:01 +03:00
Ultradesu c34485b521 Reworked main page content
Build and Publish / Build and Publish Docker Image (push) Successful in 2m33s
2026-05-25 14:42:25 +03:00
Ultradesu bc9f9605d8 Reworked main page content 2026-05-25 14:30:33 +03:00
Ultradesu 2f0ed2ee09 readme fix 2026-05-25 13:53:29 +03:00
ab dcc665563a Reworked Reviews page
Build and Publish / Build and Publish Docker Image (push) Successful in 2m47s
2026-05-25 13:50:24 +03:00
40 changed files with 7794 additions and 1524 deletions
Generated
+1030 -14
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -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"] }
+3 -2
View File
@@ -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` |
+1 -4
View File
@@ -10,8 +10,5 @@ fn main() {
.output()
.expect("failed to run rustc --version");
let version = String::from_utf8_lossy(&output.stdout);
println!(
"cargo::rustc-env=FURU_RUSTC_VERSION={}",
version.trim()
);
println!("cargo::rustc-env=FURU_RUSTC_VERSION={}", version.trim());
}
+52 -3
View File
@@ -2,6 +2,7 @@ pub mod views;
use std::sync::Arc;
use cot::App;
use cot::db::Database;
use cot::db::migrations::SyncDynMigration;
use cot::json::Json;
@@ -10,7 +11,6 @@ use cot::response::IntoResponse;
use cot::router::method::get;
use cot::router::{Route, Router};
use cot::session::Session;
use cot::App;
use serde::Deserialize;
use crate::auth;
@@ -18,7 +18,10 @@ use crate::config::AppConfig;
use crate::i18n::I18n;
use crate::scheduler::{JobRegistry, SchedulerHandle};
use crate::user::User;
use views::{ArtistForm, CronForm, OidcSettingsForm, ReleaseForm, SetImageBody, SetupForm, UploadImageBody, UserForm};
use views::{
ArtistForm, CronForm, MetadataBackfillForm, OidcSettingsForm, ReleaseForm, ReviewsBulkForm,
SetImageBody, SetupForm, UploadImageBody, UserForm,
};
#[derive(Debug, Deserialize)]
struct ReviewsQuery {
@@ -59,7 +62,11 @@ impl AdminApp {
registry: Arc<JobRegistry>,
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
) -> Self {
Self { config, registry, scheduler_handle }
Self {
config,
registry,
scheduler_handle,
}
}
}
@@ -536,6 +543,33 @@ impl App for AdminApp {
},
"admin_jobs",
),
Route::with_handler_and_name(
"/jobs/metadata_backfill/run-options",
cot::router::method::post({
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session, db: Database,
form: RequestForm<MetadataBackfillForm>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
let pg_pool = pool.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(3)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
}).await;
views::metadata_backfill_run(admin, &db, pg_pool, form).await
}
}
}),
"admin_metadata_backfill_run",
),
Route::with_handler_and_name(
"/jobs/{name}/run",
cot::router::method::post({
@@ -651,6 +685,21 @@ impl App for AdminApp {
),
"admin_reviews_clear",
),
Route::with_handler_and_name(
"/reviews/bulk",
cot::router::method::post(
|session: Session, db: Database,
form: RequestForm<ReviewsBulkForm>| async move {
let admin =
match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::reviews_bulk(admin, &db, form).await
},
),
"admin_reviews_bulk",
),
// -- Reviews ------------------------------------------------------
Route::with_handler_and_name(
"/reviews",
+545 -84
View File
@@ -9,14 +9,14 @@ use cot::{Body, Template};
use std::collections::HashMap;
use std::sync::Arc;
use crate::auth::{self, AuthenticatedUser};
use super::BUILD_INFO;
use crate::agent;
use crate::auth::{self, AuthenticatedUser};
use crate::config::{AppConfig, ConfigEntry, ConfigSources};
use crate::i18n::{I18n, Translations};
use crate::music::{Artist, MediaFile, Release, ReleaseArtist, Track, TrackArtist, RELEASE_TYPES};
use crate::music::{Artist, MediaFile, RELEASE_TYPES, Release, ReleaseArtist, Track, TrackArtist};
use crate::scheduler::{self, JobRegistry, JobRun, PendingReview, ScheduledJob};
use crate::user::User;
use super::BUILD_INFO;
use crate::agent::AgentProbeResult;
@@ -31,10 +31,7 @@ pub struct ConfigDisplayEntry {
}
/// Secret field names that should be redacted in the debug view.
const SECRET_FIELDS: &[&str] = &[
"database_url",
"oidc_client_secret",
];
const SECRET_FIELDS: &[&str] = &["database_url", "oidc_client_secret"];
fn is_secret(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
@@ -66,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,
+3 -4
View File
@@ -118,10 +118,7 @@ fn cover_name_priority(path: &Path) -> usize {
/// 2. Try to extract embedded cover art from each audio file.
///
/// Returns the first usable image found, or None.
pub async fn find_best_cover(
folder: &Path,
audio_files: &[PathBuf],
) -> Option<CoverImage> {
pub async fn find_best_cover(folder: &Path, audio_files: &[PathBuf]) -> Option<CoverImage> {
// Strategy 1: folder images
let folder_images = find_folder_images(folder);
for img_path in &folder_images {
@@ -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}"))?;
+3
View File
@@ -10,6 +10,9 @@ pub struct RawMetadata {
pub year: Option<u32>,
pub genre: Option<String>,
pub duration_secs: Option<f64>,
pub audio_bitrate: Option<i32>,
pub audio_sample_rate: Option<i32>,
pub audio_bit_depth: Option<i32>,
}
/// Hints parsed from the file path (directory structure + filename).
+36 -8
View File
@@ -18,7 +18,10 @@ use super::dto::RawMetadata;
/// Must be called from a blocking context (`spawn_blocking`).
pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
match extract_via_symphonia(path) {
Ok(meta) => Ok(meta),
Ok(mut meta) => {
fill_average_bitrate(path, &mut meta);
Ok(meta)
}
Err(e) => {
let is_mp3 = path
.extension()
@@ -27,7 +30,9 @@ pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
.unwrap_or(false);
if is_mp3 {
tracing::debug!(error = %e, "Symphonia failed on MP3, falling back to id3 crate");
extract_mp3_via_id3(path)
let mut meta = extract_mp3_via_id3(path)?;
fill_average_bitrate(path, &mut meta);
Ok(meta)
} else {
Err(e)
}
@@ -35,6 +40,22 @@ pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
}
}
fn fill_average_bitrate(path: &Path, meta: &mut RawMetadata) {
if meta.audio_bitrate.is_some() {
return;
}
let Some(duration_secs) = meta.duration_secs.filter(|duration| *duration > 0.0) else {
return;
};
let Ok(metadata) = std::fs::metadata(path) else {
return;
};
let kbps = ((metadata.len() as f64 * 8.0) / duration_secs / 1000.0).round();
if kbps.is_finite() && kbps > 0.0 && kbps <= i32::MAX as f64 {
meta.audio_bitrate = Some(kbps as i32);
}
}
fn extract_via_symphonia(path: &Path) -> anyhow::Result<RawMetadata> {
let file = std::fs::File::open(path)?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
@@ -68,17 +89,24 @@ fn extract_via_symphonia(path: &Path) -> anyhow::Result<RawMetadata> {
}
}
// Duration
meta.duration_secs = probed
let audio_track = probed
.format
.tracks()
.iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.and_then(|t| {
let n_frames = t.codec_params.n_frames?;
let tb = t.codec_params.time_base?;
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL);
if let Some(track) = audio_track {
let params = &track.codec_params;
meta.duration_secs = params.n_frames.and_then(|n_frames| {
let tb = params.time_base?;
Some(n_frames as f64 * tb.numer as f64 / tb.denom as f64)
});
meta.audio_sample_rate = params.sample_rate.and_then(|rate| i32::try_from(rate).ok());
meta.audio_bit_depth = params
.bits_per_sample
.or(params.bits_per_coded_sample)
.and_then(|bits| i32::try_from(bits).ok());
}
Ok(meta)
}
+5 -6
View File
@@ -27,11 +27,7 @@ pub struct AgentProbeResult {
/// Send a lightweight "introduce yourself" prompt to the LLM and return the
/// response together with timing / usage statistics when available.
pub async fn probe_llm(
llm_url: &str,
llm_model: &str,
llm_auth: &str,
) -> AgentProbeResult {
pub async fn probe_llm(llm_url: &str, llm_model: &str, llm_auth: &str) -> AgentProbeResult {
let start = std::time::Instant::now();
let client = match reqwest::Client::builder()
@@ -85,7 +81,10 @@ pub async fn probe_llm(
let body_text = resp.text().await.unwrap_or_default();
return AgentProbeResult {
latency_ms,
error: format!("HTTP {status}: {}", body_text.chars().take(300).collect::<String>()),
error: format!(
"HTTP {status}: {}",
body_text.chars().take(300).collect::<String>()
),
..Default::default()
};
}
+149 -50
View File
@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use super::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata, SimilarArtist, SimilarRelease};
use super::dto::{
FolderContext, NormalizedFields, PathHints, RawMetadata, SimilarArtist, SimilarRelease,
};
// ---------------------------------------------------------------------------
// Types
@@ -171,18 +173,40 @@ fn estimate_batch_tokens(
let mut per_file_tokens: u64 = 0;
for f in files {
let mut chars: u64 = 40 + f.filename.len() as u64; // header
if let Some(v) = &f.raw.title { chars += 10 + v.len() as u64; }
if let Some(v) = &f.raw.artist { chars += 12 + v.len() as u64; }
if let Some(v) = &f.raw.album { chars += 12 + v.len() as u64; }
if f.raw.year.is_some() { chars += 12; }
if f.raw.track_number.is_some() { chars += 18; }
if let Some(v) = &f.raw.genre { chars += 10 + v.len() as u64; }
if let Some(v) = &f.raw.title {
chars += 10 + v.len() as u64;
}
if let Some(v) = &f.raw.artist {
chars += 12 + v.len() as u64;
}
if let Some(v) = &f.raw.album {
chars += 12 + v.len() as u64;
}
if f.raw.year.is_some() {
chars += 12;
}
if f.raw.track_number.is_some() {
chars += 18;
}
if let Some(v) = &f.raw.genre {
chars += 10 + v.len() as u64;
}
// hints
if let Some(v) = &f.hints.artist { chars += 16 + v.len() as u64; }
if let Some(v) = &f.hints.album { chars += 16 + v.len() as u64; }
if let Some(v) = &f.hints.title { chars += 15 + v.len() as u64; }
if f.hints.year.is_some() { chars += 14; }
if f.hints.track_number.is_some() { chars += 20; }
if let Some(v) = &f.hints.artist {
chars += 16 + v.len() as u64;
}
if let Some(v) = &f.hints.album {
chars += 16 + v.len() as u64;
}
if let Some(v) = &f.hints.title {
chars += 15 + v.len() as u64;
}
if f.hints.year.is_some() {
chars += 14;
}
if f.hints.track_number.is_some() {
chars += 20;
}
per_file_tokens += chars / 4;
// Expected response per file (~150 tokens)
per_file_tokens += 150;
@@ -210,7 +234,10 @@ fn build_batch_user_message(
if !similar_artists.is_empty() {
msg.push_str("## Existing artists in database\n");
for a in similar_artists {
msg.push_str(&format!("- \"{}\" (similarity: {:.2})\n", a.name, a.similarity));
msg.push_str(&format!(
"- \"{}\" (similarity: {:.2})\n",
a.name, a.similarity
));
}
msg.push('\n');
}
@@ -219,7 +246,10 @@ fn build_batch_user_message(
msg.push_str("## Existing releases in database\n");
for r in similar_releases {
let year_str = r.year.map(|y| format!(", year: {y}")).unwrap_or_default();
msg.push_str(&format!("- \"{}\" (similarity: {:.2}{})\n", r.title, r.similarity, year_str));
msg.push_str(&format!(
"- \"{}\" (similarity: {:.2}{})\n",
r.title, r.similarity, year_str
));
}
msg.push('\n');
}
@@ -230,12 +260,24 @@ fn build_batch_user_message(
for f in files {
msg.push_str(&format!("### {}\n", f.filename));
if let Some(v) = &f.raw.title { msg.push_str(&format!("Title: \"{v}\"\n")); }
if let Some(v) = &f.raw.artist { msg.push_str(&format!("Artist: \"{v}\"\n")); }
if let Some(v) = &f.raw.album { msg.push_str(&format!("Release: \"{v}\"\n")); }
if let Some(v) = f.raw.year { msg.push_str(&format!("Year: {v}\n")); }
if let Some(v) = f.raw.track_number { msg.push_str(&format!("Track: {v}\n")); }
if let Some(v) = &f.raw.genre { msg.push_str(&format!("Genre: \"{v}\"\n")); }
if let Some(v) = &f.raw.title {
msg.push_str(&format!("Title: \"{v}\"\n"));
}
if let Some(v) = &f.raw.artist {
msg.push_str(&format!("Artist: \"{v}\"\n"));
}
if let Some(v) = &f.raw.album {
msg.push_str(&format!("Release: \"{v}\"\n"));
}
if let Some(v) = f.raw.year {
msg.push_str(&format!("Year: {v}\n"));
}
if let Some(v) = f.raw.track_number {
msg.push_str(&format!("Track: {v}\n"));
}
if let Some(v) = &f.raw.genre {
msg.push_str(&format!("Genre: \"{v}\"\n"));
}
// Path hints (only if different from tag metadata)
let has_hints = f.hints.artist.is_some()
@@ -244,11 +286,21 @@ fn build_batch_user_message(
|| f.hints.year.is_some()
|| f.hints.track_number.is_some();
if has_hints {
if let Some(v) = &f.hints.artist { msg.push_str(&format!("Path artist: \"{v}\"\n")); }
if let Some(v) = &f.hints.album { msg.push_str(&format!("Path release: \"{v}\"\n")); }
if let Some(v) = &f.hints.title { msg.push_str(&format!("Path title: \"{v}\"\n")); }
if let Some(v) = f.hints.year { msg.push_str(&format!("Path year: {v}\n")); }
if let Some(v) = f.hints.track_number { msg.push_str(&format!("Path track: {v}\n")); }
if let Some(v) = &f.hints.artist {
msg.push_str(&format!("Path artist: \"{v}\"\n"));
}
if let Some(v) = &f.hints.album {
msg.push_str(&format!("Path release: \"{v}\"\n"));
}
if let Some(v) = &f.hints.title {
msg.push_str(&format!("Path title: \"{v}\"\n"));
}
if let Some(v) = f.hints.year {
msg.push_str(&format!("Path year: {v}\n"));
}
if let Some(v) = f.hints.track_number {
msg.push_str(&format!("Path track: {v}\n"));
}
}
msg.push('\n');
}
@@ -272,7 +324,11 @@ pub async fn normalize_batch(
) -> anyhow::Result<BatchNormalizeResult> {
// Estimate tokens
let estimated = estimate_batch_tokens(
system_prompt, &files, similar_artists, similar_releases, folder_ctx,
system_prompt,
&files,
similar_artists,
similar_releases,
folder_ctx,
);
// If over 80% of context limit and more than 1 file, split
@@ -290,14 +346,30 @@ pub async fn normalize_batch(
let left = files_vec;
let left_result = Box::pin(normalize_batch(
llm_url, llm_model, llm_auth, system_prompt, context_limit,
left, similar_artists, similar_releases, folder_ctx,
)).await?;
llm_url,
llm_model,
llm_auth,
system_prompt,
context_limit,
left,
similar_artists,
similar_releases,
folder_ctx,
))
.await?;
let right_result = Box::pin(normalize_batch(
llm_url, llm_model, llm_auth, system_prompt, context_limit,
right, similar_artists, similar_releases, folder_ctx,
)).await?;
llm_url,
llm_model,
llm_auth,
system_prompt,
context_limit,
right,
similar_artists,
similar_releases,
folder_ctx,
))
.await?;
// Merge results
let mut results = left_result.results;
@@ -312,20 +384,32 @@ pub async fn normalize_batch(
}
// Build and send
let user_message = build_batch_user_message(
&files, similar_artists, similar_releases, folder_ctx,
);
let user_message =
build_batch_user_message(&files, similar_artists, similar_releases, folder_ctx);
let messages = vec![
ChatMessage { role: "system".into(), content: system_prompt.to_owned() },
ChatMessage { role: "user".into(), content: user_message },
ChatMessage {
role: "system".into(),
content: system_prompt.to_owned(),
},
ChatMessage {
role: "user".into(),
content: user_message,
},
];
let start = std::time::Instant::now();
let call_result = call_llm_chat(
llm_url, llm_model, &messages,
if llm_auth.is_empty() { None } else { Some(llm_auth) },
).await;
llm_url,
llm_model,
&messages,
if llm_auth.is_empty() {
None
} else {
Some(llm_auth)
},
)
.await;
let duration_ms = start.elapsed().as_millis() as u64;
// If LLM error and batch > 1, try splitting (handles context overflow errors)
@@ -349,13 +433,29 @@ pub async fn normalize_batch(
let left = files_vec;
let left_result = Box::pin(normalize_batch(
llm_url, llm_model, llm_auth, system_prompt, context_limit,
left, similar_artists, similar_releases, folder_ctx,
)).await?;
llm_url,
llm_model,
llm_auth,
system_prompt,
context_limit,
left,
similar_artists,
similar_releases,
folder_ctx,
))
.await?;
let right_result = Box::pin(normalize_batch(
llm_url, llm_model, llm_auth, system_prompt, context_limit,
right, similar_artists, similar_releases, folder_ctx,
)).await?;
llm_url,
llm_model,
llm_auth,
system_prompt,
context_limit,
right,
similar_artists,
similar_releases,
folder_ctx,
))
.await?;
let mut results = left_result.results;
results.extend(right_result.results);
@@ -363,7 +463,8 @@ pub async fn normalize_batch(
results,
model: left_result.model,
prompt_tokens: left_result.prompt_tokens + right_result.prompt_tokens,
completion_tokens: left_result.completion_tokens + right_result.completion_tokens,
completion_tokens: left_result.completion_tokens
+ right_result.completion_tokens,
duration_ms: left_result.duration_ms + right_result.duration_ms,
});
}
@@ -398,9 +499,7 @@ fn parse_batch_response(
// Strip markdown code fences if present
let json_str = if cleaned.starts_with("```") {
let start = cleaned.find('[')
.or_else(|| cleaned.find('{'))
.unwrap_or(0);
let start = cleaned.find('[').or_else(|| cleaned.find('{')).unwrap_or(0);
let end_bracket = cleaned.rfind(']').map(|i| i + 1);
let end_brace = cleaned.rfind('}').map(|i| i + 1);
let end = end_bracket.or(end_brace).unwrap_or(cleaned.len());
+2 -5
View File
@@ -113,11 +113,8 @@ fn parse_album_with_year(dir: &str) -> (String, Option<i32>) {
let inside = &dir[start + 1..start + end];
if let Ok(year) = inside.trim().parse::<i32>() {
if (1900..=2100).contains(&year) {
let album = format!(
"{}{}",
&dir[..start].trim(),
&dir[start + end + 1..].trim()
);
let album =
format!("{}{}", &dir[..start].trim(), &dir[start + end + 1..].trim());
let album = album.trim().to_owned();
return (album, Some(year));
}
+6 -7
View File
@@ -34,10 +34,7 @@ struct MeResponse {
role: String,
}
async fn me_handler(
session: Session,
db: Database,
) -> cot::Result<cot::response::Response> {
async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response::Response> {
let Some(user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(
cot::http::StatusCode::UNAUTHORIZED,
@@ -65,8 +62,10 @@ impl App for ApiApp {
}
fn router(&self) -> Router {
Router::with_urls([
Route::with_api_handler_and_name("/me", api_get(me_handler), "api_me"),
])
Router::with_urls([Route::with_api_handler_and_name(
"/me",
api_get(me_handler),
"api_me",
)])
}
}
+3 -5
View File
@@ -1,7 +1,7 @@
use cot::Body;
use cot::db::Database;
use cot::response::IntoResponse;
use cot::session::Session;
use cot::Body;
use crate::user::User;
@@ -78,12 +78,10 @@ pub async fn require_admin_or_redirect(
return Err(redirect("/login"));
};
if user.role != Role::Admin {
return Err(
"Forbidden"
return Err("Forbidden"
.with_status(cot::http::StatusCode::FORBIDDEN)
.into_response()
.expect("valid response"),
);
.expect("valid response"));
}
Ok(user)
}
+40 -22
View File
@@ -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
View File
@@ -2,10 +2,16 @@ mod phrases;
pub use phrases::Translations;
use cot::request::extractors::FromRequestHead;
use cot::request::RequestHead;
use cot::request::extractors::FromRequestHead;
use serde::{Deserialize, Serialize};
impl Translations {
pub fn app_version(&self) -> &'static str {
env!("CARGO_PKG_VERSION")
}
}
// ---------------------------------------------------------------------------
// Lang enum
// ---------------------------------------------------------------------------
@@ -77,7 +83,10 @@ const COOKIE_NAME: &str = "furu_lang";
/// Build a `Set-Cookie` header value that persists the language choice for 1 year.
pub fn lang_cookie(lang: Lang) -> String {
format!("{COOKIE_NAME}={}; Path=/; SameSite=Lax; Max-Age=31536000", lang.code())
format!(
"{COOKIE_NAME}={}; Path=/; SameSite=Lax; Max-Age=31536000",
lang.code()
)
}
/// Parse `furu_lang` from the `Cookie` request header.
@@ -203,10 +212,7 @@ mod tests {
#[test]
fn parse_unknown_falls_through() {
assert_eq!(
parse_accept_language("de;q=1.0,ru;q=0.5"),
Some(Lang::Ru)
);
assert_eq!(parse_accept_language("de;q=1.0,ru;q=0.5"), Some(Lang::Ru));
assert_eq!(parse_accept_language("de,fr,ja"), None);
}
+18
View File
@@ -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" , "Ожидают";
+3 -1
View File
@@ -48,7 +48,9 @@ impl Job for ArtistImageBackfillJob {
let count = result.rows_affected();
if count > 0 {
log.info(&format!("Assigned images to {count} artists from release covers"));
log.info(&format!(
"Assigned images to {count} artists from release covers"
));
} else {
log.info("All artists already have images (or no covers available)");
}
+4 -9
View File
@@ -87,10 +87,8 @@ impl Job for CoverBackfillJob {
let folder = first_path.parent().unwrap_or(Path::new("."));
// Collect all audio file paths as PathBuf
let audio_files: Vec<PathBuf> = audio_paths
.iter()
.map(|(p,)| PathBuf::from(p))
.collect();
let audio_files: Vec<PathBuf> =
audio_paths.iter().map(|(p,)| PathBuf::from(p)).collect();
// Try to find cover art
let cover = match cover_art::find_best_cover(folder, &audio_files).await {
@@ -135,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
View File
@@ -30,7 +30,10 @@ impl Job for InboxDiscoverJob {
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
// Prevent overlapping discover runs
if DISCOVER_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
if DISCOVER_RUNNING
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
log.info("Another inbox_discover is already running, skipping");
return Ok(());
}
@@ -82,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, &registry, "inbox_process",
&config,
&db,
&pool,
&registry,
"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
View File
@@ -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}")
}
}
+234
View File
@@ -0,0 +1,234 @@
use std::path::{Path, PathBuf};
use crate::scheduler::{Job, JobContext, JobLog};
#[derive(Debug, Clone, Copy)]
pub struct MetadataBackfillOptions {
pub audio_bitrate: bool,
pub audio_sample_rate: bool,
pub audio_bit_depth: bool,
pub duration_seconds: bool,
pub overwrite: bool,
}
impl MetadataBackfillOptions {
pub fn any_field(self) -> bool {
self.audio_bitrate
|| self.audio_sample_rate
|| self.audio_bit_depth
|| self.duration_seconds
}
}
#[derive(sqlx::FromRow)]
struct BackfillRow {
media_file_id: i64,
file_path: String,
audio_bitrate: Option<i32>,
audio_sample_rate: Option<i32>,
audio_bit_depth: Option<i32>,
track_id: Option<i64>,
duration_seconds: Option<f64>,
}
pub struct MetadataBackfillJob;
#[async_trait::async_trait]
impl Job for MetadataBackfillJob {
fn name(&self) -> &'static str {
"metadata_backfill"
}
fn description(&self) -> &'static str {
"Backfill technical audio metadata from existing files"
}
fn default_cron(&self) -> &'static str {
""
}
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
run_with_options(
ctx,
log,
MetadataBackfillOptions {
audio_bitrate: true,
audio_sample_rate: true,
audio_bit_depth: true,
duration_seconds: true,
overwrite: false,
},
)
.await
}
}
pub async fn run_with_options(
ctx: &JobContext,
log: &mut JobLog,
options: MetadataBackfillOptions,
) -> anyhow::Result<()> {
if !options.any_field() {
log.warn("No metadata fields selected; nothing to backfill");
return Ok(());
}
let rows = sqlx::query_as::<_, BackfillRow>(
"SELECT mf.id AS media_file_id, mf.file_path, \
mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, \
t.id AS track_id, t.duration_seconds \
FROM furumusic__media_file mf \
LEFT JOIN furumusic__track t ON t.audio_file_id = mf.id \
WHERE mf.file_type = 'audio' \
ORDER BY mf.id",
)
.fetch_all(&ctx.pool)
.await?;
log.info(&format!(
"Metadata backfill started: {} audio file(s), mode={}",
rows.len(),
if options.overwrite {
"overwrite"
} else {
"fill_missing"
}
));
let mut scanned = 0u64;
let mut media_updated = 0u64;
let mut track_updated = 0u64;
let mut unchanged = 0u64;
let mut missing = 0u64;
let mut failed = 0u64;
for row in rows {
scanned += 1;
let Some(path) = resolve_media_path(&row.file_path, &ctx.config.agent_storage_dir) else {
missing += 1;
log.warn(&format!("missing file: {}", row.file_path));
continue;
};
let extract_path = path.clone();
let raw_meta = match tokio::task::spawn_blocking(move || {
crate::agent::metadata::extract(&extract_path)
})
.await
{
Ok(Ok(meta)) => meta,
Ok(Err(e)) => {
failed += 1;
log.warn(&format!("metadata error for {}: {e}", path.display()));
continue;
}
Err(e) => {
failed += 1;
log.warn(&format!("metadata task failed for {}: {e}", path.display()));
continue;
}
};
let mut changed_media = false;
let mut next_bitrate = row.audio_bitrate;
let mut next_sample_rate = row.audio_sample_rate;
let mut next_bit_depth = row.audio_bit_depth;
if options.audio_bitrate && should_update(row.audio_bitrate, options.overwrite) {
if let Some(value) = raw_meta.audio_bitrate {
next_bitrate = Some(value);
changed_media = next_bitrate != row.audio_bitrate || changed_media;
}
}
if options.audio_sample_rate && should_update(row.audio_sample_rate, options.overwrite) {
if let Some(value) = raw_meta.audio_sample_rate {
next_sample_rate = Some(value);
changed_media = next_sample_rate != row.audio_sample_rate || changed_media;
}
}
if options.audio_bit_depth && should_update(row.audio_bit_depth, options.overwrite) {
if let Some(value) = raw_meta.audio_bit_depth {
next_bit_depth = Some(value);
changed_media = next_bit_depth != row.audio_bit_depth || changed_media;
}
}
let mut changed_track = false;
let mut next_duration = row.duration_seconds;
if options.duration_seconds
&& row.track_id.is_some()
&& should_update_duration(row.duration_seconds, options.overwrite)
{
if let Some(value) = raw_meta.duration_secs {
next_duration = Some(value);
changed_track = row
.duration_seconds
.map(|current| (current - value).abs() > 0.001)
.unwrap_or(true);
}
}
if changed_media {
sqlx::query(
"UPDATE furumusic__media_file \
SET audio_bitrate = $1, audio_sample_rate = $2, audio_bit_depth = $3 \
WHERE id = $4",
)
.bind(next_bitrate)
.bind(next_sample_rate)
.bind(next_bit_depth)
.bind(row.media_file_id)
.execute(&ctx.pool)
.await?;
media_updated += 1;
}
if changed_track {
if let (Some(track_id), Some(duration)) = (row.track_id, next_duration) {
sqlx::query("UPDATE furumusic__track SET duration_seconds = $1 WHERE id = $2")
.bind(duration)
.bind(track_id)
.execute(&ctx.pool)
.await?;
track_updated += 1;
}
}
if !changed_media && !changed_track {
unchanged += 1;
}
if scanned % 100 == 0 {
log.info(&format!(
"Progress: {scanned} scanned, {media_updated} media updated, {track_updated} tracks updated, {unchanged} unchanged, {missing} missing, {failed} failed"
));
}
}
log.info(&format!(
"Metadata backfill complete: {scanned} scanned, {media_updated} media updated, {track_updated} tracks updated, {unchanged} unchanged, {missing} missing, {failed} failed"
));
Ok(())
}
fn should_update<T>(current: Option<T>, overwrite: bool) -> bool {
overwrite || current.is_none()
}
fn should_update_duration(current: Option<f64>, overwrite: bool) -> bool {
overwrite || current.unwrap_or(0.0) <= 0.0
}
fn resolve_media_path(file_path: &str, storage_dir: &str) -> Option<PathBuf> {
let path = Path::new(file_path);
if path.exists() {
return Some(path.to_path_buf());
}
if path.is_relative() && !storage_dir.is_empty() {
let joined = Path::new(storage_dir).join(path);
if joined.exists() {
return Some(joined);
}
}
None
}
+71
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -131,8 +131,7 @@ async fn get_or_refresh_provider(
.unwrap_or(config.oidc_issuer.trim_end_matches('/'))
.to_owned();
let issuer_url = IssuerUrl::new(issuer)
.map_err(|e| format!("invalid issuer URL: {e}"))?;
let issuer_url = IssuerUrl::new(issuer).map_err(|e| format!("invalid issuer URL: {e}"))?;
let metadata = CoreProviderMetadata::discover_async(issuer_url, http)
.await
@@ -250,7 +249,9 @@ pub async fn oidc_callback_handler(
i18n: I18n,
db: Database,
session: Session,
cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery<OidcCallbackQuery>,
cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery<
OidcCallbackQuery,
>,
) -> cot::Result<cot::response::Response> {
let (config, _) = AppConfig::load_with_db(&db).await;
@@ -313,9 +314,7 @@ pub async fn oidc_callback_handler(
};
// Exchange code for tokens.
let token_request = match client
.exchange_code(AuthorizationCode::new(query.code.clone()))
{
let token_request = match client.exchange_code(AuthorizationCode::new(query.code.clone())) {
Ok(req) => req,
Err(e) => {
tracing::error!("OIDC token endpoint not configured: {e}");
@@ -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,
+195
View File
@@ -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>,
}
+48
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+72
View File
@@ -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,
}
+185
View File
@@ -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
View File
@@ -1,5 +1,4 @@
/// Job scheduler: models, migrations, Job trait, JobRegistry, and scheduler loop.
use std::collections::HashMap;
use std::sync::Arc;
@@ -74,7 +73,12 @@ impl ScheduledJob {
Self::get_by_primary_key(db, name.to_owned()).await
}
pub async fn upsert(db: &Database, name: &str, description: &str, cron_expression: &str) -> cot::db::Result<Self> {
pub async fn upsert(
db: &Database,
name: &str,
description: &str,
cron_expression: &str,
) -> cot::db::Result<Self> {
if let Some(mut existing) = Self::get_by_name(db, name).await? {
// Update cron expression and description if they changed
let mut changed = false;
@@ -170,7 +174,11 @@ pub struct JobRun {
#[allow(dead_code)]
impl JobRun {
pub async fn create_running(db: &Database, job_name: &str, trigger: &str) -> cot::db::Result<Self> {
pub async fn create_running(
db: &Database,
job_name: &str,
trigger: &str,
) -> cot::db::Result<Self> {
let mut run = Self {
id: Auto::auto(),
job_name: limited_string(job_name),
@@ -186,7 +194,12 @@ impl JobRun {
Ok(run)
}
pub async fn set_completed(&mut self, db: &Database, duration_ms: i64, log: &str) -> cot::db::Result<()> {
pub async fn set_completed(
&mut self,
db: &Database,
duration_ms: i64,
log: &str,
) -> cot::db::Result<()> {
self.status = LimitedString::new("completed").unwrap();
self.finished_at = Some(now_iso().to_string());
self.duration_ms = Some(duration_ms);
@@ -194,7 +207,13 @@ impl JobRun {
self.save(db).await
}
pub async fn set_failed(&mut self, db: &Database, duration_ms: i64, log: &str, error: &str) -> cot::db::Result<()> {
pub async fn set_failed(
&mut self,
db: &Database,
duration_ms: i64,
log: &str,
error: &str,
) -> cot::db::Result<()> {
self.status = LimitedString::new("failed").unwrap();
self.finished_at = Some(now_iso().to_string());
self.duration_ms = Some(duration_ms);
@@ -207,7 +226,11 @@ impl JobRun {
Self::get_by_primary_key(db, Auto::Fixed(id)).await
}
pub async fn list_by_job(pool: &sqlx::PgPool, job_name: &str, limit: i64) -> anyhow::Result<Vec<Self>> {
pub async fn list_by_job(
pool: &sqlx::PgPool,
job_name: &str,
limit: i64,
) -> anyhow::Result<Vec<Self>> {
let rows = sqlx::query_as::<_, JobRunRow>(
"SELECT id, job_name, status, started_at, finished_at, duration_ms, log_output, error_message, trigger \
FROM furumusic__job_run WHERE job_name = $1 ORDER BY id DESC LIMIT $2"
@@ -229,7 +252,7 @@ impl JobRun {
SET status = 'failed', \
finished_at = $1, \
error_message = 'Process restarted while job was running' \
WHERE status = 'running'"
WHERE status = 'running'",
)
.bind(&now)
.execute(pool)
@@ -472,7 +495,7 @@ impl PendingReview {
SET status = 'failed', \
error_message = 'Process restarted while review was being processed', \
updated_at = $1 \
WHERE status = 'processing'"
WHERE status = 'processing'",
)
.bind(&now)
.execute(pool)
@@ -497,6 +520,46 @@ impl PendingReview {
Ok(())
}
pub async fn delete_by_ids(db: &Database, ids: &[i64]) -> cot::db::Result<()> {
for chunk in ids.chunks(1000) {
if chunk.is_empty() {
continue;
}
let id_list = chunk
.iter()
.map(i64::to_string)
.collect::<Vec<_>>()
.join(",");
db.raw(&format!(
"DELETE FROM furumusic__pending_review WHERE id IN ({id_list})"
))
.await?;
}
Ok(())
}
pub async fn requeue_by_ids(db: &Database, ids: &[i64]) -> cot::db::Result<()> {
let now = now_iso().to_string();
for chunk in ids.chunks(1000) {
if chunk.is_empty() {
continue;
}
let id_list = chunk
.iter()
.map(i64::to_string)
.collect::<Vec<_>>()
.join(",");
db.raw(&format!(
"UPDATE furumusic__pending_review \
SET status = 'queued', error_message = NULL, updated_at = '{}' \
WHERE id IN ({id_list})",
now.replace('\'', "''")
))
.await?;
}
Ok(())
}
pub fn id_val(&self) -> i64 {
self.id.unwrap()
}
@@ -589,12 +652,19 @@ impl ProcessingStats {
Ok(all.into_iter().next())
}
pub async fn list_by_review_ids(pool: &sqlx::PgPool, ids: &[i64]) -> anyhow::Result<HashMap<i64, ProcessingStatsRow>> {
pub async fn list_by_review_ids(
pool: &sqlx::PgPool,
ids: &[i64],
) -> anyhow::Result<HashMap<i64, ProcessingStatsRow>> {
if ids.is_empty() {
return Ok(HashMap::new());
}
// Build comma-separated ID list
let id_list: String = ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
let id_list: String = ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(",");
let query = format!(
"SELECT pending_review_id, model_name, llm_duration_ms, prompt_tokens, completion_tokens \
FROM furumusic__processing_stats WHERE pending_review_id IN ({id_list})"
@@ -659,28 +729,46 @@ pub mod db_migrations {
impl migrations::Migration for M0022CreateScheduledJob {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0022_create_scheduled_job";
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
migrations::MigrationDependency::migration("furumusic", "m_0021_create_trgm_indexes"),
];
const OPERATIONS: &'static [Operation] = &[
Operation::create_model()
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
View File
@@ -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
View File
@@ -108,7 +108,9 @@ impl User {
/// Delete this user by primary key.
pub async fn delete_by_id(db: &Database, user_id: i64) -> cot::db::Result<()> {
cot::db::query!(User, $id == Auto::Fixed(user_id)).delete(db).await?;
cot::db::query!(User, $id == Auto::Fixed(user_id))
.delete(db)
.await?;
Ok(())
}
@@ -120,10 +122,16 @@ impl User {
&self.username
}
pub fn email_str(&self) -> String {
self.email.as_ref().map(|e| e.to_string()).unwrap_or_default()
self.email
.as_ref()
.map(|e| e.to_string())
.unwrap_or_default()
}
pub fn display_name_str(&self) -> String {
self.display_name.as_ref().map(|d| d.to_string()).unwrap_or_default()
self.display_name
.as_ref()
.map(|d| d.to_string())
.unwrap_or_default()
}
pub fn role_str(&self) -> &str {
&self.role
@@ -162,7 +170,9 @@ impl User {
/// Find a user by email address.
pub async fn get_by_email(db: &Database, email: &str) -> cot::db::Result<Option<Self>> {
cot::db::query!(User, $email == Some(email.to_owned())).get(db).await
cot::db::query!(User, $email == Some(email.to_owned()))
.get(db)
.await
}
}
@@ -257,9 +267,9 @@ impl OidcLink {
// ---------------------------------------------------------------------------
pub mod db_migrations {
use cot::auth::PasswordHash;
use cot::db::migrations::{self, Field, Operation, SyncDynMigration};
use cot::db::{DatabaseField, Identifier, LimitedString};
use cot::auth::PasswordHash;
// -- M0003: create furumusic__user -------------------------------------
@@ -269,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] = &[
+40 -4
View File
@@ -13,9 +13,12 @@
</table>
<div style="margin: 1rem 0; display: flex; gap: .5rem; align-items: flex-end;">
{% if job.name_str() != "metadata_backfill" %}
<form method="post" action="/admin/jobs/{{ job.name_str() }}/run" style="margin:0;">
<button type="submit" style="padding:.4rem 1rem; background:#007bff; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_run_now }}</button>
</form>
{% endif %}
{% if job.name_str() != "metadata_backfill" %}
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
{% if job.enabled() %}
<button type="submit" style="padding:.4rem 1rem; background:#dc3545; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_disable }}</button>
@@ -23,14 +26,47 @@
<button type="submit" style="padding:.4rem 1rem; background:#28a745; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_enable }}</button>
{% endif %}
</form>
{% endif %}
</div>
<h2>{{ t.jobs_cron }}</h2>
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.jobs_cron_help }}</p>
<form method="post" action="/admin/jobs/{{ job.name_str() }}/cron" style="display:flex;gap:.5rem;align-items:center;margin-bottom:1.5rem;">
{% 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>
</form>
{% endif %}
<h2>{{ t.jobs_run_history }}</h2>
{% if runs.is_empty() %}
+6
View File
@@ -23,9 +23,14 @@
<td>{{ job.last_run_at_str() }}</td>
<td>{{ job.next_run_at_str() }}</td>
<td style="display:flex;gap:.3rem;">
{% if job.name_str() == "metadata_backfill" %}
<a href="/admin/jobs/{{ job.name_str() }}" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #007bff; background:#007bff; color:#fff; cursor:pointer; text-decoration:none;">{{ t.jobs_metadata_backfill_options }}</a>
{% else %}
<form method="post" action="/admin/jobs/{{ job.name_str() }}/run" style="margin:0;">
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #007bff; background:#007bff; color:#fff; cursor:pointer;">{{ t.jobs_run_now }}</button>
</form>
{% endif %}
{% if job.name_str() != "metadata_backfill" %}
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
{% if job.enabled() %}
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #dc3545; background:#fff; color:#dc3545; cursor:pointer;">{{ t.jobs_disable }}</button>
@@ -33,6 +38,7 @@
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #28a745; background:#fff; color:#28a745; cursor:pointer;">{{ t.jobs_enable }}</button>
{% endif %}
</form>
{% endif %}
</td>
</tr>
{% endfor %}
+2 -1
View File
@@ -9,6 +9,7 @@
body { font-family: system-ui, -apple-system, sans-serif; display: flex; min-height: 100vh; background: #f5f5f5; color: #333; }
nav.sidebar { width: 220px; background: #1a1a2e; color: #eee; padding: 1rem; }
nav.sidebar h2 { font-size: 1.1rem; margin-bottom: 1.5rem; }
.admin-version { display: inline-block; margin-left: .35rem; color: #999; font-size: .72rem; font-weight: 500; vertical-align: baseline; }
nav.sidebar a { color: #ccc; text-decoration: none; display: block; padding: .4rem .6rem; border-radius: 4px; }
nav.sidebar a:hover { background: #16213e; color: #fff; }
.main-wrap { flex: 1; display: flex; flex-direction: column; }
@@ -36,7 +37,7 @@
{% block body %}
<nav class="sidebar">
<h2>{{ t.site_name }} {{ t.nav_admin }}</h2>
<h2>{{ t.site_name }} {{ t.nav_admin }} <span class="admin-version">v{{ t.app_version() }}</span></h2>
<a href="/admin/">{{ t.nav_dashboard }}</a>
<a href="/admin/artists">{{ t.nav_artists }}</a>
<a href="/admin/releases">{{ t.nav_releases }}</a>
+215 -12
View File
@@ -4,7 +4,7 @@
{% block content %}
<h1>{{ t.reviews_heading }}</h1>
<div style="margin-bottom: 1rem; display: flex; gap: .5rem; align-items: center;">
<div style="margin-bottom: 1rem; display: flex; gap: .5rem; align-items: center; flex-wrap: wrap;">
<a href="/admin/reviews" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "" %} #333; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_all }}</a>
<a href="/admin/reviews?status=pending" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "pending" %} #ffc107; color: #000{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_pending }}</a>
<a href="/admin/reviews?status=approved" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "approved" %} #28a745; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_approved }}</a>
@@ -13,7 +13,7 @@
<a href="/admin/reviews?status=processing" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "processing" %} #007bff; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_processing }}</a>
<a href="/admin/reviews?status=auto_approved" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "auto_approved" %} #28a745; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_auto_approved }}</a>
<a href="/admin/reviews?status=failed" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "failed" %} #dc3545; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_failed }}</a>
{% if !reviews.is_empty() %}
{% if !rows.is_empty() %}
<span style="flex:1;"></span>
<form method="post" action="/admin/reviews/clear{% if !status_filter.is_empty() %}?status={{ status_filter }}{% endif %}" style="margin:0;" onsubmit="return confirm('{{ t.reviews_clear_confirm }}');">
<button type="submit" style="padding:.3rem .8rem; border-radius:4px; border:1px solid #dc3545; background:#fff; color:#dc3545; cursor:pointer;">{% if status_filter.is_empty() %}{{ t.reviews_clear_all }}{% else %}{{ t.reviews_clear_filtered }}{% endif %}</button>
@@ -21,15 +21,27 @@
{% endif %}
</div>
{% if reviews.is_empty() %}
{% if rows.is_empty() %}
<p>{{ t.reviews_empty }}</p>
{% else %}
<form id="reviews-bulk-form" method="post" action="/admin/reviews/bulk" style="margin:0;">
<input type="hidden" name="selected_ids" id="selected-review-ids" value="">
<input type="hidden" name="status_filter" value="{{ status_filter }}">
<div class="review-bulk-toolbar">
<button type="button" id="select-shown-reviews" class="review-toolbar-button">{{ t.reviews_select_all }}</button>
<button type="button" id="clear-review-selection" class="review-toolbar-button">{{ t.reviews_clear_selection }}</button>
<button type="submit" name="action" value="delete" class="review-danger-button" disabled>{{ t.reviews_delete_selected }}</button>
<button type="submit" name="action" value="requeue" class="review-primary-button" disabled>{{ t.reviews_requeue_selected }}</button>
<span id="review-selection-summary" class="review-selection-summary">{{ t.reviews_selected_none }}</span>
</div>
<table>
<tr>
<th class="review-select-cell"></th>
<th>ID</th>
<th>{{ t.reviews_status }}</th>
<th>{{ t.reviews_type }}</th>
<th>{{ t.reviews_input_path }}</th>
<th>{{ t.reviews_tags }}</th>
<th>{{ t.reviews_confidence }}</th>
<th>{{ t.reviews_model }}</th>
<th>{{ t.reviews_llm_duration }}</th>
@@ -37,14 +49,22 @@
<th>{{ t.reviews_created }}</th>
<th>{{ t.jobs_actions }}</th>
</tr>
{% for review in reviews %}
{% for row in rows %}
<tr>
<td><a href="/admin/reviews/{{ review.id_val() }}">{{ review.id_val() }}</a></td>
<td><span class="badge {{ review.status_badge_class() }}">{{ review.status_str() }}</span></td>
<td>{{ review.review_type_str() }}</td>
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ review.input_path_str() }}">{{ review.input_path_str() }}</td>
<td>{% match review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}</td>
{% match stats_map.get(&review.id_val()) %}
<td class="review-select-cell">
<input type="checkbox" class="review-select" value="{{ row.review.id_val() }}" data-status="{{ row.review.status_str() }}" aria-label="Select review {{ row.review.id_val() }}">
</td>
<td><a href="/admin/reviews/{{ row.review.id_val() }}">{{ row.review.id_val() }}</a></td>
<td><span class="badge {{ row.review.status_badge_class() }}">{{ row.review.status_str() }}</span></td>
<td>{{ row.review.review_type_str() }}</td>
<td class="review-input-path" title="{{ row.review.input_path_str() }}">{{ row.display_input_path }}</td>
<td class="review-tag-cell">
{% for tag in row.media_tags %}
<span class="review-tag review-tag-{{ tag.kind }}">{{ tag.label }}</span>
{% endfor %}
</td>
<td>{% match row.review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}</td>
{% match stats_map.get(&row.review.id_val()) %}
{% when Some with (s) %}
<td>{{ s.model_name }}</td>
<td>{{ s.duration_display() }}</td>
@@ -54,20 +74,203 @@
<td>-</td>
<td>-</td>
{% endmatch %}
<td>{{ review.created_at_str() }}</td>
<td>{{ row.review.created_at_str() }}</td>
<td>
<a href="/admin/reviews/{{ review.id_val() }}">{{ t.reviews_view }}</a>
<a href="/admin/reviews/{{ row.review.id_val() }}">{{ t.reviews_view }}</a>
</td>
</tr>
{% endfor %}
</table>
</form>
{% endif %}
<style>
.review-bulk-toolbar {
margin-bottom: .75rem;
display: flex;
gap: .5rem;
align-items: center;
flex-wrap: wrap;
}
.review-toolbar-button,
.review-danger-button,
.review-primary-button {
padding: .35rem .7rem;
border-radius: 4px;
border: 1px solid #ced4da;
background: #fff;
color: #212529;
cursor: pointer;
}
.review-danger-button {
border-color: #dc3545;
color: #dc3545;
}
.review-primary-button {
border-color: #17a2b8;
color: #0c5460;
}
.review-danger-button:disabled,
.review-primary-button:disabled {
cursor: not-allowed;
opacity: .45;
}
.review-selection-summary {
min-height: 1.7rem;
padding: .35rem .6rem;
border: 1px solid #dee2e6;
border-radius: 4px;
background: #f8f9fa;
color: #495057;
font-size: .9rem;
white-space: nowrap;
}
.review-select-cell {
width: 2.25rem;
text-align: center;
}
.review-select {
width: 1rem;
height: 1rem;
}
.review-input-path {
max-width: 34rem;
white-space: normal;
overflow-wrap: anywhere;
}
.review-tag-cell {
max-width: 18rem;
}
.review-tag {
display: inline-block;
margin: .1rem .15rem .1rem 0;
padding: .12rem .35rem;
border: 1px solid #ced4da;
border-radius: 4px;
background: #f8f9fa;
color: #495057;
font-size: .8rem;
white-space: nowrap;
}
.review-tag-format { border-color: #9ec5fe; background: #e7f1ff; color: #084298; }
.review-tag-bitrate { border-color: #a3cfbb; background: #d1e7dd; color: #0f5132; }
.review-tag-sample { border-color: #ffda6a; background: #fff3cd; color: #664d03; }
.review-tag-depth { border-color: #d0bfff; background: #f0e7ff; color: #3d246c; }
.review-tag-size { border-color: #ced4da; background: #f8f9fa; color: #495057; }
.badge-completed { background: #d4edda; color: #155724; }
.badge-failed { background: #f8d7da; color: #721c24; }
.badge-pending { background: #fff3cd; color: #856404; }
.badge-queued { background: #d1ecf1; color: #0c5460; }
.badge-processing { background: #cce5ff; color: #004085; }
</style>
<script>
(() => {
const form = document.getElementById("reviews-bulk-form");
if (!form) {
return;
}
const checkboxes = Array.from(form.querySelectorAll(".review-select"));
const selectedIdsInput = document.getElementById("selected-review-ids");
const summary = document.getElementById("review-selection-summary");
const selectShownButton = document.getElementById("select-shown-reviews");
const clearSelectionButton = document.getElementById("clear-review-selection");
const submitButtons = Array.from(form.querySelectorAll("button[type='submit']"));
const selected = new Set();
const statusCounts = new Map();
const labels = {
pending: "{{ t.reviews_filter_pending }}",
approved: "{{ t.reviews_filter_approved }}",
rejected: "{{ t.reviews_filter_rejected }}",
queued: "{{ t.reviews_filter_queued }}",
processing: "{{ t.reviews_filter_processing }}",
auto_approved: "{{ t.reviews_filter_auto_approved }}",
failed: "{{ t.reviews_filter_failed }}"
};
function setStatusCount(status, delta) {
const next = (statusCounts.get(status) || 0) + delta;
if (next > 0) {
statusCounts.set(status, next);
} else {
statusCounts.delete(status);
}
}
function syncControls() {
selectedIdsInput.value = Array.from(selected).join(",");
const total = selected.size;
for (const button of submitButtons) {
button.disabled = total === 0;
}
if (total === 0) {
summary.textContent = "{{ t.reviews_selected_none }}";
return;
}
const parts = Array.from(statusCounts.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([status, count]) => `${labels[status] || status}: ${count}`);
summary.textContent = `{{ t.reviews_selected_prefix }}: ${total} (${parts.join(", ")})`;
}
function setChecked(checkbox, checked) {
const id = checkbox.value;
const isSelected = selected.has(id);
checkbox.checked = checked;
if (isSelected === checked) {
return;
}
const status = checkbox.dataset.status || "unknown";
if (checked) {
selected.add(id);
setStatusCount(status, 1);
} else {
selected.delete(id);
setStatusCount(status, -1);
}
}
for (const checkbox of checkboxes) {
checkbox.addEventListener("change", () => {
setChecked(checkbox, checkbox.checked);
syncControls();
});
}
selectShownButton.addEventListener("click", () => {
for (const checkbox of checkboxes) {
setChecked(checkbox, true);
}
syncControls();
});
clearSelectionButton.addEventListener("click", () => {
for (const checkbox of checkboxes) {
setChecked(checkbox, false);
}
syncControls();
});
form.addEventListener("submit", (event) => {
syncControls();
if (selected.size === 0) {
event.preventDefault();
alert("{{ t.reviews_none_selected_confirm }}");
return;
}
const action = event.submitter ? event.submitter.value : "";
const message = action === "requeue"
? "{{ t.reviews_requeue_selected_confirm }}"
: "{{ t.reviews_delete_selected_confirm }}";
if (!confirm(message)) {
event.preventDefault();
}
});
syncControls();
})();
</script>
{% endblock content %}
+5
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff