use std::collections::{HashMap, HashSet}; use std::path::Path; use cot::db::{Database, Model}; use cot::html::Html; use cot::http::StatusCode; use cot::http::header::CONTENT_TYPE; use cot::json::Json; use cot::response::IntoResponse; use cot::session::Session; use cot::{Body, Template}; use schemars::JsonSchema; use serde::{Deserialize, Deserializer, Serialize}; use sqlx::{PgPool, Postgres, QueryBuilder}; use super::BUILD_INFO; use crate::agent; use crate::auth::{self, AuthenticatedUser, Role}; use crate::config::{AppConfig, ConfigEntry, ConfigSources}; use crate::i18n::{I18n, Translations}; use crate::scheduler::{self, JobRegistry, JobRun, ScheduledJob}; #[derive(Debug, Template)] #[template(path = "admin/v2.html")] struct AdminV2Template { t: &'static Translations, user_name: String, user_role: String, version: &'static str, } #[derive(Debug, Deserialize)] pub(super) struct ReviewsQuery { pub(super) status: Option, pub(super) search: Option, pub(super) limit: Option, pub(super) offset: Option, } #[derive(Debug, Deserialize)] pub(super) struct LibraryQuery { pub(super) kind: Option, pub(super) search: Option, pub(super) limit: Option, pub(super) offset: Option, } #[derive(Debug, Deserialize)] pub(super) struct UsersQuery { pub(super) search: Option, pub(super) limit: Option, pub(super) offset: Option, } #[derive(Debug, Deserialize)] pub(super) struct BulkReviewsRequest { action: String, mode: Option, ids: Option>, filter: Option, } #[derive(Debug, Deserialize)] pub(super) struct BulkLibraryRequest { action: String, kind: String, mode: Option, ids: Option>, filter: Option, } #[derive(Debug, Deserialize)] pub struct MetadataBackfillRunRequest { #[serde(default = "default_true")] audio_bitrate: bool, #[serde(default = "default_true")] audio_sample_rate: bool, #[serde(default = "default_true")] audio_bit_depth: bool, #[serde(default = "default_true")] duration_seconds: bool, #[serde(default = "default_true")] local_genres: bool, #[serde(default = "default_true")] lastfm_tags: bool, #[serde(default = "default_true")] musicbrainz_tags: bool, #[serde(default)] overwrite: bool, } #[derive(Debug, Deserialize)] pub struct ArtworkBackfillRunRequest { #[serde(default)] overwrite_existing: bool, } #[derive(Debug, Deserialize)] pub(super) struct UpdateLibraryItemRequest { kind: String, id: i64, title: String, hidden: bool, release_type: Option, #[serde(default, deserialize_with = "deserialize_optional_stringish")] year: Option, release_id: Option, #[serde(default, deserialize_with = "deserialize_optional_stringish")] track_number: Option, #[serde(default, deserialize_with = "deserialize_optional_stringish")] disc_number: Option, artist_ids: Option>, release_tracks: Option>, } #[derive(Debug, Deserialize)] pub(super) struct LibraryItemDetailQuery { kind: String, id: i64, } #[derive(Debug, Deserialize)] pub(super) struct TrackSearchQuery { search: Option, limit: Option, } #[derive(Debug, Deserialize)] struct ReleaseTrackUpdateRequest { id: i64, #[serde(default, deserialize_with = "deserialize_optional_stringish")] track_number: Option, #[serde(default, deserialize_with = "deserialize_optional_stringish")] disc_number: Option, } #[derive(Debug, Deserialize)] pub(super) struct SetLibraryImageRequest { kind: String, id: i64, media_file_id: Option, } #[derive(Debug, Deserialize)] pub(super) struct UploadLibraryImageRequest { kind: String, id: i64, data: String, filename: String, mime_type: String, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] struct ReviewFilter { status: Option, search: Option, } #[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] struct LibraryFilter { search: Option, } #[derive(Debug, Serialize, JsonSchema)] struct AdminUserDto { id: i64, name: String, role: String, } #[derive(Debug, Serialize, JsonSchema)] struct BuildDto { package: &'static str, version: &'static str, profile: &'static str, target: &'static str, rustc_version: &'static str, } #[derive(Debug, Serialize, JsonSchema)] struct AdminDashboardDto { user: AdminUserDto, build: BuildDto, stats: OverviewStatsDto, runtime: RuntimeOverviewDto, reviews: ReviewPageDto, jobs: Vec, recent_runs: Vec, library: LibraryOverviewDto, } #[derive(Debug, Serialize, JsonSchema)] struct AdminUsersPageDto { items: Vec, total: i64, limit: i64, offset: i64, search: Option, online_count: i64, } #[derive(Debug, Serialize, JsonSchema)] struct AdminUserRowDto { id: i64, username: String, display_name: Option, email: Option, role: String, is_active: bool, is_online: bool, last_seen_ms: Option, } #[derive(Debug, Serialize, JsonSchema)] struct AdminUserDetailDto { user: AdminUserRowDto, stats: AdminUserStatsDto, recent_plays: Vec, } #[derive(Debug, Serialize, JsonSchema)] struct AdminUserStatsDto { plays: i64, completed_plays: i64, listened_seconds: i64, liked_tracks: i64, followed_artists: i64, own_playlists: i64, saved_playlists: i64, uploaded_tracks: i64, torrent_sessions: i64, lastfm_connected: bool, } #[derive(Debug, Serialize, JsonSchema)] struct AdminUserPlayDto { history_id: i64, played_at: String, duration_listened: Option, completed: bool, track_id: i64, title: String, artists: String, release_id: i64, release_title: String, release_year: Option, cover_url: Option, track_duration_seconds: f64, uploader_name: String, audio_format: Option, audio_bitrate: Option, } #[derive(Debug, Serialize, JsonSchema)] struct OverviewStatsDto { tracks: i64, releases: i64, artists: i64, playlists: i64, hidden_tracks: i64, hidden_releases: i64, hidden_artists: i64, } #[derive(Debug, Serialize, JsonSchema)] struct RuntimeOverviewDto { agent: AgentStatusDto, storage: Vec, node: NodeStatsDto, } #[derive(Debug, Serialize, JsonSchema)] struct AgentStatusDto { status: String, enabled: bool, llm_configured: bool, model: String, concurrency: u64, } #[derive(Debug, Serialize, JsonSchema)] struct StoragePathDto { label: String, path: String, exists: bool, free_bytes: Option, total_bytes: Option, } #[derive(Debug, Serialize, JsonSchema)] struct NodeStatsDto { hostname: String, os: &'static str, arch: &'static str, pid: u32, cpu_count: usize, } #[derive(Debug, Serialize, JsonSchema)] struct StatusCountDto { status: String, count: i64, } #[derive(Debug, Serialize, JsonSchema)] struct TagDto { label: String, kind: String, } #[derive(Debug, Serialize, JsonSchema)] struct MetadataTagDto { name: String, source: String, weight: f64, updated_at: String, } #[derive(Debug, Serialize, JsonSchema)] struct ReviewPageDto { items: Vec, total: i64, total_all: i64, limit: i64, offset: i64, status: Option, search: Option, status_counts: Vec, } #[derive(Debug, Serialize, JsonSchema)] struct ReviewDto { id: i64, job_run_id: i64, review_type: String, input_path: String, display_path: String, filename: String, status: String, confidence: Option, model_name: Option, llm_duration_ms: Option, token_count: Option, tags: Vec, error_message: Option, normalized: ReviewEditDto, created_at: String, updated_at: String, } #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] pub(super) struct ReviewEditDto { title: String, artist: String, album: String, year: String, track_number: String, genre: String, featured_artists: String, release_type: String, notes: String, } #[derive(Debug, Serialize, JsonSchema)] struct JobDto { name: String, description: String, cron_expression: String, enabled: bool, health: String, is_running: bool, last_run_at: Option, next_run_at: Option, recent_runs: Vec, } #[derive(Debug, Clone, Serialize, JsonSchema)] struct JobRunDto { id: i64, job_name: String, status: String, started_at: String, finished_at: Option, duration_ms: Option, trigger: String, error_message: Option, log_excerpt: String, } #[derive(Debug, Serialize, JsonSchema)] struct JobRunStartedDto { ok: bool, run_id: i64, } #[derive(Debug, Serialize, JsonSchema)] struct JobRunsDto { job_name: String, runs: Vec, } #[derive(Debug, Serialize, JsonSchema)] struct JobRunDetailDto { run: JobRunDto, log_output: String, } #[derive(Debug, Serialize, JsonSchema)] struct BulkReviewsResponse { ok: bool, affected: u64, } #[derive(Debug, Serialize, JsonSchema)] struct MutationResponse { ok: bool, affected: u64, } #[derive(Debug, Serialize, JsonSchema)] struct AdminSettingsDto { values: AdminSettingsValues, sources: AdminSettingsSources, lastfm_api_key_configured: bool, lastfm_shared_secret_configured: bool, lastfm_scrobbling_configured: bool, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] struct AdminSettingsValues { auth_password_enabled: bool, auth_sso_enabled: bool, oidc_button_text: String, oidc_issuer: String, oidc_client_id: String, oidc_client_secret: String, oidc_admin_groups: String, oidc_user_groups: String, swagger_enabled: bool, lastfm_api_key: String, lastfm_shared_secret: String, agent_enabled: bool, agent_inbox_dir: String, agent_storage_dir: String, agent_llm_url: String, agent_llm_model: String, agent_llm_auth: String, agent_confidence_threshold: String, agent_context_limit: String, agent_concurrency: String, } #[derive(Debug, Clone, Serialize, JsonSchema)] struct AdminSettingsSources { auth_password_enabled: &'static str, auth_sso_enabled: &'static str, oidc_button_text: &'static str, oidc_issuer: &'static str, oidc_client_id: &'static str, oidc_client_secret: &'static str, oidc_admin_groups: &'static str, oidc_user_groups: &'static str, swagger_enabled: &'static str, lastfm_api_key: &'static str, lastfm_shared_secret: &'static str, agent_enabled: &'static str, agent_inbox_dir: &'static str, agent_storage_dir: &'static str, agent_llm_url: &'static str, agent_llm_model: &'static str, agent_llm_auth: &'static str, agent_confidence_threshold: &'static str, agent_context_limit: &'static str, agent_concurrency: &'static str, } #[derive(Debug, Deserialize)] pub(super) struct UpdateSettingsRequest { auth_password_enabled: bool, auth_sso_enabled: bool, oidc_button_text: String, oidc_issuer: String, oidc_client_id: String, oidc_client_secret: String, oidc_admin_groups: String, oidc_user_groups: String, swagger_enabled: bool, lastfm_api_key: String, lastfm_shared_secret: String, agent_enabled: bool, agent_inbox_dir: String, agent_storage_dir: String, agent_llm_url: String, agent_llm_model: String, agent_llm_auth: String, agent_confidence_threshold: String, agent_context_limit: String, agent_concurrency: String, } #[derive(Debug, Serialize, JsonSchema)] struct AgentProbeDto { status: String, ok: bool, model_intro: String, model_name: String, prompt_tokens: Option, completion_tokens: Option, tokens_per_sec: Option, latency_ms: u64, error: String, } #[derive(Debug, Serialize, JsonSchema)] struct LibraryOverviewDto { artists: i64, releases: i64, tracks: i64, playlists: i64, } #[derive(Debug, Serialize, JsonSchema)] struct LibraryPageDto { kind: String, items: Vec, total: i64, limit: i64, offset: i64, search: Option, } #[derive(Debug, Serialize, JsonSchema)] struct LibraryItemDto { id: i64, kind: String, title: String, subtitle: String, is_hidden: Option, tags: Vec, updated_at: Option, } #[derive(Debug, Serialize, JsonSchema)] struct LibraryItemDetailDto { item: LibraryItemDto, title: String, hidden: bool, release_type: Option, year: Option, release_id: Option, track_number: Option, disc_number: Option, current_image_url: Option, selected_artist_ids: Vec, artists: Vec, releases: Vec, release_tracks: Vec, available_covers: Vec, metadata_tags: Vec, } #[derive(Debug, Serialize, JsonSchema)] struct ArtistOptionDto { id: i64, name: String, } #[derive(Debug, Serialize, JsonSchema)] struct ReleaseOptionDto { id: i64, title: String, subtitle: String, } #[derive(Debug, Serialize, JsonSchema)] struct ReleaseTrackDto { id: i64, title: String, artists: String, release_id: Option, release_title: Option, track_number: Option, disc_number: Option, duration_seconds: f64, is_hidden: bool, } #[derive(Debug, Serialize, JsonSchema)] struct AvailableCoverDto { media_file_id: i64, release_title: String, cover_url: String, } #[derive(Debug, sqlx::FromRow)] struct IdRow { id: i64, } #[derive(Debug, sqlx::FromRow)] struct CountRow { count: i64, } #[derive(Debug, sqlx::FromRow)] struct StatusCountRow { status: String, count: i64, } #[derive(Debug, sqlx::FromRow)] struct ReviewRow { id: i64, job_run_id: i64, review_type: String, input_path: Option, context_json: Option, result_json: Option, status: String, created_at: String, updated_at: String, error_message: Option, } #[derive(Debug, sqlx::FromRow)] struct ReviewStatsRow { pending_review_id: i64, model_name: String, llm_duration_ms: i64, prompt_tokens: i64, completion_tokens: i64, } #[derive(Debug, Clone, sqlx::FromRow)] struct ReviewMediaRow { sha256_hash: String, original_filename: String, file_size_bytes: i64, audio_format: Option, audio_bitrate: Option, audio_sample_rate: Option, audio_bit_depth: Option, } #[derive(Debug, Clone, sqlx::FromRow)] struct JobRunRow { id: i64, job_name: String, status: String, started_at: String, finished_at: Option, duration_ms: Option, trigger: String, error_message: Option, log_excerpt: String, } #[derive(Debug, sqlx::FromRow)] struct JobRunDetailRow { id: i64, job_name: String, status: String, started_at: String, finished_at: Option, duration_ms: Option, trigger: String, error_message: Option, log_excerpt: String, log_output: Option, } #[derive(Debug, sqlx::FromRow)] struct LibraryItemRow { id: i64, title: String, subtitle: Option, is_hidden: Option, primary_count: i64, secondary_count: i64, tertiary_count: i64, updated_at: Option, } #[derive(Debug, sqlx::FromRow)] struct ReleaseTrackRow { id: i64, title: String, artists: String, release_id: Option, release_title: Option, track_number: Option, disc_number: Option, duration_seconds: f64, is_hidden: bool, } pub async fn page(admin: AuthenticatedUser, i18n: I18n) -> cot::Result { let template = AdminV2Template { t: i18n.t, user_name: admin.name, user_role: admin.role.code().to_owned(), version: BUILD_INFO.pkg_version, }; Ok(Html::new(template.render()?)) } pub async fn dashboard( session: Session, db: Database, pool: &PgPool, registry: &JobRegistry, ) -> cot::Result { let admin = match require_admin_json(&session, &db).await { Ok(admin) => admin, Err(response) => return Ok(response), }; sync_registered_jobs(&db, registry).await; let reviews_query = ReviewsQuery { status: None, search: None, limit: Some(80), offset: Some(0), }; let (config, _) = AppConfig::load_with_db(&db).await; let runtime = load_runtime_overview(&config); let (reviews, stats, jobs, recent_runs, library) = tokio::try_join!( load_review_page(pool, reviews_query), load_overview_stats(pool), load_jobs(&db, pool, registry), load_recent_runs(pool, 5), load_library_overview(pool), ) .map_err(|e| cot::Error::internal(e.to_string()))?; Json(AdminDashboardDto { user: AdminUserDto { id: admin.id, name: admin.name, role: admin.role.code().to_owned(), }, build: build_dto(), stats, runtime, reviews, jobs, recent_runs, library, }) .into_response() } pub async fn reviews( session: Session, db: Database, pool: &PgPool, query: ReviewsQuery, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let page = load_review_page(pool, query) .await .map_err(|e| cot::Error::internal(e.to_string()))?; Json(page).into_response() } pub async fn users( session: Session, db: Database, pool: &PgPool, query: UsersQuery, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let page = load_admin_users_page(pool, query) .await .map_err(|e| cot::Error::internal(e.to_string()))?; Json(page).into_response() } pub async fn user_detail( session: Session, db: Database, pool: &PgPool, user_id: i64, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let detail = load_admin_user_detail(pool, user_id) .await .map_err(|e| cot::Error::internal(e.to_string()))?; match detail { Some(detail) => Json(detail).into_response(), None => Ok(json_error(StatusCode::NOT_FOUND, "user not found")), } } pub async fn bulk_reviews( session: Session, db: Database, pool: &PgPool, Json(body): Json, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let action = body.action.trim(); if action != "delete" && action != "requeue" { return Ok(json_error(StatusCode::BAD_REQUEST, "unknown bulk action")); } let mode = body.mode.as_deref().unwrap_or("ids"); let affected = if mode == "filter" { apply_review_filter_action(pool, action, body.filter.unwrap_or_default()).await? } else { let mut ids = body.ids.unwrap_or_default(); ids.retain(|id| *id > 0); ids.sort_unstable(); ids.dedup(); if ids.is_empty() { 0 } else if action == "delete" { crate::scheduler::PendingReview::delete_by_ids(&db, &ids) .await .map_err(|e| cot::Error::internal(e.to_string()))?; ids.len() as u64 } else { crate::scheduler::PendingReview::requeue_by_ids(&db, &ids) .await .map_err(|e| cot::Error::internal(e.to_string()))?; ids.len() as u64 } }; Json(BulkReviewsResponse { ok: true, affected }).into_response() } pub async fn approve_review( session: Session, db: Database, pool: &PgPool, review_id: i64, Json(body): Json, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let mut review = crate::scheduler::PendingReview::get_by_id(&db, review_id) .await .map_err(|e| cot::Error::internal(e.to_string()))? .ok_or_else(|| cot::Error::internal("review not found"))?; let normalized = normalized_from_review_edit(&body); let result_json = serde_json::to_string(&normalized) .map_err(|e| cot::Error::internal(format!("failed to serialize review fields: {e}")))?; review .set_result_json(&db, result_json) .await .map_err(|e| cot::Error::internal(e.to_string()))?; let context: serde_json::Value = serde_json::from_str(review.context_json_str()).unwrap_or_default(); let input_path = review.input_path_str().to_owned(); let (live_config, _) = AppConfig::load_with_db(&db).await; let stats = crate::scheduler::ProcessingStats::get_by_review_id(&db, review_id) .await .unwrap_or(None); let model_name = stats.as_ref().map(|s| s.model_name.to_string()); match crate::jobs::inbox_process::finalize_approved( &db, pool, &live_config, &input_path, &normalized, &context, &live_config.agent_storage_dir, model_name.as_deref(), ) .await { Ok(()) => { let _ = review.set_approved(&db).await; Json(serde_json::json!({ "ok": true })).into_response() } Err(error) => { tracing::error!(?error, "review approval failed"); let _ = review.set_rejected(&db).await; Ok(json_error( StatusCode::INTERNAL_SERVER_ERROR, "review approval failed", )) } } } pub async fn jobs( session: Session, db: Database, pool: &PgPool, registry: &JobRegistry, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } sync_registered_jobs(&db, registry).await; let jobs = load_jobs(&db, pool, registry) .await .map_err(|e| cot::Error::internal(e.to_string()))?; Json(jobs).into_response() } pub async fn settings(session: Session, db: Database) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let (config, sources) = AppConfig::load_with_db(&db).await; Json(settings_dto(config, sources)).into_response() } pub async fn update_settings( session: Session, db: Database, Json(body): Json, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let fields = [ ( "auth_password_enabled", body.auth_password_enabled.to_string(), ), ("auth_sso_enabled", body.auth_sso_enabled.to_string()), ("oidc_button_text", body.oidc_button_text.trim().to_string()), ("oidc_issuer", body.oidc_issuer.trim().to_string()), ("oidc_client_id", body.oidc_client_id.trim().to_string()), ( "oidc_client_secret", body.oidc_client_secret.trim().to_string(), ), ( "oidc_admin_groups", body.oidc_admin_groups.trim().to_string(), ), ("oidc_user_groups", body.oidc_user_groups.trim().to_string()), ("swagger_enabled", body.swagger_enabled.to_string()), ("lastfm_api_key", body.lastfm_api_key.trim().to_string()), ( "lastfm_shared_secret", body.lastfm_shared_secret.trim().to_string(), ), ("agent_enabled", body.agent_enabled.to_string()), ("agent_inbox_dir", body.agent_inbox_dir.trim().to_string()), ( "agent_storage_dir", body.agent_storage_dir.trim().to_string(), ), ("agent_llm_url", body.agent_llm_url.trim().to_string()), ("agent_llm_model", body.agent_llm_model.trim().to_string()), ("agent_llm_auth", body.agent_llm_auth.trim().to_string()), ( "agent_confidence_threshold", body.agent_confidence_threshold.trim().to_string(), ), ( "agent_context_limit", body.agent_context_limit.trim().to_string(), ), ( "agent_concurrency", body.agent_concurrency.trim().to_string(), ), ]; for (key, value) in fields { let mut entry = ConfigEntry::new(key.to_string(), value); entry .save(&db) .await .map_err(|e| cot::Error::internal(e.to_string()))?; } Json(serde_json::json!({ "ok": true })).into_response() } pub async fn settings_probe( session: Session, db: Database, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let (config, _) = 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 } else { agent::AgentProbeResult::default() }; let status = if !config.agent_enabled { "disabled" } else if config.agent_llm_url.is_empty() { "not_configured" } else if probe.ok { "ok" } else { "error" }; Json(AgentProbeDto { status: status.to_string(), ok: probe.ok, model_intro: probe.model_intro, model_name: probe.model_name, prompt_tokens: probe.prompt_tokens, completion_tokens: probe.completion_tokens, tokens_per_sec: probe.tokens_per_sec, latency_ms: probe.latency_ms, error: probe.error, }) .into_response() } fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto { AdminSettingsDto { lastfm_api_key_configured: !config.lastfm_api_key.trim().is_empty(), lastfm_shared_secret_configured: !config.lastfm_shared_secret.trim().is_empty(), lastfm_scrobbling_configured: !config.lastfm_api_key.trim().is_empty() && !config.lastfm_shared_secret.trim().is_empty(), values: AdminSettingsValues { auth_password_enabled: config.auth_password_enabled, auth_sso_enabled: config.auth_sso_enabled, oidc_button_text: config.oidc_button_text, oidc_issuer: config.oidc_issuer, oidc_client_id: config.oidc_client_id, oidc_client_secret: config.oidc_client_secret, oidc_admin_groups: config.oidc_admin_groups, oidc_user_groups: config.oidc_user_groups, swagger_enabled: config.swagger_enabled, lastfm_api_key: config.lastfm_api_key, lastfm_shared_secret: config.lastfm_shared_secret, agent_enabled: config.agent_enabled, agent_inbox_dir: config.agent_inbox_dir, agent_storage_dir: config.agent_storage_dir, agent_llm_url: config.agent_llm_url, agent_llm_model: config.agent_llm_model, agent_llm_auth: config.agent_llm_auth, agent_confidence_threshold: config.agent_confidence_threshold.to_string(), agent_context_limit: config.agent_context_limit.to_string(), agent_concurrency: config.agent_concurrency.to_string(), }, sources: AdminSettingsSources { auth_password_enabled: sources.auth_password_enabled.code(), auth_sso_enabled: sources.auth_sso_enabled.code(), oidc_button_text: sources.oidc_button_text.code(), oidc_issuer: sources.oidc_issuer.code(), oidc_client_id: sources.oidc_client_id.code(), oidc_client_secret: sources.oidc_client_secret.code(), oidc_admin_groups: sources.oidc_admin_groups.code(), oidc_user_groups: sources.oidc_user_groups.code(), swagger_enabled: sources.swagger_enabled.code(), lastfm_api_key: sources.lastfm_api_key.code(), lastfm_shared_secret: sources.lastfm_shared_secret.code(), agent_enabled: sources.agent_enabled.code(), agent_inbox_dir: sources.agent_inbox_dir.code(), agent_storage_dir: sources.agent_storage_dir.code(), agent_llm_url: sources.agent_llm_url.code(), agent_llm_model: sources.agent_llm_model.code(), agent_llm_auth: sources.agent_llm_auth.code(), agent_confidence_threshold: sources.agent_confidence_threshold.code(), agent_context_limit: sources.agent_context_limit.code(), agent_concurrency: sources.agent_concurrency.code(), }, } } pub async fn run_job( session: Session, db: Database, handle_cell: &std::sync::Arc< tokio::sync::OnceCell>, >, job_name: &str, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let Some(handle) = handle_cell.get() else { return Ok(json_error( StatusCode::SERVICE_UNAVAILABLE, "scheduler is not ready", )); }; match std::sync::Arc::clone(handle) .trigger_job_now_background(job_name) .await { Ok(run_id) => Json(JobRunStartedDto { ok: true, run_id }).into_response(), Err(e) => Ok(json_error(StatusCode::BAD_REQUEST, &e.to_string())), } } pub async fn run_metadata_backfill( session: Session, db: Database, pool: &PgPool, Json(body): Json, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let options = crate::jobs::metadata_backfill::MetadataBackfillOptions { audio_bitrate: body.audio_bitrate, audio_sample_rate: body.audio_sample_rate, audio_bit_depth: body.audio_bit_depth, duration_seconds: body.duration_seconds, local_genres: body.local_genres, lastfm_tags: body.lastfm_tags, musicbrainz_tags: body.musicbrainz_tags, overwrite: body.overwrite, }; if !options.any_field() { return Ok(json_error( StatusCode::BAD_REQUEST, "select at least one metadata field", )); } 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 (live_config, _) = AppConfig::load_with_db(&db).await; let db_for_task = db.clone(); let pool_for_task = pool.clone(); tokio::spawn(async move { let start = std::time::Instant::now(); let ctx = scheduler::JobContext { config: std::sync::Arc::new(live_config), db: db_for_task.clone(), pool: pool_for_task.clone(), run_id, registry: std::sync::Arc::new(JobRegistry::new()), }; let mut log = scheduler::JobLog::with_live_flush(pool_for_task.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_for_task, duration_ms, &log.output()) .await; } Err(err) => { let _ = run .set_failed(&db_for_task, duration_ms, &log.output(), &err.to_string()) .await; } } }); Json(JobRunStartedDto { ok: true, run_id }).into_response() } pub async fn run_artwork_backfill( session: Session, db: Database, pool: &PgPool, Json(body): Json, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let options = crate::jobs::artwork_backfill::ArtworkBackfillOptions { overwrite_existing: body.overwrite_existing, }; let mut run = JobRun::create_running(&db, "artwork_backfill", "manual") .await .map_err(|e| cot::Error::internal(format!("failed to create job run: {e}")))?; let run_id = run.id_val(); let (live_config, _) = AppConfig::load_with_db(&db).await; let db_for_task = db.clone(); let pool_for_task = pool.clone(); tokio::spawn(async move { let start = std::time::Instant::now(); let ctx = scheduler::JobContext { config: std::sync::Arc::new(live_config), db: db_for_task.clone(), pool: pool_for_task.clone(), run_id, registry: std::sync::Arc::new(JobRegistry::new()), }; let mut log = scheduler::JobLog::with_live_flush(pool_for_task.clone(), run_id); let result = crate::jobs::artwork_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_for_task, duration_ms, &log.output()) .await; } Err(err) => { let _ = run .set_failed(&db_for_task, duration_ms, &log.output(), &err.to_string()) .await; } } }); Json(JobRunStartedDto { ok: true, run_id }).into_response() } pub async fn toggle_job( session: Session, db: Database, handle_cell: &std::sync::Arc< tokio::sync::OnceCell>, >, job_name: &str, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let job = ScheduledJob::get_by_name(&db, job_name) .await .map_err(|e| cot::Error::internal(e.to_string()))? .ok_or_else(|| cot::Error::internal("job not found"))?; let Some(handle) = handle_cell.get() else { return Ok(json_error( StatusCode::SERVICE_UNAVAILABLE, "scheduler is not ready", )); }; let enabled = !job.enabled; if let Err(e) = handle.toggle_job(job_name, enabled).await { return Ok(json_error(StatusCode::BAD_REQUEST, &e.to_string())); } Json(serde_json::json!({ "ok": true, "enabled": enabled })).into_response() } pub async fn job_runs( session: Session, db: Database, pool: &PgPool, job_name: &str, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let runs = load_runs_for_job(pool, job_name, 40) .await .map_err(|e| cot::Error::internal(e.to_string()))?; Json(JobRunsDto { job_name: job_name.to_owned(), runs, }) .into_response() } pub async fn job_run_detail( session: Session, db: Database, pool: &PgPool, run_id: i64, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let row = sqlx::query_as::<_, JobRunDetailRow>( "SELECT id, job_name::text AS job_name, status::text AS status, started_at::text AS started_at, \ finished_at, duration_ms, trigger::text AS trigger, error_message, \ LEFT(COALESCE(log_output, ''), 1600) AS log_excerpt, log_output \ FROM furumusic__job_run WHERE id = $1", ) .bind(run_id) .fetch_optional(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; let Some(row) = row else { return Ok(json_error(StatusCode::NOT_FOUND, "run not found")); }; let log_output = row.log_output.clone().unwrap_or_default(); Json(JobRunDetailDto { run: row.into(), log_output, }) .into_response() } pub async fn library( session: Session, db: Database, pool: &PgPool, query: LibraryQuery, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let page = load_library_page(pool, query) .await .map_err(|e| cot::Error::internal(e.to_string()))?; Json(page).into_response() } pub async fn library_item_detail( session: Session, db: Database, pool: &PgPool, query: LibraryItemDetailQuery, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let kind = normalize_library_kind(Some(query.kind.as_str())); if kind == "releases" && query.id == 0 { let item = LibraryItemDto { id: 0, kind: kind.clone(), title: String::new(), subtitle: String::new(), is_hidden: Some(false), tags: Vec::new(), updated_at: None, }; let detail = load_library_item_detail(pool, &kind, item) .await .map_err(|e| cot::Error::internal(e.to_string()))?; return Json(detail).into_response(); } let Some(item) = fetch_library_item(pool, &kind, query.id) .await .map_err(|e| cot::Error::internal(e.to_string()))? else { return Ok(json_error(StatusCode::NOT_FOUND, "library item not found")); }; let detail = load_library_item_detail(pool, &kind, item) .await .map_err(|e| cot::Error::internal(e.to_string()))?; Json(detail).into_response() } pub async fn track_search( session: Session, db: Database, pool: &PgPool, query: TrackSearchQuery, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let Some(search) = clean_search(query.search.as_deref()) else { return Json(Vec::::new()).into_response(); }; let tracks = search_tracks(pool, &search, query.limit.unwrap_or(16).clamp(1, 40)) .await .map_err(|e| cot::Error::internal(e.to_string()))?; Json(tracks).into_response() } pub async fn update_library_item( session: Session, db: Database, pool: &PgPool, Json(body): Json, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let kind = normalize_library_kind(Some(body.kind.as_str())); let title = body.title.trim(); if title.is_empty() { return Ok(json_error(StatusCode::BAD_REQUEST, "title cannot be empty")); } let now = now_string(); if kind == "releases" && body.id == 0 { let release_id = create_release_library_item(pool, &body, title, &now) .await .map_err(|e| cot::Error::internal(e.to_string()))?; let Some(item) = fetch_library_item(pool, &kind, release_id) .await .map_err(|e| cot::Error::internal(e.to_string()))? else { return Ok(json_error(StatusCode::NOT_FOUND, "library item not found")); }; return Json(item).into_response(); } let affected = match kind.as_str() { "artists" => { sqlx::query( "UPDATE furumusic__artist \ SET name = $1, name_sort = $2, is_hidden = $3, updated_at = $4 \ WHERE id = $5", ) .bind(title) .bind(normalize_name(title)) .bind(body.hidden) .bind(&now) .bind(body.id) .execute(pool) .await } "releases" => { let release_type = body .release_type .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("album"); let year = body .year .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .and_then(|value| value.parse::().ok()); sqlx::query( "UPDATE furumusic__release \ SET title = $1, title_sort = $2, release_type = $3, year = $4, is_hidden = $5, updated_at = $6 \ WHERE id = $7", ) .bind(title) .bind(normalize_name(title)) .bind(release_type) .bind(year) .bind(body.hidden) .bind(&now) .bind(body.id) .execute(pool) .await } "tracks" => { let release_id = body.release_id.unwrap_or(0); if release_id <= 0 { return Ok(json_error(StatusCode::BAD_REQUEST, "release is required")); } let release_exists: Option = sqlx::query_scalar("SELECT id FROM furumusic__release WHERE id = $1") .bind(release_id) .fetch_optional(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; if release_exists.is_none() { return Ok(json_error(StatusCode::NOT_FOUND, "release not found")); } let year = parse_optional_admin_i32(body.year.as_deref(), 0, 3000); let track_number = parse_optional_admin_i32(body.track_number.as_deref(), 1, 9999); let disc_number = parse_optional_admin_i32(body.disc_number.as_deref(), 1, 999); sqlx::query( "UPDATE furumusic__track \ SET title = $1, title_sort = $2, release_id = $3, track_number = $4, disc_number = $5, year = $6, is_hidden = $7, updated_at = $8 \ WHERE id = $9", ) .bind(title) .bind(normalize_name(title)) .bind(release_id) .bind(track_number) .bind(disc_number) .bind(year) .bind(body.hidden) .bind(&now) .bind(body.id) .execute(pool) .await } "playlists" => { sqlx::query( "UPDATE furumusic__playlist \ SET title = $1, is_public = $2, updated_at = $3 \ WHERE id = $4", ) .bind(title) .bind(!body.hidden) .bind(&now) .bind(body.id) .execute(pool) .await } _ => unreachable!(), } .map_err(|e| cot::Error::internal(e.to_string()))? .rows_affected(); if affected == 0 { return Ok(json_error(StatusCode::NOT_FOUND, "library item not found")); } if kind == "releases" || kind == "tracks" { if let Some(mut artist_ids) = body.artist_ids { let mut seen_artist_ids = HashSet::new(); artist_ids.retain(|id| *id > 0 && seen_artist_ids.insert(*id)); if kind == "releases" { set_release_artists(pool, body.id, &artist_ids) .await .map_err(|e| cot::Error::internal(e.to_string()))?; } else { sqlx::query( "DELETE FROM furumusic__track_artist WHERE track_id = $1 AND role = 'main'", ) .bind(body.id) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; for (position, artist_id) in artist_ids.iter().enumerate() { sqlx::query( "INSERT INTO furumusic__track_artist (track_id, artist_id, role, position) VALUES ($1, $2, 'main', $3)", ) .bind(body.id) .bind(*artist_id) .bind(position as i32) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; } } } } if kind == "releases" { if let Some(release_tracks) = body.release_tracks.as_deref() { update_release_tracks(pool, body.id, release_tracks, &now) .await .map_err(|e| cot::Error::internal(e.to_string()))?; } } let Some(item) = fetch_library_item(pool, &kind, body.id) .await .map_err(|e| cot::Error::internal(e.to_string()))? else { return Ok(json_error(StatusCode::NOT_FOUND, "library item not found")); }; Json(item).into_response() } pub async fn set_library_item_image( session: Session, db: Database, pool: &PgPool, Json(body): Json, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let kind = normalize_library_kind(Some(body.kind.as_str())); if kind != "artists" && kind != "releases" { return Ok(json_error(StatusCode::BAD_REQUEST, "unsupported kind")); } if let Some(fid) = body.media_file_id { let exists: Option = sqlx::query_scalar( "SELECT id FROM furumusic__media_file WHERE id = $1 AND file_type = 'cover_art'", ) .bind(fid) .fetch_optional(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; if exists.is_none() { return Ok(json_error(StatusCode::NOT_FOUND, "image not found")); } } let now = now_string(); let result = if kind == "releases" { sqlx::query( "UPDATE furumusic__release SET cover_file_id = $1, updated_at = $2 WHERE id = $3", ) .bind(body.media_file_id) .bind(&now) .bind(body.id) .execute(pool) .await } else { sqlx::query( "UPDATE furumusic__artist SET image_file_id = $1, updated_at = $2 WHERE id = $3", ) .bind(body.media_file_id) .bind(&now) .bind(body.id) .execute(pool) .await } .map_err(|e| cot::Error::internal(e.to_string()))?; if result.rows_affected() == 0 { return Ok(json_error(StatusCode::NOT_FOUND, "library item not found")); } Json(serde_json::json!({ "ok": true })).into_response() } pub async fn upload_library_item_image( session: Session, db: Database, pool: &PgPool, Json(body): Json, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let kind = normalize_library_kind(Some(body.kind.as_str())); if kind != "artists" && kind != "releases" { return Ok(json_error(StatusCode::BAD_REQUEST, "unsupported kind")); } let storage_dir = AppConfig::load_with_db(&db).await.0.agent_storage_dir; if storage_dir.trim().is_empty() { return Err(cot::Error::internal("agent_storage_dir is not configured")); } use base64::Engine; let image_data = base64::engine::general_purpose::STANDARD .decode(body.data.trim()) .map_err(|e| cot::Error::internal(format!("invalid base64: {e}")))?; if image_data.is_empty() { return Ok(json_error(StatusCode::BAD_REQUEST, "image is empty")); } let title: Option = if kind == "releases" { sqlx::query_scalar("SELECT title::text FROM furumusic__release WHERE id = $1") } else { sqlx::query_scalar("SELECT name::text FROM furumusic__artist WHERE id = $1") } .bind(body.id) .fetch_optional(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; let Some(title) = title else { return Ok(json_error(StatusCode::NOT_FOUND, "library item not found")); }; let cover = crate::agent::cover_art::CoverImage { data: image_data, mime_type: body.mime_type, source: crate::agent::cover_art::CoverSource::FolderFile(std::path::PathBuf::from( body.filename, )), }; let media_file_id = crate::agent::cover_art::save_cover_to_storage( &db, pool, &storage_dir, &title, if kind == "artists" { "__artist_image__" } else { "__release_cover__" }, &cover, ) .await .map_err(|e| cot::Error::internal(format!("failed to save image: {e}")))?; set_library_item_image( session, db, pool, Json(SetLibraryImageRequest { kind, id: body.id, media_file_id: Some(media_file_id), }), ) .await } pub async fn bulk_library( session: Session, db: Database, pool: &PgPool, Json(body): Json, ) -> cot::Result { if let Err(response) = require_admin_json(&session, &db).await { return Ok(response); } let kind = normalize_library_kind(Some(body.kind.as_str())); let action = body.action.trim(); if !matches!(action, "hide" | "show" | "delete") { return Ok(json_error( StatusCode::BAD_REQUEST, "unknown library action", )); } let mut ids = if body.mode.as_deref() == Some("filter") { library_ids_by_filter(pool, &kind, body.filter.unwrap_or_default()).await? } else { body.ids.unwrap_or_default() }; ids.retain(|id| *id > 0); ids.sort_unstable(); ids.dedup(); if ids.is_empty() { return Json(MutationResponse { ok: true, affected: 0, }) .into_response(); } let affected = apply_library_action(pool, &kind, action, &ids).await?; Json(MutationResponse { ok: true, affected }).into_response() } async fn require_admin_json( session: &Session, db: &Database, ) -> Result { let Some(user) = auth::get_session_user(session, db).await else { return Err(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; if user.role != Role::Admin { return Err(json_error(StatusCode::FORBIDDEN, "forbidden")); } Ok(user) } fn json_error(status: StatusCode, message: &str) -> cot::response::Response { let body = serde_json::json!({ "error": message }); cot::http::Response::builder() .status(status) .header(CONTENT_TYPE, "application/json") .body(Body::fixed(body.to_string())) .expect("valid response") } fn build_dto() -> BuildDto { BuildDto { package: BUILD_INFO.pkg_name, version: BUILD_INFO.pkg_version, profile: BUILD_INFO.profile, target: BUILD_INFO.target, rustc_version: BUILD_INFO.rustc_version, } } 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 { 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()); let _ = ScheduledJob::delete_by_name(db, sched_job.name_str()).await; } } } } async fn load_overview_stats(pool: &PgPool) -> anyhow::Result { let tracks: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM furumusic__track") .fetch_one(pool) .await?; let releases: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM furumusic__release") .fetch_one(pool) .await?; let artists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM furumusic__artist") .fetch_one(pool) .await?; let playlists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM furumusic__playlist") .fetch_one(pool) .await?; let hidden_tracks: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM furumusic__track WHERE is_hidden") .fetch_one(pool) .await?; let hidden_releases: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM furumusic__release WHERE is_hidden") .fetch_one(pool) .await?; let hidden_artists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM furumusic__artist WHERE is_hidden") .fetch_one(pool) .await?; Ok(OverviewStatsDto { tracks, releases, artists, playlists, hidden_tracks, hidden_releases, hidden_artists, }) } #[derive(Debug, sqlx::FromRow)] struct AdminUserSqlRow { id: i64, username: String, display_name: Option, email: Option, role: String, is_active: bool, } async fn load_admin_users_page( pool: &PgPool, query: UsersQuery, ) -> anyhow::Result { let limit = query.limit.unwrap_or(40).clamp(10, 200); let offset = query.offset.unwrap_or(0).max(0); let search = query .search .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_owned); let pattern = search.as_ref().map(|value| format!("%{value}%")); let mut count_qb = QueryBuilder::::new("SELECT COUNT(*) FROM furumusic__user WHERE 1=1"); if let Some(pattern) = pattern.as_ref() { count_qb.push(" AND (username ILIKE "); count_qb.push_bind(pattern); count_qb.push(" OR COALESCE(display_name, '') ILIKE "); count_qb.push_bind(pattern); count_qb.push(" OR COALESCE(email, '') ILIKE "); count_qb.push_bind(pattern); count_qb.push(")"); } let total: i64 = count_qb.build_query_scalar().fetch_one(pool).await?; let mut qb = QueryBuilder::::new( "SELECT id, username::text, display_name, email, role::text, is_active FROM furumusic__user WHERE 1=1", ); if let Some(pattern) = pattern.as_ref() { qb.push(" AND (username ILIKE "); qb.push_bind(pattern); qb.push(" OR COALESCE(display_name, '') ILIKE "); qb.push_bind(pattern); qb.push(" OR COALESCE(email, '') ILIKE "); qb.push_bind(pattern); qb.push(")"); } qb.push(" ORDER BY username ASC LIMIT "); qb.push_bind(limit); qb.push(" OFFSET "); qb.push_bind(offset); let rows: Vec = qb.build_query_as().fetch_all(pool).await?; let active = crate::metrics::active_user_last_seen_ms(); let online_cutoff_ms = 60_000; let items = rows .into_iter() .map(|row| admin_user_row(row, &active, online_cutoff_ms)) .collect::>(); let online_count = active .values() .filter(|last_seen_ms| **last_seen_ms <= online_cutoff_ms) .count() as i64; Ok(AdminUsersPageDto { items, total, limit, offset, search, online_count, }) } async fn load_admin_user_detail( pool: &PgPool, user_id: i64, ) -> anyhow::Result> { let row = sqlx::query_as::<_, AdminUserSqlRow>( "SELECT id, username::text, display_name, email, role::text, is_active FROM furumusic__user WHERE id = $1", ) .bind(user_id) .fetch_optional(pool) .await?; let Some(row) = row else { return Ok(None); }; let active = crate::metrics::active_user_last_seen_ms(); let user = admin_user_row(row, &active, 60_000); let ( plays, completed_plays, listened_seconds, liked_tracks, followed_artists, own_playlists, saved_playlists, uploaded_tracks, torrent_sessions, lastfm_connected, ) = tokio::try_join!( sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM furumusic__play_history WHERE user_id = $1" ) .bind(user_id) .fetch_one(pool), sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM furumusic__play_history WHERE user_id = $1 AND completed" ) .bind(user_id) .fetch_one(pool), sqlx::query_scalar::<_, i64>( "SELECT COALESCE(SUM(duration_listened), 0)::bigint FROM furumusic__play_history WHERE user_id = $1" ) .bind(user_id) .fetch_one(pool), sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM furumusic__user_liked_track WHERE user_id = $1" ) .bind(user_id) .fetch_one(pool), sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM furumusic__user_followed_artist WHERE user_id = $1" ) .bind(user_id) .fetch_one(pool), sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM furumusic__playlist WHERE owner_id = $1" ) .bind(user_id) .fetch_one(pool), sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM furumusic__saved_playlist WHERE user_id = $1" ) .bind(user_id) .fetch_one(pool), sqlx::query_scalar::<_, i64>( "SELECT COUNT(DISTINCT t.id) FROM furumusic__track t JOIN furumusic__media_file mf ON mf.id = t.audio_file_id WHERE mf.uploaded_by_user_id = $1" ) .bind(user_id) .fetch_one(pool), sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM furumusic__torrent_session WHERE user_id = $1" ) .bind(user_id) .fetch_one(pool), sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM furumusic__lastfm_account WHERE user_id = $1 AND session_key <> '')" ) .bind(user_id) .fetch_one(pool), )?; let recent_plays = load_admin_user_recent_plays(pool, user_id, 30).await?; Ok(Some(AdminUserDetailDto { user, stats: AdminUserStatsDto { plays, completed_plays, listened_seconds, liked_tracks, followed_artists, own_playlists, saved_playlists, uploaded_tracks, torrent_sessions, lastfm_connected, }, recent_plays, })) } async fn load_admin_user_recent_plays( pool: &PgPool, user_id: i64, limit: i64, ) -> anyhow::Result> { let rows = sqlx::query_as::<_, AdminUserPlaySqlRow>( r#"SELECT ph.id AS history_id, ph.played_at::text AS played_at, ph.duration_listened, ph.completed, t.id AS track_id, t.title::text AS title, COALESCE(NULLIF(STRING_AGG(a.name::text, ', ' ORDER BY ta.position), ''), 'Unknown artist') AS artists, t.release_id, COALESCE(r.title::text, '') AS release_title, r.year AS release_year, t.cover_file_id, r.cover_file_id AS release_cover_file_id, t.duration_seconds AS track_duration_seconds, COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name, mf.audio_format, mf.audio_bitrate FROM furumusic__play_history ph JOIN furumusic__track t ON t.id = ph.track_id LEFT JOIN furumusic__release r ON r.id = t.release_id LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id AND ta.role = 'main' LEFT JOIN furumusic__artist a ON a.id = ta.artist_id WHERE ph.user_id = $1 GROUP BY ph.id, ph.played_at, ph.duration_listened, ph.completed, t.id, t.title, t.release_id, r.title, r.year, t.cover_file_id, r.cover_file_id, t.duration_seconds, mf.uploader_name, mf.audio_format, mf.audio_bitrate ORDER BY ph.played_at DESC, ph.id DESC LIMIT $2"#, ) .bind(user_id) .bind(limit) .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|row| AdminUserPlayDto { history_id: row.history_id, played_at: row.played_at, duration_listened: row.duration_listened, completed: row.completed, track_id: row.track_id, title: row.title, artists: row.artists, release_id: row.release_id, release_title: row.release_title, release_year: row.release_year, cover_url: admin_track_cover_url(row.cover_file_id, row.release_cover_file_id), track_duration_seconds: row.track_duration_seconds, uploader_name: row.uploader_name, audio_format: row.audio_format, audio_bitrate: row.audio_bitrate, }) .collect()) } #[derive(Debug, sqlx::FromRow)] struct AdminUserPlaySqlRow { history_id: i64, played_at: String, duration_listened: Option, completed: bool, track_id: i64, title: String, artists: String, release_id: i64, release_title: String, release_year: Option, cover_file_id: Option, release_cover_file_id: Option, track_duration_seconds: f64, uploader_name: String, audio_format: Option, audio_bitrate: Option, } fn admin_track_cover_url(track_cover: Option, release_cover: Option) -> Option { track_cover .or(release_cover) .map(|id| format!("/api/player/cover/{id}/medium")) } fn admin_user_row( row: AdminUserSqlRow, active: &HashMap, online_cutoff_ms: i64, ) -> AdminUserRowDto { let last_seen_ms = active.get(&row.id).copied(); AdminUserRowDto { id: row.id, username: row.username, display_name: row.display_name, email: row.email, role: row.role, is_active: row.is_active, is_online: last_seen_ms.is_some_and(|value| value <= online_cutoff_ms), last_seen_ms, } } fn load_runtime_overview(config: &AppConfig) -> RuntimeOverviewDto { let llm_configured = !config.agent_llm_url.trim().is_empty(); let agent_status = if !config.agent_enabled { "disabled" } else if !llm_configured { "not_configured" } else { "enabled" }; RuntimeOverviewDto { agent: AgentStatusDto { status: agent_status.to_owned(), enabled: config.agent_enabled, llm_configured, model: config.agent_llm_model.clone(), concurrency: config.agent_concurrency, }, storage: vec![ storage_path_dto("Inbox", &config.agent_inbox_dir), storage_path_dto("Library", &config.agent_storage_dir), ], node: NodeStatsDto { hostname: node_hostname(), os: std::env::consts::OS, arch: std::env::consts::ARCH, pid: std::process::id(), cpu_count: std::thread::available_parallelism() .map(|count| count.get()) .unwrap_or(1), }, } } fn storage_path_dto(label: &str, raw_path: &str) -> StoragePathDto { let path = raw_path.trim(); let path_ref = Path::new(path); let usage = if path.is_empty() { None } else { disk_usage(path_ref).or_else(|| { path_ref .parent() .filter(|parent| !parent.as_os_str().is_empty()) .and_then(disk_usage) }) }; StoragePathDto { label: label.to_owned(), path: path.to_owned(), exists: !path.is_empty() && path_ref.exists(), free_bytes: usage.map(|value| value.free_bytes), total_bytes: usage.map(|value| value.total_bytes), } } fn node_hostname() -> String { std::env::var("HOSTNAME") .or_else(|_| std::env::var("COMPUTERNAME")) .unwrap_or_else(|_| "unknown".to_owned()) } #[derive(Debug, Clone, Copy)] struct DiskUsage { free_bytes: u64, total_bytes: u64, } #[cfg(windows)] fn disk_usage(path: &Path) -> Option { use std::os::windows::ffi::OsStrExt; #[link(name = "kernel32")] unsafe extern "system" { fn GetDiskFreeSpaceExW( lpDirectoryName: *const u16, lpFreeBytesAvailableToCaller: *mut u64, lpTotalNumberOfBytes: *mut u64, lpTotalNumberOfFreeBytes: *mut u64, ) -> i32; } let mut wide: Vec = path.as_os_str().encode_wide().collect(); wide.push(0); let mut free_available = 0_u64; let mut total = 0_u64; let mut total_free = 0_u64; let ok = unsafe { GetDiskFreeSpaceExW( wide.as_ptr(), &mut free_available, &mut total, &mut total_free, ) }; (ok != 0).then_some(DiskUsage { free_bytes: free_available, total_bytes: total, }) } #[cfg(any(target_os = "linux", target_os = "android"))] fn disk_usage(path: &Path) -> Option { use std::ffi::CString; use std::os::unix::ffi::OsStrExt; #[repr(C)] struct Statvfs { f_bsize: std::ffi::c_ulong, f_frsize: std::ffi::c_ulong, f_blocks: std::ffi::c_ulong, f_bfree: std::ffi::c_ulong, f_bavail: std::ffi::c_ulong, f_files: std::ffi::c_ulong, f_ffree: std::ffi::c_ulong, f_favail: std::ffi::c_ulong, f_fsid: std::ffi::c_ulong, f_flag: std::ffi::c_ulong, f_namemax: std::ffi::c_ulong, __f_spare: [std::ffi::c_int; 6], } unsafe extern "C" { fn statvfs(path: *const std::ffi::c_char, buf: *mut Statvfs) -> std::ffi::c_int; } let c_path = CString::new(path.as_os_str().as_bytes()).ok()?; let mut stat = std::mem::MaybeUninit::::uninit(); let ok = unsafe { statvfs(c_path.as_ptr(), stat.as_mut_ptr()) }; if ok != 0 { return None; } let stat = unsafe { stat.assume_init() }; let fragment_size = if stat.f_frsize > 0 { stat.f_frsize as u64 } else { stat.f_bsize as u64 }; Some(DiskUsage { free_bytes: stat.f_bavail as u64 * fragment_size, total_bytes: stat.f_blocks as u64 * fragment_size, }) } #[cfg(not(any(windows, target_os = "linux", target_os = "android")))] fn disk_usage(_path: &Path) -> Option { None } async fn load_library_overview(pool: &PgPool) -> anyhow::Result { let stats = load_overview_stats(pool).await?; Ok(LibraryOverviewDto { artists: stats.artists, releases: stats.releases, tracks: stats.tracks, playlists: stats.playlists, }) } async fn load_review_page(pool: &PgPool, query: ReviewsQuery) -> anyhow::Result { let limit = query.limit.unwrap_or(80).clamp(10, 250); let offset = query.offset.unwrap_or(0).max(0); let status = normalize_status(query.status.as_deref()); let search = clean_search(query.search.as_deref()); let search_pattern = search.as_ref().map(|s| format!("%{s}%")); let total = count_reviews(pool, status.clone(), search_pattern.clone()).await?; let total_all = count_reviews(pool, None, search_pattern.clone()).await?; let status_counts = load_review_status_counts(pool, None, search_pattern.clone()).await?; let mut qb = QueryBuilder::::new( "SELECT id, job_run_id, review_type::text AS review_type, input_path, context_json, \ result_json, status::text AS status, created_at::text AS created_at, \ updated_at::text AS updated_at, error_message \ FROM furumusic__pending_review WHERE 1=1", ); push_review_filters(&mut qb, status.clone(), search_pattern.clone()); qb.push(" ORDER BY id DESC LIMIT "); qb.push_bind(limit); qb.push(" OFFSET "); qb.push_bind(offset); let rows = qb.build_query_as::().fetch_all(pool).await?; let stats = load_review_stats(pool, rows.iter().map(|row| row.id).collect()).await?; let media = load_review_media(pool, &rows).await?; let items = rows .into_iter() .map(|row| review_dto(row, &stats, &media)) .collect(); Ok(ReviewPageDto { items, total, total_all, limit, offset, status, search, status_counts, }) } async fn count_reviews( pool: &PgPool, status: Option, search_pattern: Option, ) -> anyhow::Result { let mut qb = QueryBuilder::::new( "SELECT COUNT(*) AS count FROM furumusic__pending_review WHERE 1=1", ); push_review_filters(&mut qb, status, search_pattern); Ok(qb.build_query_as::().fetch_one(pool).await?.count) } async fn load_review_status_counts( pool: &PgPool, status: Option, search_pattern: Option, ) -> anyhow::Result> { let mut qb = QueryBuilder::::new( "SELECT status::text AS status, COUNT(*) AS count \ FROM furumusic__pending_review WHERE 1=1", ); push_review_filters(&mut qb, status, search_pattern); qb.push(" GROUP BY status ORDER BY status"); let rows = qb .build_query_as::() .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|row| StatusCountDto { status: row.status, count: row.count, }) .collect()) } fn push_review_filters( qb: &mut QueryBuilder<'_, Postgres>, status: Option, search_pattern: Option, ) { if let Some(status) = status { qb.push(" AND status = "); qb.push_bind(status); } if let Some(pattern) = search_pattern { qb.push(" AND (input_path ILIKE "); qb.push_bind(pattern.clone()); qb.push(" OR review_type::text ILIKE "); qb.push_bind(pattern.clone()); qb.push(" OR COALESCE(error_message, '') ILIKE "); qb.push_bind(pattern); qb.push(")"); } } async fn load_review_stats( pool: &PgPool, ids: Vec, ) -> anyhow::Result> { if ids.is_empty() { return Ok(HashMap::new()); } let rows = sqlx::query_as::<_, ReviewStatsRow>( "SELECT pending_review_id, model_name::text AS model_name, llm_duration_ms, \ prompt_tokens, completion_tokens \ FROM furumusic__processing_stats WHERE pending_review_id = ANY($1)", ) .bind(&ids) .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|row| (row.pending_review_id, row)) .collect()) } async fn load_review_media( pool: &PgPool, rows: &[ReviewRow], ) -> anyhow::Result> { let mut hashes = rows .iter() .filter_map(|row| context_sha256(row.context_json.as_deref().unwrap_or(""))) .collect::>(); hashes.sort(); hashes.dedup(); if hashes.is_empty() { return Ok(HashMap::new()); } let media_rows = sqlx::query_as::<_, ReviewMediaRow>( "SELECT sha256_hash::text AS sha256_hash, original_filename::text AS original_filename, \ file_size_bytes, audio_format, audio_bitrate, audio_sample_rate, audio_bit_depth \ FROM furumusic__media_file \ WHERE file_type = 'audio' AND sha256_hash = ANY($1)", ) .bind(&hashes) .fetch_all(pool) .await?; Ok(media_rows .into_iter() .map(|row| (row.sha256_hash.to_ascii_lowercase(), row)) .collect()) } fn review_dto( row: ReviewRow, stats: &HashMap, media: &HashMap, ) -> ReviewDto { let input_path = row.input_path.unwrap_or_default(); let filename = file_name(&input_path); let sha = context_sha256(row.context_json.as_deref().unwrap_or("")); let media_row = sha.as_ref().and_then(|hash| media.get(hash)); let tags = media_row.map(media_tags).unwrap_or_default(); let stat = stats.get(&row.id); let confidence = row .result_json .as_deref() .and_then(|json| serde_json::from_str::(json).ok()) .and_then(|value| value.get("confidence").and_then(|v| v.as_f64())); let normalized = row .result_json .as_deref() .map(review_edit_dto_from_json) .unwrap_or_default(); ReviewDto { id: row.id, job_run_id: row.job_run_id, review_type: row.review_type, display_path: compact_path_tail(&input_path, 96), input_path, filename, status: row.status, confidence, model_name: stat.map(|s| s.model_name.clone()), llm_duration_ms: stat.map(|s| s.llm_duration_ms), token_count: stat.map(|s| s.prompt_tokens + s.completion_tokens), tags, error_message: row.error_message, normalized, created_at: row.created_at, updated_at: row.updated_at, } } fn review_edit_dto_from_json(result_json: &str) -> ReviewEditDto { let Ok(normalized) = serde_json::from_str::(result_json) else { return ReviewEditDto::default(); }; ReviewEditDto { title: normalized.title.unwrap_or_default(), artist: normalized.artist.unwrap_or_default(), album: normalized.album.unwrap_or_default(), year: normalized.year.map(|v| v.to_string()).unwrap_or_default(), track_number: normalized .track_number .map(|v| v.to_string()) .unwrap_or_default(), genre: normalized.genre.unwrap_or_default(), featured_artists: normalized.featured_artists.join(", "), release_type: normalized .release_type .unwrap_or_else(|| "album".to_owned()), notes: normalized.notes.unwrap_or_default(), } } fn optional_trimmed(value: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_owned()) } } fn parse_optional_i32(value: &str) -> Option { value.trim().parse::().ok() } fn parse_featured_artists(value: &str) -> Vec { value .split(',') .map(str::trim) .filter(|part| !part.is_empty()) .map(str::to_owned) .collect() } fn normalized_from_review_edit(edit: &ReviewEditDto) -> crate::agent::dto::NormalizedFields { crate::agent::dto::NormalizedFields { title: optional_trimmed(&edit.title), artist: optional_trimmed(&edit.artist), album: optional_trimmed(&edit.album), year: parse_optional_i32(&edit.year), track_number: parse_optional_i32(&edit.track_number), genre: optional_trimmed(&edit.genre), featured_artists: parse_featured_artists(&edit.featured_artists), release_type: optional_trimmed(&edit.release_type).or_else(|| Some("album".to_owned())), confidence: Some(1.0), notes: optional_trimmed(&edit.notes), } } fn media_tags(row: &ReviewMediaRow) -> Vec { let mut tags = Vec::new(); if let Some(format) = row.audio_format.as_deref().filter(|s| !s.trim().is_empty()) { tags.push(tag(format.to_ascii_lowercase(), "format")); } else if let Some(ext) = file_extension(&row.original_filename) { tags.push(tag(ext, "format")); } if let Some(bitrate) = row.audio_bitrate { tags.push(tag(format!("{bitrate} kbps"), "bitrate")); } if let Some(sample_rate) = row.audio_sample_rate { if sample_rate % 1000 == 0 { tags.push(tag(format!("{} kHz", sample_rate / 1000), "sample")); } else { tags.push(tag( format!("{:.1} kHz", sample_rate as f64 / 1000.0), "sample", )); } } if let Some(bit_depth) = row.audio_bit_depth { tags.push(tag(format!("{bit_depth}-bit"), "depth")); } tags.push(tag(size_display(row.file_size_bytes), "size")); tags } async fn apply_review_filter_action( pool: &PgPool, action: &str, filter: ReviewFilter, ) -> cot::Result { let status = normalize_status(filter.status.as_deref()); let search_pattern = clean_search(filter.search.as_deref()).map(|s| format!("%{s}%")); let result = if action == "delete" { let mut qb = QueryBuilder::::new("DELETE FROM furumusic__pending_review WHERE 1=1"); push_review_filters(&mut qb, status, search_pattern); qb.build() .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))? } else { let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); let mut qb = QueryBuilder::::new( "UPDATE furumusic__pending_review \ SET status = 'queued', error_message = NULL, updated_at = ", ); qb.push_bind(now); qb.push(" WHERE 1=1"); push_review_filters(&mut qb, status, search_pattern); qb.build() .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))? }; Ok(result.rows_affected()) } async fn load_jobs( db: &Database, pool: &PgPool, _registry: &JobRegistry, ) -> anyhow::Result> { let mut jobs = ScheduledJob::list_all(db).await?; jobs.sort_by(|a, b| a.name_str().cmp(b.name_str())); let recent_runs = load_recent_runs_per_job(pool, 8).await?; let mut runs_by_job: HashMap> = HashMap::new(); for run in recent_runs { runs_by_job .entry(run.job_name.clone()) .or_default() .push(run); } Ok(jobs .into_iter() .map(|job| { let runs = runs_by_job.remove(job.name_str()).unwrap_or_default(); let is_running = runs.iter().any(|run| run.status == "running"); let last_run = runs.first(); let health = if !job.enabled { "disabled" } else if is_running { "running" } else if last_run.is_some_and(|run| run.status == "failed") { "failed" } else if last_run.is_some() { "ok" } else { "idle" }; JobDto { name: job.name_str().to_owned(), description: job.description_str().to_owned(), cron_expression: job.cron_expression_str().to_owned(), enabled: job.enabled, health: health.to_owned(), is_running, last_run_at: optional_job_time(job.last_run_at_str()), next_run_at: optional_job_time(job.next_run_at_str()), recent_runs: runs, } }) .collect()) } async fn load_recent_runs(pool: &PgPool, limit: i64) -> anyhow::Result> { let rows = sqlx::query_as::<_, JobRunRow>( "SELECT id, job_name::text AS job_name, status::text AS status, started_at::text AS started_at, \ finished_at, duration_ms, trigger::text AS trigger, error_message, \ LEFT(COALESCE(log_output, ''), 1600) AS log_excerpt \ FROM furumusic__job_run ORDER BY id DESC LIMIT $1", ) .bind(limit) .fetch_all(pool) .await?; Ok(rows.into_iter().map(Into::into).collect()) } async fn load_recent_runs_per_job(pool: &PgPool, per_job: i64) -> anyhow::Result> { let rows = sqlx::query_as::<_, JobRunRow>( "WITH ranked AS ( \ SELECT id, job_name::text AS job_name, status::text AS status, started_at::text AS started_at, \ finished_at, duration_ms, trigger::text AS trigger, error_message, \ LEFT(COALESCE(log_output, ''), 1600) AS log_excerpt, \ ROW_NUMBER() OVER (PARTITION BY job_name ORDER BY id DESC) AS rn \ FROM furumusic__job_run \ ) \ SELECT id, job_name, status, started_at, finished_at, duration_ms, trigger, error_message, log_excerpt \ FROM ranked WHERE rn <= $1 ORDER BY id DESC", ) .bind(per_job) .fetch_all(pool) .await?; Ok(rows.into_iter().map(Into::into).collect()) } async fn load_runs_for_job( pool: &PgPool, job_name: &str, limit: i64, ) -> anyhow::Result> { let rows = sqlx::query_as::<_, JobRunRow>( "SELECT id, job_name::text AS job_name, status::text AS status, started_at::text AS started_at, \ finished_at, duration_ms, trigger::text AS trigger, error_message, \ LEFT(COALESCE(log_output, ''), 1600) AS log_excerpt \ FROM furumusic__job_run WHERE job_name = $1 ORDER BY id DESC LIMIT $2", ) .bind(job_name) .bind(limit) .fetch_all(pool) .await?; Ok(rows.into_iter().map(Into::into).collect()) } async fn load_library_page(pool: &PgPool, query: LibraryQuery) -> anyhow::Result { let kind = normalize_library_kind(query.kind.as_deref()); let limit = query.limit.unwrap_or(40).clamp(10, 120); let offset = query.offset.unwrap_or(0).max(0); let search = clean_search(query.search.as_deref()); let search_pattern = search.as_ref().map(|s| format!("%{s}%")); let total = count_library(pool, &kind, search_pattern.clone()).await?; let rows = match kind.as_str() { "releases" => load_release_items(pool, search_pattern.clone(), limit, offset).await?, "tracks" => load_track_items(pool, search_pattern.clone(), limit, offset).await?, "playlists" => load_playlist_items(pool, search_pattern.clone(), limit, offset).await?, _ => load_artist_items(pool, search_pattern.clone(), limit, offset).await?, }; let items = rows .into_iter() .map(|row| library_item_dto(&kind, row)) .collect(); Ok(LibraryPageDto { kind, items, total, limit, offset, search, }) } async fn fetch_library_item( pool: &PgPool, kind: &str, id: i64, ) -> anyhow::Result> { let row = match kind { "releases" => { sqlx::query_as::<_, LibraryItemRow>( "SELECT r.id, r.title::text AS title, \ (COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') || COALESCE(' / ' || r.year::text, '')) AS subtitle, \ r.is_hidden, COUNT(DISTINCT t.id)::bigint AS primary_count, \ COUNT(DISTINCT ra.artist_id)::bigint AS secondary_count, \ COUNT(DISTINCT ph.id)::bigint AS tertiary_count, \ r.updated_at::text AS updated_at \ FROM furumusic__release r \ LEFT JOIN furumusic__track t ON t.release_id = r.id \ LEFT JOIN furumusic__release_artist ra ON ra.release_id = r.id \ LEFT JOIN furumusic__artist a ON a.id = ra.artist_id \ LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \ WHERE r.id = $1 \ GROUP BY r.id", ) .bind(id) .fetch_optional(pool) .await? } "playlists" => { sqlx::query_as::<_, LibraryItemRow>( "SELECT p.id, p.title::text AS title, \ COALESCE(u.display_name, u.username, 'unknown') AS subtitle, \ (NOT p.is_public) AS is_hidden, COUNT(pt.id)::bigint AS primary_count, \ CASE WHEN p.is_public THEN 1 ELSE 0 END::bigint AS secondary_count, \ 0::bigint AS tertiary_count, p.updated_at::text AS updated_at \ FROM furumusic__playlist p \ LEFT JOIN furumusic__playlist_track pt ON pt.playlist_id = p.id \ LEFT JOIN furumusic__user u ON u.id = p.owner_id \ WHERE p.id = $1 \ GROUP BY p.id, u.display_name, u.username", ) .bind(id) .fetch_optional(pool) .await? } "tracks" => { sqlx::query_as::<_, LibraryItemRow>( "SELECT t.id, t.title::text AS title, \ CONCAT(COALESCE(r.title::text, 'No release'), COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \ t.is_hidden, COUNT(DISTINCT ta.artist_id)::bigint AS primary_count, \ COUNT(DISTINCT ph.id)::bigint AS secondary_count, \ COUNT(DISTINCT pt.playlist_id)::bigint AS tertiary_count, \ t.updated_at::text AS updated_at \ FROM furumusic__track t \ LEFT JOIN furumusic__release r ON r.id = t.release_id \ LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \ LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \ LEFT JOIN furumusic__playlist_track pt ON pt.track_id = t.id \ WHERE t.id = $1 \ GROUP BY t.id, r.title", ) .bind(id) .fetch_optional(pool) .await? } _ => { sqlx::query_as::<_, LibraryItemRow>( "SELECT a.id, a.name::text AS title, NULL::text AS subtitle, a.is_hidden, \ COUNT(DISTINCT ra.release_id)::bigint AS primary_count, \ COUNT(DISTINCT ta.track_id)::bigint AS secondary_count, \ COUNT(DISTINCT ufa.user_id)::bigint AS tertiary_count, \ a.updated_at::text AS updated_at \ FROM furumusic__artist a \ LEFT JOIN furumusic__release_artist ra ON ra.artist_id = a.id \ LEFT JOIN furumusic__track_artist ta ON ta.artist_id = a.id \ LEFT JOIN furumusic__user_followed_artist ufa ON ufa.artist_id = a.id \ WHERE a.id = $1 \ GROUP BY a.id", ) .bind(id) .fetch_optional(pool) .await? } }; Ok(row.map(|row| library_item_dto(kind, row))) } async fn load_library_item_detail( pool: &PgPool, kind: &str, item: LibraryItemDto, ) -> anyhow::Result { let mut detail = LibraryItemDetailDto { title: item.title.clone(), hidden: item.is_hidden.unwrap_or(false), release_type: None, year: None, release_id: None, track_number: None, disc_number: None, current_image_url: None, selected_artist_ids: Vec::new(), artists: Vec::new(), releases: Vec::new(), release_tracks: Vec::new(), available_covers: Vec::new(), metadata_tags: load_metadata_tags(pool, kind, item.id).await?, item, }; match kind { "artists" => { let image_file_id: Option = sqlx::query_scalar("SELECT image_file_id FROM furumusic__artist WHERE id = $1") .bind(detail.item.id) .fetch_optional(pool) .await? .flatten(); detail.current_image_url = image_file_id.map(|id| format!("/api/player/cover/{id}/large")); detail.available_covers = artist_available_covers(pool, detail.item.id).await?; } "releases" => { let row: Option<(Option, Option, Option)> = sqlx::query_as( "SELECT release_type::text, year, cover_file_id FROM furumusic__release WHERE id = $1", ) .bind(detail.item.id) .fetch_optional(pool) .await?; if let Some((release_type, year, cover_file_id)) = row { detail.release_type = release_type; detail.year = year; detail.current_image_url = cover_file_id.map(|id| format!("/api/player/cover/{id}/large")); } detail.selected_artist_ids = sqlx::query_as::<_, IdRow>( "SELECT artist_id AS id FROM furumusic__release_artist WHERE release_id = $1 ORDER BY position, artist_id", ) .bind(detail.item.id) .fetch_all(pool) .await? .into_iter() .map(|row| row.id) .collect(); detail.artists = load_artist_options(pool).await?; if detail.item.id > 0 { detail.release_tracks = load_release_tracks(pool, detail.item.id).await?; } } "tracks" => { let row: Option<(Option, Option, Option, Option)> = sqlx::query_as( "SELECT r.id AS release_id, t.track_number, t.disc_number, t.year \ FROM furumusic__track t \ LEFT JOIN furumusic__release r ON r.id = t.release_id \ WHERE t.id = $1", ) .bind(detail.item.id) .fetch_optional(pool) .await?; if let Some((release_id, track_number, disc_number, year)) = row { detail.release_id = release_id; detail.track_number = track_number; detail.disc_number = disc_number; detail.year = year; } detail.selected_artist_ids = sqlx::query_as::<_, IdRow>( "SELECT artist_id AS id FROM furumusic__track_artist WHERE track_id = $1 AND role = 'main' ORDER BY position, artist_id", ) .bind(detail.item.id) .fetch_all(pool) .await? .into_iter() .map(|row| row.id) .collect(); detail.artists = load_artist_options(pool).await?; detail.releases = load_release_options(pool).await?; } _ => {} } Ok(detail) } async fn load_metadata_tags( pool: &PgPool, kind: &str, id: i64, ) -> anyhow::Result> { let entity_kind = match kind { "artists" => "artist", "releases" => "release", "tracks" => "track", _ => return Ok(Vec::new()), }; let rows = sqlx::query_as::<_, (String, String, f64, String)>( r#"SELECT name, source, weight, updated_at FROM ( SELECT g.name::text AS name, egt.source::text AS source, egt.weight, egt.updated_at::text AS updated_at FROM furumusic__entity_genre_tag egt JOIN furumusic__genre g ON g.id = egt.genre_id WHERE egt.entity_kind = $1 AND egt.entity_id = $2 UNION ALL SELECT g.name::text AS name, 'track_genre'::text AS source, 1.0::double precision AS weight, ''::text AS updated_at FROM furumusic__track_genre tg JOIN furumusic__genre g ON g.id = tg.genre_id WHERE $1 = 'track' AND tg.track_id = $2 AND NOT EXISTS ( SELECT 1 FROM furumusic__entity_genre_tag egt WHERE egt.entity_kind = 'track' AND egt.entity_id = tg.track_id AND egt.genre_id = tg.genre_id ) UNION ALL SELECT g.name::text AS name, ('release_' || egt.source)::text AS source, egt.weight, egt.updated_at::text AS updated_at FROM furumusic__track t JOIN furumusic__entity_genre_tag egt ON egt.entity_kind = 'release' AND egt.entity_id = t.release_id JOIN furumusic__genre g ON g.id = egt.genre_id WHERE $1 = 'track' AND t.id = $2 AND NOT EXISTS ( SELECT 1 FROM furumusic__entity_genre_tag direct_egt WHERE direct_egt.entity_kind = 'track' AND direct_egt.entity_id = t.id AND direct_egt.genre_id = egt.genre_id ) AND NOT EXISTS ( SELECT 1 FROM furumusic__track_genre tg WHERE tg.track_id = t.id AND tg.genre_id = egt.genre_id ) ) tags ORDER BY CASE source WHEN 'lastfm' THEN 0 WHEN 'release_lastfm' THEN 1 WHEN 'review' THEN 2 WHEN 'release_review' THEN 3 WHEN 'file' THEN 4 WHEN 'release_file' THEN 5 WHEN 'track_genre' THEN 6 ELSE 7 END, weight DESC, name ASC"#, ) .bind(entity_kind) .bind(id) .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|(name, source, weight, updated_at)| MetadataTagDto { name, source, weight, updated_at, }) .collect()) } async fn load_artist_options(pool: &PgPool) -> anyhow::Result> { let rows = sqlx::query_as::<_, (i64, String)>( "SELECT id, name::text FROM furumusic__artist ORDER BY name ASC", ) .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|(id, name)| ArtistOptionDto { id, name }) .collect()) } async fn load_release_options(pool: &PgPool) -> anyhow::Result> { let rows = sqlx::query_as::<_, (i64, String, Option)>( "SELECT r.id, r.title::text AS title, \ (COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') || COALESCE(' / ' || r.year::text, '')) AS subtitle \ FROM furumusic__release r \ LEFT JOIN furumusic__release_artist ra ON ra.release_id = r.id \ LEFT JOIN furumusic__artist a ON a.id = ra.artist_id \ GROUP BY r.id \ ORDER BY r.title_sort ASC, r.year NULLS LAST, r.id ASC", ) .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|(id, title, subtitle)| ReleaseOptionDto { id, title, subtitle: subtitle.unwrap_or_default(), }) .collect()) } async fn create_release_library_item( pool: &PgPool, body: &UpdateLibraryItemRequest, title: &str, now: &str, ) -> anyhow::Result { let release_type = body .release_type .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("album"); let year = body .year .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .and_then(|value| value.parse::().ok()); let release_id: i64 = sqlx::query_scalar( "INSERT INTO furumusic__release \ (title, title_sort, release_type, year, cover_file_id, total_tracks, total_discs, is_hidden, model_name, created_at, updated_at) \ VALUES ($1, $2, $3, $4, NULL, NULL, NULL, $5, NULL, $6, $6) \ RETURNING id", ) .bind(title) .bind(normalize_name(title)) .bind(release_type) .bind(year) .bind(body.hidden) .bind(now) .fetch_one(pool) .await?; if let Some(artist_ids) = body.artist_ids.as_deref() { set_release_artists(pool, release_id, artist_ids).await?; } if let Some(release_tracks) = body.release_tracks.as_deref() { update_release_tracks(pool, release_id, release_tracks, now).await?; } Ok(release_id) } async fn set_release_artists( pool: &PgPool, release_id: i64, artist_ids: &[i64], ) -> anyhow::Result<()> { sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = $1") .bind(release_id) .execute(pool) .await?; let mut seen_artist_ids = HashSet::new(); let unique_artist_ids = artist_ids .iter() .copied() .filter(|id| *id > 0 && seen_artist_ids.insert(*id)) .collect::>(); for (position, artist_id) in unique_artist_ids.iter().enumerate() { sqlx::query( "INSERT INTO furumusic__release_artist (release_id, artist_id, position) VALUES ($1, $2, $3)", ) .bind(release_id) .bind(*artist_id) .bind(position as i32) .execute(pool) .await?; } Ok(()) } async fn load_release_tracks( pool: &PgPool, release_id: i64, ) -> anyhow::Result> { let rows = sqlx::query_as::<_, ReleaseTrackRow>( "SELECT t.id, t.title::text AS title, \ COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') AS artists, \ NULLIF(t.release_id, 0) AS release_id, r.title::text AS release_title, \ t.track_number, t.disc_number, t.duration_seconds, t.is_hidden \ FROM furumusic__track t \ LEFT JOIN furumusic__release r ON r.id = t.release_id \ LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id AND ta.role = 'main' \ LEFT JOIN furumusic__artist a ON a.id = ta.artist_id \ WHERE t.release_id = $1 \ GROUP BY t.id, r.id, r.title \ ORDER BY t.disc_number NULLS FIRST, t.track_number NULLS LAST, t.title ASC, t.id ASC", ) .bind(release_id) .fetch_all(pool) .await?; Ok(rows.into_iter().map(release_track_dto).collect()) } async fn search_tracks( pool: &PgPool, search: &str, limit: i64, ) -> anyhow::Result> { let pattern = format!("%{search}%"); let starts_with = format!("{search}%"); let rows = sqlx::query_as::<_, ReleaseTrackRow>( "SELECT t.id, t.title::text AS title, \ COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') AS artists, \ NULLIF(t.release_id, 0) AS release_id, r.title::text AS release_title, \ t.track_number, t.disc_number, t.duration_seconds, t.is_hidden \ FROM furumusic__track t \ LEFT JOIN furumusic__release r ON r.id = t.release_id \ LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id AND ta.role = 'main' \ LEFT JOIN furumusic__artist a ON a.id = ta.artist_id \ WHERE t.title ILIKE $1 OR COALESCE(r.title::text, '') ILIKE $1 OR COALESCE(a.name::text, '') ILIKE $1 \ GROUP BY t.id, r.id, r.title \ ORDER BY CASE \ WHEN LOWER(t.title::text) = LOWER($2) THEN 0 \ WHEN t.title ILIKE $3 THEN 1 \ ELSE 2 \ END, \ t.title_sort ASC, t.id ASC \ LIMIT $4", ) .bind(pattern) .bind(search) .bind(starts_with) .bind(limit) .fetch_all(pool) .await?; Ok(rows.into_iter().map(release_track_dto).collect()) } async fn update_release_tracks( pool: &PgPool, release_id: i64, tracks: &[ReleaseTrackUpdateRequest], now: &str, ) -> anyhow::Result<()> { let mut seen_ids = HashSet::new(); let selected = tracks .iter() .filter(|track| track.id > 0 && seen_ids.insert(track.id)) .collect::>(); let selected_ids = selected.iter().map(|track| track.id).collect::>(); let mut tx = pool.begin().await?; if selected_ids.is_empty() { sqlx::query( "UPDATE furumusic__track \ SET release_id = 0, updated_at = $2 \ WHERE release_id = $1", ) .bind(release_id) .bind(now) .execute(&mut *tx) .await?; } else { sqlx::query( "UPDATE furumusic__track \ SET release_id = 0, updated_at = $2 \ WHERE release_id = $1 AND id <> ALL($3)", ) .bind(release_id) .bind(now) .bind(&selected_ids) .execute(&mut *tx) .await?; } for track in selected { let track_number = parse_optional_admin_i32(track.track_number.as_deref(), 1, 9999); let disc_number = parse_optional_admin_i32(track.disc_number.as_deref(), 1, 999); sqlx::query( "UPDATE furumusic__track \ SET release_id = $1, track_number = $2, disc_number = $3, updated_at = $4 \ WHERE id = $5", ) .bind(release_id) .bind(track_number) .bind(disc_number) .bind(now) .bind(track.id) .execute(&mut *tx) .await?; } tx.commit().await?; Ok(()) } fn release_track_dto(row: ReleaseTrackRow) -> ReleaseTrackDto { ReleaseTrackDto { id: row.id, title: row.title, artists: row.artists, release_id: row.release_id, release_title: row.release_title, track_number: row.track_number, disc_number: row.disc_number, duration_seconds: row.duration_seconds, is_hidden: row.is_hidden, } } async fn artist_available_covers( pool: &PgPool, artist_id: i64, ) -> anyhow::Result> { let rows = sqlx::query_as::<_, (i64, String)>( "SELECT DISTINCT r.cover_file_id AS media_file_id, r.title::text AS release_title \ FROM furumusic__release r \ LEFT JOIN furumusic__release_artist ra ON ra.release_id = r.id \ LEFT JOIN furumusic__track t ON t.release_id = r.id \ LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \ WHERE r.cover_file_id IS NOT NULL AND (ra.artist_id = $1 OR ta.artist_id = $1) \ ORDER BY r.title::text ASC", ) .bind(artist_id) .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|(media_file_id, release_title)| AvailableCoverDto { media_file_id, release_title, cover_url: format!("/api/player/cover/{media_file_id}/medium"), }) .collect()) } async fn library_ids_by_filter( pool: &PgPool, kind: &str, filter: LibraryFilter, ) -> cot::Result> { let search_pattern = clean_search(filter.search.as_deref()).map(|s| format!("%{s}%")); let mut qb = match kind { "releases" => QueryBuilder::::new( "SELECT DISTINCT r.id \ FROM furumusic__release r \ LEFT JOIN furumusic__release_artist ra ON ra.release_id = r.id \ LEFT JOIN furumusic__artist a ON a.id = ra.artist_id WHERE 1=1", ), "tracks" => QueryBuilder::::new( "SELECT DISTINCT t.id \ FROM furumusic__track t \ LEFT JOIN furumusic__release r ON r.id = t.release_id \ LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \ LEFT JOIN furumusic__artist a ON a.id = ta.artist_id WHERE 1=1", ), "playlists" => QueryBuilder::::new( "SELECT DISTINCT p.id \ FROM furumusic__playlist p \ LEFT JOIN furumusic__user u ON u.id = p.owner_id WHERE 1=1", ), _ => { QueryBuilder::::new("SELECT DISTINCT a.id FROM furumusic__artist a WHERE 1=1") } }; push_library_search_filter(&mut qb, kind, search_pattern); let rows = qb .build_query_as::() .fetch_all(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; Ok(rows.into_iter().map(|row| row.id).collect()) } async fn apply_library_action( pool: &PgPool, kind: &str, action: &str, ids: &[i64], ) -> cot::Result { match action { "hide" | "show" => set_library_visibility(pool, kind, ids, action == "hide").await, "delete" => delete_library_items(pool, kind, ids).await, _ => Ok(0), } } async fn set_library_visibility( pool: &PgPool, kind: &str, ids: &[i64], hidden: bool, ) -> cot::Result { let now = now_string(); let result = match kind { "releases" => sqlx::query( "UPDATE furumusic__release SET is_hidden = $1, updated_at = $2 WHERE id = ANY($3)", ) .bind(hidden) .bind(&now) .bind(ids) .execute(pool) .await, "playlists" => sqlx::query( "UPDATE furumusic__playlist SET is_public = $1, updated_at = $2 WHERE id = ANY($3)", ) .bind(!hidden) .bind(&now) .bind(ids) .execute(pool) .await, "tracks" => sqlx::query( "UPDATE furumusic__track SET is_hidden = $1, updated_at = $2 WHERE id = ANY($3)", ) .bind(hidden) .bind(&now) .bind(ids) .execute(pool) .await, _ => sqlx::query( "UPDATE furumusic__artist SET is_hidden = $1, updated_at = $2 WHERE id = ANY($3)", ) .bind(hidden) .bind(&now) .bind(ids) .execute(pool) .await, } .map_err(|e| cot::Error::internal(e.to_string()))?; Ok(result.rows_affected()) } async fn delete_library_items(pool: &PgPool, kind: &str, ids: &[i64]) -> cot::Result { match kind { "releases" => delete_releases(pool, ids).await, "tracks" => delete_tracks(pool, ids).await, "playlists" => delete_playlists(pool, ids).await, _ => delete_artists(pool, ids).await, } } async fn delete_artists(pool: &PgPool, ids: &[i64]) -> cot::Result { sqlx::query("DELETE FROM furumusic__user_followed_artist WHERE artist_id = ANY($1)") .bind(ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; sqlx::query("DELETE FROM furumusic__track_artist WHERE artist_id = ANY($1)") .bind(ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; sqlx::query("DELETE FROM furumusic__release_artist WHERE artist_id = ANY($1)") .bind(ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; let result = sqlx::query("DELETE FROM furumusic__artist WHERE id = ANY($1)") .bind(ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; Ok(result.rows_affected()) } async fn delete_releases(pool: &PgPool, ids: &[i64]) -> cot::Result { let track_ids = sqlx::query_as::<_, IdRow>("SELECT id FROM furumusic__track WHERE release_id = ANY($1)") .bind(ids) .fetch_all(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))? .into_iter() .map(|row| row.id) .collect::>(); if !track_ids.is_empty() { sqlx::query("DELETE FROM furumusic__playlist_track WHERE track_id = ANY($1)") .bind(&track_ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; sqlx::query("DELETE FROM furumusic__user_liked_track WHERE track_id = ANY($1)") .bind(&track_ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; sqlx::query("DELETE FROM furumusic__play_history WHERE track_id = ANY($1)") .bind(&track_ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; sqlx::query("DELETE FROM furumusic__track_genre WHERE track_id = ANY($1)") .bind(&track_ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; sqlx::query("DELETE FROM furumusic__track_artist WHERE track_id = ANY($1)") .bind(&track_ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; } sqlx::query("DELETE FROM furumusic__track WHERE release_id = ANY($1)") .bind(ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = ANY($1)") .bind(ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; let result = sqlx::query("DELETE FROM furumusic__release WHERE id = ANY($1)") .bind(ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; Ok(result.rows_affected()) } async fn delete_tracks(pool: &PgPool, ids: &[i64]) -> cot::Result { sqlx::query("DELETE FROM furumusic__playlist_track WHERE track_id = ANY($1)") .bind(ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; sqlx::query("DELETE FROM furumusic__user_liked_track WHERE track_id = ANY($1)") .bind(ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; sqlx::query("DELETE FROM furumusic__play_history WHERE track_id = ANY($1)") .bind(ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; sqlx::query("DELETE FROM furumusic__track_genre WHERE track_id = ANY($1)") .bind(ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; sqlx::query("DELETE FROM furumusic__track_artist WHERE track_id = ANY($1)") .bind(ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; let result = sqlx::query("DELETE FROM furumusic__track WHERE id = ANY($1)") .bind(ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; Ok(result.rows_affected()) } async fn delete_playlists(pool: &PgPool, ids: &[i64]) -> cot::Result { sqlx::query("DELETE FROM furumusic__playlist_track WHERE playlist_id = ANY($1)") .bind(ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; sqlx::query("DELETE FROM furumusic__saved_playlist WHERE playlist_id = ANY($1)") .bind(ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; let result = sqlx::query("DELETE FROM furumusic__playlist WHERE id = ANY($1)") .bind(ids) .execute(pool) .await .map_err(|e| cot::Error::internal(e.to_string()))?; Ok(result.rows_affected()) } async fn count_library( pool: &PgPool, kind: &str, search_pattern: Option, ) -> anyhow::Result { let mut qb = match kind { "releases" => QueryBuilder::::new( "SELECT COUNT(DISTINCT r.id) AS count \ FROM furumusic__release r \ LEFT JOIN furumusic__release_artist ra ON ra.release_id = r.id \ LEFT JOIN furumusic__artist a ON a.id = ra.artist_id WHERE 1=1", ), "tracks" => QueryBuilder::::new( "SELECT COUNT(DISTINCT t.id) AS count \ FROM furumusic__track t \ LEFT JOIN furumusic__release r ON r.id = t.release_id \ LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \ LEFT JOIN furumusic__artist a ON a.id = ta.artist_id WHERE 1=1", ), "playlists" => QueryBuilder::::new( "SELECT COUNT(DISTINCT p.id) AS count \ FROM furumusic__playlist p \ LEFT JOIN furumusic__user u ON u.id = p.owner_id WHERE 1=1", ), _ => QueryBuilder::::new( "SELECT COUNT(DISTINCT a.id) AS count FROM furumusic__artist a WHERE 1=1", ), }; push_library_search_filter(&mut qb, kind, search_pattern); Ok(qb.build_query_as::().fetch_one(pool).await?.count) } fn push_library_search_filter( qb: &mut QueryBuilder<'_, Postgres>, kind: &str, search_pattern: Option, ) { let Some(pattern) = search_pattern else { return; }; match kind { "releases" => { qb.push(" AND (r.title ILIKE "); qb.push_bind(pattern.clone()); qb.push(" OR a.name ILIKE "); qb.push_bind(pattern); qb.push(")"); } "playlists" => { qb.push(" AND (p.title ILIKE "); qb.push_bind(pattern.clone()); qb.push(" OR COALESCE(p.description, '') ILIKE "); qb.push_bind(pattern.clone()); qb.push(" OR COALESCE(u.display_name, u.username, '') ILIKE "); qb.push_bind(pattern); qb.push(")"); } "tracks" => { qb.push(" AND (t.title ILIKE "); qb.push_bind(pattern.clone()); qb.push(" OR r.title ILIKE "); qb.push_bind(pattern.clone()); qb.push(" OR a.name ILIKE "); qb.push_bind(pattern); qb.push(")"); } _ => { qb.push(" AND a.name ILIKE "); qb.push_bind(pattern); } } } async fn load_artist_items( pool: &PgPool, search_pattern: Option, limit: i64, offset: i64, ) -> anyhow::Result> { let mut qb = QueryBuilder::::new( "SELECT a.id, a.name::text AS title, NULL::text AS subtitle, a.is_hidden, \ COUNT(DISTINCT ra.release_id)::bigint AS primary_count, \ COUNT(DISTINCT ta.track_id)::bigint AS secondary_count, \ COUNT(DISTINCT ufa.user_id)::bigint AS tertiary_count, \ a.updated_at::text AS updated_at \ FROM furumusic__artist a \ LEFT JOIN furumusic__release_artist ra ON ra.artist_id = a.id \ LEFT JOIN furumusic__track_artist ta ON ta.artist_id = a.id \ LEFT JOIN furumusic__user_followed_artist ufa ON ufa.artist_id = a.id \ WHERE 1=1", ); if let Some(pattern) = search_pattern { qb.push(" AND a.name ILIKE "); qb.push_bind(pattern); } qb.push(" GROUP BY a.id ORDER BY secondary_count DESC, a.name ASC LIMIT "); qb.push_bind(limit); qb.push(" OFFSET "); qb.push_bind(offset); Ok(qb .build_query_as::() .fetch_all(pool) .await?) } async fn load_release_items( pool: &PgPool, search_pattern: Option, limit: i64, offset: i64, ) -> anyhow::Result> { let mut qb = QueryBuilder::::new( "SELECT r.id, r.title::text AS title, \ (COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') || COALESCE(' / ' || r.year::text, '')) AS subtitle, \ r.is_hidden, COUNT(DISTINCT t.id)::bigint AS primary_count, \ COUNT(DISTINCT ra.artist_id)::bigint AS secondary_count, \ COUNT(DISTINCT ph.id)::bigint AS tertiary_count, \ r.updated_at::text AS updated_at \ FROM furumusic__release r \ LEFT JOIN furumusic__track t ON t.release_id = r.id \ LEFT JOIN furumusic__release_artist ra ON ra.release_id = r.id \ LEFT JOIN furumusic__artist a ON a.id = ra.artist_id \ LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \ WHERE 1=1", ); if let Some(pattern) = search_pattern { qb.push(" AND (r.title ILIKE "); qb.push_bind(pattern.clone()); qb.push(" OR a.name ILIKE "); qb.push_bind(pattern); qb.push(")"); } qb.push(" GROUP BY r.id ORDER BY r.updated_at DESC, r.title ASC LIMIT "); qb.push_bind(limit); qb.push(" OFFSET "); qb.push_bind(offset); Ok(qb .build_query_as::() .fetch_all(pool) .await?) } async fn load_track_items( pool: &PgPool, search_pattern: Option, limit: i64, offset: i64, ) -> anyhow::Result> { let mut qb = QueryBuilder::::new( "SELECT t.id, t.title::text AS title, \ CONCAT(COALESCE(r.title::text, 'No release'), COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \ t.is_hidden, COUNT(DISTINCT ta.artist_id)::bigint AS primary_count, \ COUNT(DISTINCT ph.id)::bigint AS secondary_count, \ COUNT(DISTINCT pt.playlist_id)::bigint AS tertiary_count, \ t.updated_at::text AS updated_at \ FROM furumusic__track t \ LEFT JOIN furumusic__release r ON r.id = t.release_id \ LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \ LEFT JOIN furumusic__artist a ON a.id = ta.artist_id \ LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \ LEFT JOIN furumusic__playlist_track pt ON pt.track_id = t.id \ WHERE 1=1", ); if let Some(pattern) = search_pattern { qb.push(" AND (t.title ILIKE "); qb.push_bind(pattern.clone()); qb.push(" OR r.title ILIKE "); qb.push_bind(pattern.clone()); qb.push(" OR a.name ILIKE "); qb.push_bind(pattern); qb.push(")"); } qb.push(" GROUP BY t.id, r.title ORDER BY COALESCE(r.title::text, '') ASC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title ASC LIMIT "); qb.push_bind(limit); qb.push(" OFFSET "); qb.push_bind(offset); Ok(qb .build_query_as::() .fetch_all(pool) .await?) } async fn load_playlist_items( pool: &PgPool, search_pattern: Option, limit: i64, offset: i64, ) -> anyhow::Result> { let mut qb = QueryBuilder::::new( "SELECT p.id, p.title::text AS title, \ COALESCE(u.display_name, u.username, 'unknown') AS subtitle, \ (NOT p.is_public) AS is_hidden, COUNT(pt.id)::bigint AS primary_count, \ CASE WHEN p.is_public THEN 1 ELSE 0 END::bigint AS secondary_count, \ 0::bigint AS tertiary_count, p.updated_at::text AS updated_at \ FROM furumusic__playlist p \ LEFT JOIN furumusic__playlist_track pt ON pt.playlist_id = p.id \ LEFT JOIN furumusic__user u ON u.id = p.owner_id \ WHERE 1=1", ); if let Some(pattern) = search_pattern { qb.push(" AND (p.title ILIKE "); qb.push_bind(pattern.clone()); qb.push(" OR COALESCE(p.description, '') ILIKE "); qb.push_bind(pattern.clone()); qb.push(" OR COALESCE(u.display_name, u.username, '') ILIKE "); qb.push_bind(pattern); qb.push(")"); } qb.push( " GROUP BY p.id, u.display_name, u.username ORDER BY p.updated_at DESC, p.title ASC LIMIT ", ); qb.push_bind(limit); qb.push(" OFFSET "); qb.push_bind(offset); Ok(qb .build_query_as::() .fetch_all(pool) .await?) } fn library_item_dto(kind: &str, row: LibraryItemRow) -> LibraryItemDto { let tags = match kind { "releases" => vec![ tag(format!("{} tracks", row.primary_count), "count"), tag(format!("{} artists", row.secondary_count), "relation"), tag(format!("{} plays", row.tertiary_count), "plays"), ], "tracks" => vec![ tag(format!("{} artists", row.primary_count), "relation"), tag(format!("{} plays", row.secondary_count), "plays"), tag(format!("{} playlists", row.tertiary_count), "count"), ], "playlists" => vec![ tag(format!("{} tracks", row.primary_count), "count"), tag( if row.secondary_count > 0 { "public" } else { "private" }, "visibility", ), ], _ => vec![ tag(format!("{} tracks", row.secondary_count), "count"), tag(format!("{} releases", row.primary_count), "relation"), tag(format!("{} followers", row.tertiary_count), "followers"), ], }; LibraryItemDto { id: row.id, kind: kind.to_owned(), title: row.title, subtitle: row.subtitle.unwrap_or_default(), is_hidden: row.is_hidden, tags, updated_at: row.updated_at, } } impl Default for ReviewFilter { fn default() -> Self { Self { status: None, search: None, } } } impl From for JobRunDto { fn from(row: JobRunRow) -> Self { Self { id: row.id, job_name: row.job_name, status: row.status, started_at: row.started_at, finished_at: row.finished_at, duration_ms: row.duration_ms, trigger: row.trigger, error_message: row.error_message, log_excerpt: row.log_excerpt, } } } impl From for JobRunDto { fn from(row: JobRunDetailRow) -> Self { Self { id: row.id, job_name: row.job_name, status: row.status, started_at: row.started_at, finished_at: row.finished_at, duration_ms: row.duration_ms, trigger: row.trigger, error_message: row.error_message, log_excerpt: row.log_excerpt, } } } fn tag(label: impl Into, kind: impl Into) -> TagDto { TagDto { label: label.into(), kind: kind.into(), } } fn default_true() -> bool { true } fn optional_job_time(value: &str) -> Option { if value.is_empty() { None } else { Some(value.to_owned()) } } fn normalize_library_kind(kind: Option<&str>) -> String { let kind = kind.unwrap_or_default().trim().to_ascii_lowercase(); match kind.as_str() { "release" | "releases" => "releases", "track" | "tracks" => "tracks", "playlist" | "playlists" => "playlists", "artist" | "artists" => "artists", _ => "artists", } .to_owned() } fn normalize_status(status: Option<&str>) -> Option { let status = status?.trim(); if status.is_empty() || status == "all" { return None; } Some(status.to_owned()) } fn clean_search(search: Option<&str>) -> Option { search .map(str::trim) .filter(|s| !s.is_empty()) .map(|s| s.chars().take(120).collect()) } fn normalize_name(value: &str) -> String { value.trim().to_lowercase() } fn parse_optional_admin_i32(value: Option<&str>, min: i32, max: i32) -> Option { let value = value?.trim(); if value.is_empty() { return None; } value .parse::() .ok() .map(|parsed| parsed.clamp(min, max)) } fn deserialize_optional_stringish<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { let Some(value) = Option::::deserialize(deserializer)? else { return Ok(None); }; match value { serde_json::Value::Null => Ok(None), serde_json::Value::String(value) => Ok(Some(value)), serde_json::Value::Number(value) => Ok(Some(value.to_string())), other => Err(serde::de::Error::custom(format!( "expected string, number, or null, got {other}" ))), } } fn now_string() -> String { chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() } fn context_sha256(context_json: &str) -> Option { let value = serde_json::from_str::(context_json).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_name(path: &str) -> String { path.replace('\\', "/") .rsplit('/') .next() .unwrap_or(path) .to_owned() } fn compact_path_tail(path: &str, max_chars: usize) -> String { let normalized = path.replace('\\', "/"); if normalized.chars().count() <= max_chars { return normalized; } let filename = file_name(&normalized); let filename_len = filename.chars().count(); if filename_len + 4 <= max_chars { return format!(".../{filename}"); } let suffix_len = max_chars.saturating_sub(3); let suffix = filename .chars() .skip(filename_len.saturating_sub(suffix_len)) .collect::(); format!("...{suffix}") } fn file_extension(filename: &str) -> Option { 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") } }