2026-05-27 15:56:57 +03:00
use std ::collections ::{ HashMap , HashSet };
2026-06-01 14:53:51 +03:00
use std ::path ::Path ;
2026-05-26 00:19:11 +03:00
2026-05-26 18:16:34 +03:00
use cot ::db ::{ Database , Model };
2026-05-26 00:19:11 +03:00
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 ;
2026-05-29 01:13:04 +03:00
use serde ::{ Deserialize , Deserializer , Serialize };
2026-05-26 00:19:11 +03:00
use sqlx ::{ PgPool , Postgres , QueryBuilder };
use super ::BUILD_INFO ;
2026-05-26 18:40:05 +03:00
use crate ::agent ;
2026-05-26 00:19:11 +03:00
use crate ::auth ::{ self , AuthenticatedUser , Role };
2026-05-26 18:40:05 +03:00
use crate ::config ::{ AppConfig , ConfigEntry , ConfigSources };
2026-05-26 00:19:11 +03:00
use crate ::i18n ::{ I18n , Translations };
2026-05-29 17:04:30 +03:00
use crate ::scheduler ::{ self , JobRegistry , JobRun , ScheduledJob };
2026-05-26 00:19:11 +03:00
#[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 > ,
}
2026-06-02 20:45:00 +03:00
#[derive(Debug, Deserialize)]
pub ( super ) struct UsersQuery {
pub ( super ) search : Option < String > ,
pub ( super ) limit : Option < i64 > ,
pub ( super ) offset : Option < i64 > ,
}
2026-05-26 00:19:11 +03:00
#[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 > ,
}
2026-05-29 17:04:30 +03:00
#[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 ,
2026-06-03 03:39:16 +03:00
#[serde(default = "default_true" )]
musicbrainz_tags : bool ,
2026-05-29 17:04:30 +03:00
#[serde(default)]
overwrite : bool ,
}
2026-06-03 03:39:16 +03:00
#[derive(Debug, Deserialize)]
pub struct ArtworkBackfillRunRequest {
#[serde(default)]
overwrite_existing : bool ,
}
2026-05-26 00:19:11 +03:00
#[derive(Debug, Deserialize)]
pub ( super ) struct UpdateLibraryItemRequest {
kind : String ,
id : i64 ,
title : String ,
hidden : bool ,
2026-05-27 15:56:57 +03:00
release_type : Option < String > ,
2026-05-29 01:13:04 +03:00
#[serde(default, deserialize_with = "deserialize_optional_stringish" )]
2026-05-27 15:56:57 +03:00
year : Option < String > ,
2026-05-29 00:43:32 +03:00
release_id : Option < i64 > ,
2026-05-29 01:13:04 +03:00
#[serde(default, deserialize_with = "deserialize_optional_stringish" )]
2026-05-29 00:43:32 +03:00
track_number : Option < String > ,
2026-05-29 01:13:04 +03:00
#[serde(default, deserialize_with = "deserialize_optional_stringish" )]
2026-05-29 00:43:32 +03:00
disc_number : Option < String > ,
2026-05-27 15:56:57 +03:00
artist_ids : Option < Vec < i64 >> ,
2026-06-09 15:06:01 +01:00
release_tracks : Option < Vec < ReleaseTrackUpdateRequest >> ,
2026-05-27 15:56:57 +03:00
}
#[derive(Debug, Deserialize)]
pub ( super ) struct LibraryItemDetailQuery {
kind : String ,
id : i64 ,
}
2026-06-09 15:06:01 +01:00
#[derive(Debug, Deserialize)]
pub ( super ) struct TrackSearchQuery {
search : Option < String > ,
limit : Option < i64 > ,
}
#[derive(Debug, Deserialize)]
struct ReleaseTrackUpdateRequest {
id : i64 ,
#[serde(default, deserialize_with = "deserialize_optional_stringish" )]
track_number : Option < String > ,
#[serde(default, deserialize_with = "deserialize_optional_stringish" )]
disc_number : Option < String > ,
}
2026-05-27 15:56:57 +03:00
#[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 ,
2026-05-26 00:19:11 +03:00
}
#[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 ,
2026-06-01 14:53:51 +03:00
runtime : RuntimeOverviewDto ,
2026-05-26 00:19:11 +03:00
reviews : ReviewPageDto ,
jobs : Vec < JobDto > ,
recent_runs : Vec < JobRunDto > ,
library : LibraryOverviewDto ,
}
2026-06-02 20:45:00 +03:00
#[derive(Debug, Serialize, JsonSchema)]
struct AdminUsersPageDto {
items : Vec < AdminUserRowDto > ,
total : i64 ,
limit : i64 ,
offset : i64 ,
search : Option < String > ,
online_count : i64 ,
}
#[derive(Debug, Serialize, JsonSchema)]
struct AdminUserRowDto {
id : i64 ,
username : String ,
display_name : Option < String > ,
email : Option < String > ,
role : String ,
is_active : bool ,
is_online : bool ,
last_seen_ms : Option < i64 > ,
}
#[derive(Debug, Serialize, JsonSchema)]
struct AdminUserDetailDto {
user : AdminUserRowDto ,
stats : AdminUserStatsDto ,
2026-06-03 02:02:23 +03:00
recent_plays : Vec < AdminUserPlayDto > ,
2026-06-02 20:45:00 +03:00
}
#[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 ,
}
2026-06-03 02:02:23 +03:00
#[derive(Debug, Serialize, JsonSchema)]
struct AdminUserPlayDto {
history_id : i64 ,
played_at : String ,
duration_listened : Option < i32 > ,
completed : bool ,
track_id : i64 ,
title : String ,
artists : String ,
release_id : i64 ,
release_title : String ,
release_year : Option < i32 > ,
cover_url : Option < String > ,
track_duration_seconds : f64 ,
uploader_name : String ,
audio_format : Option < String > ,
audio_bitrate : Option < i32 > ,
}
2026-05-26 00:19:11 +03:00
#[derive(Debug, Serialize, JsonSchema)]
struct OverviewStatsDto {
tracks : i64 ,
releases : i64 ,
artists : i64 ,
playlists : i64 ,
hidden_tracks : i64 ,
hidden_releases : i64 ,
hidden_artists : i64 ,
}
2026-06-01 14:53:51 +03:00
#[derive(Debug, Serialize, JsonSchema)]
struct RuntimeOverviewDto {
agent : AgentStatusDto ,
storage : Vec < StoragePathDto > ,
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 < u64 > ,
total_bytes : Option < u64 > ,
}
#[derive(Debug, Serialize, JsonSchema)]
struct NodeStatsDto {
hostname : String ,
os : & 'static str ,
arch : & 'static str ,
pid : u32 ,
cpu_count : usize ,
}
2026-05-26 00:19:11 +03:00
#[derive(Debug, Serialize, JsonSchema)]
struct StatusCountDto {
status : String ,
count : i64 ,
}
#[derive(Debug, Serialize, JsonSchema)]
struct TagDto {
label : String ,
kind : String ,
}
2026-05-29 17:04:30 +03:00
#[derive(Debug, Serialize, JsonSchema)]
struct MetadataTagDto {
name : String ,
source : String ,
weight : f64 ,
updated_at : String ,
}
2026-05-26 00:19:11 +03:00
#[derive(Debug, Serialize, JsonSchema)]
struct ReviewPageDto {
items : Vec < ReviewDto > ,
total : i64 ,
2026-06-03 03:39:16 +03:00
total_all : i64 ,
2026-05-26 00:19:11 +03:00
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 > ,
2026-05-27 15:03:06 +03:00
normalized : ReviewEditDto ,
2026-05-26 00:19:11 +03:00
created_at : String ,
updated_at : String ,
}
2026-05-27 15:03:06 +03:00
#[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 ,
}
2026-05-26 00:19:11 +03:00
#[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 ,
}
2026-05-26 18:16:34 +03:00
#[derive(Debug, Serialize, JsonSchema)]
struct AdminSettingsDto {
2026-05-26 18:40:05 +03:00
values : AdminSettingsValues ,
sources : AdminSettingsSources ,
2026-05-26 18:16:34 +03:00
lastfm_api_key_configured : bool ,
2026-05-27 16:40:06 +03:00
lastfm_shared_secret_configured : bool ,
lastfm_scrobbling_configured : bool ,
2026-05-26 18:16:34 +03:00
}
2026-05-26 18:40:05 +03:00
#[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 ,
2026-05-27 16:40:06 +03:00
lastfm_shared_secret : String ,
2026-05-26 18:40:05 +03:00
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 ,
2026-05-27 16:40:06 +03:00
lastfm_shared_secret : & 'static str ,
2026-05-26 18:40:05 +03:00
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 ,
}
2026-05-26 18:16:34 +03:00
#[derive(Debug, Deserialize)]
pub ( super ) struct UpdateSettingsRequest {
2026-05-26 18:40:05 +03:00
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 ,
2026-05-26 18:16:34 +03:00
lastfm_api_key : String ,
2026-05-27 16:40:06 +03:00
lastfm_shared_secret : String ,
2026-05-26 18:40:05 +03:00
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 ,
2026-05-26 18:16:34 +03:00
}
2026-05-26 00:19:11 +03:00
#[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 > ,
}
2026-05-27 15:56:57 +03:00
#[derive(Debug, Serialize, JsonSchema)]
struct LibraryItemDetailDto {
item : LibraryItemDto ,
title : String ,
hidden : bool ,
release_type : Option < String > ,
year : Option < i32 > ,
2026-05-29 00:43:32 +03:00
release_id : Option < i64 > ,
track_number : Option < i32 > ,
disc_number : Option < i32 > ,
2026-05-27 15:56:57 +03:00
current_image_url : Option < String > ,
selected_artist_ids : Vec < i64 > ,
artists : Vec < ArtistOptionDto > ,
2026-05-29 00:43:32 +03:00
releases : Vec < ReleaseOptionDto > ,
2026-06-09 15:06:01 +01:00
release_tracks : Vec < ReleaseTrackDto > ,
2026-05-27 15:56:57 +03:00
available_covers : Vec < AvailableCoverDto > ,
2026-05-29 17:04:30 +03:00
metadata_tags : Vec < MetadataTagDto > ,
2026-05-27 15:56:57 +03:00
}
#[derive(Debug, Serialize, JsonSchema)]
struct ArtistOptionDto {
id : i64 ,
name : String ,
}
2026-05-29 00:43:32 +03:00
#[derive(Debug, Serialize, JsonSchema)]
struct ReleaseOptionDto {
id : i64 ,
title : String ,
subtitle : String ,
}
2026-06-09 15:06:01 +01:00
#[derive(Debug, Serialize, JsonSchema)]
struct ReleaseTrackDto {
id : i64 ,
title : String ,
artists : String ,
release_id : Option < i64 > ,
release_title : Option < String > ,
track_number : Option < i32 > ,
disc_number : Option < i32 > ,
duration_seconds : f64 ,
is_hidden : bool ,
}
2026-05-27 15:56:57 +03:00
#[derive(Debug, Serialize, JsonSchema)]
struct AvailableCoverDto {
media_file_id : i64 ,
release_title : String ,
cover_url : String ,
}
2026-05-26 00:19:11 +03:00
#[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 > ,
}
2026-06-09 15:06:01 +01:00
#[derive(Debug, sqlx::FromRow)]
struct ReleaseTrackRow {
id : i64 ,
title : String ,
artists : String ,
release_id : Option < i64 > ,
release_title : Option < String > ,
track_number : Option < i32 > ,
disc_number : Option < i32 > ,
duration_seconds : f64 ,
is_hidden : bool ,
}
2026-05-26 00:19:11 +03:00
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 ),
};
2026-06-01 14:53:51 +03:00
let ( config , _ ) = AppConfig ::load_with_db ( & db ). await ;
let runtime = load_runtime_overview ( & config );
2026-05-26 00:19:11 +03:00
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 ,
2026-06-01 14:53:51 +03:00
runtime ,
2026-05-26 00:19:11 +03:00
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 ()
}
2026-06-02 20:45:00 +03:00
pub async fn users (
session : Session ,
db : Database ,
pool : & PgPool ,
query : UsersQuery ,
) -> cot ::Result < cot ::response ::Response > {
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 < cot ::response ::Response > {
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" )),
}
}
2026-05-26 00:19:11 +03:00
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 ()
}
2026-05-27 15:03:06 +03:00
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" ,
))
}
}
}
2026-05-26 00:19:11 +03:00
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 ()
}
2026-05-26 18:16:34 +03:00
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 );
}
2026-05-26 18:40:05 +03:00
let ( config , sources ) = AppConfig ::load_with_db ( & db ). await ;
Json ( settings_dto ( config , sources )). into_response ()
2026-05-26 18:16:34 +03:00
}
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 );
}
2026-05-26 18:40:05 +03:00
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 ()),
2026-05-27 16:40:06 +03:00
(
"lastfm_shared_secret" ,
body . lastfm_shared_secret . trim (). to_string (),
),
2026-05-26 18:40:05 +03:00
( "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 ())) ? ;
}
2026-05-26 18:16:34 +03:00
Json ( serde_json ::json! ({ "ok" : true })). into_response ()
}
2026-05-26 18:40:05 +03:00
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 (),
2026-05-27 16:40:06 +03:00
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 (),
2026-05-26 18:40:05 +03:00
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 ,
2026-05-27 16:40:06 +03:00
lastfm_shared_secret : config . lastfm_shared_secret ,
2026-05-26 18:40:05 +03:00
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 (),
2026-05-27 16:40:06 +03:00
lastfm_shared_secret : sources . lastfm_shared_secret . code (),
2026-05-26 18:40:05 +03:00
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 (),
},
}
}
2026-05-26 00:19:11 +03:00
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 ())),
}
}
2026-05-29 17:04:30 +03:00
pub async fn run_metadata_backfill (
session : Session ,
db : Database ,
pool : & PgPool ,
Json ( body ) : Json < MetadataBackfillRunRequest > ,
) -> cot ::Result < cot ::response ::Response > {
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 ,
2026-06-03 03:39:16 +03:00
musicbrainz_tags : body . musicbrainz_tags ,
2026-05-29 17:04:30 +03:00
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 (()) => {
2026-06-01 14:53:51 +03:00
let _ = run
. set_completed ( & db_for_task , duration_ms , & log . output ())
. await ;
2026-05-29 17:04:30 +03:00
}
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 ()
}
2026-06-03 03:39:16 +03:00
pub async fn run_artwork_backfill (
session : Session ,
db : Database ,
pool : & PgPool ,
Json ( body ) : Json < ArtworkBackfillRunRequest > ,
) -> cot ::Result < cot ::response ::Response > {
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 ()
}
2026-05-26 00:19:11 +03:00
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 ()
}
2026-05-27 15:56:57 +03:00
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 ()));
2026-06-09 15:06:01 +01:00
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 ();
}
2026-05-27 15:56:57 +03:00
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 ()
}
2026-06-09 15:06:01 +01:00
pub async fn track_search (
session : Session ,
db : Database ,
pool : & PgPool ,
query : TrackSearchQuery ,
) -> cot ::Result < cot ::response ::Response > {
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 ::< ReleaseTrackDto > ::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 ()
}
2026-05-26 00:19:11 +03:00
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 ();
2026-06-09 15:06:01 +01:00
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 ();
}
2026-05-26 00:19:11 +03:00
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" => {
2026-05-27 15:56:57 +03:00
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 ());
2026-05-26 00:19:11 +03:00
sqlx ::query (
"UPDATE furumusic__release \
2026-05-27 15:56:57 +03:00
SET title = $1, title_sort = $2, release_type = $3, year = $4, is_hidden = $5, updated_at = $6 \
WHERE id = $7" ,
2026-05-26 00:19:11 +03:00
)
. bind ( title )
. bind ( normalize_name ( title ))
2026-05-27 15:56:57 +03:00
. bind ( release_type )
. bind ( year )
2026-05-26 00:19:11 +03:00
. bind ( body . hidden )
. bind ( & now )
. bind ( body . id )
. execute ( pool )
. await
}
2026-05-29 00:43:32 +03:00
"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 < i64 > =
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
}
2026-05-26 00:19:11 +03:00
"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" ));
}
2026-05-29 00:43:32 +03:00
if kind == "releases" || kind == "tracks" {
2026-05-27 15:56:57 +03:00
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 ));
2026-05-29 00:43:32 +03:00
if kind == "releases" {
2026-06-09 15:06:01 +01:00
set_release_artists ( pool , body . id , & artist_ids )
2026-05-29 00:43:32 +03:00
. await
. map_err ( | e | cot ::Error ::internal ( e . to_string ())) ? ;
} else {
2026-06-01 14:53:51 +03:00
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 ())) ? ;
2026-05-29 00:43:32 +03:00
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 ())) ? ;
}
2026-05-27 15:56:57 +03:00
}
}
}
2026-05-26 00:19:11 +03:00
2026-06-09 15:06:01 +01:00
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 ())) ? ;
}
}
2026-05-26 00:19:11 +03:00
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 ()
}
2026-05-27 15:56:57 +03:00
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
}
2026-05-26 00:19:11 +03:00
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 ,
})
}
2026-06-02 20:45:00 +03:00
#[derive(Debug, sqlx::FromRow)]
struct AdminUserSqlRow {
id : i64 ,
username : String ,
display_name : Option < String > ,
email : Option < String > ,
role : String ,
is_active : bool ,
}
async fn load_admin_users_page (
pool : & PgPool ,
query : UsersQuery ,
) -> anyhow ::Result < AdminUsersPageDto > {
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 ::< Postgres > ::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 ::< Postgres > ::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 < AdminUserSqlRow > = 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 ::< Vec < _ >> ();
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 < Option < AdminUserDetailDto >> {
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 > (
2026-06-03 02:02:23 +03:00
"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"
2026-06-02 20:45:00 +03:00
)
. 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 ),
) ? ;
2026-06-03 02:02:23 +03:00
let recent_plays = load_admin_user_recent_plays ( pool , user_id , 30 ). await ? ;
2026-06-02 20:45:00 +03:00
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 ,
},
2026-06-03 02:02:23 +03:00
recent_plays ,
2026-06-02 20:45:00 +03:00
}))
}
2026-06-03 02:02:23 +03:00
async fn load_admin_user_recent_plays (
pool : & PgPool ,
user_id : i64 ,
limit : i64 ,
) -> anyhow ::Result < Vec < AdminUserPlayDto >> {
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 < i32 > ,
completed : bool ,
track_id : i64 ,
title : String ,
artists : String ,
release_id : i64 ,
release_title : String ,
release_year : Option < i32 > ,
cover_file_id : Option < i64 > ,
release_cover_file_id : Option < i64 > ,
track_duration_seconds : f64 ,
uploader_name : String ,
audio_format : Option < String > ,
audio_bitrate : Option < i32 > ,
}
fn admin_track_cover_url ( track_cover : Option < i64 > , release_cover : Option < i64 > ) -> Option < String > {
track_cover
. or ( release_cover )
. map ( | id | format! ( "/api/player/cover/ {id} /medium" ))
}
2026-06-02 20:45:00 +03:00
fn admin_user_row (
row : AdminUserSqlRow ,
active : & HashMap < i64 , i64 > ,
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 ,
}
}
2026-06-01 14:53:51 +03:00
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 < DiskUsage > {
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 < u16 > = 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 < DiskUsage > {
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 ::< Statvfs > ::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 < DiskUsage > {
None
}
2026-05-26 00:19:11 +03:00
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 ? ;
2026-06-03 03:39:16 +03:00
let total_all = count_reviews ( pool , None , search_pattern . clone ()). await ? ;
2026-05-26 00:19:11 +03:00
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 ,
2026-06-03 03:39:16 +03:00
total_all ,
2026-05-26 00:19:11 +03:00
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 ()));
2026-05-27 15:03:06 +03:00
let normalized = row
. result_json
. as_deref ()
. map ( review_edit_dto_from_json )
. unwrap_or_default ();
2026-05-26 00:19:11 +03:00
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 ,
2026-05-27 15:03:06 +03:00
normalized ,
2026-05-26 00:19:11 +03:00
created_at : row . created_at ,
updated_at : row . updated_at ,
}
}
2026-05-27 15:03:06 +03:00
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 ),
}
}
2026-05-26 00:19:11 +03:00
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 ? ,
2026-05-29 00:43:32 +03:00
"tracks" => load_track_items ( pool , search_pattern . clone (), limit , offset ). await ? ,
2026-05-26 00:19:11 +03:00
"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, \
2026-05-29 01:13:04 +03:00
(COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') || COALESCE(' / ' || r.year::text, '')) AS subtitle, \
2026-05-26 00:19:11 +03:00
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 \
2026-05-29 01:13:04 +03:00
LEFT JOIN furumusic__artist a ON a.id = ra.artist_id \
2026-05-26 00:19:11 +03:00
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 ?
}
2026-05-29 00:43:32 +03:00
"tracks" => {
sqlx ::query_as ::< _ , LibraryItemRow > (
"SELECT t.id, t.title::text AS title, \
2026-06-09 15:06:01 +01:00
CONCAT(COALESCE(r.title::text, 'No release'), COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \
2026-05-29 00:43:32 +03:00
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 \
2026-06-09 15:06:01 +01:00
LEFT JOIN furumusic__release r ON r.id = t.release_id \
2026-05-29 00:43:32 +03:00
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 ?
}
2026-05-26 00:19:11 +03:00
_ => {
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 )))
}
2026-05-27 15:56:57 +03:00
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 ,
2026-05-29 00:43:32 +03:00
release_id : None ,
track_number : None ,
disc_number : None ,
2026-05-27 15:56:57 +03:00
current_image_url : None ,
selected_artist_ids : Vec ::new (),
artists : Vec ::new (),
2026-05-29 00:43:32 +03:00
releases : Vec ::new (),
2026-06-09 15:06:01 +01:00
release_tracks : Vec ::new (),
2026-05-27 15:56:57 +03:00
available_covers : Vec ::new (),
2026-05-29 17:04:30 +03:00
metadata_tags : load_metadata_tags ( pool , kind , item . id ). await ? ,
2026-05-27 15:56:57 +03:00
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 ? ;
2026-06-09 15:06:01 +01:00
if detail . item . id > 0 {
detail . release_tracks = load_release_tracks ( pool , detail . item . id ). await ? ;
}
2026-05-27 15:56:57 +03:00
}
2026-05-29 00:43:32 +03:00
"tracks" => {
2026-06-09 15:06:01 +01:00
let row : Option < ( Option < i64 > , Option < i32 > , Option < i32 > , Option < i32 > ) > = 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" ,
2026-05-29 00:43:32 +03:00
)
. bind ( detail . item . id )
. fetch_optional ( pool )
. await ? ;
if let Some (( release_id , track_number , disc_number , year )) = row {
2026-06-09 15:06:01 +01:00
detail . release_id = release_id ;
2026-05-29 00:43:32 +03:00
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 ? ;
}
2026-05-27 15:56:57 +03:00
_ => {}
}
Ok ( detail )
}
2026-05-29 17:04:30 +03:00
async fn load_metadata_tags (
pool : & PgPool ,
kind : & str ,
id : i64 ,
) -> anyhow ::Result < Vec < MetadataTagDto >> {
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 ())
}
2026-05-27 15:56:57 +03:00
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 ())
}
2026-05-29 00:43:32 +03:00
async fn load_release_options ( pool : & PgPool ) -> anyhow ::Result < Vec < ReleaseOptionDto >> {
let rows = sqlx ::query_as ::< _ , ( i64 , String , Option < String > ) > (
"SELECT r.id, r.title::text AS title, \
2026-05-29 01:13:04 +03:00
(COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') || COALESCE(' / ' || r.year::text, '')) AS subtitle \
2026-05-29 00:43:32 +03:00
FROM furumusic__release r \
2026-05-29 01:13:04 +03:00
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 \
2026-05-29 00:43:32 +03:00
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 ())
}
2026-06-09 15:06:01 +01:00
async fn create_release_library_item (
pool : & PgPool ,
body : & UpdateLibraryItemRequest ,
title : & str ,
now : & str ,
) -> anyhow ::Result < i64 > {
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 ());
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 ::< Vec < _ >> ();
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 < Vec < ReleaseTrackDto >> {
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 < Vec < ReleaseTrackDto >> {
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 ::< Vec < _ >> ();
let selected_ids = selected . iter (). map ( | track | track . id ). collect ::< Vec < _ >> ();
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 ,
}
}
2026-05-27 15:56:57 +03:00
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 ())
}
2026-05-26 00:19:11 +03:00
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" ,
),
2026-05-29 00:43:32 +03:00
"tracks" => QueryBuilder ::< Postgres > ::new (
"SELECT DISTINCT t.id \
FROM furumusic__track t \
2026-06-09 15:06:01 +01:00
LEFT JOIN furumusic__release r ON r.id = t.release_id \
2026-05-29 00:43:32 +03:00
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" ,
),
2026-05-26 00:19:11 +03:00
"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 ,
2026-05-29 00:43:32 +03:00
"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 ,
2026-05-26 00:19:11 +03:00
_ => 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 ,
2026-05-29 00:43:32 +03:00
"tracks" => delete_tracks ( pool , ids ). await ,
2026-05-26 00:19:11 +03:00
"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 ())
}
2026-05-29 00:43:32 +03:00
async fn delete_tracks ( pool : & PgPool , ids : & [ i64 ]) -> cot ::Result < u64 > {
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 ())
}
2026-05-26 00:19:11 +03:00
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" ,
),
2026-05-29 00:43:32 +03:00
"tracks" => QueryBuilder ::< Postgres > ::new (
"SELECT COUNT(DISTINCT t.id) AS count \
FROM furumusic__track t \
2026-06-09 15:06:01 +01:00
LEFT JOIN furumusic__release r ON r.id = t.release_id \
2026-05-29 00:43:32 +03:00
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" ,
),
2026-05-26 00:19:11 +03:00
"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 ( ")" );
}
2026-05-29 00:43:32 +03:00
"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 ( ")" );
}
2026-05-26 00:19:11 +03:00
_ => {
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, \
2026-05-29 01:13:04 +03:00
(COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') || COALESCE(' / ' || r.year::text, '')) AS subtitle, \
2026-05-26 00:19:11 +03:00
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 ? )
}
2026-05-29 00:43:32 +03:00
async fn load_track_items (
pool : & PgPool ,
search_pattern : Option < String > ,
limit : i64 ,
offset : i64 ,
) -> anyhow ::Result < Vec < LibraryItemRow >> {
let mut qb = QueryBuilder ::< Postgres > ::new (
"SELECT t.id, t.title::text AS title, \
2026-06-09 15:06:01 +01:00
CONCAT(COALESCE(r.title::text, 'No release'), COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \
2026-05-29 00:43:32 +03:00
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 \
2026-06-09 15:06:01 +01:00
LEFT JOIN furumusic__release r ON r.id = t.release_id \
2026-05-29 00:43:32 +03:00
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 ( ")" );
}
2026-06-09 15:06:01 +01:00
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 " );
2026-05-29 00:43:32 +03:00
qb . push_bind ( limit );
qb . push ( " OFFSET " );
qb . push_bind ( offset );
Ok ( qb
. build_query_as ::< LibraryItemRow > ()
. fetch_all ( pool )
. await ? )
}
2026-05-26 00:19:11 +03:00
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" ),
],
2026-05-29 00:43:32 +03:00
"tracks" => vec! [
tag ( format! ( " {} artists" , row . primary_count ), "relation" ),
tag ( format! ( " {} plays" , row . secondary_count ), "plays" ),
tag ( format! ( " {} playlists" , row . tertiary_count ), "count" ),
],
2026-05-26 00:19:11 +03:00
"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 (),
}
}
2026-05-29 17:04:30 +03:00
fn default_true () -> bool {
true
}
2026-05-26 00:19:11 +03:00
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 {
2026-05-29 01:13:04 +03:00
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" ,
2026-05-26 00:19:11 +03:00
_ => "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 ()
}
2026-05-29 00:43:32 +03:00
fn parse_optional_admin_i32 ( value : Option <& str > , min : i32 , max : i32 ) -> Option < i32 > {
let value = value ? . trim ();
if value . is_empty () {
return None ;
}
value
. parse ::< i32 > ()
. ok ()
. map ( | parsed | parsed . clamp ( min , max ))
}
2026-05-29 01:13:04 +03:00
fn deserialize_optional_stringish < 'de , D > ( deserializer : D ) -> Result < Option < String > , D ::Error >
where
D : Deserializer < 'de > ,
{
let Some ( value ) = Option ::< serde_json ::Value > ::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} "
))),
}
}
2026-05-26 00:19:11 +03:00
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" )
}
}