Files
furumusic/src/admin/v2.rs
T
Ultradesu 1c70349df8
Build and Publish / Build and Publish Docker Image (push) Successful in 2m50s
ADMIN: added releases and artists management form
2026-05-27 15:56:57 +03:00

2482 lines
77 KiB
Rust

use std::collections::{HashMap, HashSet};
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, 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::{JobRegistry, 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<String>,
pub(super) search: Option<String>,
pub(super) limit: Option<i64>,
pub(super) offset: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub(super) struct LibraryQuery {
pub(super) kind: Option<String>,
pub(super) search: Option<String>,
pub(super) limit: Option<i64>,
pub(super) offset: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub(super) struct BulkReviewsRequest {
action: String,
mode: Option<String>,
ids: Option<Vec<i64>>,
filter: Option<ReviewFilter>,
}
#[derive(Debug, Deserialize)]
pub(super) struct BulkLibraryRequest {
action: String,
kind: String,
mode: Option<String>,
ids: Option<Vec<i64>>,
filter: Option<LibraryFilter>,
}
#[derive(Debug, Deserialize)]
pub(super) struct UpdateLibraryItemRequest {
kind: String,
id: i64,
title: String,
hidden: bool,
release_type: Option<String>,
year: Option<String>,
artist_ids: Option<Vec<i64>>,
}
#[derive(Debug, Deserialize)]
pub(super) struct LibraryItemDetailQuery {
kind: String,
id: i64,
}
#[derive(Debug, Deserialize)]
pub(super) struct SetLibraryImageRequest {
kind: String,
id: i64,
media_file_id: Option<i64>,
}
#[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<String>,
search: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
struct LibraryFilter {
search: Option<String>,
}
#[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,
reviews: ReviewPageDto,
jobs: Vec<JobDto>,
recent_runs: Vec<JobRunDto>,
library: LibraryOverviewDto,
}
#[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 StatusCountDto {
status: String,
count: i64,
}
#[derive(Debug, Serialize, JsonSchema)]
struct TagDto {
label: String,
kind: String,
}
#[derive(Debug, Serialize, JsonSchema)]
struct ReviewPageDto {
items: Vec<ReviewDto>,
total: i64,
limit: i64,
offset: i64,
status: Option<String>,
search: Option<String>,
status_counts: Vec<StatusCountDto>,
}
#[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<f64>,
model_name: Option<String>,
llm_duration_ms: Option<i64>,
token_count: Option<i64>,
tags: Vec<TagDto>,
error_message: Option<String>,
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<String>,
next_run_at: Option<String>,
recent_runs: Vec<JobRunDto>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
struct JobRunDto {
id: i64,
job_name: String,
status: String,
started_at: String,
finished_at: Option<String>,
duration_ms: Option<i64>,
trigger: String,
error_message: Option<String>,
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<JobRunDto>,
}
#[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,
}
#[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,
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,
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,
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<u32>,
completion_tokens: Option<u32>,
tokens_per_sec: Option<f64>,
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<LibraryItemDto>,
total: i64,
limit: i64,
offset: i64,
search: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct LibraryItemDto {
id: i64,
kind: String,
title: String,
subtitle: String,
is_hidden: Option<bool>,
tags: Vec<TagDto>,
updated_at: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct LibraryItemDetailDto {
item: LibraryItemDto,
title: String,
hidden: bool,
release_type: Option<String>,
year: Option<i32>,
current_image_url: Option<String>,
selected_artist_ids: Vec<i64>,
artists: Vec<ArtistOptionDto>,
available_covers: Vec<AvailableCoverDto>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct ArtistOptionDto {
id: i64,
name: String,
}
#[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<String>,
context_json: Option<String>,
result_json: Option<String>,
status: String,
created_at: String,
updated_at: String,
error_message: Option<String>,
}
#[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<String>,
audio_bitrate: Option<i32>,
audio_sample_rate: Option<i32>,
audio_bit_depth: Option<i32>,
}
#[derive(Debug, Clone, sqlx::FromRow)]
struct JobRunRow {
id: i64,
job_name: String,
status: String,
started_at: String,
finished_at: Option<String>,
duration_ms: Option<i64>,
trigger: String,
error_message: Option<String>,
log_excerpt: String,
}
#[derive(Debug, sqlx::FromRow)]
struct JobRunDetailRow {
id: i64,
job_name: String,
status: String,
started_at: String,
finished_at: Option<String>,
duration_ms: Option<i64>,
trigger: String,
error_message: Option<String>,
log_excerpt: String,
log_output: Option<String>,
}
#[derive(Debug, sqlx::FromRow)]
struct LibraryItemRow {
id: i64,
title: String,
subtitle: Option<String>,
is_hidden: Option<bool>,
primary_count: i64,
secondary_count: i64,
tertiary_count: i64,
updated_at: Option<String>,
}
pub async fn page(admin: AuthenticatedUser, i18n: I18n) -> cot::Result<Html> {
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<cot::response::Response> {
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 (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,
reviews,
jobs,
recent_runs,
library,
})
.into_response()
}
pub async fn reviews(
session: Session,
db: Database,
pool: &PgPool,
query: ReviewsQuery,
) -> cot::Result<cot::response::Response> {
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 bulk_reviews(
session: Session,
db: Database,
pool: &PgPool,
Json(body): Json<BulkReviewsRequest>,
) -> cot::Result<cot::response::Response> {
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<ReviewEditDto>,
) -> cot::Result<cot::response::Response> {
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<cot::response::Response> {
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<cot::response::Response> {
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<UpdateSettingsRequest>,
) -> cot::Result<cot::response::Response> {
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()),
("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<cot::response::Response> {
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(),
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,
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(),
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<std::sync::Arc<crate::scheduler::SchedulerHandle>>,
>,
job_name: &str,
) -> cot::Result<cot::response::Response> {
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 toggle_job(
session: Session,
db: Database,
handle_cell: &std::sync::Arc<
tokio::sync::OnceCell<std::sync::Arc<crate::scheduler::SchedulerHandle>>,
>,
job_name: &str,
) -> cot::Result<cot::response::Response> {
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<cot::response::Response> {
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<cot::response::Response> {
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<cot::response::Response> {
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<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
let kind = normalize_library_kind(Some(query.kind.as_str()));
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 update_library_item(
session: Session,
db: Database,
pool: &PgPool,
Json(body): Json<UpdateLibraryItemRequest>,
) -> cot::Result<cot::response::Response> {
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();
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::<i32>().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
}
"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" {
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));
sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = $1")
.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__release_artist (release_id, artist_id, position) VALUES ($1, $2, $3)",
)
.bind(body.id)
.bind(*artist_id)
.bind(position as i32)
.execute(pool)
.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<SetLibraryImageRequest>,
) -> cot::Result<cot::response::Response> {
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<i64> = 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<UploadLibraryImageRequest>,
) -> cot::Result<cot::response::Response> {
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<String> = 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<BulkLibraryRequest>,
) -> cot::Result<cot::response::Response> {
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<AuthenticatedUser, cot::response::Response> {
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<OverviewStatsDto> {
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,
})
}
async fn load_library_overview(pool: &PgPool) -> anyhow::Result<LibraryOverviewDto> {
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<ReviewPageDto> {
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 status_counts = load_review_status_counts(pool, None, search_pattern.clone()).await?;
let mut qb = QueryBuilder::<Postgres>::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::<ReviewRow>().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,
limit,
offset,
status,
search,
status_counts,
})
}
async fn count_reviews(
pool: &PgPool,
status: Option<String>,
search_pattern: Option<String>,
) -> anyhow::Result<i64> {
let mut qb = QueryBuilder::<Postgres>::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::<CountRow>().fetch_one(pool).await?.count)
}
async fn load_review_status_counts(
pool: &PgPool,
status: Option<String>,
search_pattern: Option<String>,
) -> anyhow::Result<Vec<StatusCountDto>> {
let mut qb = QueryBuilder::<Postgres>::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::<StatusCountRow>()
.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<String>,
search_pattern: Option<String>,
) {
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<i64>,
) -> anyhow::Result<HashMap<i64, ReviewStatsRow>> {
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<HashMap<String, ReviewMediaRow>> {
let mut hashes = rows
.iter()
.filter_map(|row| context_sha256(row.context_json.as_deref().unwrap_or("")))
.collect::<Vec<_>>();
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<i64, ReviewStatsRow>,
media: &HashMap<String, ReviewMediaRow>,
) -> 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::<serde_json::Value>(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::<crate::agent::dto::NormalizedFields>(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<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
}
fn parse_optional_i32(value: &str) -> Option<i32> {
value.trim().parse::<i32>().ok()
}
fn parse_featured_artists(value: &str) -> Vec<String> {
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<TagDto> {
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<u64> {
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::<Postgres>::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::<Postgres>::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<Vec<JobDto>> {
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<String, Vec<JobRunDto>> = 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<Vec<JobRunDto>> {
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<Vec<JobRunDto>> {
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<Vec<JobRunDto>> {
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<LibraryPageDto> {
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?,
"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<Option<LibraryItemDto>> {
let row = match kind {
"releases" => {
sqlx::query_as::<_, LibraryItemRow>(
"SELECT r.id, r.title::text AS title, \
CONCAT(r.release_type::text, 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__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?
}
_ => {
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<LibraryItemDetailDto> {
let mut detail = LibraryItemDetailDto {
title: item.title.clone(),
hidden: item.is_hidden.unwrap_or(false),
release_type: None,
year: None,
current_image_url: None,
selected_artist_ids: Vec::new(),
artists: Vec::new(),
available_covers: Vec::new(),
item,
};
match kind {
"artists" => {
let image_file_id: Option<i64> =
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<String>, Option<i32>, Option<i64>)> = 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?;
}
_ => {}
}
Ok(detail)
}
async fn load_artist_options(pool: &PgPool) -> anyhow::Result<Vec<ArtistOptionDto>> {
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 artist_available_covers(
pool: &PgPool,
artist_id: i64,
) -> anyhow::Result<Vec<AvailableCoverDto>> {
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<Vec<i64>> {
let search_pattern = clean_search(filter.search.as_deref()).map(|s| format!("%{s}%"));
let mut qb = match kind {
"releases" => QueryBuilder::<Postgres>::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",
),
"playlists" => QueryBuilder::<Postgres>::new(
"SELECT DISTINCT p.id \
FROM furumusic__playlist p \
LEFT JOIN furumusic__user u ON u.id = p.owner_id WHERE 1=1",
),
_ => {
QueryBuilder::<Postgres>::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::<IdRow>()
.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<u64> {
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<u64> {
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,
_ => 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<u64> {
match kind {
"releases" => delete_releases(pool, ids).await,
"playlists" => delete_playlists(pool, ids).await,
_ => delete_artists(pool, ids).await,
}
}
async fn delete_artists(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> {
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<u64> {
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::<Vec<_>>();
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_playlists(pool: &PgPool, ids: &[i64]) -> cot::Result<u64> {
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<String>,
) -> anyhow::Result<i64> {
let mut qb = match kind {
"releases" => QueryBuilder::<Postgres>::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",
),
"playlists" => QueryBuilder::<Postgres>::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::<Postgres>::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::<CountRow>().fetch_one(pool).await?.count)
}
fn push_library_search_filter(
qb: &mut QueryBuilder<'_, Postgres>,
kind: &str,
search_pattern: Option<String>,
) {
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(")");
}
_ => {
qb.push(" AND a.name ILIKE ");
qb.push_bind(pattern);
}
}
}
async fn load_artist_items(
pool: &PgPool,
search_pattern: Option<String>,
limit: i64,
offset: i64,
) -> anyhow::Result<Vec<LibraryItemRow>> {
let mut qb = QueryBuilder::<Postgres>::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::<LibraryItemRow>()
.fetch_all(pool)
.await?)
}
async fn load_release_items(
pool: &PgPool,
search_pattern: Option<String>,
limit: i64,
offset: i64,
) -> anyhow::Result<Vec<LibraryItemRow>> {
let mut qb = QueryBuilder::<Postgres>::new(
"SELECT r.id, r.title::text AS title, \
CONCAT(r.release_type::text, 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::<LibraryItemRow>()
.fetch_all(pool)
.await?)
}
async fn load_playlist_items(
pool: &PgPool,
search_pattern: Option<String>,
limit: i64,
offset: i64,
) -> anyhow::Result<Vec<LibraryItemRow>> {
let mut qb = QueryBuilder::<Postgres>::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::<LibraryItemRow>()
.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"),
],
"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<JobRunRow> 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<JobRunDetailRow> 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<String>, kind: impl Into<String>) -> TagDto {
TagDto {
label: label.into(),
kind: kind.into(),
}
}
fn optional_job_time(value: &str) -> Option<String> {
if value.is_empty() {
None
} else {
Some(value.to_owned())
}
}
fn normalize_library_kind(kind: Option<&str>) -> String {
match kind {
Some("releases") => "releases",
Some("playlists") => "playlists",
_ => "artists",
}
.to_owned()
}
fn normalize_status(status: Option<&str>) -> Option<String> {
let status = status?.trim();
if status.is_empty() || status == "all" {
return None;
}
Some(status.to_owned())
}
fn clean_search(search: Option<&str>) -> Option<String> {
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 now_string() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
fn context_sha256(context_json: &str) -> Option<String> {
let value = serde_json::from_str::<serde_json::Value>(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::<String>();
format!("...{suffix}")
}
fn file_extension(filename: &str) -> Option<String> {
std::path::Path::new(filename)
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.trim().to_ascii_lowercase())
.filter(|ext| !ext.is_empty())
}
fn size_display(bytes: i64) -> String {
if bytes >= 1_073_741_824 {
format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
} else if bytes >= 1_048_576 {
format!("{:.1} MB", bytes as f64 / 1_048_576.0)
} else if bytes >= 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{bytes} B")
}
}