Added user page. fixed player.
Build and Publish / Build and Publish Docker Image (push) Successful in 3m32s

This commit is contained in:
Ultradesu
2026-06-02 20:45:00 +03:00
parent a1dafaa5f2
commit f716c22f86
10 changed files with 835 additions and 25 deletions
+49
View File
@@ -227,6 +227,55 @@ impl App for AdminApp {
},
"admin_v2_reviews_bulk",
),
Route::with_handler_and_name(
"/v2/api/users",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
get(move |session: Session, db: Database,
query: UrlQuery<v2::UsersQuery>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
})
.await;
v2::users(session, db, pg_pool, query.0).await
}
})
},
"admin_v2_users",
),
Route::with_handler_and_name(
"/v2/api/users/{id}",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
get(move |session: Session, db: Database, path: Path<PathId>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
})
.await;
v2::user_detail(session, db, pg_pool, path.0.id).await
}
})
},
"admin_v2_user_detail",
),
Route::with_handler_and_name(
"/v2/api/reviews/{id}/approve",
{
+275
View File
@@ -45,6 +45,13 @@ pub(super) struct LibraryQuery {
pub(super) offset: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub(super) struct UsersQuery {
pub(super) search: Option<String>,
pub(super) limit: Option<i64>,
pub(super) offset: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub(super) struct BulkReviewsRequest {
action: String,
@@ -158,6 +165,48 @@ struct AdminDashboardDto {
library: LibraryOverviewDto,
}
#[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,
}
#[derive(Debug, Serialize, JsonSchema)]
struct AdminUserStatsDto {
plays: i64,
completed_plays: i64,
listened_seconds: i64,
liked_tracks: i64,
followed_artists: i64,
own_playlists: i64,
saved_playlists: i64,
uploaded_tracks: i64,
torrent_sessions: i64,
lastfm_connected: bool,
}
#[derive(Debug, Serialize, JsonSchema)]
struct OverviewStatsDto {
tracks: i64,
@@ -645,6 +694,41 @@ pub async fn reviews(
Json(page).into_response()
}
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")),
}
}
pub async fn bulk_reviews(
session: Session,
db: Database,
@@ -1554,6 +1638,197 @@ async fn load_overview_stats(pool: &PgPool) -> anyhow::Result<OverviewStatsDto>
})
}
#[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>(
"SELECT COUNT(DISTINCT t.id) FROM furumusic__track t JOIN furumusic__media_file mf ON mf.id = t.media_file_id WHERE mf.uploaded_by_user_id = $1"
)
.bind(user_id)
.fetch_one(pool),
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM furumusic__torrent_session WHERE user_id = $1"
)
.bind(user_id)
.fetch_one(pool),
sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM furumusic__lastfm_account WHERE user_id = $1 AND session_key <> '')"
)
.bind(user_id)
.fetch_one(pool),
)?;
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,
},
}))
}
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,
}
}
fn load_runtime_overview(config: &AppConfig) -> RuntimeOverviewDto {
let llm_configured = !config.agent_llm_url.trim().is_empty();
let agent_status = if !config.agent_enabled {
+1
View File
@@ -298,6 +298,7 @@ translations! {
player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков...";
player_connection_lost: "Server connection lost" , "Нет соединения с сервером";
player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю...";
player_active_device: "Active device" , "Активный девайс";
player_no_results: "No results found" , "Ничего не найдено";
player_new_playlist: "New Playlist" , "Новый плейлист";
player_rename_playlist: "Rename Playlist" , "Переименовать плейлист";
+8
View File
@@ -159,6 +159,14 @@ pub fn record_active_user(user_id: i64) {
users.insert(user_id, Instant::now());
}
pub fn active_user_last_seen_ms() -> HashMap<i64, i64> {
let users = ACTIVE_USERS.lock().expect("active user lock");
users
.iter()
.map(|(user_id, last_seen)| (*user_id, last_seen.elapsed().as_millis() as i64))
.collect()
}
pub fn record_auth_attempt(method: &'static str, outcome: &'static str, reason: &'static str) {
REGISTRY.inc_counter(
"furumusic_auth_login_attempts_total",