Improve player library and admin user stats

This commit is contained in:
Ultradesu
2026-06-03 02:02:23 +03:00
parent f716c22f86
commit 0a4f78acfa
8 changed files with 474 additions and 54 deletions
Generated
+1 -1
View File
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "furumusic"
version = "0.2.13"
version = "0.2.14"
dependencies = [
"anyhow",
"async-trait",
+111 -1
View File
@@ -191,6 +191,7 @@ struct AdminUserRowDto {
struct AdminUserDetailDto {
user: AdminUserRowDto,
stats: AdminUserStatsDto,
recent_plays: Vec<AdminUserPlayDto>,
}
#[derive(Debug, Serialize, JsonSchema)]
@@ -207,6 +208,25 @@ struct AdminUserStatsDto {
lastfm_connected: bool,
}
#[derive(Debug, Serialize, JsonSchema)]
struct AdminUserPlayDto {
history_id: i64,
played_at: String,
duration_listened: Option<i32>,
completed: bool,
track_id: i64,
title: String,
artists: String,
release_id: i64,
release_title: String,
release_year: Option<i32>,
cover_url: Option<String>,
track_duration_seconds: f64,
uploader_name: String,
audio_format: Option<String>,
audio_bitrate: Option<i32>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct OverviewStatsDto {
tracks: i64,
@@ -1778,7 +1798,7 @@ async fn load_admin_user_detail(
.bind(user_id)
.fetch_one(pool),
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(DISTINCT t.id) FROM furumusic__track t JOIN furumusic__media_file mf ON mf.id = t.media_file_id WHERE mf.uploaded_by_user_id = $1"
"SELECT COUNT(DISTINCT t.id) FROM furumusic__track t JOIN furumusic__media_file mf ON mf.id = t.audio_file_id WHERE mf.uploaded_by_user_id = $1"
)
.bind(user_id)
.fetch_one(pool),
@@ -1793,6 +1813,7 @@ async fn load_admin_user_detail(
.bind(user_id)
.fetch_one(pool),
)?;
let recent_plays = load_admin_user_recent_plays(pool, user_id, 30).await?;
Ok(Some(AdminUserDetailDto {
user,
@@ -1808,9 +1829,98 @@ async fn load_admin_user_detail(
torrent_sessions,
lastfm_connected,
},
recent_plays,
}))
}
async fn load_admin_user_recent_plays(
pool: &PgPool,
user_id: i64,
limit: i64,
) -> anyhow::Result<Vec<AdminUserPlayDto>> {
let rows = sqlx::query_as::<_, AdminUserPlaySqlRow>(
r#"SELECT ph.id AS history_id,
ph.played_at::text AS played_at,
ph.duration_listened,
ph.completed,
t.id AS track_id,
t.title::text AS title,
COALESCE(NULLIF(STRING_AGG(a.name::text, ', ' ORDER BY ta.position), ''), 'Unknown artist') AS artists,
t.release_id,
COALESCE(r.title::text, '') AS release_title,
r.year AS release_year,
t.cover_file_id,
r.cover_file_id AS release_cover_file_id,
t.duration_seconds AS track_duration_seconds,
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
mf.audio_format,
mf.audio_bitrate
FROM furumusic__play_history ph
JOIN furumusic__track t ON t.id = ph.track_id
LEFT JOIN furumusic__release r ON r.id = t.release_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id AND ta.role = 'main'
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id
WHERE ph.user_id = $1
GROUP BY ph.id, ph.played_at, ph.duration_listened, ph.completed, t.id, t.title,
t.release_id, r.title, r.year, t.cover_file_id, r.cover_file_id,
t.duration_seconds, mf.uploader_name, mf.audio_format, mf.audio_bitrate
ORDER BY ph.played_at DESC, ph.id DESC
LIMIT $2"#,
)
.bind(user_id)
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|row| AdminUserPlayDto {
history_id: row.history_id,
played_at: row.played_at,
duration_listened: row.duration_listened,
completed: row.completed,
track_id: row.track_id,
title: row.title,
artists: row.artists,
release_id: row.release_id,
release_title: row.release_title,
release_year: row.release_year,
cover_url: admin_track_cover_url(row.cover_file_id, row.release_cover_file_id),
track_duration_seconds: row.track_duration_seconds,
uploader_name: row.uploader_name,
audio_format: row.audio_format,
audio_bitrate: row.audio_bitrate,
})
.collect())
}
#[derive(Debug, sqlx::FromRow)]
struct AdminUserPlaySqlRow {
history_id: i64,
played_at: String,
duration_listened: Option<i32>,
completed: bool,
track_id: i64,
title: String,
artists: String,
release_id: i64,
release_title: String,
release_year: Option<i32>,
cover_file_id: Option<i64>,
release_cover_file_id: Option<i64>,
track_duration_seconds: f64,
uploader_name: String,
audio_format: Option<String>,
audio_bitrate: Option<i32>,
}
fn admin_track_cover_url(track_cover: Option<i64>, release_cover: Option<i64>) -> Option<String> {
track_cover
.or(release_cover)
.map(|id| format!("/api/player/cover/{id}/medium"))
}
fn admin_user_row(
row: AdminUserSqlRow,
active: &HashMap<i64, i64>,
+99 -39
View File
@@ -17,28 +17,99 @@ pub fn is_orchestrator_running() -> bool {
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
}
/// Try to acquire the PostgreSQL advisory lock for the orchestrator.
/// Returns true if the lock was acquired (no other orchestrator is running).
async fn try_acquire_orchestrator_lock(pool: &sqlx::PgPool) -> bool {
match sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)")
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
.fetch_one(pool)
.await
{
Ok(acquired) => acquired,
Err(e) => {
tracing::error!("Failed to acquire advisory lock: {e}");
false
}
struct OrchestratorAdvisoryGuard {
conn: Option<sqlx::pool::PoolConnection<sqlx::Postgres>>,
}
impl Drop for OrchestratorAdvisoryGuard {
fn drop(&mut self) {
let Some(mut conn) = self.conn.take() else {
return;
};
tokio::spawn(async move {
match sqlx::query_scalar::<_, bool>("SELECT pg_advisory_unlock($1)")
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
.fetch_one(&mut *conn)
.await
{
Ok(true) => tracing::info!("inbox_process: advisory lock released"),
Ok(false) => tracing::warn!(
"inbox_process: advisory lock was not held by the guard connection"
),
Err(e) => tracing::error!("inbox_process: failed to release advisory lock: {e}"),
}
});
}
}
/// Release the PostgreSQL advisory lock for the orchestrator.
async fn release_orchestrator_lock(pool: &sqlx::PgPool) {
let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
async fn connection_holds_orchestrator_lock(
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
) -> anyhow::Result<bool> {
let holds_lock = sqlx::query_scalar::<_, bool>(
r#"
SELECT EXISTS (
SELECT 1
FROM pg_locks
WHERE locktype = 'advisory'
AND pid = pg_backend_pid()
AND mode = 'ExclusiveLock'
AND granted
AND objsubid = 1
AND classid::bigint = (($1::bigint >> 32) & 4294967295::bigint)
AND objid::bigint = ($1::bigint & 4294967295::bigint)
)
"#,
)
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
.fetch_one(&mut **conn)
.await?;
Ok(holds_lock)
}
/// Try to acquire the PostgreSQL advisory lock for the orchestrator.
///
/// The guard owns the same pooled connection that acquired the session-level
/// lock. This matters because PostgreSQL session advisory locks must be
/// released on the same connection, not just through the same pool.
async fn try_acquire_orchestrator_lock(
pool: &sqlx::PgPool,
) -> anyhow::Result<Option<OrchestratorAdvisoryGuard>> {
let mut conn = pool.acquire().await?;
// Older versions acquired the lock through the pool and could return a
// locked idle connection. If we get such a connection, drain that stale
// re-entrant lock before taking the new guarded lock.
let mut cleaned_stale_locks = 0u8;
while connection_holds_orchestrator_lock(&mut conn).await? {
let unlocked = sqlx::query_scalar::<_, bool>("SELECT pg_advisory_unlock($1)")
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
.fetch_one(&mut *conn)
.await?;
cleaned_stale_locks = cleaned_stale_locks.saturating_add(u8::from(unlocked));
if cleaned_stale_locks >= 8 {
break;
}
}
if cleaned_stale_locks > 0 {
tracing::warn!(
count = cleaned_stale_locks,
"inbox_process: released stale advisory lock(s) from an idle pooled connection"
);
}
let acquired = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)")
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
.execute(pool)
.await;
.fetch_one(&mut *conn)
.await?;
if acquired {
Ok(Some(OrchestratorAdvisoryGuard { conn: Some(conn) }))
} else {
Ok(None)
}
}
use crate::agent::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata};
@@ -83,10 +154,10 @@ impl Job for InboxProcessJob {
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
log.info(
log.warn(
"Another inbox_process orchestrator is already running (AtomicBool), skipping",
);
return Ok(());
anyhow::bail!("another inbox_process orchestrator is already running in this process");
}
struct AtomicGuard;
impl Drop for AtomicGuard {
@@ -98,27 +169,16 @@ impl Job for InboxProcessJob {
let _atomic_guard = AtomicGuard;
// --- Guard 2: PostgreSQL advisory lock (cross-process/binary safe) ---
if !try_acquire_orchestrator_lock(&ctx.pool).await {
log.info("Another inbox_process orchestrator holds the advisory lock, skipping");
return Ok(());
}
tracing::info!("inbox_process: advisory lock acquired");
let pool_for_unlock = ctx.pool.clone();
struct AdvisoryGuard {
pool: sqlx::PgPool,
}
impl Drop for AdvisoryGuard {
fn drop(&mut self) {
let pool = self.pool.clone();
tokio::spawn(async move {
release_orchestrator_lock(&pool).await;
tracing::info!("inbox_process: advisory lock released");
});
let _advisory_guard = match try_acquire_orchestrator_lock(&ctx.pool).await? {
Some(guard) => guard,
None => {
log.warn("Another inbox_process orchestrator holds the advisory lock");
anyhow::bail!(
"inbox_process advisory lock is held by another database session; no in-process orchestrator is running"
);
}
}
let _advisory_guard = AdvisoryGuard {
pool: pool_for_unlock,
};
tracing::info!("inbox_process: advisory lock acquired");
let config = Arc::clone(&ctx.config);
let mut total_ok = 0u64;
+60 -1
View File
@@ -2774,13 +2774,72 @@ async fn artists_handler(
pool: &sqlx::PgPool,
query: cot::request::extractors::UrlQuery<PaginationQuery>,
) -> cot::Result<cot::response::Response> {
let Some(_user) = auth::get_session_user(&session, &db).await else {
let Some(user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
};
let page = query.0.page.unwrap_or(1).max(1);
let per_page = query.0.limit.unwrap_or(60).clamp(1, 200);
let offset = (page - 1) as i64 * per_page as i64;
let mine = query.0.mine.unwrap_or(false);
if mine {
let total_row = sqlx::query_as::<_, CountRow>(
r#"SELECT COUNT(DISTINCT a.id) AS count
FROM furumusic__artist a
JOIN furumusic__track_artist ta ON ta.artist_id = a.id AND ta.role <> 'featuring'
JOIN furumusic__track t ON t.id = ta.track_id AND t.is_hidden = false
JOIN furumusic__release r ON r.id = t.release_id AND r.is_hidden = false
JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
WHERE a.is_hidden = false
AND mf.uploaded_by_user_id = $1"#,
)
.bind(user.id)
.fetch_one(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let rows = sqlx::query_as::<_, ArtistRow>(
r#"SELECT a.id, a.name::text as name, a.image_file_id,
COUNT(DISTINCT r.id) AS release_count,
COUNT(DISTINCT t.id) AS track_count
FROM furumusic__artist a
JOIN furumusic__track_artist ta ON ta.artist_id = a.id AND ta.role <> 'featuring'
JOIN furumusic__track t ON t.id = ta.track_id AND t.is_hidden = false
JOIN furumusic__release r ON r.id = t.release_id AND r.is_hidden = false
JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
WHERE a.is_hidden = false
AND mf.uploaded_by_user_id = $1
GROUP BY a.id, a.name, a.name_sort, a.image_file_id
ORDER BY release_count DESC, track_count DESC, a.name_sort
LIMIT $2 OFFSET $3"#,
)
.bind(user.id)
.bind(per_page as i64)
.bind(offset)
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let items: Vec<ArtistCard> = rows
.into_iter()
.map(|r| ArtistCard {
id: r.id,
name: r.name,
image_url: cover_variant_url(r.image_file_id, "medium"),
release_count: r.release_count,
track_count: r.track_count,
})
.collect();
return Json(Paginated {
items,
total: total_row.count,
page,
per_page,
})
.into_response();
}
let total_row = sqlx::query_as::<_, CountRow>(
r#"SELECT COUNT(DISTINCT a.id) AS count
+1
View File
@@ -49,6 +49,7 @@ pub(super) struct RemoveTrackRequest {
pub(super) struct PaginationQuery {
pub(super) page: Option<i32>,
pub(super) limit: Option<i32>,
pub(super) mine: Option<bool>,
}
#[derive(Debug, Deserialize)]
+131 -2
View File
@@ -1332,6 +1332,89 @@ tbody tr:hover {
.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>
{% endblock head_extra %}
@@ -2160,7 +2243,7 @@ tbody tr:hover {
<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'" disabled>Activity</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'">
@@ -2200,6 +2283,32 @@ tbody tr:hover {
</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>
@@ -2837,7 +2946,7 @@ function adminV2() {
async openUser(user) {
if (!user) return;
this.userModalTab = 'overview';
this.activeUserDetail = { user, stats: {} };
this.activeUserDetail = { user, stats: {}, recent_plays: [] };
this.userModalOpen = true;
try {
this.activeUserDetail = await this.request(`${this.apiBase}/users/${user.id}`);
@@ -3788,6 +3897,26 @@ function adminV2() {
];
},
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`;
+48 -6
View File
@@ -1847,11 +1847,13 @@ document.addEventListener('alpine:init', () => {
// -----------------------------------------------------------------------
Alpine.store('library', {
view: 'artists',
artistFilter: 'all',
artists: [],
artistsPage: 0,
artistsTotal: 0,
loading: false,
_allLoaded: false,
_artistsLoadToken: 0,
currentArtist: null,
currentRelease: null,
currentPlaylist: null,
@@ -1866,7 +1868,6 @@ document.addEventListener('alpine:init', () => {
_hashNav: false, // guard against circular hash updates
init() {
this.loadArtists(1);
this._setupScroll();
// Listen for browser back/forward
@@ -1903,7 +1904,10 @@ document.addEventListener('alpine:init', () => {
const params = match[3] || '';
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 (view === 'artist' && id) {
this.openArtist(id, options);
@@ -1969,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 = {}) {
this._beginNavigation('#artists', options);
if (this.artistFilter !== 'all' || this.artistsPage === 0) {
this._resetArtistList('all');
this.loadArtists(1);
}
this.view = 'artists';
this.currentArtist = null;
this.currentRelease = null;
@@ -1982,13 +2000,35 @@ document.addEventListener('alpine:init', () => {
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) {
if (this.loading || this._allLoaded) return;
this.loading = true;
const filter = this.artistFilter;
const token = this._artistsLoadToken + 1;
this._artistsLoadToken = token;
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');
const data = await res.json();
if (token !== this._artistsLoadToken || filter !== this.artistFilter) return;
if (page === 1) {
this.artists = data.items;
} else {
@@ -2000,7 +2040,9 @@ document.addEventListener('alpine:init', () => {
this._allLoaded = true;
}
} catch {}
this.loading = false;
if (token === this._artistsLoadToken) {
this.loading = false;
}
},
async openArtist(id, options = {}) {
@@ -2337,8 +2379,8 @@ document.addEventListener('alpine:init', () => {
this.searchLoading = false;
if (this.view === 'search') {
this.view = this._previousView || 'artists';
this._setHash('#artists');
if (this.view === 'artists') {
this._setHash(this.view === 'my_uploads' ? '#uploads' : '#artists');
if (this.view === 'artists' || this.view === 'my_uploads') {
this.$nextTick(() => { this._setupScroll(); });
}
}
+23 -4
View File
@@ -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>
{{ t.player_artists }}
</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 class="sidebar-section">
<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>
{{ t.player_artists }}
</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 class="mobile-drawer-section">
@@ -507,9 +519,9 @@
</template>
<!-- Artists Grid -->
<template x-if="$store.library.view === 'artists'">
<template x-if="$store.library.view === 'artists' || $store.library.view === 'my_uploads'">
<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">
<template x-for="artist in $store.library.artists" :key="artist.id">
<div class="card" @click="$store.library.openArtist(artist.id)">
@@ -537,6 +549,11 @@
</div>
</template>
</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">
<div class="loading-spinner"><div class="spinner"></div></div>
</template>
@@ -548,7 +565,8 @@
<template x-if="$store.library.view === 'artist_detail' && $store.library.currentArtist">
<div>
<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 x-text="$store.library.currentArtist.name"></span>
</div>
@@ -708,7 +726,8 @@
<template x-if="$store.library.view === 'release_detail' && $store.library.currentRelease">
<div>
<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>
<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>