Added OIDC users group filter
Build and Publish / Build and Publish Docker Image (push) Successful in 2m41s
Build and Publish / Build and Publish Docker Image (push) Successful in 2m41s
This commit is contained in:
Generated
+1
-1
@@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "furumusic"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "furumusic"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
edition = "2024"
|
||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" , "Артисты";
|
||||
|
||||
@@ -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
@@ -384,9 +384,23 @@ 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(
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -357,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,
|
||||
@@ -633,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,
|
||||
@@ -640,6 +745,7 @@ async fn artist_detail_handler(
|
||||
total_track_count,
|
||||
total_play_count,
|
||||
releases: release_cards,
|
||||
featured_tracks,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+125
-7
@@ -435,6 +435,17 @@ button.user-stat:hover {
|
||||
.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 */
|
||||
@@ -2318,7 +2329,14 @@ button.user-stat:hover {
|
||||
<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">
|
||||
@@ -2433,6 +2451,59 @@ button.user-stat:hover {
|
||||
</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>
|
||||
|
||||
@@ -2460,7 +2531,14 @@ button.user-stat:hover {
|
||||
<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)">
|
||||
@@ -2499,7 +2577,14 @@ button.user-stat:hover {
|
||||
<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">
|
||||
@@ -2551,7 +2636,14 @@ button.user-stat:hover {
|
||||
<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">
|
||||
@@ -2614,7 +2706,14 @@ button.user-stat:hover {
|
||||
</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">
|
||||
@@ -2642,7 +2741,14 @@ button.user-stat:hover {
|
||||
</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>
|
||||
@@ -3308,7 +3414,7 @@ document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('queue', {
|
||||
tracks: [],
|
||||
currentIndex: 0,
|
||||
visible: true,
|
||||
visible: false,
|
||||
_dragIdx: null,
|
||||
|
||||
add(track) {
|
||||
@@ -3523,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;
|
||||
|
||||
Reference in New Issue
Block a user