Improve player library and admin user stats
This commit is contained in:
+111
-1
@@ -191,6 +191,7 @@ struct AdminUserRowDto {
|
||||
struct AdminUserDetailDto {
|
||||
user: AdminUserRowDto,
|
||||
stats: AdminUserStatsDto,
|
||||
recent_plays: Vec<AdminUserPlayDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
@@ -207,6 +208,25 @@ struct AdminUserStatsDto {
|
||||
lastfm_connected: bool,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
struct OverviewStatsDto {
|
||||
tracks: i64,
|
||||
@@ -1778,7 +1798,7 @@ async fn load_admin_user_detail(
|
||||
.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"
|
||||
"SELECT COUNT(DISTINCT t.id) FROM furumusic__track t JOIN furumusic__media_file mf ON mf.id = t.audio_file_id WHERE mf.uploaded_by_user_id = $1"
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(pool),
|
||||
@@ -1793,6 +1813,7 @@ async fn load_admin_user_detail(
|
||||
.bind(user_id)
|
||||
.fetch_one(pool),
|
||||
)?;
|
||||
let recent_plays = load_admin_user_recent_plays(pool, user_id, 30).await?;
|
||||
|
||||
Ok(Some(AdminUserDetailDto {
|
||||
user,
|
||||
@@ -1808,9 +1829,98 @@ async fn load_admin_user_detail(
|
||||
torrent_sessions,
|
||||
lastfm_connected,
|
||||
},
|
||||
recent_plays,
|
||||
}))
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
fn admin_user_row(
|
||||
row: AdminUserSqlRow,
|
||||
active: &HashMap<i64, i64>,
|
||||
|
||||
+99
-39
@@ -17,28 +17,99 @@ pub fn is_orchestrator_running() -> bool {
|
||||
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Try to acquire the PostgreSQL advisory lock for the orchestrator.
|
||||
/// Returns true if the lock was acquired (no other orchestrator is running).
|
||||
async fn try_acquire_orchestrator_lock(pool: &sqlx::PgPool) -> bool {
|
||||
match sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)")
|
||||
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
{
|
||||
Ok(acquired) => acquired,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to acquire advisory lock: {e}");
|
||||
false
|
||||
}
|
||||
struct OrchestratorAdvisoryGuard {
|
||||
conn: Option<sqlx::pool::PoolConnection<sqlx::Postgres>>,
|
||||
}
|
||||
|
||||
impl Drop for OrchestratorAdvisoryGuard {
|
||||
fn drop(&mut self) {
|
||||
let Some(mut conn) = self.conn.take() else {
|
||||
return;
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
match sqlx::query_scalar::<_, bool>("SELECT pg_advisory_unlock($1)")
|
||||
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
{
|
||||
Ok(true) => tracing::info!("inbox_process: advisory lock released"),
|
||||
Ok(false) => tracing::warn!(
|
||||
"inbox_process: advisory lock was not held by the guard connection"
|
||||
),
|
||||
Err(e) => tracing::error!("inbox_process: failed to release advisory lock: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Release the PostgreSQL advisory lock for the orchestrator.
|
||||
async fn release_orchestrator_lock(pool: &sqlx::PgPool) {
|
||||
let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
|
||||
async fn connection_holds_orchestrator_lock(
|
||||
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
|
||||
) -> anyhow::Result<bool> {
|
||||
let holds_lock = sqlx::query_scalar::<_, bool>(
|
||||
r#"
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_locks
|
||||
WHERE locktype = 'advisory'
|
||||
AND pid = pg_backend_pid()
|
||||
AND mode = 'ExclusiveLock'
|
||||
AND granted
|
||||
AND objsubid = 1
|
||||
AND classid::bigint = (($1::bigint >> 32) & 4294967295::bigint)
|
||||
AND objid::bigint = ($1::bigint & 4294967295::bigint)
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
||||
.fetch_one(&mut **conn)
|
||||
.await?;
|
||||
|
||||
Ok(holds_lock)
|
||||
}
|
||||
|
||||
/// Try to acquire the PostgreSQL advisory lock for the orchestrator.
|
||||
///
|
||||
/// The guard owns the same pooled connection that acquired the session-level
|
||||
/// lock. This matters because PostgreSQL session advisory locks must be
|
||||
/// released on the same connection, not just through the same pool.
|
||||
async fn try_acquire_orchestrator_lock(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> anyhow::Result<Option<OrchestratorAdvisoryGuard>> {
|
||||
let mut conn = pool.acquire().await?;
|
||||
|
||||
// Older versions acquired the lock through the pool and could return a
|
||||
// locked idle connection. If we get such a connection, drain that stale
|
||||
// re-entrant lock before taking the new guarded lock.
|
||||
let mut cleaned_stale_locks = 0u8;
|
||||
while connection_holds_orchestrator_lock(&mut conn).await? {
|
||||
let unlocked = sqlx::query_scalar::<_, bool>("SELECT pg_advisory_unlock($1)")
|
||||
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
|
||||
cleaned_stale_locks = cleaned_stale_locks.saturating_add(u8::from(unlocked));
|
||||
if cleaned_stale_locks >= 8 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if cleaned_stale_locks > 0 {
|
||||
tracing::warn!(
|
||||
count = cleaned_stale_locks,
|
||||
"inbox_process: released stale advisory lock(s) from an idle pooled connection"
|
||||
);
|
||||
}
|
||||
|
||||
let acquired = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)")
|
||||
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
||||
.execute(pool)
|
||||
.await;
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
|
||||
if acquired {
|
||||
Ok(Some(OrchestratorAdvisoryGuard { conn: Some(conn) }))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
use crate::agent::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata};
|
||||
@@ -83,10 +154,10 @@ impl Job for InboxProcessJob {
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
log.info(
|
||||
log.warn(
|
||||
"Another inbox_process orchestrator is already running (AtomicBool), skipping",
|
||||
);
|
||||
return Ok(());
|
||||
anyhow::bail!("another inbox_process orchestrator is already running in this process");
|
||||
}
|
||||
struct AtomicGuard;
|
||||
impl Drop for AtomicGuard {
|
||||
@@ -98,27 +169,16 @@ impl Job for InboxProcessJob {
|
||||
let _atomic_guard = AtomicGuard;
|
||||
|
||||
// --- Guard 2: PostgreSQL advisory lock (cross-process/binary safe) ---
|
||||
if !try_acquire_orchestrator_lock(&ctx.pool).await {
|
||||
log.info("Another inbox_process orchestrator holds the advisory lock, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
tracing::info!("inbox_process: advisory lock acquired");
|
||||
let pool_for_unlock = ctx.pool.clone();
|
||||
struct AdvisoryGuard {
|
||||
pool: sqlx::PgPool,
|
||||
}
|
||||
impl Drop for AdvisoryGuard {
|
||||
fn drop(&mut self) {
|
||||
let pool = self.pool.clone();
|
||||
tokio::spawn(async move {
|
||||
release_orchestrator_lock(&pool).await;
|
||||
tracing::info!("inbox_process: advisory lock released");
|
||||
});
|
||||
let _advisory_guard = match try_acquire_orchestrator_lock(&ctx.pool).await? {
|
||||
Some(guard) => guard,
|
||||
None => {
|
||||
log.warn("Another inbox_process orchestrator holds the advisory lock");
|
||||
anyhow::bail!(
|
||||
"inbox_process advisory lock is held by another database session; no in-process orchestrator is running"
|
||||
);
|
||||
}
|
||||
}
|
||||
let _advisory_guard = AdvisoryGuard {
|
||||
pool: pool_for_unlock,
|
||||
};
|
||||
tracing::info!("inbox_process: advisory lock acquired");
|
||||
|
||||
let config = Arc::clone(&ctx.config);
|
||||
let mut total_ok = 0u64;
|
||||
|
||||
+60
-1
@@ -2774,13 +2774,72 @@ async fn artists_handler(
|
||||
pool: &sqlx::PgPool,
|
||||
query: cot::request::extractors::UrlQuery<PaginationQuery>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let Some(_user) = auth::get_session_user(&session, &db).await else {
|
||||
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||
};
|
||||
|
||||
let page = query.0.page.unwrap_or(1).max(1);
|
||||
let per_page = query.0.limit.unwrap_or(60).clamp(1, 200);
|
||||
let offset = (page - 1) as i64 * per_page as i64;
|
||||
let mine = query.0.mine.unwrap_or(false);
|
||||
|
||||
if mine {
|
||||
let total_row = sqlx::query_as::<_, CountRow>(
|
||||
r#"SELECT COUNT(DISTINCT a.id) AS count
|
||||
FROM furumusic__artist a
|
||||
JOIN furumusic__track_artist ta ON ta.artist_id = a.id AND ta.role <> 'featuring'
|
||||
JOIN furumusic__track t ON t.id = ta.track_id AND t.is_hidden = false
|
||||
JOIN furumusic__release r ON r.id = t.release_id AND r.is_hidden = false
|
||||
JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||
WHERE a.is_hidden = false
|
||||
AND mf.uploaded_by_user_id = $1"#,
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
let rows = sqlx::query_as::<_, ArtistRow>(
|
||||
r#"SELECT a.id, a.name::text as name, a.image_file_id,
|
||||
COUNT(DISTINCT r.id) AS release_count,
|
||||
COUNT(DISTINCT t.id) AS track_count
|
||||
FROM furumusic__artist a
|
||||
JOIN furumusic__track_artist ta ON ta.artist_id = a.id AND ta.role <> 'featuring'
|
||||
JOIN furumusic__track t ON t.id = ta.track_id AND t.is_hidden = false
|
||||
JOIN furumusic__release r ON r.id = t.release_id AND r.is_hidden = false
|
||||
JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||
WHERE a.is_hidden = false
|
||||
AND mf.uploaded_by_user_id = $1
|
||||
GROUP BY a.id, a.name, a.name_sort, a.image_file_id
|
||||
ORDER BY release_count DESC, track_count DESC, a.name_sort
|
||||
LIMIT $2 OFFSET $3"#,
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(per_page as i64)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
let items: Vec<ArtistCard> = rows
|
||||
.into_iter()
|
||||
.map(|r| ArtistCard {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
image_url: cover_variant_url(r.image_file_id, "medium"),
|
||||
release_count: r.release_count,
|
||||
track_count: r.track_count,
|
||||
})
|
||||
.collect();
|
||||
|
||||
return Json(Paginated {
|
||||
items,
|
||||
total: total_row.count,
|
||||
page,
|
||||
per_page,
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let total_row = sqlx::query_as::<_, CountRow>(
|
||||
r#"SELECT COUNT(DISTINCT a.id) AS count
|
||||
|
||||
@@ -49,6 +49,7 @@ pub(super) struct RemoveTrackRequest {
|
||||
pub(super) struct PaginationQuery {
|
||||
pub(super) page: Option<i32>,
|
||||
pub(super) limit: Option<i32>,
|
||||
pub(super) mine: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
Reference in New Issue
Block a user