Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2a8f301b8 | |||
| 0a4f78acfa | |||
| f716c22f86 | |||
| a1dafaa5f2 |
Generated
+1
-1
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.2.12"
|
version = "0.2.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.2.13"
|
version = "0.2.15"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||||
|
|
||||||
|
|||||||
@@ -227,6 +227,55 @@ impl App for AdminApp {
|
|||||||
},
|
},
|
||||||
"admin_v2_reviews_bulk",
|
"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(
|
Route::with_handler_and_name(
|
||||||
"/v2/api/reviews/{id}/approve",
|
"/v2/api/reviews/{id}/approve",
|
||||||
{
|
{
|
||||||
|
|||||||
+385
@@ -45,6 +45,13 @@ pub(super) struct LibraryQuery {
|
|||||||
pub(super) offset: Option<i64>,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct BulkReviewsRequest {
|
pub(super) struct BulkReviewsRequest {
|
||||||
action: String,
|
action: String,
|
||||||
@@ -158,6 +165,68 @@ struct AdminDashboardDto {
|
|||||||
library: LibraryOverviewDto,
|
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,
|
||||||
|
recent_plays: Vec<AdminUserPlayDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 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)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
struct OverviewStatsDto {
|
struct OverviewStatsDto {
|
||||||
tracks: i64,
|
tracks: i64,
|
||||||
@@ -645,6 +714,41 @@ pub async fn reviews(
|
|||||||
Json(page).into_response()
|
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(
|
pub async fn bulk_reviews(
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -1554,6 +1658,287 @@ 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.audio_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),
|
||||||
|
)?;
|
||||||
|
let recent_plays = load_admin_user_recent_plays(pool, user_id, 30).await?;
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
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>,
|
||||||
|
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 {
|
fn load_runtime_overview(config: &AppConfig) -> RuntimeOverviewDto {
|
||||||
let llm_configured = !config.agent_llm_url.trim().is_empty();
|
let llm_configured = !config.agent_llm_url.trim().is_empty();
|
||||||
let agent_status = if !config.agent_enabled {
|
let agent_status = if !config.agent_enabled {
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ translations! {
|
|||||||
player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков...";
|
player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков...";
|
||||||
player_connection_lost: "Server connection lost" , "Нет соединения с сервером";
|
player_connection_lost: "Server connection lost" , "Нет соединения с сервером";
|
||||||
player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю...";
|
player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю...";
|
||||||
|
player_active_device: "Active device" , "Активный девайс";
|
||||||
player_no_results: "No results found" , "Ничего не найдено";
|
player_no_results: "No results found" , "Ничего не найдено";
|
||||||
player_new_playlist: "New Playlist" , "Новый плейлист";
|
player_new_playlist: "New Playlist" , "Новый плейлист";
|
||||||
player_rename_playlist: "Rename Playlist" , "Переименовать плейлист";
|
player_rename_playlist: "Rename Playlist" , "Переименовать плейлист";
|
||||||
|
|||||||
+95
-35
@@ -17,28 +17,99 @@ pub fn is_orchestrator_running() -> bool {
|
|||||||
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
|
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to acquire the PostgreSQL advisory lock for the orchestrator.
|
struct OrchestratorAdvisoryGuard {
|
||||||
/// Returns true if the lock was acquired (no other orchestrator is running).
|
conn: Option<sqlx::pool::PoolConnection<sqlx::Postgres>>,
|
||||||
async fn try_acquire_orchestrator_lock(pool: &sqlx::PgPool) -> bool {
|
}
|
||||||
match sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)")
|
|
||||||
|
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)
|
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
||||||
.fetch_one(pool)
|
.fetch_one(&mut *conn)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(acquired) => acquired,
|
Ok(true) => tracing::info!("inbox_process: advisory lock released"),
|
||||||
Err(e) => {
|
Ok(false) => tracing::warn!(
|
||||||
tracing::error!("Failed to acquire advisory lock: {e}");
|
"inbox_process: advisory lock was not held by the guard connection"
|
||||||
false
|
),
|
||||||
|
Err(e) => tracing::error!("inbox_process: failed to release advisory lock: {e}"),
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Release the PostgreSQL advisory lock for the orchestrator.
|
async fn connection_holds_orchestrator_lock(
|
||||||
async fn release_orchestrator_lock(pool: &sqlx::PgPool) {
|
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
|
||||||
let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
|
) -> 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)
|
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
||||||
.execute(pool)
|
.fetch_one(&mut **conn)
|
||||||
.await;
|
.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)
|
||||||
|
.fetch_one(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if acquired {
|
||||||
|
Ok(Some(OrchestratorAdvisoryGuard { conn: Some(conn) }))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::agent::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata};
|
use crate::agent::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata};
|
||||||
@@ -83,10 +154,10 @@ impl Job for InboxProcessJob {
|
|||||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
log.info(
|
log.warn(
|
||||||
"Another inbox_process orchestrator is already running (AtomicBool), skipping",
|
"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;
|
struct AtomicGuard;
|
||||||
impl Drop for AtomicGuard {
|
impl Drop for AtomicGuard {
|
||||||
@@ -98,27 +169,16 @@ impl Job for InboxProcessJob {
|
|||||||
let _atomic_guard = AtomicGuard;
|
let _atomic_guard = AtomicGuard;
|
||||||
|
|
||||||
// --- Guard 2: PostgreSQL advisory lock (cross-process/binary safe) ---
|
// --- Guard 2: PostgreSQL advisory lock (cross-process/binary safe) ---
|
||||||
if !try_acquire_orchestrator_lock(&ctx.pool).await {
|
let _advisory_guard = match try_acquire_orchestrator_lock(&ctx.pool).await? {
|
||||||
log.info("Another inbox_process orchestrator holds the advisory lock, skipping");
|
Some(guard) => guard,
|
||||||
return Ok(());
|
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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
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 = AdvisoryGuard {
|
|
||||||
pool: pool_for_unlock,
|
|
||||||
};
|
};
|
||||||
|
tracing::info!("inbox_process: advisory lock acquired");
|
||||||
|
|
||||||
let config = Arc::clone(&ctx.config);
|
let config = Arc::clone(&ctx.config);
|
||||||
let mut total_ok = 0u64;
|
let mut total_ok = 0u64;
|
||||||
|
|||||||
+157
-26
@@ -84,6 +84,12 @@ struct LastfmTagStats {
|
|||||||
failed: u64,
|
failed: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum LastfmTagPassResult {
|
||||||
|
Completed,
|
||||||
|
RateLimited,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct MetadataBackfillJob;
|
pub struct MetadataBackfillJob;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -345,9 +351,21 @@ async fn backfill_lastfm_tags(
|
|||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
let mut stats = LastfmTagStats::default();
|
let mut stats = LastfmTagStats::default();
|
||||||
backfill_lastfm_artist_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?;
|
if backfill_lastfm_artist_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?
|
||||||
backfill_lastfm_release_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?;
|
== LastfmTagPassResult::RateLimited
|
||||||
backfill_lastfm_track_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?;
|
{
|
||||||
|
return Ok(stats);
|
||||||
|
}
|
||||||
|
if backfill_lastfm_release_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?
|
||||||
|
== LastfmTagPassResult::RateLimited
|
||||||
|
{
|
||||||
|
return Ok(stats);
|
||||||
|
}
|
||||||
|
if backfill_lastfm_track_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?
|
||||||
|
== LastfmTagPassResult::RateLimited
|
||||||
|
{
|
||||||
|
return Ok(stats);
|
||||||
|
}
|
||||||
Ok(stats)
|
Ok(stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,7 +376,7 @@ async fn backfill_lastfm_artist_tags(
|
|||||||
api_key: &str,
|
api_key: &str,
|
||||||
overwrite: bool,
|
overwrite: bool,
|
||||||
stats: &mut LastfmTagStats,
|
stats: &mut LastfmTagStats,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<LastfmTagPassResult> {
|
||||||
let rows = sqlx::query_as::<_, LastfmArtistTagRow>(
|
let rows = sqlx::query_as::<_, LastfmArtistTagRow>(
|
||||||
r#"SELECT DISTINCT a.id, a.name::text AS name
|
r#"SELECT DISTINCT a.id, a.name::text AS name
|
||||||
FROM furumusic__artist a
|
FROM furumusic__artist a
|
||||||
@@ -376,7 +394,17 @@ async fn backfill_lastfm_artist_tags(
|
|||||||
));
|
));
|
||||||
let total = rows.len();
|
let total = rows.len();
|
||||||
for (index, row) in rows.into_iter().enumerate() {
|
for (index, row) in rows.into_iter().enumerate() {
|
||||||
if should_skip_lastfm_entity(&ctx.pool, "artist", row.id, overwrite).await? {
|
if should_log_lastfm_item(index + 1, total, 25) {
|
||||||
|
log.info(&format!(
|
||||||
|
"Last.fm artist tags {}/{}: artist {} \"{}\"",
|
||||||
|
index + 1,
|
||||||
|
total,
|
||||||
|
row.id,
|
||||||
|
row.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
match should_skip_lastfm_entity(&ctx.pool, "artist", row.id, overwrite).await {
|
||||||
|
Ok(true) => {
|
||||||
stats.skipped_existing += 1;
|
stats.skipped_existing += 1;
|
||||||
if should_log_lastfm_progress(index + 1, total, 25) {
|
if should_log_lastfm_progress(index + 1, total, 25) {
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
@@ -387,20 +415,45 @@ async fn backfill_lastfm_artist_tags(
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(err) => {
|
||||||
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Last.fm artist tags skip check failed for artist {} \"{}\": {err}",
|
||||||
|
row.id, row.name
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
stats.considered += 1;
|
stats.considered += 1;
|
||||||
match fetch_lastfm_artist_tags(client, api_key, &row.name).await {
|
match fetch_lastfm_artist_tags(client, api_key, &row.name).await {
|
||||||
Ok(tags) if !tags.is_empty() => {
|
Ok(tags) if !tags.is_empty() => {
|
||||||
let saved =
|
match replace_entity_tags(&ctx.pool, "artist", row.id, &tags, "lastfm", false)
|
||||||
replace_entity_tags(&ctx.pool, "artist", row.id, &tags, "lastfm", false)
|
.await
|
||||||
.await?;
|
{
|
||||||
|
Ok(saved) => {
|
||||||
stats.tags_saved += saved;
|
stats.tags_saved += saved;
|
||||||
stats.updated_entities += 1;
|
stats.updated_entities += 1;
|
||||||
}
|
}
|
||||||
|
Err(err) => {
|
||||||
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Last.fm artist tags save failed for artist {} \"{}\": {err}",
|
||||||
|
row.id, row.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
stats.not_found += 1;
|
stats.not_found += 1;
|
||||||
}
|
}
|
||||||
Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => {
|
Err(err) if is_lastfm_rate_limit_error(&err) => {
|
||||||
return Err(err);
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Last.fm rate limit reached while fetching artist tags for artist {} \"{}\"; stopping Last.fm tag backfill for this run",
|
||||||
|
row.id, row.name
|
||||||
|
));
|
||||||
|
return Ok(LastfmTagPassResult::RateLimited);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
stats.failed += 1;
|
stats.failed += 1;
|
||||||
@@ -419,7 +472,7 @@ async fn backfill_lastfm_artist_tags(
|
|||||||
}
|
}
|
||||||
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
|
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(LastfmTagPassResult::Completed)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn backfill_lastfm_release_tags(
|
async fn backfill_lastfm_release_tags(
|
||||||
@@ -429,7 +482,7 @@ async fn backfill_lastfm_release_tags(
|
|||||||
api_key: &str,
|
api_key: &str,
|
||||||
overwrite: bool,
|
overwrite: bool,
|
||||||
stats: &mut LastfmTagStats,
|
stats: &mut LastfmTagStats,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<LastfmTagPassResult> {
|
||||||
let rows = sqlx::query_as::<_, LastfmReleaseTagRow>(
|
let rows = sqlx::query_as::<_, LastfmReleaseTagRow>(
|
||||||
r#"SELECT r.id,
|
r#"SELECT r.id,
|
||||||
r.title::text AS title,
|
r.title::text AS title,
|
||||||
@@ -454,7 +507,17 @@ async fn backfill_lastfm_release_tags(
|
|||||||
));
|
));
|
||||||
let total = rows.len();
|
let total = rows.len();
|
||||||
for (index, row) in rows.into_iter().enumerate() {
|
for (index, row) in rows.into_iter().enumerate() {
|
||||||
if should_skip_lastfm_entity(&ctx.pool, "release", row.id, overwrite).await? {
|
if should_log_lastfm_item(index + 1, total, 25) {
|
||||||
|
log.info(&format!(
|
||||||
|
"Last.fm release tags {}/{}: release {} \"{}\"",
|
||||||
|
index + 1,
|
||||||
|
total,
|
||||||
|
row.id,
|
||||||
|
row.title
|
||||||
|
));
|
||||||
|
}
|
||||||
|
match should_skip_lastfm_entity(&ctx.pool, "release", row.id, overwrite).await {
|
||||||
|
Ok(true) => {
|
||||||
stats.skipped_existing += 1;
|
stats.skipped_existing += 1;
|
||||||
if should_log_lastfm_progress(index + 1, total, 25) {
|
if should_log_lastfm_progress(index + 1, total, 25) {
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
@@ -465,6 +528,16 @@ async fn backfill_lastfm_release_tags(
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(err) => {
|
||||||
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Last.fm release tags skip check failed for release {} \"{}\": {err}",
|
||||||
|
row.id, row.title
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
let Some(artist) = row.artist_name.as_deref().filter(|value| !value.trim().is_empty())
|
let Some(artist) = row.artist_name.as_deref().filter(|value| !value.trim().is_empty())
|
||||||
else {
|
else {
|
||||||
stats.not_found += 1;
|
stats.not_found += 1;
|
||||||
@@ -480,17 +553,32 @@ async fn backfill_lastfm_release_tags(
|
|||||||
stats.considered += 1;
|
stats.considered += 1;
|
||||||
match fetch_lastfm_album_tags(client, api_key, artist, &row.title).await {
|
match fetch_lastfm_album_tags(client, api_key, artist, &row.title).await {
|
||||||
Ok(tags) if !tags.is_empty() => {
|
Ok(tags) if !tags.is_empty() => {
|
||||||
let saved =
|
match replace_entity_tags(&ctx.pool, "release", row.id, &tags, "lastfm", false)
|
||||||
replace_entity_tags(&ctx.pool, "release", row.id, &tags, "lastfm", false)
|
.await
|
||||||
.await?;
|
{
|
||||||
|
Ok(saved) => {
|
||||||
stats.tags_saved += saved;
|
stats.tags_saved += saved;
|
||||||
stats.updated_entities += 1;
|
stats.updated_entities += 1;
|
||||||
}
|
}
|
||||||
|
Err(err) => {
|
||||||
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Last.fm release tags save failed for release {} \"{}\" / \"{}\": {err}",
|
||||||
|
row.id, artist, row.title
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
stats.not_found += 1;
|
stats.not_found += 1;
|
||||||
}
|
}
|
||||||
Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => {
|
Err(err) if is_lastfm_rate_limit_error(&err) => {
|
||||||
return Err(err);
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Last.fm rate limit reached while fetching release tags for release {} \"{}\" / \"{}\"; stopping Last.fm tag backfill for this run",
|
||||||
|
row.id, artist, row.title
|
||||||
|
));
|
||||||
|
return Ok(LastfmTagPassResult::RateLimited);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
stats.failed += 1;
|
stats.failed += 1;
|
||||||
@@ -509,7 +597,7 @@ async fn backfill_lastfm_release_tags(
|
|||||||
}
|
}
|
||||||
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
|
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(LastfmTagPassResult::Completed)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn backfill_lastfm_track_tags(
|
async fn backfill_lastfm_track_tags(
|
||||||
@@ -519,7 +607,7 @@ async fn backfill_lastfm_track_tags(
|
|||||||
api_key: &str,
|
api_key: &str,
|
||||||
overwrite: bool,
|
overwrite: bool,
|
||||||
stats: &mut LastfmTagStats,
|
stats: &mut LastfmTagStats,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<LastfmTagPassResult> {
|
||||||
let rows = sqlx::query_as::<_, LastfmTrackTagRow>(
|
let rows = sqlx::query_as::<_, LastfmTrackTagRow>(
|
||||||
r#"SELECT t.id,
|
r#"SELECT t.id,
|
||||||
t.title::text AS title,
|
t.title::text AS title,
|
||||||
@@ -544,7 +632,17 @@ async fn backfill_lastfm_track_tags(
|
|||||||
));
|
));
|
||||||
let total = rows.len();
|
let total = rows.len();
|
||||||
for (index, row) in rows.into_iter().enumerate() {
|
for (index, row) in rows.into_iter().enumerate() {
|
||||||
if should_skip_lastfm_entity(&ctx.pool, "track", row.id, overwrite).await? {
|
if should_log_lastfm_item(index + 1, total, 50) {
|
||||||
|
log.info(&format!(
|
||||||
|
"Last.fm track tags {}/{}: track {} \"{}\"",
|
||||||
|
index + 1,
|
||||||
|
total,
|
||||||
|
row.id,
|
||||||
|
row.title
|
||||||
|
));
|
||||||
|
}
|
||||||
|
match should_skip_lastfm_entity(&ctx.pool, "track", row.id, overwrite).await {
|
||||||
|
Ok(true) => {
|
||||||
stats.skipped_existing += 1;
|
stats.skipped_existing += 1;
|
||||||
if should_log_lastfm_progress(index + 1, total, 50) {
|
if should_log_lastfm_progress(index + 1, total, 50) {
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
@@ -555,6 +653,16 @@ async fn backfill_lastfm_track_tags(
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(err) => {
|
||||||
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Last.fm track tags skip check failed for track {} \"{}\": {err}",
|
||||||
|
row.id, row.title
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
let Some(artist) = row.artist_name.as_deref().filter(|value| !value.trim().is_empty())
|
let Some(artist) = row.artist_name.as_deref().filter(|value| !value.trim().is_empty())
|
||||||
else {
|
else {
|
||||||
stats.not_found += 1;
|
stats.not_found += 1;
|
||||||
@@ -570,16 +678,31 @@ async fn backfill_lastfm_track_tags(
|
|||||||
stats.considered += 1;
|
stats.considered += 1;
|
||||||
match fetch_lastfm_track_tags(client, api_key, artist, &row.title).await {
|
match fetch_lastfm_track_tags(client, api_key, artist, &row.title).await {
|
||||||
Ok(tags) if !tags.is_empty() => {
|
Ok(tags) if !tags.is_empty() => {
|
||||||
let saved =
|
match replace_entity_tags(&ctx.pool, "track", row.id, &tags, "lastfm", true).await
|
||||||
replace_entity_tags(&ctx.pool, "track", row.id, &tags, "lastfm", true).await?;
|
{
|
||||||
|
Ok(saved) => {
|
||||||
stats.tags_saved += saved;
|
stats.tags_saved += saved;
|
||||||
stats.updated_entities += 1;
|
stats.updated_entities += 1;
|
||||||
}
|
}
|
||||||
|
Err(err) => {
|
||||||
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Last.fm track tags save failed for track {} \"{}\" / \"{}\": {err}",
|
||||||
|
row.id, artist, row.title
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
stats.not_found += 1;
|
stats.not_found += 1;
|
||||||
}
|
}
|
||||||
Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => {
|
Err(err) if is_lastfm_rate_limit_error(&err) => {
|
||||||
return Err(err);
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Last.fm rate limit reached while fetching track tags for track {} \"{}\" / \"{}\"; stopping Last.fm tag backfill for this run",
|
||||||
|
row.id, artist, row.title
|
||||||
|
));
|
||||||
|
return Ok(LastfmTagPassResult::RateLimited);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
stats.failed += 1;
|
stats.failed += 1;
|
||||||
@@ -598,13 +721,21 @@ async fn backfill_lastfm_track_tags(
|
|||||||
}
|
}
|
||||||
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
|
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(LastfmTagPassResult::Completed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_log_lastfm_progress(done: usize, total: usize, every: usize) -> bool {
|
fn should_log_lastfm_progress(done: usize, total: usize, every: usize) -> bool {
|
||||||
total > 0 && (done == total || done % every == 0)
|
total > 0 && (done == total || done % every == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn should_log_lastfm_item(done: usize, total: usize, every: usize) -> bool {
|
||||||
|
total > 0 && (done == 1 || done == total || done % every == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_lastfm_rate_limit_error(err: &anyhow::Error) -> bool {
|
||||||
|
err.to_string().contains("Last.fm rate limit exceeded")
|
||||||
|
}
|
||||||
|
|
||||||
async fn should_skip_lastfm_entity(
|
async fn should_skip_lastfm_entity(
|
||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
entity_kind: &str,
|
entity_kind: &str,
|
||||||
|
|||||||
@@ -159,6 +159,14 @@ pub fn record_active_user(user_id: i64) {
|
|||||||
users.insert(user_id, Instant::now());
|
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) {
|
pub fn record_auth_attempt(method: &'static str, outcome: &'static str, reason: &'static str) {
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_auth_login_attempts_total",
|
"furumusic_auth_login_attempts_total",
|
||||||
|
|||||||
+60
-1
@@ -2774,13 +2774,72 @@ async fn artists_handler(
|
|||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
query: cot::request::extractors::UrlQuery<PaginationQuery>,
|
query: cot::request::extractors::UrlQuery<PaginationQuery>,
|
||||||
) -> cot::Result<cot::response::Response> {
|
) -> 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"));
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
};
|
};
|
||||||
|
|
||||||
let page = query.0.page.unwrap_or(1).max(1);
|
let page = query.0.page.unwrap_or(1).max(1);
|
||||||
let per_page = query.0.limit.unwrap_or(60).clamp(1, 200);
|
let per_page = query.0.limit.unwrap_or(60).clamp(1, 200);
|
||||||
let offset = (page - 1) as i64 * per_page as i64;
|
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>(
|
let total_row = sqlx::query_as::<_, CountRow>(
|
||||||
r#"SELECT COUNT(DISTINCT a.id) AS count
|
r#"SELECT COUNT(DISTINCT a.id) AS count
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ pub(super) struct RemoveTrackRequest {
|
|||||||
pub(super) struct PaginationQuery {
|
pub(super) struct PaginationQuery {
|
||||||
pub(super) page: Option<i32>,
|
pub(super) page: Option<i32>,
|
||||||
pub(super) limit: Option<i32>,
|
pub(super) limit: Option<i32>,
|
||||||
|
pub(super) mine: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
+433
-1
@@ -307,7 +307,8 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled,
|
.btn:disabled,
|
||||||
.icon-btn:disabled {
|
.icon-btn:disabled,
|
||||||
|
.seg-btn:disabled {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
@@ -416,6 +417,15 @@ tbody tr:hover {
|
|||||||
.col-tags { width: 250px; }
|
.col-tags { width: 250px; }
|
||||||
.col-time { width: 132px; }
|
.col-time { width: 132px; }
|
||||||
|
|
||||||
|
.users-table-wrap {
|
||||||
|
max-height: calc(100vh - 300px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table .user-status-column { width: 116px; }
|
||||||
|
.users-table .user-role-column { width: 110px; }
|
||||||
|
.users-table .user-active-column { width: 112px; }
|
||||||
|
.users-table .user-seen-column { width: 170px; }
|
||||||
|
|
||||||
.check {
|
.check {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
@@ -1274,6 +1284,137 @@ tbody tr:hover {
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-modal {
|
||||||
|
width: min(860px, calc(100vw - 80px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-modal-tabs {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-summary-grid,
|
||||||
|
.user-profile-facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-summary-card,
|
||||||
|
.user-profile-facts > div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-summary-card span,
|
||||||
|
.user-profile-facts span {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-summary-card strong,
|
||||||
|
.user-profile-facts strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-stats-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-row {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 52px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-cover {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-cover svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-title,
|
||||||
|
.user-activity-meta,
|
||||||
|
.user-activity-sub {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-title {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-meta {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-sub {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-time {
|
||||||
|
min-width: 118px;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-time strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock head_extra %}
|
{% endblock head_extra %}
|
||||||
|
|
||||||
@@ -1309,6 +1450,11 @@ tbody tr:hover {
|
|||||||
<i data-lucide="wrench"></i>
|
<i data-lucide="wrench"></i>
|
||||||
<span>Future Tools</span>
|
<span>Future Tools</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-btn" :class="{active: activeView === 'users'}" @click="openUsers()">
|
||||||
|
<i data-lucide="users"></i>
|
||||||
|
<span>Users</span>
|
||||||
|
<span class="nav-count" x-text="users.online_count ? users.online_count + ' online' : ''"></span>
|
||||||
|
</button>
|
||||||
<button class="nav-btn" :class="{active: activeView === 'settings'}" @click="openSettings()">
|
<button class="nav-btn" :class="{active: activeView === 'settings'}" @click="openSettings()">
|
||||||
<i data-lucide="settings"></i>
|
<i data-lucide="settings"></i>
|
||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
@@ -1729,6 +1875,79 @@ tbody tr:hover {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="content" x-show="activeView === 'users'">
|
||||||
|
<section class="stats-strip">
|
||||||
|
<div class="stat-cell">
|
||||||
|
<div class="stat-value" x-text="fmt(users.total || 0)"></div>
|
||||||
|
<div class="stat-label">Users</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-cell">
|
||||||
|
<div class="stat-value" x-text="fmt(users.online_count || 0)"></div>
|
||||||
|
<div class="stat-label">Online now</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<strong>Users</strong>
|
||||||
|
<span x-text="usersSubtitle()"></span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<input class="search" placeholder="Search users" x-model="userSearch" @input.debounce.350ms="loadUsers()" />
|
||||||
|
<button class="btn" @click="loadUsers(false)">
|
||||||
|
<i data-lucide="refresh-cw"></i>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap users-table-wrap">
|
||||||
|
<table class="users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="user-status-column">Status</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th class="user-role-column">Role</th>
|
||||||
|
<th class="user-active-column">Account</th>
|
||||||
|
<th class="user-seen-column">Last activity</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="user in users.items" :key="user.id">
|
||||||
|
<tr @click="openUser(user)">
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="userStatusClass(user)" x-text="userStatusLabel(user)"></span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="primary-line" x-text="user.display_name || user.username"></div>
|
||||||
|
<div class="secondary-line" x-text="user.email ? user.username + ' · ' + user.email : user.username"></div>
|
||||||
|
</td>
|
||||||
|
<td><span x-text="user.role"></span></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="user.is_active ? 'ok' : 'disabled'" x-text="user.is_active ? 'active' : 'disabled'"></span>
|
||||||
|
</td>
|
||||||
|
<td><span x-text="userLastSeenLabel(user)"></span></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty" x-show="!usersLoading && users.items.length === 0">No users in this filter</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-strip">
|
||||||
|
<span class="selection-summary" x-text="usersRangeText()"></span>
|
||||||
|
<div class="toolbar">
|
||||||
|
<select class="search" style="width:92px" x-model.number="users.limit" @change="users.offset = 0; loadUsers(false)">
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="40">40</option>
|
||||||
|
<option value="80">80</option>
|
||||||
|
<option value="120">120</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn" @click="pageUsers(-1)" :disabled="users.offset === 0">Previous</button>
|
||||||
|
<button class="btn" @click="pageUsers(1)" :disabled="users.offset + users.limit >= users.total">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="content" x-show="activeView === 'tools'">
|
<div class="content" x-show="activeView === 'tools'">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
@@ -2010,6 +2229,90 @@ tbody tr:hover {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" x-show="userModalOpen && activeUserDetail" x-transition @click.self="userModalOpen = false">
|
||||||
|
<section class="modal user-modal">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<strong x-text="activeUserDetail ? (activeUserDetail.user.display_name || activeUserDetail.user.username) : 'User'"></strong>
|
||||||
|
<span x-text="activeUserDetail ? userStatusLabel(activeUserDetail.user) + ' · ' + activeUserDetail.user.role : ''"></span>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" @click="userModalOpen = false">
|
||||||
|
<i data-lucide="x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" x-show="activeUserDetail">
|
||||||
|
<div class="segmented user-modal-tabs">
|
||||||
|
<button class="seg-btn" :class="{active: userModalTab === 'overview'}" @click="userModalTab = 'overview'">Overview</button>
|
||||||
|
<button class="seg-btn" :class="{active: userModalTab === 'activity'}" @click="userModalTab = 'activity'">Activity</button>
|
||||||
|
<button class="seg-btn" :class="{active: userModalTab === 'library'}" @click="userModalTab = 'library'" disabled>Library</button>
|
||||||
|
</div>
|
||||||
|
<div x-show="userModalTab === 'overview'">
|
||||||
|
<section class="user-summary-grid">
|
||||||
|
<div class="user-summary-card">
|
||||||
|
<span>Status</span>
|
||||||
|
<strong x-text="userStatusLabel(activeUserDetail.user)"></strong>
|
||||||
|
</div>
|
||||||
|
<div class="user-summary-card">
|
||||||
|
<span>Last activity</span>
|
||||||
|
<strong x-text="userLastSeenLabel(activeUserDetail.user)"></strong>
|
||||||
|
</div>
|
||||||
|
<div class="user-summary-card">
|
||||||
|
<span>Account</span>
|
||||||
|
<strong x-text="activeUserDetail.user.is_active ? 'active' : 'disabled'"></strong>
|
||||||
|
</div>
|
||||||
|
<div class="user-summary-card">
|
||||||
|
<span>Last.fm</span>
|
||||||
|
<strong x-text="activeUserDetail.stats.lastfm_connected ? 'connected' : 'not connected'"></strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="stats-strip user-stats-grid">
|
||||||
|
<template x-for="cell in userStatCards()" :key="cell.label">
|
||||||
|
<div class="stat-cell">
|
||||||
|
<div class="stat-value" x-text="cell.display"></div>
|
||||||
|
<div class="stat-label" x-text="cell.label"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
<div class="field">
|
||||||
|
<label>Profile</label>
|
||||||
|
<div class="user-profile-facts">
|
||||||
|
<div><span>Username</span><strong x-text="activeUserDetail.user.username"></strong></div>
|
||||||
|
<div><span>Email</span><strong x-text="activeUserDetail.user.email || 'not set'"></strong></div>
|
||||||
|
<div><span>Display name</span><strong x-text="activeUserDetail.user.display_name || 'not set'"></strong></div>
|
||||||
|
<div><span>Role</span><strong x-text="activeUserDetail.user.role"></strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-show="userModalTab === 'activity'">
|
||||||
|
<div class="user-activity-list">
|
||||||
|
<template x-for="play in (activeUserDetail?.recent_plays || [])" :key="play.history_id">
|
||||||
|
<div class="user-activity-row">
|
||||||
|
<div class="user-activity-cover">
|
||||||
|
<template x-if="play.cover_url">
|
||||||
|
<img :src="play.cover_url" :alt="play.release_title || play.title" loading="lazy">
|
||||||
|
</template>
|
||||||
|
<template x-if="!play.cover_url">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="user-activity-main">
|
||||||
|
<div class="user-activity-title" x-text="play.title"></div>
|
||||||
|
<div class="user-activity-meta" x-text="play.artists"></div>
|
||||||
|
<div class="user-activity-sub" x-text="userPlayMeta(play)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="user-activity-time">
|
||||||
|
<strong x-text="userPlayListened(play)"></strong>
|
||||||
|
<span x-text="shortDate(play.played_at)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="empty" x-show="activeUserDetail && (!activeUserDetail.recent_plays || activeUserDetail.recent_plays.length === 0)">No play history for this user yet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-backdrop" x-show="reviewModalOpen && activeReview" x-transition @click.self="reviewModalOpen = false">
|
<div class="modal-backdrop" x-show="reviewModalOpen && activeReview" x-transition @click.self="reviewModalOpen = false">
|
||||||
<section class="modal">
|
<section class="modal">
|
||||||
<div class="modal-head">
|
<div class="modal-head">
|
||||||
@@ -2307,6 +2610,12 @@ function adminV2() {
|
|||||||
runtime: { agent: {}, storage: [], node: {} },
|
runtime: { agent: {}, storage: [], node: {} },
|
||||||
libraryOverview: {},
|
libraryOverview: {},
|
||||||
reviews: { items: [], total: 0, limit: 80, offset: 0, status_counts: [] },
|
reviews: { items: [], total: 0, limit: 80, offset: 0, status_counts: [] },
|
||||||
|
users: { items: [], total: 0, limit: 40, offset: 0, online_count: 0 },
|
||||||
|
usersLoading: false,
|
||||||
|
userSearch: '',
|
||||||
|
userModalOpen: false,
|
||||||
|
userModalTab: 'overview',
|
||||||
|
activeUserDetail: null,
|
||||||
reviewFilter: { status: null, search: '' },
|
reviewFilter: { status: null, search: '' },
|
||||||
selectedReviewIds: {},
|
selectedReviewIds: {},
|
||||||
reviewSelectionScope: 'ids',
|
reviewSelectionScope: 'ids',
|
||||||
@@ -2450,6 +2759,8 @@ function adminV2() {
|
|||||||
this.activeJobName ? this.loadRunsForJob(this.activeJobName, false) : Promise.resolve(),
|
this.activeJobName ? this.loadRunsForJob(this.activeJobName, false) : Promise.resolve(),
|
||||||
this.refreshActiveRunDetail()
|
this.refreshActiveRunDetail()
|
||||||
]);
|
]);
|
||||||
|
} else if (this.activeView === 'users') {
|
||||||
|
await Promise.allSettled([this.loadUsers(false)]);
|
||||||
} else {
|
} else {
|
||||||
await Promise.allSettled([this.loadJobs(false), this.loadReviews(false)]);
|
await Promise.allSettled([this.loadJobs(false), this.loadReviews(false)]);
|
||||||
}
|
}
|
||||||
@@ -2482,6 +2793,8 @@ function adminV2() {
|
|||||||
this.libraryKind = nextKind;
|
this.libraryKind = nextKind;
|
||||||
} else if (view === 'settings') {
|
} else if (view === 'settings') {
|
||||||
this.activeView = 'settings';
|
this.activeView = 'settings';
|
||||||
|
} else if (view === 'users') {
|
||||||
|
this.activeView = 'users';
|
||||||
} else if (view === 'tools') {
|
} else if (view === 'tools') {
|
||||||
this.activeView = 'tools';
|
this.activeView = 'tools';
|
||||||
} else {
|
} else {
|
||||||
@@ -2513,6 +2826,9 @@ function adminV2() {
|
|||||||
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
||||||
await this.loadSettingsProbe(false);
|
await this.loadSettingsProbe(false);
|
||||||
}
|
}
|
||||||
|
} else if (this.activeView === 'users') {
|
||||||
|
if (updateRoute) this.setRoute('#users');
|
||||||
|
await this.loadUsers(false);
|
||||||
} else if (this.activeView === 'tools' && updateRoute) {
|
} else if (this.activeView === 'tools' && updateRoute) {
|
||||||
this.setRoute('#tools');
|
this.setRoute('#tools');
|
||||||
}
|
}
|
||||||
@@ -2555,6 +2871,12 @@ function adminV2() {
|
|||||||
this.setRoute('#tools');
|
this.setRoute('#tools');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
openUsers() {
|
||||||
|
this.activeView = 'users';
|
||||||
|
this.setRoute('#users');
|
||||||
|
this.loadUsers();
|
||||||
|
},
|
||||||
|
|
||||||
async loadReviews(resetOffset = true) {
|
async loadReviews(resetOffset = true) {
|
||||||
if (resetOffset) this.reviews.offset = 0;
|
if (resetOffset) this.reviews.offset = 0;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -2603,6 +2925,38 @@ function adminV2() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async loadUsers(resetOffset = true) {
|
||||||
|
if (this.activeView !== 'users' && resetOffset) return;
|
||||||
|
if (resetOffset) this.users.offset = 0;
|
||||||
|
this.usersLoading = true;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('limit', this.users.limit || 40);
|
||||||
|
params.set('offset', this.users.offset || 0);
|
||||||
|
if (this.userSearch) params.set('search', this.userSearch);
|
||||||
|
try {
|
||||||
|
this.users = await this.request(`${this.apiBase}/users?${params.toString()}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.showToast(error.message);
|
||||||
|
} finally {
|
||||||
|
this.usersLoading = false;
|
||||||
|
this.icons();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async openUser(user) {
|
||||||
|
if (!user) return;
|
||||||
|
this.userModalTab = 'overview';
|
||||||
|
this.activeUserDetail = { user, stats: {}, recent_plays: [] };
|
||||||
|
this.userModalOpen = true;
|
||||||
|
try {
|
||||||
|
this.activeUserDetail = await this.request(`${this.apiBase}/users/${user.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.showToast(error.message);
|
||||||
|
} finally {
|
||||||
|
this.icons();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async loadSettings(showErrors = true) {
|
async loadSettings(showErrors = true) {
|
||||||
try {
|
try {
|
||||||
this.settings = await this.request(`${this.apiBase}/settings`);
|
this.settings = await this.request(`${this.apiBase}/settings`);
|
||||||
@@ -3413,6 +3767,13 @@ function adminV2() {
|
|||||||
this.loadLibrary(false);
|
this.loadLibrary(false);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
pageUsers(delta) {
|
||||||
|
const next = Math.max(0, (this.users.offset || 0) + delta * (this.users.limit || 40));
|
||||||
|
if (next === this.users.offset) return;
|
||||||
|
this.users.offset = next;
|
||||||
|
this.loadUsers(false);
|
||||||
|
},
|
||||||
|
|
||||||
statCells() {
|
statCells() {
|
||||||
const agent = (this.runtime && this.runtime.agent) || {};
|
const agent = (this.runtime && this.runtime.agent) || {};
|
||||||
const storage = (this.runtime && this.runtime.storage) || [];
|
const storage = (this.runtime && this.runtime.storage) || [];
|
||||||
@@ -3469,6 +3830,7 @@ function adminV2() {
|
|||||||
if (this.activeView === 'jobs') return 'Tasks';
|
if (this.activeView === 'jobs') return 'Tasks';
|
||||||
if (this.activeView === 'tools') return 'Future Tools';
|
if (this.activeView === 'tools') return 'Future Tools';
|
||||||
if (this.activeView === 'settings') return 'Settings';
|
if (this.activeView === 'settings') return 'Settings';
|
||||||
|
if (this.activeView === 'users') return 'Users';
|
||||||
return 'Review Queue';
|
return 'Review Queue';
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -3477,6 +3839,7 @@ function adminV2() {
|
|||||||
if (this.activeView === 'jobs') return 'Scheduler state, recent runs, and manual controls in one place';
|
if (this.activeView === 'jobs') return 'Scheduler state, recent runs, and manual controls in one place';
|
||||||
if (this.activeView === 'tools') return 'Reserved space for merge, split, enrichment, and destructive workflows';
|
if (this.activeView === 'tools') return 'Reserved space for merge, split, enrichment, and destructive workflows';
|
||||||
if (this.activeView === 'settings') return 'Application configuration and external API credentials';
|
if (this.activeView === 'settings') return 'Application configuration and external API credentials';
|
||||||
|
if (this.activeView === 'users') return 'Account status, live presence, and per-user listening statistics';
|
||||||
return 'Full-screen review triage with filter-aware bulk actions';
|
return 'Full-screen review triage with filter-aware bulk actions';
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -3493,6 +3856,75 @@ function adminV2() {
|
|||||||
return `${this.libraryKind} · ${this.fmt(this.library.total || 0)} rows`;
|
return `${this.libraryKind} · ${this.fmt(this.library.total || 0)} rows`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
usersSubtitle() {
|
||||||
|
return `${this.fmt(this.users.total || 0)} users · ${this.fmt(this.users.online_count || 0)} online`;
|
||||||
|
},
|
||||||
|
|
||||||
|
userStatusClass(user) {
|
||||||
|
return user?.is_online ? 'ok' : 'disabled';
|
||||||
|
},
|
||||||
|
|
||||||
|
userStatusLabel(user) {
|
||||||
|
return user?.is_online ? 'online' : 'offline';
|
||||||
|
},
|
||||||
|
|
||||||
|
userLastSeenLabel(user) {
|
||||||
|
if (!user || user.last_seen_ms == null) return 'never in this session';
|
||||||
|
if (user.is_online) return 'online now';
|
||||||
|
return this.durationApprox(user.last_seen_ms / 1000) + ' ago';
|
||||||
|
},
|
||||||
|
|
||||||
|
usersRangeText() {
|
||||||
|
const total = this.users.total || 0;
|
||||||
|
if (!total) return 'No users';
|
||||||
|
const start = (this.users.offset || 0) + 1;
|
||||||
|
const end = Math.min(total, (this.users.offset || 0) + (this.users.limit || 40));
|
||||||
|
return `${start}-${end} of ${this.fmt(total)}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
userStatCards() {
|
||||||
|
const stats = this.activeUserDetail?.stats || {};
|
||||||
|
return [
|
||||||
|
{ label: 'Plays', display: this.fmt(stats.plays || 0) },
|
||||||
|
{ label: 'Completed plays', display: this.fmt(stats.completed_plays || 0) },
|
||||||
|
{ label: 'Listened time', display: this.durationApprox(stats.listened_seconds || 0) },
|
||||||
|
{ label: 'Liked tracks', display: this.fmt(stats.liked_tracks || 0) },
|
||||||
|
{ label: 'Followed artists', display: this.fmt(stats.followed_artists || 0) },
|
||||||
|
{ label: 'Own playlists', display: this.fmt(stats.own_playlists || 0) },
|
||||||
|
{ label: 'Saved playlists', display: this.fmt(stats.saved_playlists || 0) },
|
||||||
|
{ label: 'Uploaded tracks', display: this.fmt(stats.uploaded_tracks || 0) },
|
||||||
|
{ label: 'Torrent sessions', display: this.fmt(stats.torrent_sessions || 0) }
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
userPlayListened(play) {
|
||||||
|
const listened = play?.duration_listened;
|
||||||
|
const duration = play?.track_duration_seconds;
|
||||||
|
const listenedText = listened == null ? 'unknown' : this.durationApprox(listened);
|
||||||
|
const durationText = duration ? this.durationApprox(duration) : null;
|
||||||
|
return durationText ? `${listenedText} / ${durationText}` : listenedText;
|
||||||
|
},
|
||||||
|
|
||||||
|
userPlayMeta(play) {
|
||||||
|
const parts = [];
|
||||||
|
if (play.release_title) {
|
||||||
|
parts.push(play.release_year ? `${play.release_title} (${play.release_year})` : play.release_title);
|
||||||
|
}
|
||||||
|
if (play.audio_format) parts.push(String(play.audio_format).toUpperCase());
|
||||||
|
if (play.audio_bitrate) parts.push(`${play.audio_bitrate} kbps`);
|
||||||
|
if (play.uploader_name) parts.push(`uploaded by ${play.uploader_name}`);
|
||||||
|
parts.push(play.completed ? 'completed' : 'partial');
|
||||||
|
return parts.join(' · ');
|
||||||
|
},
|
||||||
|
|
||||||
|
durationApprox(seconds) {
|
||||||
|
const value = Math.max(0, Number(seconds || 0));
|
||||||
|
if (value < 60) return `${Math.floor(value)}s`;
|
||||||
|
if (value < 3600) return `${Math.floor(value / 60)}m`;
|
||||||
|
if (value < 86400) return `${Math.floor(value / 3600)}h ${Math.floor((value % 3600) / 60)}m`;
|
||||||
|
return `${Math.floor(value / 86400)}d ${Math.floor((value % 86400) / 3600)}h`;
|
||||||
|
},
|
||||||
|
|
||||||
librarySelectionSummary() {
|
librarySelectionSummary() {
|
||||||
const count = this.selectedLibraryCount();
|
const count = this.selectedLibraryCount();
|
||||||
if (!count) return 'Nothing selected';
|
if (!count) return 'Nothing selected';
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const T = {
|
|||||||
radioFailed: "{{ t.player_radio_failed }}",
|
radioFailed: "{{ t.player_radio_failed }}",
|
||||||
connectionLost: "{{ t.player_connection_lost }}",
|
connectionLost: "{{ t.player_connection_lost }}",
|
||||||
connectionLostDetail: "{{ t.player_connection_lost_detail }}",
|
connectionLostDetail: "{{ t.player_connection_lost_detail }}",
|
||||||
|
activeDevice: "{{ t.player_active_device }}",
|
||||||
trackWord: "{{ t.player_tracks_count }}",
|
trackWord: "{{ t.player_tracks_count }}",
|
||||||
releaseWord: "{{ t.player_releases_count }}",
|
releaseWord: "{{ t.player_releases_count }}",
|
||||||
ofWord: "{{ t.player_of }}",
|
ofWord: "{{ t.player_of }}",
|
||||||
@@ -1176,6 +1177,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
jamUsers: [],
|
jamUsers: [],
|
||||||
jamSelectedUsers: [],
|
jamSelectedUsers: [],
|
||||||
jamSearching: false,
|
jamSearching: false,
|
||||||
|
remoteHintVisible: false,
|
||||||
|
remoteHintDeviceName: '',
|
||||||
|
_remoteHintShown: false,
|
||||||
|
_remoteHintTimer: null,
|
||||||
_pollTimer: null,
|
_pollTimer: null,
|
||||||
_jamSearchTimer: null,
|
_jamSearchTimer: null,
|
||||||
_stateRefreshTick: 0,
|
_stateRefreshTick: 0,
|
||||||
@@ -1265,6 +1270,34 @@ document.addEventListener('alpine:init', () => {
|
|||||||
if (wasActive && !this.isActive()) {
|
if (wasActive && !this.isActive()) {
|
||||||
Alpine.store('player')?._pauseLocal();
|
Alpine.store('player')?._pauseLocal();
|
||||||
}
|
}
|
||||||
|
this._maybeShowRemoteHint();
|
||||||
|
},
|
||||||
|
|
||||||
|
remoteActiveDevice() {
|
||||||
|
if (!this.activeDeviceId || this.activeDeviceId === this.id) return null;
|
||||||
|
return this.devices.find(device => device.id === this.activeDeviceId) || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
_maybeShowRemoteHint() {
|
||||||
|
if (this._remoteHintShown || this.remoteHintVisible || this.open) return;
|
||||||
|
const active = this.remoteActiveDevice();
|
||||||
|
if (!active) return;
|
||||||
|
this.remoteHintDeviceName = active.name || 'Devices';
|
||||||
|
this.remoteHintVisible = true;
|
||||||
|
this._remoteHintShown = true;
|
||||||
|
clearTimeout(this._remoteHintTimer);
|
||||||
|
this._remoteHintTimer = setTimeout(() => this.dismissRemoteHint(), 60000);
|
||||||
|
},
|
||||||
|
|
||||||
|
dismissRemoteHint() {
|
||||||
|
this.remoteHintVisible = false;
|
||||||
|
this._remoteHintShown = true;
|
||||||
|
clearTimeout(this._remoteHintTimer);
|
||||||
|
this._remoteHintTimer = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
remoteHintText() {
|
||||||
|
return `${T.activeDevice}: ${this.remoteHintDeviceName || this.activeLabel()}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
selectedJam() {
|
selectedJam() {
|
||||||
@@ -1330,6 +1363,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
|
this.dismissRemoteHint();
|
||||||
this.open = !this.open;
|
this.open = !this.open;
|
||||||
if (this.open) this.poll();
|
if (this.open) this.poll();
|
||||||
},
|
},
|
||||||
@@ -1813,11 +1847,13 @@ document.addEventListener('alpine:init', () => {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
Alpine.store('library', {
|
Alpine.store('library', {
|
||||||
view: 'artists',
|
view: 'artists',
|
||||||
|
artistFilter: 'all',
|
||||||
artists: [],
|
artists: [],
|
||||||
artistsPage: 0,
|
artistsPage: 0,
|
||||||
artistsTotal: 0,
|
artistsTotal: 0,
|
||||||
loading: false,
|
loading: false,
|
||||||
_allLoaded: false,
|
_allLoaded: false,
|
||||||
|
_artistsLoadToken: 0,
|
||||||
currentArtist: null,
|
currentArtist: null,
|
||||||
currentRelease: null,
|
currentRelease: null,
|
||||||
currentPlaylist: null,
|
currentPlaylist: null,
|
||||||
@@ -1832,7 +1868,6 @@ document.addEventListener('alpine:init', () => {
|
|||||||
_hashNav: false, // guard against circular hash updates
|
_hashNav: false, // guard against circular hash updates
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.loadArtists(1);
|
|
||||||
this._setupScroll();
|
this._setupScroll();
|
||||||
|
|
||||||
// Listen for browser back/forward
|
// Listen for browser back/forward
|
||||||
@@ -1869,7 +1904,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const params = match[3] || '';
|
const params = match[3] || '';
|
||||||
|
|
||||||
if (view === 'artists' && !id) {
|
if (view === 'artists' && !id) {
|
||||||
if (this.view !== 'artists') this.goArtists(options);
|
if (this.view !== 'artists' || this.artistsPage === 0) this.goArtists(options);
|
||||||
|
else if (options.restoreScroll) this._restoreScrollPosition(hash);
|
||||||
|
} else if (view === 'uploads' && !id) {
|
||||||
|
if (this.view !== 'my_uploads' || this.artistsPage === 0) this.goMyUploads(options);
|
||||||
else if (options.restoreScroll) this._restoreScrollPosition(hash);
|
else if (options.restoreScroll) this._restoreScrollPosition(hash);
|
||||||
} else if (view === 'artist' && id) {
|
} else if (view === 'artist' && id) {
|
||||||
this.openArtist(id, options);
|
this.openArtist(id, options);
|
||||||
@@ -1935,8 +1973,22 @@ document.addEventListener('alpine:init', () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_resetArtistList(filter) {
|
||||||
|
this.artistFilter = filter;
|
||||||
|
this.artists = [];
|
||||||
|
this.artistsPage = 0;
|
||||||
|
this.artistsTotal = 0;
|
||||||
|
this._allLoaded = false;
|
||||||
|
this.loading = false;
|
||||||
|
this._artistsLoadToken += 1;
|
||||||
|
},
|
||||||
|
|
||||||
goArtists(options = {}) {
|
goArtists(options = {}) {
|
||||||
this._beginNavigation('#artists', options);
|
this._beginNavigation('#artists', options);
|
||||||
|
if (this.artistFilter !== 'all' || this.artistsPage === 0) {
|
||||||
|
this._resetArtistList('all');
|
||||||
|
this.loadArtists(1);
|
||||||
|
}
|
||||||
this.view = 'artists';
|
this.view = 'artists';
|
||||||
this.currentArtist = null;
|
this.currentArtist = null;
|
||||||
this.currentRelease = null;
|
this.currentRelease = null;
|
||||||
@@ -1948,13 +2000,35 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this._afterNavigation(options);
|
this._afterNavigation(options);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
goMyUploads(options = {}) {
|
||||||
|
this._beginNavigation('#uploads', options);
|
||||||
|
if (this.artistFilter !== 'uploads' || this.artistsPage === 0) {
|
||||||
|
this._resetArtistList('uploads');
|
||||||
|
this.loadArtists(1);
|
||||||
|
}
|
||||||
|
this.view = 'my_uploads';
|
||||||
|
this.currentArtist = null;
|
||||||
|
this.currentRelease = null;
|
||||||
|
this.currentPlaylist = null;
|
||||||
|
this.searchQuery = '';
|
||||||
|
this.searchResults = null;
|
||||||
|
this._previousView = 'my_uploads';
|
||||||
|
this.$nextTick(() => { this._setupScroll(); });
|
||||||
|
this._afterNavigation(options);
|
||||||
|
},
|
||||||
|
|
||||||
async loadArtists(page) {
|
async loadArtists(page) {
|
||||||
if (this.loading || this._allLoaded) return;
|
if (this.loading || this._allLoaded) return;
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
const filter = this.artistFilter;
|
||||||
|
const token = this._artistsLoadToken + 1;
|
||||||
|
this._artistsLoadToken = token;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/player/artists?page=${page}&limit=60`);
|
const mine = filter === 'uploads' ? '&mine=true' : '';
|
||||||
|
const res = await fetch(`/api/player/artists?page=${page}&limit=60${mine}`);
|
||||||
if (!res.ok) throw new Error('failed');
|
if (!res.ok) throw new Error('failed');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (token !== this._artistsLoadToken || filter !== this.artistFilter) return;
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
this.artists = data.items;
|
this.artists = data.items;
|
||||||
} else {
|
} else {
|
||||||
@@ -1966,7 +2040,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this._allLoaded = true;
|
this._allLoaded = true;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
if (token === this._artistsLoadToken) {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async openArtist(id, options = {}) {
|
async openArtist(id, options = {}) {
|
||||||
@@ -2303,8 +2379,8 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.searchLoading = false;
|
this.searchLoading = false;
|
||||||
if (this.view === 'search') {
|
if (this.view === 'search') {
|
||||||
this.view = this._previousView || 'artists';
|
this.view = this._previousView || 'artists';
|
||||||
this._setHash('#artists');
|
this._setHash(this.view === 'my_uploads' ? '#uploads' : '#artists');
|
||||||
if (this.view === 'artists') {
|
if (this.view === 'artists' || this.view === 'my_uploads') {
|
||||||
this.$nextTick(() => { this._setupScroll(); });
|
this.$nextTick(() => { this._setupScroll(); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,12 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||||
{{ t.player_artists }}
|
{{ t.player_artists }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sidebar-nav-item"
|
||||||
|
:class="{ active: $store.library.view === 'my_uploads' }"
|
||||||
|
@click="$store.library.goMyUploads()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
||||||
|
{{ t.player_my_uploads }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<div class="sidebar-section-title">
|
<div class="sidebar-section-title">
|
||||||
@@ -170,6 +176,12 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||||
{{ t.player_artists }}
|
{{ t.player_artists }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sidebar-nav-item"
|
||||||
|
:class="{ active: $store.library.view === 'my_uploads' }"
|
||||||
|
@click="$store.library.goMyUploads(); $store.mobile.closeLibrary()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
||||||
|
{{ t.player_my_uploads }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mobile-drawer-section">
|
<div class="mobile-drawer-section">
|
||||||
@@ -507,9 +519,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Artists Grid -->
|
<!-- Artists Grid -->
|
||||||
<template x-if="$store.library.view === 'artists'">
|
<template x-if="$store.library.view === 'artists' || $store.library.view === 'my_uploads'">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="section-title">{{ t.player_artists }}</h1>
|
<h1 class="section-title" x-text="$store.library.view === 'my_uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_artists }}'"></h1>
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
<template x-for="artist in $store.library.artists" :key="artist.id">
|
<template x-for="artist in $store.library.artists" :key="artist.id">
|
||||||
<div class="card" @click="$store.library.openArtist(artist.id)">
|
<div class="card" @click="$store.library.openArtist(artist.id)">
|
||||||
@@ -537,6 +549,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<template x-if="!$store.library.loading && $store.library.artists.length === 0">
|
||||||
|
<div class="empty-state">
|
||||||
|
<p x-text="$store.library.view === 'my_uploads' ? '{{ t.player_no_uploaded_tracks }}' : '{{ t.artists_empty }}'"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template x-if="$store.library.loading">
|
<template x-if="$store.library.loading">
|
||||||
<div class="loading-spinner"><div class="spinner"></div></div>
|
<div class="loading-spinner"><div class="spinner"></div></div>
|
||||||
</template>
|
</template>
|
||||||
@@ -548,7 +565,8 @@
|
|||||||
<template x-if="$store.library.view === 'artist_detail' && $store.library.currentArtist">
|
<template x-if="$store.library.view === 'artist_detail' && $store.library.currentArtist">
|
||||||
<div>
|
<div>
|
||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
<a @click="$store.library.goArtists()">{{ t.player_artists }}</a>
|
<a @click="$store.library.artistFilter === 'uploads' ? $store.library.goMyUploads() : $store.library.goArtists()"
|
||||||
|
x-text="$store.library.artistFilter === 'uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_artists }}'"></a>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span x-text="$store.library.currentArtist.name"></span>
|
<span x-text="$store.library.currentArtist.name"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -639,11 +657,24 @@
|
|||||||
<span style="text-align:right">{{ t.player_duration }}</span>
|
<span style="text-align:right">{{ t.player_duration }}</span>
|
||||||
</div>
|
</div>
|
||||||
<template x-for="(track, idx) in $store.library.currentArtist.featured_tracks" :key="track.id">
|
<template x-for="(track, idx) in $store.library.currentArtist.featured_tracks" :key="track.id">
|
||||||
<div class="track-row"
|
<div class="track-row artist-appearance-row"
|
||||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||||||
@dblclick="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)">
|
@dblclick="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)">
|
||||||
<span class="track-num" x-text="idx + 1"></span>
|
<span class="track-num" x-text="idx + 1"></span>
|
||||||
<div class="track-info">
|
<div class="track-info">
|
||||||
|
<button class="artist-appearance-cover"
|
||||||
|
type="button"
|
||||||
|
@click.stop="$store.library.openRelease(track.release_id)"
|
||||||
|
:title="track.release_title"
|
||||||
|
aria-label="{{ t.player_release }}">
|
||||||
|
<template x-if="track.cover_url">
|
||||||
|
<img :src="track.cover_url" :alt="track.release_title" loading="lazy">
|
||||||
|
</template>
|
||||||
|
<template x-if="!track.cover_url">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
<div class="artist-appearance-copy">
|
||||||
<div class="track-title">
|
<div class="track-title">
|
||||||
<span x-text="track.title"></span>
|
<span x-text="track.title"></span>
|
||||||
<span style="color:var(--text-subdued)"> · </span>
|
<span style="color:var(--text-subdued)"> · </span>
|
||||||
@@ -658,6 +689,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<span></span>
|
<span></span>
|
||||||
<div class="track-actions">
|
<div class="track-actions">
|
||||||
<button class="track-action-btn info-btn popularity-info-btn"
|
<button class="track-action-btn info-btn popularity-info-btn"
|
||||||
@@ -694,7 +726,8 @@
|
|||||||
<template x-if="$store.library.view === 'release_detail' && $store.library.currentRelease">
|
<template x-if="$store.library.view === 'release_detail' && $store.library.currentRelease">
|
||||||
<div>
|
<div>
|
||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
<a @click="$store.library.goArtists()">{{ t.player_artists }}</a>
|
<a @click="$store.library.artistFilter === 'uploads' ? $store.library.goMyUploads() : $store.library.goArtists()"
|
||||||
|
x-text="$store.library.artistFilter === 'uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_artists }}'"></a>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<template x-if="$store.library.currentRelease.artists.length > 0">
|
<template x-if="$store.library.currentRelease.artists.length > 0">
|
||||||
<a @click="$store.library.openArtist($store.library.currentRelease.artists[0].id)" x-text="$store.library.currentRelease.artists[0].name"></a>
|
<a @click="$store.library.openArtist($store.library.currentRelease.artists[0].id)" x-text="$store.library.currentRelease.artists[0].name"></a>
|
||||||
@@ -1074,6 +1107,16 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="device-picker" @click.outside="$store.devices.open = false">
|
<div class="device-picker" @click.outside="$store.devices.open = false">
|
||||||
|
<template x-if="$store.devices.remoteHintVisible">
|
||||||
|
<button class="remote-playback-hint"
|
||||||
|
type="button"
|
||||||
|
@click="$store.devices.dismissRemoteHint()"
|
||||||
|
x-transition
|
||||||
|
x-cloak>
|
||||||
|
<span class="remote-playback-hint-text" x-text="$store.devices.remoteHintText()"></span>
|
||||||
|
<span class="remote-playback-hint-progress"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
<button class="queue-toggle-btn device-toggle-btn"
|
<button class="queue-toggle-btn device-toggle-btn"
|
||||||
:class="{ active: $store.devices.isActive() }"
|
:class="{ active: $store.devices.isActive() }"
|
||||||
@click="$store.devices.toggle()"
|
@click="$store.devices.toggle()"
|
||||||
|
|||||||
+126
-10
@@ -735,6 +735,47 @@ button.user-stat:hover {
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.artist-appearance-row .track-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-appearance-cover {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-subdued);
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 0 0 42px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-appearance-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-appearance-cover svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-appearance-cover:hover {
|
||||||
|
filter: brightness(1.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-appearance-copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.track-album { font-size: 13px; color: var(--text-subdued); }
|
.track-album { font-size: 13px; color: var(--text-subdued); }
|
||||||
.track-duration { font-size: 13px; color: var(--text-subdued); text-align: right; pointer-events: none; }
|
.track-duration { font-size: 13px; color: var(--text-subdued); text-align: right; pointer-events: none; }
|
||||||
|
|
||||||
@@ -1370,19 +1411,35 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.volume-slider {
|
.volume-slider {
|
||||||
width: 104px;
|
width: 104px;
|
||||||
height: 4px;
|
height: 28px;
|
||||||
background: var(--bg-active);
|
background: transparent;
|
||||||
border-radius: 2px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--bg-active);
|
||||||
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider-fill {
|
.volume-slider-fill {
|
||||||
height: 100%;
|
height: 4px;
|
||||||
background: var(--text-primary);
|
background: var(--text-primary);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider:hover .volume-slider-fill { background: var(--accent); }
|
.volume-slider:hover .volume-slider-fill { background: var(--accent); }
|
||||||
@@ -1452,6 +1509,51 @@ button.user-stat:hover {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.remote-playback-hint {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 38px;
|
||||||
|
width: max-content;
|
||||||
|
max-width: min(280px, calc(100vw - 24px));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: 0 14px 34px rgba(0,0,0,0.46);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
text-align: left;
|
||||||
|
z-index: 44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-playback-hint-text {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-playback-hint-progress {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
transform-origin: left center;
|
||||||
|
animation: remote-playback-hint-progress 60s linear forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes remote-playback-hint-progress {
|
||||||
|
from { transform: scaleX(1); }
|
||||||
|
to { transform: scaleX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
.device-popover {
|
.device-popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -4164,13 +4266,19 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider {
|
.volume-slider {
|
||||||
display: block;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 9px;
|
height: 34px;
|
||||||
border-radius: 999px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider-fill {
|
.volume-slider-fill {
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider::before {
|
||||||
|
height: 7px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4417,8 +4525,8 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.player-bar.mobile-expanded .mobile-full-player .volume-slider {
|
.player-bar.mobile-expanded .mobile-full-player .volume-slider {
|
||||||
display: block;
|
display: flex;
|
||||||
height: 7px;
|
height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-bar.mobile-expanded .mobile-full-player .device-popover {
|
.player-bar.mobile-expanded .mobile-full-player .device-popover {
|
||||||
@@ -4432,6 +4540,14 @@ button.user-stat:hover {
|
|||||||
max-height: 42dvh;
|
max-height: 42dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-bar.mobile-expanded .mobile-full-player .remote-playback-hint {
|
||||||
|
position: fixed;
|
||||||
|
top: auto;
|
||||||
|
bottom: calc(90px + var(--safe-bottom));
|
||||||
|
right: 18px;
|
||||||
|
max-width: calc(100vw - 36px);
|
||||||
|
}
|
||||||
|
|
||||||
.player-bar.mobile-expanded .mobile-full-player .mobile-expanded-queue {
|
.player-bar.mobile-expanded .mobile-full-player .mobile-expanded-queue {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -5053,7 +5169,7 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider {
|
.volume-slider {
|
||||||
display: block;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user