Added user page. fixed player.
Build and Publish / Build and Publish Docker Image (push) Successful in 3m32s
Build and Publish / Build and Publish Docker Image (push) Successful in 3m32s
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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" , "Переименовать плейлист";
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user