Compare commits

...

2 Commits

Author SHA1 Message Date
Ultradesu cae77e9401 Added OIDC users group filter
Build and Publish / Build and Publish Docker Image (push) Successful in 2m41s
2026-05-25 16:26:45 +03:00
Ultradesu 709f319bc5 Fixed UI
Build and Publish / Build and Publish Docker Image (push) Successful in 2m42s
2026-05-25 15:57:10 +03:00
11 changed files with 686 additions and 21 deletions
Generated
+1 -1
View File
@@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "furumusic"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "furumusic"
version = "0.1.4"
version = "0.1.6"
edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
+2 -1
View File
@@ -87,7 +87,7 @@ Full OpenID Connect authorization code flow with PKCE:
Provider metadata is cached for 1 hour and invalidated when OIDC config changes.
**Group-to-role mapping:** The `oidc_admin_groups` config field lists OIDC group names (comma-separated) that grant the admin role. Groups are extracted from the `groups` claim in the ID token JWT payload.
**Group access and role mapping:** The `oidc_user_groups` config field lists OIDC group names (comma-separated) allowed to access the service. When it is set, users outside both `oidc_user_groups` and `oidc_admin_groups` are denied before provisioning/login. The `oidc_admin_groups` config field lists OIDC group names that grant the admin role. Groups are extracted from the `groups` claim in the ID token JWT payload.
**User provisioning order:**
1. Find existing `OidcLink` by issuer+sub → update claims, update role
@@ -197,4 +197,5 @@ All prefixed with `FURU_`. Priority: env var > DB override > compiled default.
| `FURU_OIDC_CLIENT_SECRET` | OIDC client secret | *(empty)* |
| `FURU_OIDC_BUTTON_TEXT` | SSO button label | `Sign in with SSO` |
| `FURU_OIDC_ADMIN_GROUPS` | Comma-separated OIDC groups that grant admin | *(empty)* |
| `FURU_OIDC_USER_GROUPS` | Comma-separated OIDC groups allowed to access the service. Empty means any authenticated SSO user is allowed. | *(empty)* |
| `FURU_SWAGGER_ENABLED` | Serve Swagger UI at `/swagger/` | `false` |
+13 -1
View File
@@ -129,6 +129,11 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<Co
config.oidc_admin_groups.clone(),
defaults.oidc_admin_groups.clone()
),
entry!(
oidc_user_groups,
config.oidc_user_groups.clone(),
defaults.oidc_user_groups.clone()
),
entry!(
swagger_enabled,
config.swagger_enabled.to_string(),
@@ -248,6 +253,8 @@ struct SettingsTemplate {
oidc_client_secret_source: &'static str,
oidc_admin_groups: String,
oidc_admin_groups_source: &'static str,
oidc_user_groups: String,
oidc_user_groups_source: &'static str,
swagger_enabled: bool,
swagger_enabled_source: &'static str,
agent_enabled: bool,
@@ -298,6 +305,8 @@ pub async fn settings_handler(
oidc_client_secret_source: sources.oidc_client_secret.code(),
oidc_admin_groups: config.oidc_admin_groups,
oidc_admin_groups_source: sources.oidc_admin_groups.code(),
oidc_user_groups: config.oidc_user_groups,
oidc_user_groups_source: sources.oidc_user_groups.code(),
swagger_enabled: config.swagger_enabled,
swagger_enabled_source: sources.swagger_enabled.code(),
agent_enabled: config.agent_enabled,
@@ -331,6 +340,7 @@ pub struct OidcSettingsForm {
oidc_client_id: Option<String>,
oidc_client_secret: Option<String>,
oidc_admin_groups: Option<String>,
oidc_user_groups: Option<String>,
swagger_enabled: Option<String>,
agent_enabled: Option<String>,
agent_inbox_dir: Option<String>,
@@ -378,6 +388,7 @@ pub async fn settings_submit(
let oidc_client_id = data.oidc_client_id.unwrap_or_default();
let oidc_client_secret = data.oidc_client_secret.unwrap_or_default();
let oidc_admin_groups = data.oidc_admin_groups.unwrap_or_default();
let oidc_user_groups = data.oidc_user_groups.unwrap_or_default();
let agent_inbox_dir = data.agent_inbox_dir.unwrap_or_default();
let agent_storage_dir = data.agent_storage_dir.unwrap_or_default();
let agent_llm_url = data.agent_llm_url.unwrap_or_default();
@@ -386,7 +397,7 @@ pub async fn settings_submit(
let agent_confidence_threshold = data.agent_confidence_threshold.unwrap_or_default();
let agent_context_limit = data.agent_context_limit.unwrap_or_default();
let agent_concurrency = data.agent_concurrency.unwrap_or_default();
let fields: [(&str, &str); 17] = [
let fields: [(&str, &str); 18] = [
("auth_password_enabled", pw_enabled),
("auth_sso_enabled", sso_enabled),
("oidc_button_text", &oidc_button_text),
@@ -394,6 +405,7 @@ pub async fn settings_submit(
("oidc_client_id", &oidc_client_id),
("oidc_client_secret", &oidc_client_secret),
("oidc_admin_groups", &oidc_admin_groups),
("oidc_user_groups", &oidc_user_groups),
("swagger_enabled", swagger),
("agent_enabled", agent_en),
("agent_inbox_dir", &agent_inbox_dir),
+7
View File
@@ -122,6 +122,7 @@ pub struct ConfigSources {
pub auth_sso_enabled: ConfigSource,
pub oidc_button_text: ConfigSource,
pub oidc_admin_groups: ConfigSource,
pub oidc_user_groups: ConfigSource,
pub swagger_enabled: ConfigSource,
pub agent_enabled: ConfigSource,
pub agent_inbox_dir: ConfigSource,
@@ -146,6 +147,7 @@ impl Default for ConfigSources {
auth_sso_enabled: ConfigSource::Default,
oidc_button_text: ConfigSource::Default,
oidc_admin_groups: ConfigSource::Default,
oidc_user_groups: ConfigSource::Default,
swagger_enabled: ConfigSource::Default,
agent_enabled: ConfigSource::Default,
agent_inbox_dir: ConfigSource::Default,
@@ -238,6 +240,8 @@ pub struct AppConfig {
pub oidc_button_text: String,
/// Comma-separated list of OIDC group names that grant admin role.
pub oidc_admin_groups: String,
/// Comma-separated list of OIDC group names that are allowed to use the service.
pub oidc_user_groups: String,
/// Whether the Swagger UI is served at /swagger/.
pub swagger_enabled: bool,
/// Whether the AI agent background loop is enabled.
@@ -272,6 +276,7 @@ impl Default for AppConfig {
auth_sso_enabled: false,
oidc_button_text: "Sign in with SSO".into(),
oidc_admin_groups: String::new(),
oidc_user_groups: String::new(),
swagger_enabled: false,
agent_enabled: false,
agent_inbox_dir: String::new(),
@@ -297,6 +302,7 @@ impl_env_overrides!(
auth_sso_enabled,
oidc_button_text,
oidc_admin_groups,
oidc_user_groups,
swagger_enabled,
agent_enabled,
agent_inbox_dir,
@@ -372,6 +378,7 @@ impl AppConfig {
apply_db_field!(auth_sso_enabled);
apply_db_field!(oidc_button_text);
apply_db_field!(oidc_admin_groups);
apply_db_field!(oidc_user_groups);
apply_db_field!(swagger_enabled);
apply_db_field!(agent_enabled);
apply_db_field!(agent_inbox_dir);
+3
View File
@@ -70,6 +70,8 @@ translations! {
settings_oidc_issuer_help: "Base URL of the OIDC provider (e.g. https://accounts.google.com)" , "Базовый URL провайдера OIDC (напр. https://accounts.google.com)";
settings_oidc_admin_groups: "Admin groups" , "Группы администраторов";
settings_oidc_admin_groups_help: "Comma-separated OIDC group names that grant admin role (e.g. /admin,/furumusic-admins)" , "OIDC группы через запятую, дающие роль администратора (напр. /admin,/furumusic-admins)";
settings_oidc_user_groups: "User groups" , "Группы пользователей";
settings_oidc_user_groups_help: "Comma-separated OIDC group names allowed to access the service. If empty, any authenticated SSO user is allowed." , "OIDC группы через запятую, которым разрешён доступ к сервису. Если пусто, разрешён любой SSO пользователь.";
// User management
nav_users: "Users" , "Пользователи";
@@ -97,6 +99,7 @@ translations! {
// OIDC login errors
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
login_sso_disabled: "SSO login is not configured." , "Вход через SSO не настроен.";
login_access_denied: "Access denied. Contact your administrator." , "Доступ запрещён. Обратитесь к администратору.";
// Artist management
nav_artists: "Artists" , "Артисты";
+1
View File
@@ -281,6 +281,7 @@ impl Project for FuruProject {
" FURU_OIDC_CLIENT_SECRET OIDC client secret\n",
" FURU_OIDC_BUTTON_TEXT SSO button label (default: Sign in with SSO)\n",
" FURU_OIDC_ADMIN_GROUPS OIDC groups that grant admin role\n",
" FURU_OIDC_USER_GROUPS OIDC groups allowed to access the service\n",
"\n",
" API:\n",
" FURU_SWAGGER_ENABLED Enable Swagger UI at /swagger/ (default: false)\n",
+36 -1
View File
@@ -384,10 +384,24 @@ pub async fn oidc_callback_handler(
.unwrap_or_default();
tracing::info!(
"OIDC login: sub={sub}, groups={groups:?}, admin_groups={:?}",
"OIDC login: sub={sub}, groups={groups:?}, admin_groups={:?}, user_groups={:?}",
config.oidc_admin_groups,
config.oidc_user_groups,
);
if !is_allowed_by_groups(
&groups,
&config.oidc_user_groups,
&config.oidc_admin_groups,
) {
tracing::warn!(
"OIDC login denied by group allowlist: sub={sub}, groups={groups:?}, user_groups={:?}, admin_groups={:?}",
config.oidc_user_groups,
config.oidc_admin_groups,
);
return redirect_login_with_error(i18n.t.login_access_denied);
}
// User provisioning logic.
let user = match provision_user(
&db,
@@ -458,6 +472,27 @@ fn resolve_role(groups: &[String], admin_groups: &str) -> &'static str {
auth::Role::User.code()
}
fn parse_group_set(groups: &str) -> std::collections::HashSet<&str> {
groups
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect()
}
fn has_any_group(groups: &[String], allowed: &std::collections::HashSet<&str>) -> bool {
groups.iter().any(|g| allowed.contains(g.as_str()))
}
fn is_allowed_by_groups(groups: &[String], user_groups: &str, admin_groups: &str) -> bool {
let user_set = parse_group_set(user_groups);
if user_set.is_empty() {
return true;
}
let admin_set = parse_group_set(admin_groups);
has_any_group(groups, &user_set) || has_any_group(groups, &admin_set)
}
async fn provision_user(
db: &Database,
issuer: &str,
+230 -2
View File
@@ -71,6 +71,7 @@ struct ArtistDetail {
total_track_count: i64,
total_play_count: i64,
releases: Vec<ReleaseCard>,
featured_tracks: Vec<ArtistAppearanceTrack>,
}
#[derive(Debug, Serialize, JsonSchema)]
@@ -92,6 +93,19 @@ struct TrackItem {
stream_url: String,
}
#[derive(Debug, Serialize, JsonSchema)]
struct ArtistAppearanceTrack {
id: i64,
title: String,
release_id: i64,
release_title: String,
duration_seconds: f64,
artists: Vec<ArtistRef>,
featured_artists: Vec<ArtistRef>,
cover_url: Option<String>,
stream_url: String,
}
#[derive(Debug, Serialize, JsonSchema)]
struct ReleaseDetail {
id: i64,
@@ -155,6 +169,25 @@ struct UserProfile {
stats: UserStats,
}
#[derive(Debug, Serialize, JsonSchema)]
struct PlayHistoryItem {
id: i64,
track_id: i64,
track_title: String,
release_title: Option<String>,
played_at: String,
duration_listened: Option<i32>,
completed: bool,
}
#[derive(Debug, Serialize, JsonSchema)]
struct PlayHistoryPage {
items: Vec<PlayHistoryItem>,
total: i64,
page: i32,
per_page: i32,
}
#[derive(Debug, Deserialize)]
struct HistoryEntry {
track_id: i64,
@@ -162,6 +195,12 @@ struct HistoryEntry {
completed: bool,
}
#[derive(Debug, Deserialize)]
struct HistoryQuery {
page: Option<i32>,
limit: Option<i32>,
}
#[derive(Debug, Deserialize)]
struct TracksByIdsRequest {
ids: Vec<i64>,
@@ -332,6 +371,17 @@ struct PlaylistTrackRow {
release_cover_file_id: Option<i64>,
}
#[derive(sqlx::FromRow)]
struct AppearanceTrackRow {
id: i64,
title: String,
release_id: i64,
release_title: String,
duration_seconds: f64,
cover_file_id: Option<i64>,
release_cover_file_id: Option<i64>,
}
#[derive(sqlx::FromRow)]
struct SearchArtistRow {
id: i64,
@@ -362,6 +412,17 @@ struct SearchTrackRow {
release_cover_file_id: Option<i64>,
}
#[derive(sqlx::FromRow)]
struct PlayHistoryRow {
id: i64,
track_id: i64,
track_title: String,
release_title: Option<String>,
played_at: String,
duration_listened: Option<i32>,
completed: bool,
}
#[derive(sqlx::FromRow)]
struct ReleaseInfoRow {
id: i64,
@@ -471,7 +532,7 @@ async fn artists_handler(
FROM furumusic__artist a
JOIN furumusic__release_artist ra ON ra.artist_id = a.id
JOIN furumusic__release r ON r.id = ra.release_id
WHERE a.is_hidden = false AND r.is_hidden = false"#,
WHERE a.is_hidden = false AND r.is_hidden = false AND ra.position = 0"#,
)
.fetch_one(pool)
.await
@@ -489,6 +550,7 @@ async fn artists_handler(
FROM furumusic__release_artist ra
JOIN furumusic__release r ON r.id = ra.release_id AND r.is_hidden = false
LEFT JOIN furumusic__track t ON t.release_id = r.id AND t.is_hidden = false
WHERE ra.position = 0
GROUP BY ra.artist_id
) s ON s.artist_id = a.id
WHERE a.is_hidden = false
@@ -596,6 +658,86 @@ async fn artist_detail_handler(
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let featured_rows = sqlx::query_as::<_, AppearanceTrackRow>(
r#"SELECT DISTINCT t.id,
t.title::text AS title,
r.id AS release_id,
r.title::text AS release_title,
t.duration_seconds,
t.cover_file_id,
r.cover_file_id AS release_cover_file_id
FROM furumusic__track_artist ta
JOIN furumusic__track t ON t.id = ta.track_id
JOIN furumusic__release r ON r.id = t.release_id
WHERE ta.artist_id = $1
AND ta.role = 'featuring'
AND t.is_hidden = false
AND r.is_hidden = false
ORDER BY r.title::text, t.title::text"#,
)
.bind(artist_id)
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let featured_track_ids: Vec<i64> = featured_rows.iter().map(|t| t.id).collect();
let featured_track_artists = if featured_track_ids.is_empty() {
Vec::new()
} else {
sqlx::query_as::<_, TrackArtistRow>(
r#"SELECT ta.track_id, ta.artist_id, a.name::text as artist_name, ta.role::text as role
FROM furumusic__track_artist ta
JOIN furumusic__artist a ON a.id = ta.artist_id
WHERE ta.track_id = ANY($1)
ORDER BY ta.track_id, ta.position"#,
)
.bind(&featured_track_ids)
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
};
let mut featured_main_artists: std::collections::HashMap<i64, Vec<ArtistRef>> =
std::collections::HashMap::new();
let mut featured_feat_artists: std::collections::HashMap<i64, Vec<ArtistRef>> =
std::collections::HashMap::new();
for ta in &featured_track_artists {
let artist_ref = ArtistRef {
id: ta.artist_id,
name: ta.artist_name.clone(),
};
if ta.role == "featuring" {
featured_feat_artists
.entry(ta.track_id)
.or_default()
.push(artist_ref);
} else {
featured_main_artists
.entry(ta.track_id)
.or_default()
.push(artist_ref);
}
}
let featured_tracks: Vec<ArtistAppearanceTrack> = featured_rows
.into_iter()
.map(|t| {
let tid = t.id;
ArtistAppearanceTrack {
id: t.id,
title: t.title,
release_id: t.release_id,
release_title: t.release_title,
duration_seconds: t.duration_seconds,
artists: featured_main_artists.remove(&tid).unwrap_or_default(),
featured_artists: featured_feat_artists.remove(&tid).unwrap_or_default(),
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
stream_url: format!("/api/player/stream/{tid}"),
}
})
.collect();
Json(ArtistDetail {
id: artist.id,
name: artist.name,
@@ -603,6 +745,7 @@ async fn artist_detail_handler(
total_track_count,
total_play_count,
releases: release_cards,
featured_tracks,
})
.into_response()
}
@@ -1238,6 +1381,69 @@ async fn put_state_handler(
// POST /api/player/history
// ---------------------------------------------------------------------------
async fn history_list_handler(
session: Session,
db: Database,
pool: &sqlx::PgPool,
query: cot::request::extractors::UrlQuery<HistoryQuery>,
) -> cot::Result<cot::response::Response> {
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(20).clamp(1, 100);
let offset = (page - 1) as i64 * per_page as i64;
let total: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM furumusic__play_history WHERE user_id = $1")
.bind(user.id)
.fetch_one(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let rows = sqlx::query_as::<_, PlayHistoryRow>(
r#"SELECT ph.id,
ph.track_id,
t.title::text AS track_title,
r.title::text AS release_title,
ph.played_at::text AS played_at,
ph.duration_listened,
ph.completed
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
WHERE ph.user_id = $1
ORDER BY ph.played_at DESC, ph.id DESC
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()))?;
Json(PlayHistoryPage {
items: rows
.into_iter()
.map(|row| PlayHistoryItem {
id: row.id,
track_id: row.track_id,
track_title: row.track_title,
release_title: row.release_title,
played_at: row.played_at,
duration_listened: row.duration_listened,
completed: row.completed,
})
.collect(),
total,
page,
per_page,
})
.into_response()
}
async fn history_handler(
session: Session,
db: Database,
@@ -2625,7 +2831,29 @@ impl App for PlayerApp {
// -- Play history --
Route::with_handler_and_name(
"/history",
cot::router::method::post({
get({
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session,
db: Database,
query: cot::request::extractors::UrlQuery<HistoryQuery>| {
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("player pool")
})
.await;
history_list_handler(session, db, pg_pool, query).await
}
}
})
.post({
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session, db: Database, json: Json<HistoryEntry>| {
+5
View File
@@ -67,6 +67,11 @@
<td><input name="oidc_admin_groups" id="oidc_admin_groups" value="{{ oidc_admin_groups }}" style="width:100%"></td>
<td><span class="badge badge-{{ oidc_admin_groups_source }}">{{ oidc_admin_groups_source }}</span></td>
</tr>
<tr>
<td><label for="oidc_user_groups">{{ t.settings_oidc_user_groups }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_oidc_user_groups_help }}</span></td>
<td><input name="oidc_user_groups" id="oidc_user_groups" value="{{ oidc_user_groups }}" style="width:100%"></td>
<td><span class="badge badge-{{ oidc_user_groups_source }}">{{ oidc_user_groups_source }}</span></td>
</tr>
</table>
<h2>{{ t.settings_api }}</h2>
<table>
+387 -14
View File
@@ -142,6 +142,17 @@ body {
background: var(--bg-primary);
}
button.user-stat {
border: 0;
color: inherit;
cursor: pointer;
text-align: left;
}
button.user-stat:hover {
background: var(--bg-hover);
}
.user-stat-value {
display: block;
font-size: 13px;
@@ -424,6 +435,17 @@ body {
.release-meta .release-type { font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); }
.release-meta .release-title { font-size: 36px; font-weight: 900; line-height: 1.2; margin: 4px 0; }
.release-meta .release-artists { font-size: 14px; color: var(--text-secondary); }
.artist-link {
color: inherit;
cursor: pointer;
text-decoration: none;
}
.artist-link:hover {
color: var(--text-primary);
text-decoration: underline;
}
.release-meta .release-year { font-size: 14px; color: var(--text-subdued); margin-top: 4px; }
/* Track list table */
@@ -980,6 +1002,29 @@ body {
background: var(--bg-hover);
}
.mobile-account-popover {
position: absolute;
right: 16px;
top: 66px;
z-index: 80;
width: min(286px, calc(100vw - 32px));
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-elevated);
box-shadow: 0 16px 36px rgba(0,0,0,0.42);
}
.mobile-account-popover .user-widget-main {
grid-template-columns: 36px minmax(0, 1fr);
}
.mobile-account-logout {
width: 100%;
margin-top: 12px;
justify-content: center;
}
.torrent-import-btn {
display: flex;
align-items: center;
@@ -1284,6 +1329,60 @@ body {
overflow: hidden;
}
.history-modal {
width: min(620px, calc(100vw - 32px));
max-width: 620px;
}
.history-list {
margin-top: 12px;
overflow-y: auto;
max-height: min(54vh, 460px);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.history-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 12px;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
}
.history-row:last-child { border-bottom: 0; }
.history-title {
min-width: 0;
color: var(--text-primary);
font-size: 13px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-release,
.history-date,
.history-duration {
color: var(--text-subdued);
font-size: 12px;
}
.history-date,
.history-duration {
text-align: right;
white-space: nowrap;
}
.history-pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 12px;
}
.torrent-modal-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
@@ -1774,6 +1873,11 @@ body {
display: none;
}
.mobile-account-popover {
right: 8px;
top: 64px;
}
.card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
@@ -2017,17 +2121,17 @@ body {
</button>
</div>
<div class="user-stats">
<div class="user-stat">
<button class="user-stat" @click="$store.history.open()">
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.plays)"></span>
<span class="user-stat-label">plays</span>
</div>
</button>
<div class="user-stat">
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.liked_tracks)"></span>
<span class="user-stat-label">likes</span>
</div>
<div class="user-stat">
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.listened_minutes)"></span>
<span class="user-stat-label">min</span>
<span class="user-stat-value" x-text="$store.user.duration($store.user.profile?.stats?.listened_minutes)"></span>
<span class="user-stat-label">listened</span>
</div>
</div>
</div>
@@ -2082,7 +2186,7 @@ body {
<!-- Center Content -->
<div class="center-content" id="center-scroll">
<!-- Search / account bar -->
<div class="content-topbar">
<div class="content-topbar" @click.outside="$store.user.menuOpen = false">
<div class="search-bar">
<span class="search-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
<input id="search-input" type="text" placeholder="Search artists, releases, tracks..."
@@ -2111,11 +2215,40 @@ body {
<button class="mobile-account-chip"
x-show="$store.user.profile"
x-cloak
@click="$store.user.logout()"
:title="'Log out ' + ($store.user.profile?.name || '')">
@click="$store.user.menuOpen = !$store.user.menuOpen"
:title="$store.user.profile?.name || 'Account'">
<span class="user-avatar" x-text="$store.user.initials()"></span>
<span class="mobile-account-name" x-text="$store.user.profile?.name || ''"></span>
</button>
<div class="mobile-account-popover"
x-show="$store.user.menuOpen && $store.user.profile"
x-cloak>
<div class="user-widget-main">
<span class="user-avatar" x-text="$store.user.initials()"></span>
<div style="min-width:0">
<div class="user-name" x-text="$store.user.profile?.name || ''"></div>
<div class="user-role" x-text="$store.user.profile?.role || ''"></div>
</div>
</div>
<div class="user-stats">
<button class="user-stat" @click="$store.history.open(); $store.user.menuOpen = false">
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.plays)"></span>
<span class="user-stat-label">plays</span>
</button>
<div class="user-stat">
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.liked_tracks)"></span>
<span class="user-stat-label">likes</span>
</div>
<div class="user-stat">
<span class="user-stat-value" x-text="$store.user.duration($store.user.profile?.stats?.listened_minutes)"></span>
<span class="user-stat-label">listened</span>
</div>
</div>
<button class="modal-btn modal-btn-primary mobile-account-logout"
@click="$store.user.logout()">
Log out
</button>
</div>
</div>
<!-- Search Results -->
@@ -2196,7 +2329,14 @@ body {
<span class="track-num" x-text="idx + 1"></span>
<div class="track-info">
<div class="track-title" x-text="track.title"></div>
<div class="track-artists-inline" x-text="[...track.artists, ...track.featured_artists.map(a => ({...a, name: 'ft. ' + a.name}))].map(a => a.name).join(', ')"></div>
<div class="track-artists-inline">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div>
<span></span>
<div class="track-actions">
@@ -2311,6 +2451,59 @@ body {
</div>
</section>
</template>
<template x-if="$store.library.currentArtist.featured_tracks && $store.library.currentArtist.featured_tracks.length > 0">
<section class="artist-release-group">
<h2 class="artist-release-group-title">Appears on</h2>
<div class="track-list-header">
<span>#</span>
<span>Title</span>
<span></span>
<span></span>
<span style="text-align:right">Duration</span>
</div>
<template x-for="(track, idx) in $store.library.currentArtist.featured_tracks" :key="track.id">
<div class="track-row"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
@dblclick="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)">
<span class="track-num" x-text="idx + 1"></span>
<div class="track-info">
<div class="track-title">
<span x-text="track.title"></span>
<span style="color:var(--text-subdued)"> · </span>
<a class="artist-link" @click.stop="$store.library.openRelease(track.release_id)" x-text="track.release_title"></a>
</div>
<div class="track-artists-inline">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div>
<span></span>
<div class="track-actions">
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)" title="Play">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="Like">
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.queue.addNextInQueue([track])" title="Play next">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.queue.addToEnd([track])" title="Add to queue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.playlists.showPicker([track.id])" title="Add to playlist">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
</button>
</div>
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
</div>
</template>
</section>
</template>
</div>
</template>
@@ -2338,7 +2531,14 @@ body {
<div class="release-meta">
<div class="release-type" x-text="$store.library.currentRelease.release_type"></div>
<div class="release-title" x-text="$store.library.currentRelease.title"></div>
<div class="release-artists" x-text="$store.library.currentRelease.artists.map(a => a.name).join(', ')"></div>
<div class="release-artists">
<template x-for="(artist, artistIdx) in $store.library.currentRelease.artists" :key="artist.id">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click="$store.library.openArtist(artist.id)" x-text="artist.name"></a>
</span>
</template>
</div>
<div class="release-year" x-text="$store.library.currentRelease.year || ''"></div>
<div class="release-actions">
<button class="release-action-btn primary" @click="$store.queue.playRelease($store.library.currentRelease.tracks, 0)">
@@ -2377,7 +2577,14 @@ body {
<span class="track-num" x-text="track.track_number || (idx + 1)"></span>
<div class="track-info">
<div class="track-title" x-text="track.title"></div>
<div class="track-artists-inline" x-text="[...track.artists, ...track.featured_artists.map(a => ({...a, name: 'ft. ' + a.name}))].map(a => a.name).join(', ')"></div>
<div class="track-artists-inline">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div>
<span></span>
<div class="track-actions">
@@ -2429,7 +2636,14 @@ body {
<span class="track-num" x-text="idx + 1"></span>
<div class="track-info">
<div class="track-title" x-text="track.title"></div>
<div class="track-artists-inline" x-text="[...track.artists, ...track.featured_artists.map(a => ({...a, name: 'ft. ' + a.name}))].map(a => a.name).join(', ')"></div>
<div class="track-artists-inline">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div>
<span></span>
<div class="track-actions">
@@ -2492,7 +2706,14 @@ body {
</div>
<div class="queue-track-info">
<div class="queue-track-title" x-text="track.title"></div>
<div class="queue-track-artist" x-text="track.artists.map(a => a.name).join(', ')"></div>
<div class="queue-track-artist">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div>
<div class="queue-track-actions">
<button class="queue-track-remove" @click.stop="$store.queue.remove(idx)" title="Remove">
@@ -2520,7 +2741,14 @@ body {
</div>
<div class="player-track-info">
<div class="player-track-title" x-text="$store.player.currentTrack.title"></div>
<div class="player-track-artist" x-text="$store.player.currentTrack.artists.map(a => a.name).join(', ')"></div>
<div class="player-track-artist">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks($store.player.currentTrack)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div>
</div>
</template>
@@ -2719,6 +2947,50 @@ body {
</div>
</div>
</template>
<!-- Play History Modal -->
<template x-if="$store.history.modal">
<div class="modal-overlay" @click.self="$store.history.close()">
<div class="modal-box history-modal">
<h3>Play history</h3>
<p class="torrent-message" :class="{ error: $store.history.error }"
x-text="$store.history.message"></p>
<div class="history-list">
<template x-if="!$store.history.loading && $store.history.items.length === 0">
<div class="empty-state" style="padding:32px 16px">
<p>No plays yet</p>
</div>
</template>
<template x-for="item in $store.history.items" :key="item.id">
<div class="history-row">
<div style="min-width:0">
<div class="history-title" x-text="item.track_title"></div>
<div class="history-release" x-text="item.release_title || 'Unknown release'"></div>
</div>
<div>
<div class="history-date" x-text="$store.history.date(item.played_at)"></div>
<div class="history-duration" x-text="$store.history.duration(item.duration_listened)"></div>
</div>
</div>
</template>
</div>
<div class="history-pager">
<button class="modal-btn modal-btn-ghost"
@click="$store.history.load($store.history.page - 1)"
:disabled="$store.history.loading || $store.history.page <= 1">
Previous
</button>
<span class="history-release"
x-text="'Page ' + $store.history.page + ' of ' + $store.history.totalPages()"></span>
<button class="modal-btn modal-btn-primary"
@click="$store.history.load($store.history.page + 1)"
:disabled="$store.history.loading || $store.history.page >= $store.history.totalPages()">
Next
</button>
</div>
</div>
</div>
</template>
</div>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
@@ -2743,6 +3015,7 @@ document.addEventListener('alpine:init', () => {
// -----------------------------------------------------------------------
Alpine.store('user', {
profile: null,
menuOpen: false,
init() {
this.load();
@@ -2767,11 +3040,99 @@ document.addEventListener('alpine:init', () => {
return new Intl.NumberFormat().format(value || 0);
},
duration(minutes) {
let value = Number(minutes || 0);
const units = [
['y', 525600],
['mo', 43800],
['d', 1440],
['h', 60],
['m', 1],
];
const parts = [];
for (const [label, size] of units) {
if (value >= size) {
const count = Math.floor(value / size);
value -= count * size;
parts.push(count + label);
}
if (parts.length >= 2) break;
}
return parts.length ? parts.join(' ') : '0m';
},
logout() {
window.location.href = '/logout';
},
});
// -----------------------------------------------------------------------
// Play history store
// -----------------------------------------------------------------------
Alpine.store('history', {
modal: false,
items: [],
page: 1,
perPage: 20,
total: 0,
loading: false,
message: '',
error: false,
open() {
this.modal = true;
this.load(1);
},
close() {
this.modal = false;
},
totalPages() {
return Math.max(1, Math.ceil(this.total / this.perPage));
},
async load(page) {
page = Math.max(1, page || 1);
this.loading = true;
this.error = false;
this.message = 'Loading history...';
try {
const res = await fetch(`/api/player/history?page=${page}&limit=${this.perPage}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to load history');
this.items = data.items || [];
this.page = data.page || page;
this.perPage = data.per_page || this.perPage;
this.total = data.total || 0;
this.message = this.total ? (this.total + ' total plays') : '';
} catch (err) {
this.error = true;
this.message = err.message || String(err);
} finally {
this.loading = false;
}
},
date(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
},
duration(seconds) {
if (!seconds) return '0:00';
return formatTime(Number(seconds));
},
});
// -----------------------------------------------------------------------
// Player store
// -----------------------------------------------------------------------
@@ -3053,7 +3414,7 @@ document.addEventListener('alpine:init', () => {
Alpine.store('queue', {
tracks: [],
currentIndex: 0,
visible: true,
visible: false,
_dragIdx: null,
add(track) {
@@ -3268,6 +3629,18 @@ document.addEventListener('alpine:init', () => {
}));
},
trackArtistLinks(track) {
const main = (track?.artists || []).map(artist => ({
id: artist.id,
label: artist.name,
}));
const featured = (track?.featured_artists || []).map(artist => ({
id: artist.id,
label: 'ft. ' + artist.name,
}));
return [...main, ...featured];
},
async openRelease(id) {
this.searchQuery = '';
this.searchResults = null;