Compare commits

...

5 Commits

Author SHA1 Message Date
Ultradesu d9d0fbb7d1 Added cli client SSO login support
Build and Publish / Build and Publish Docker Image (push) Successful in 3m11s
2026-06-10 13:34:38 +01:00
Ultradesu 71d6556ba8 Improved artist page
Build and Publish / Build and Publish Docker Image (push) Successful in 2m37s
2026-06-09 17:18:55 +01:00
Ultradesu 0ac59eb0ca Improved release editor
Build and Publish / Build and Publish Docker Image (push) Successful in 2m35s
2026-06-09 15:06:01 +01:00
Ultradesu 652c6a470d Fix queue display
Build and Publish / Build and Publish Docker Image (push) Successful in 2m33s
2026-06-08 17:59:56 +01:00
Ultradesu 1c54782dd7 Fix queue display 2026-06-08 17:59:46 +01:00
11 changed files with 990 additions and 113 deletions
Generated
+1 -1
View File
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "furumusic"
version = "0.4.1"
version = "0.4.5"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "furumusic"
version = "0.4.2"
version = "0.4.6"
edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
+26
View File
@@ -555,6 +555,32 @@ impl App for AdminApp {
},
"admin_v2_library_item_detail",
),
Route::with_handler_and_name(
"/v2/api/library/tracks/search",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
get(move |session: Session,
db: Database,
query: UrlQuery<v2::TrackSearchQuery>| {
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::track_search(session, db, pg_pool, query.0).await
}
})
},
"admin_v2_library_tracks_search",
),
Route::with_handler_and_name(
"/v2/api/library/item/image",
{
+320 -24
View File
@@ -110,6 +110,7 @@ pub(super) struct UpdateLibraryItemRequest {
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
disc_number: Option<String>,
artist_ids: Option<Vec<i64>>,
release_tracks: Option<Vec<ReleaseTrackUpdateRequest>>,
}
#[derive(Debug, Deserialize)]
@@ -118,6 +119,21 @@ pub(super) struct LibraryItemDetailQuery {
id: i64,
}
#[derive(Debug, Deserialize)]
pub(super) struct TrackSearchQuery {
search: Option<String>,
limit: Option<i64>,
}
#[derive(Debug, Deserialize)]
struct ReleaseTrackUpdateRequest {
id: i64,
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
track_number: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
disc_number: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(super) struct SetLibraryImageRequest {
kind: String,
@@ -538,6 +554,7 @@ struct LibraryItemDetailDto {
selected_artist_ids: Vec<i64>,
artists: Vec<ArtistOptionDto>,
releases: Vec<ReleaseOptionDto>,
release_tracks: Vec<ReleaseTrackDto>,
available_covers: Vec<AvailableCoverDto>,
metadata_tags: Vec<MetadataTagDto>,
}
@@ -555,6 +572,19 @@ struct ReleaseOptionDto {
subtitle: String,
}
#[derive(Debug, Serialize, JsonSchema)]
struct ReleaseTrackDto {
id: i64,
title: String,
artists: String,
release_id: Option<i64>,
release_title: Option<String>,
track_number: Option<i32>,
disc_number: Option<i32>,
duration_seconds: f64,
is_hidden: bool,
}
#[derive(Debug, Serialize, JsonSchema)]
struct AvailableCoverDto {
media_file_id: i64,
@@ -651,6 +681,19 @@ struct LibraryItemRow {
updated_at: Option<String>,
}
#[derive(Debug, sqlx::FromRow)]
struct ReleaseTrackRow {
id: i64,
title: String,
artists: String,
release_id: Option<i64>,
release_title: Option<String>,
track_number: Option<i32>,
disc_number: Option<i32>,
duration_seconds: f64,
is_hidden: bool,
}
pub async fn page(admin: AuthenticatedUser, i18n: I18n) -> cot::Result<Html> {
let template = AdminV2Template {
t: i18n.t,
@@ -1289,6 +1332,21 @@ pub async fn library_item_detail(
return Ok(response);
}
let kind = normalize_library_kind(Some(query.kind.as_str()));
if kind == "releases" && query.id == 0 {
let item = LibraryItemDto {
id: 0,
kind: kind.clone(),
title: String::new(),
subtitle: String::new(),
is_hidden: Some(false),
tags: Vec::new(),
updated_at: None,
};
let detail = load_library_item_detail(pool, &kind, item)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
return Json(detail).into_response();
}
let Some(item) = fetch_library_item(pool, &kind, query.id)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
@@ -1301,6 +1359,25 @@ pub async fn library_item_detail(
Json(detail).into_response()
}
pub async fn track_search(
session: Session,
db: Database,
pool: &PgPool,
query: TrackSearchQuery,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
let Some(search) = clean_search(query.search.as_deref()) else {
return Json(Vec::<ReleaseTrackDto>::new()).into_response();
};
let tracks = search_tracks(pool, &search, query.limit.unwrap_or(16).clamp(1, 40))
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Json(tracks).into_response()
}
pub async fn update_library_item(
session: Session,
db: Database,
@@ -1318,6 +1395,19 @@ pub async fn update_library_item(
}
let now = now_string();
if kind == "releases" && body.id == 0 {
let release_id = create_release_library_item(pool, &body, title, &now)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let Some(item) = fetch_library_item(pool, &kind, release_id)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
else {
return Ok(json_error(StatusCode::NOT_FOUND, "library item not found"));
};
return Json(item).into_response();
}
let affected = match kind.as_str() {
"artists" => {
sqlx::query(
@@ -1421,22 +1511,9 @@ pub async fn update_library_item(
let mut seen_artist_ids = HashSet::new();
artist_ids.retain(|id| *id > 0 && seen_artist_ids.insert(*id));
if kind == "releases" {
sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = $1")
.bind(body.id)
.execute(pool)
set_release_artists(pool, body.id, &artist_ids)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
for (position, artist_id) in artist_ids.iter().enumerate() {
sqlx::query(
"INSERT INTO furumusic__release_artist (release_id, artist_id, position) VALUES ($1, $2, $3)",
)
.bind(body.id)
.bind(*artist_id)
.bind(position as i32)
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
}
} else {
sqlx::query(
"DELETE FROM furumusic__track_artist WHERE track_id = $1 AND role = 'main'",
@@ -1460,6 +1537,14 @@ pub async fn update_library_item(
}
}
if kind == "releases" {
if let Some(release_tracks) = body.release_tracks.as_deref() {
update_release_tracks(pool, body.id, release_tracks, &now)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
}
}
let Some(item) = fetch_library_item(pool, &kind, body.id)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
@@ -2647,13 +2732,13 @@ async fn fetch_library_item(
"tracks" => {
sqlx::query_as::<_, LibraryItemRow>(
"SELECT t.id, t.title::text AS title, \
CONCAT(r.title::text, COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \
CONCAT(COALESCE(r.title::text, 'No release'), COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \
t.is_hidden, COUNT(DISTINCT ta.artist_id)::bigint AS primary_count, \
COUNT(DISTINCT ph.id)::bigint AS secondary_count, \
COUNT(DISTINCT pt.playlist_id)::bigint AS tertiary_count, \
t.updated_at::text AS updated_at \
FROM furumusic__track t \
JOIN furumusic__release r ON r.id = t.release_id \
LEFT JOIN furumusic__release r ON r.id = t.release_id \
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \
LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \
LEFT JOIN furumusic__playlist_track pt ON pt.track_id = t.id \
@@ -2704,6 +2789,7 @@ async fn load_library_item_detail(
selected_artist_ids: Vec::new(),
artists: Vec::new(),
releases: Vec::new(),
release_tracks: Vec::new(),
available_covers: Vec::new(),
metadata_tags: load_metadata_tags(pool, kind, item.id).await?,
item,
@@ -2744,16 +2830,22 @@ async fn load_library_item_detail(
.map(|row| row.id)
.collect();
detail.artists = load_artist_options(pool).await?;
if detail.item.id > 0 {
detail.release_tracks = load_release_tracks(pool, detail.item.id).await?;
}
}
"tracks" => {
let row: Option<(i64, Option<i32>, Option<i32>, Option<i32>)> = sqlx::query_as(
"SELECT release_id, track_number, disc_number, year FROM furumusic__track WHERE id = $1",
let row: Option<(Option<i64>, Option<i32>, Option<i32>, Option<i32>)> = sqlx::query_as(
"SELECT r.id AS release_id, t.track_number, t.disc_number, t.year \
FROM furumusic__track t \
LEFT JOIN furumusic__release r ON r.id = t.release_id \
WHERE t.id = $1",
)
.bind(detail.item.id)
.fetch_optional(pool)
.await?;
if let Some((release_id, track_number, disc_number, year)) = row {
detail.release_id = Some(release_id);
detail.release_id = release_id;
detail.track_number = track_number;
detail.disc_number = disc_number;
detail.year = year;
@@ -2901,6 +2993,210 @@ async fn load_release_options(pool: &PgPool) -> anyhow::Result<Vec<ReleaseOption
.collect())
}
async fn create_release_library_item(
pool: &PgPool,
body: &UpdateLibraryItemRequest,
title: &str,
now: &str,
) -> anyhow::Result<i64> {
let release_type = body
.release_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("album");
let year = body
.year
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(|value| value.parse::<i32>().ok());
let release_id: i64 = sqlx::query_scalar(
"INSERT INTO furumusic__release \
(title, title_sort, release_type, year, cover_file_id, total_tracks, total_discs, is_hidden, model_name, created_at, updated_at) \
VALUES ($1, $2, $3, $4, NULL, NULL, NULL, $5, NULL, $6, $6) \
RETURNING id",
)
.bind(title)
.bind(normalize_name(title))
.bind(release_type)
.bind(year)
.bind(body.hidden)
.bind(now)
.fetch_one(pool)
.await?;
if let Some(artist_ids) = body.artist_ids.as_deref() {
set_release_artists(pool, release_id, artist_ids).await?;
}
if let Some(release_tracks) = body.release_tracks.as_deref() {
update_release_tracks(pool, release_id, release_tracks, now).await?;
}
Ok(release_id)
}
async fn set_release_artists(
pool: &PgPool,
release_id: i64,
artist_ids: &[i64],
) -> anyhow::Result<()> {
sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = $1")
.bind(release_id)
.execute(pool)
.await?;
let mut seen_artist_ids = HashSet::new();
let unique_artist_ids = artist_ids
.iter()
.copied()
.filter(|id| *id > 0 && seen_artist_ids.insert(*id))
.collect::<Vec<_>>();
for (position, artist_id) in unique_artist_ids.iter().enumerate() {
sqlx::query(
"INSERT INTO furumusic__release_artist (release_id, artist_id, position) VALUES ($1, $2, $3)",
)
.bind(release_id)
.bind(*artist_id)
.bind(position as i32)
.execute(pool)
.await?;
}
Ok(())
}
async fn load_release_tracks(
pool: &PgPool,
release_id: i64,
) -> anyhow::Result<Vec<ReleaseTrackDto>> {
let rows = sqlx::query_as::<_, ReleaseTrackRow>(
"SELECT t.id, t.title::text AS title, \
COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') AS artists, \
NULLIF(t.release_id, 0) AS release_id, r.title::text AS release_title, \
t.track_number, t.disc_number, t.duration_seconds, t.is_hidden \
FROM furumusic__track t \
LEFT JOIN furumusic__release r ON r.id = t.release_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 t.release_id = $1 \
GROUP BY t.id, r.id, r.title \
ORDER BY t.disc_number NULLS FIRST, t.track_number NULLS LAST, t.title ASC, t.id ASC",
)
.bind(release_id)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(release_track_dto).collect())
}
async fn search_tracks(
pool: &PgPool,
search: &str,
limit: i64,
) -> anyhow::Result<Vec<ReleaseTrackDto>> {
let pattern = format!("%{search}%");
let starts_with = format!("{search}%");
let rows = sqlx::query_as::<_, ReleaseTrackRow>(
"SELECT t.id, t.title::text AS title, \
COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') AS artists, \
NULLIF(t.release_id, 0) AS release_id, r.title::text AS release_title, \
t.track_number, t.disc_number, t.duration_seconds, t.is_hidden \
FROM furumusic__track t \
LEFT JOIN furumusic__release r ON r.id = t.release_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 t.title ILIKE $1 OR COALESCE(r.title::text, '') ILIKE $1 OR COALESCE(a.name::text, '') ILIKE $1 \
GROUP BY t.id, r.id, r.title \
ORDER BY CASE \
WHEN LOWER(t.title::text) = LOWER($2) THEN 0 \
WHEN t.title ILIKE $3 THEN 1 \
ELSE 2 \
END, \
t.title_sort ASC, t.id ASC \
LIMIT $4",
)
.bind(pattern)
.bind(search)
.bind(starts_with)
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(release_track_dto).collect())
}
async fn update_release_tracks(
pool: &PgPool,
release_id: i64,
tracks: &[ReleaseTrackUpdateRequest],
now: &str,
) -> anyhow::Result<()> {
let mut seen_ids = HashSet::new();
let selected = tracks
.iter()
.filter(|track| track.id > 0 && seen_ids.insert(track.id))
.collect::<Vec<_>>();
let selected_ids = selected.iter().map(|track| track.id).collect::<Vec<_>>();
let mut tx = pool.begin().await?;
if selected_ids.is_empty() {
sqlx::query(
"UPDATE furumusic__track \
SET release_id = 0, updated_at = $2 \
WHERE release_id = $1",
)
.bind(release_id)
.bind(now)
.execute(&mut *tx)
.await?;
} else {
sqlx::query(
"UPDATE furumusic__track \
SET release_id = 0, updated_at = $2 \
WHERE release_id = $1 AND id <> ALL($3)",
)
.bind(release_id)
.bind(now)
.bind(&selected_ids)
.execute(&mut *tx)
.await?;
}
for track in selected {
let track_number = parse_optional_admin_i32(track.track_number.as_deref(), 1, 9999);
let disc_number = parse_optional_admin_i32(track.disc_number.as_deref(), 1, 999);
sqlx::query(
"UPDATE furumusic__track \
SET release_id = $1, track_number = $2, disc_number = $3, updated_at = $4 \
WHERE id = $5",
)
.bind(release_id)
.bind(track_number)
.bind(disc_number)
.bind(now)
.bind(track.id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
fn release_track_dto(row: ReleaseTrackRow) -> ReleaseTrackDto {
ReleaseTrackDto {
id: row.id,
title: row.title,
artists: row.artists,
release_id: row.release_id,
release_title: row.release_title,
track_number: row.track_number,
disc_number: row.disc_number,
duration_seconds: row.duration_seconds,
is_hidden: row.is_hidden,
}
}
async fn artist_available_covers(
pool: &PgPool,
artist_id: i64,
@@ -2943,7 +3239,7 @@ async fn library_ids_by_filter(
"tracks" => QueryBuilder::<Postgres>::new(
"SELECT DISTINCT t.id \
FROM furumusic__track t \
JOIN furumusic__release r ON r.id = t.release_id \
LEFT JOIN furumusic__release r ON r.id = t.release_id \
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id WHERE 1=1",
),
@@ -3182,7 +3478,7 @@ async fn count_library(
"tracks" => QueryBuilder::<Postgres>::new(
"SELECT COUNT(DISTINCT t.id) AS count \
FROM furumusic__track t \
JOIN furumusic__release r ON r.id = t.release_id \
LEFT JOIN furumusic__release r ON r.id = t.release_id \
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id WHERE 1=1",
),
@@ -3319,13 +3615,13 @@ async fn load_track_items(
) -> anyhow::Result<Vec<LibraryItemRow>> {
let mut qb = QueryBuilder::<Postgres>::new(
"SELECT t.id, t.title::text AS title, \
CONCAT(r.title::text, COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \
CONCAT(COALESCE(r.title::text, 'No release'), COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \
t.is_hidden, COUNT(DISTINCT ta.artist_id)::bigint AS primary_count, \
COUNT(DISTINCT ph.id)::bigint AS secondary_count, \
COUNT(DISTINCT pt.playlist_id)::bigint AS tertiary_count, \
t.updated_at::text AS updated_at \
FROM furumusic__track t \
JOIN furumusic__release r ON r.id = t.release_id \
LEFT JOIN furumusic__release r ON r.id = t.release_id \
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id \
LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \
@@ -3341,7 +3637,7 @@ async fn load_track_items(
qb.push_bind(pattern);
qb.push(")");
}
qb.push(" GROUP BY t.id, r.title ORDER BY r.title ASC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title ASC LIMIT ");
qb.push(" GROUP BY t.id, r.title ORDER BY COALESCE(r.title::text, '') ASC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title ASC LIMIT ");
qb.push_bind(limit);
qb.push(" OFFSET ");
qb.push_bind(offset);
+1
View File
@@ -373,6 +373,7 @@ translations! {
player_repeat: "Repeat" , "Повтор";
player_volume: "Volume" , "Громкость";
player_appears_on: "Appears on" , "Участвует в";
player_top_tracks: "Popular tracks" , "Популярные треки";
player_albums: "Albums" , "Альбомы";
player_eps: "EPs" , "EP";
player_singles: "Singles" , "Синглы";
+74 -5
View File
@@ -977,27 +977,54 @@ fn safe_mobile_redirect_uri(raw: Option<&str>) -> Option<String> {
if lower.starts_with("furumi://") || lower.starts_with("furumusic://") {
return Some(value.to_owned());
}
if is_loopback_http_redirect(&lower) {
return Some(value.to_owned());
}
None
}
/// RFC 8252 §7.3: native apps without a custom URL scheme (the CLI client)
/// receive the callback on a loopback listener with an ephemeral port.
fn is_loopback_http_redirect(lower: &str) -> bool {
let Some(rest) = lower.strip_prefix("http://") else {
return false;
};
let host_port = rest.split(['/', '?', '#']).next().unwrap_or("");
let Some((host, port)) = host_port.rsplit_once(':') else {
return false;
};
matches!(host, "127.0.0.1" | "localhost" | "[::1]")
&& !port.is_empty()
&& port.len() <= 5
&& port.bytes().all(|b| b.is_ascii_digit())
}
fn mobile_redirect_success(app_redirect_uri: &str, code: &str) -> cot::response::Response {
let deep_link = append_query_param(app_redirect_uri, "code", code);
if is_loopback_http_redirect(&app_redirect_uri.to_ascii_lowercase()) {
return auth::redirect(&deep_link);
}
mobile_deep_link_page(
"success",
"Sign-in complete",
"Furumi should open automatically. You can close this window after the app opens.",
"Furumi should open automatically. If it doesn't, use the button or copy the code below.",
None,
Some(code),
&deep_link,
)
}
fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response {
let deep_link = append_query_param(app_redirect_uri, "error", error);
if is_loopback_http_redirect(&app_redirect_uri.to_ascii_lowercase()) {
return auth::redirect(&deep_link);
}
mobile_deep_link_page(
"error",
"Sign-in failed",
"Furumi should open automatically and show the sign-in error. You can close this window after the app opens.",
"Furumi should open automatically and show the sign-in error.",
Some(error),
None,
&deep_link,
)
}
@@ -1007,6 +1034,7 @@ fn mobile_deep_link_page(
title: &str,
message: &str,
detail: Option<&str>,
code: Option<&str>,
deep_link: &str,
) -> cot::response::Response {
let state_class = html_escape(state);
@@ -1015,6 +1043,15 @@ fn mobile_deep_link_page(
let detail_html = detail
.map(|value| format!(r#"<p class="detail">Reason: {}</p>"#, html_escape(value)))
.unwrap_or_default();
let code_html = code
.map(|value| {
format!(
r#"<p class="hint">Signing in from a terminal? Paste this code there:</p>
<input class="code" readonly value="{}" onclick="this.select()">"#,
html_escape(value)
)
})
.unwrap_or_default();
let deep_link_html = html_escape(deep_link);
let deep_link_js =
serde_json::to_string(deep_link).expect("serializing URL string cannot fail");
@@ -1095,6 +1132,19 @@ fn mobile_deep_link_page(
font-size: 13px;
color: #89847c;
}}
.code {{
width: 100%;
margin-top: 8px;
padding: 10px 12px;
box-sizing: border-box;
border: 1px solid #3a3c42;
border-radius: 8px;
background: #1a1c20;
color: #e8d8a8;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
text-align: center;
}}
</style>
</head>
<body>
@@ -1105,15 +1155,13 @@ fn mobile_deep_link_page(
{detail_html}
<a href="{deep_link_html}">Open Furumi</a>
<p class="hint">If nothing happens, use the button above.</p>
{code_html}
</main>
<script>
const deepLink = {deep_link_js};
window.setTimeout(() => {{
window.location.href = deepLink;
}}, 100);
window.setTimeout(() => {{
window.close();
}}, 1800);
</script>
</body>
</html>"#,
@@ -1230,4 +1278,25 @@ mod tests {
);
assert!(safe_mobile_redirect_uri(Some("https://example.com/callback")).is_none());
}
#[test]
fn mobile_oidc_redirect_uri_allows_loopback_http() {
assert_eq!(
safe_mobile_redirect_uri(Some("http://127.0.0.1:8753/callback")).as_deref(),
Some("http://127.0.0.1:8753/callback")
);
assert_eq!(
safe_mobile_redirect_uri(Some("http://localhost:1234/callback")).as_deref(),
Some("http://localhost:1234/callback")
);
assert_eq!(
safe_mobile_redirect_uri(Some("http://[::1]:1234/callback")).as_deref(),
Some("http://[::1]:1234/callback")
);
// Non-loopback hosts, missing ports and https stay rejected.
assert!(safe_mobile_redirect_uri(Some("http://127.0.0.1/callback")).is_none());
assert!(safe_mobile_redirect_uri(Some("http://evil.com:80/callback")).is_none());
assert!(safe_mobile_redirect_uri(Some("https://127.0.0.1:80/callback")).is_none());
assert!(safe_mobile_redirect_uri(Some("http://127.0.0.1:notaport/x")).is_none());
}
}
+38 -34
View File
@@ -3201,40 +3201,44 @@ async fn artist_detail_handler(
.collect();
let top_tracks = sqlx::query_as::<_, PlaylistTrackRow>(
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
t.duration_seconds, t.cover_file_id,
r.cover_file_id as release_cover_file_id,
r.id as release_id,
r.title::text as release_title,
r.year as release_year,
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
mf.audio_format,
mf.audio_bitrate,
mf.audio_sample_rate,
mf.audio_bit_depth,
mf.file_size_bytes,
t.lastfm_listeners,
t.lastfm_playcount,
t.lastfm_rating,
t.lastfm_updated_at
FROM furumusic__track t
JOIN furumusic__release r ON r.id = t.release_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
WHERE t.is_hidden = false
AND r.is_hidden = false
AND EXISTS (
SELECT 1
FROM furumusic__track_artist ta
WHERE ta.track_id = t.id
AND ta.artist_id = $1
AND ta.role <> 'featuring'
)
ORDER BY COALESCE(t.lastfm_rating, 0) DESC,
COALESCE(t.lastfm_playcount, 0) DESC,
COALESCE(t.lastfm_listeners, 0) DESC,
r.year DESC NULLS LAST,
t.track_number NULLS LAST,
t.id
r#"SELECT * FROM (
SELECT DISTINCT ON (lower(t.title::text))
t.id, t.title::text as title, t.track_number, t.disc_number,
t.duration_seconds, t.cover_file_id,
r.cover_file_id as release_cover_file_id,
r.id as release_id,
r.title::text as release_title,
r.year as release_year,
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
mf.audio_format,
mf.audio_bitrate,
mf.audio_sample_rate,
mf.audio_bit_depth,
mf.file_size_bytes,
t.lastfm_listeners,
t.lastfm_playcount,
t.lastfm_rating,
t.lastfm_updated_at
FROM furumusic__track t
JOIN furumusic__release r ON r.id = t.release_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
WHERE t.is_hidden = false
AND r.is_hidden = false
AND EXISTS (
SELECT 1
FROM furumusic__track_artist ta
WHERE ta.track_id = t.id
AND ta.artist_id = $1
AND ta.role <> 'featuring'
)
ORDER BY lower(t.title::text), COALESCE(t.lastfm_rating, 0) DESC
) deduped
ORDER BY COALESCE(lastfm_rating, 0) DESC,
COALESCE(lastfm_playcount, 0) DESC,
COALESCE(lastfm_listeners, 0) DESC,
release_year DESC NULLS LAST,
track_number NULLS LAST,
id
LIMIT 50"#,
)
.bind(artist_id)
+323 -13
View File
@@ -1193,6 +1193,77 @@ tbody tr:hover {
gap: 12px;
}
.release-track-search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
margin-bottom: 10px;
}
.release-track-list {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
overflow: hidden;
}
.release-track-head,
.release-track-row {
display: grid;
grid-template-columns: 72px 82px minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, .9fr) 70px 36px;
gap: 8px;
align-items: center;
padding: 8px;
}
.release-track-head {
min-height: 34px;
border-bottom: 1px solid var(--border-color);
color: var(--text-subdued);
font-size: 10px;
font-weight: 850;
text-transform: uppercase;
}
.release-track-row {
min-height: 48px;
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
}
.release-track-row:last-child {
border-bottom: 0;
}
.release-track-row:hover {
background: rgba(255, 255, 255, 0.03);
}
.release-track-row input {
width: 100%;
height: 30px;
min-height: 30px;
padding: 0 8px;
}
.release-track-title,
.release-track-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.release-track-title {
color: var(--text-primary);
font-size: 12px;
font-weight: 750;
}
.release-track-meta {
color: var(--text-subdued);
font-size: 11px;
}
.image-actions {
display: flex;
align-items: center;
@@ -1836,6 +1907,10 @@ tbody tr:hover {
<i data-lucide="square-pen"></i>
Edit
</button>
<button class="btn primary" x-show="libraryKind === 'releases'" @click="openReleaseCreator()">
<i data-lucide="plus"></i>
New release
</button>
<button class="btn warn" @click="mockAction('Merge wizard will open from this action slot')">
<i data-lucide="git-merge"></i>
Merge
@@ -2419,8 +2494,8 @@ tbody tr:hover {
<section class="modal">
<div class="modal-head">
<div class="panel-title">
<strong x-text="activeLibraryItem?.title || 'Editor'"></strong>
<span x-text="activeLibraryItem?.kind || 'Library entity'"></span>
<strong x-text="editorTitle()"></strong>
<span x-text="editorSubtitle()"></span>
</div>
<button class="icon-btn" @click="editorOpen = false">
<i data-lucide="x"></i>
@@ -2454,6 +2529,54 @@ tbody tr:hover {
</div>
</div>
<div class="field" x-show="isReleaseEditor()">
<label>Release tracks</label>
<div class="release-track-search-row">
<div class="artist-picker">
<input class="search" placeholder="Search track" x-model="releaseTrackSearch" @input.debounce.300ms="searchReleaseTracks()" @keydown.enter.prevent="addBestReleaseTrack()" @keydown.escape="clearReleaseTrackSearch()" />
<div class="artist-results" x-show="releaseTrackSearchOpen()" x-transition>
<template x-for="track in availableReleaseTrackResults()" :key="track.id">
<button class="artist-result" type="button" @click="addReleaseTrack(track)">
<span x-text="track.title"></span>
<small x-text="releaseTrackSearchMeta(track)"></small>
</button>
</template>
<div class="artist-result muted" x-show="releaseTrackSearchLoading">Searching...</div>
<div class="artist-result muted" x-show="!releaseTrackSearchLoading && availableReleaseTrackResults().length === 0">No matching tracks</div>
</div>
</div>
<button class="btn" type="button" @click="addBestReleaseTrack()" :disabled="!releaseTrackSearch.trim()">
<i data-lucide="plus"></i>
Add
</button>
</div>
<div class="release-track-list" x-show="releaseTracks().length">
<div class="release-track-head">
<span>Disc</span>
<span>Track #</span>
<span>Title</span>
<span>Artists</span>
<span>Current release</span>
<span>Time</span>
<span></span>
</div>
<template x-for="track in releaseTracks()" :key="track.id">
<div class="release-track-row">
<input type="number" min="1" max="999" x-model="track.disc_number" />
<input type="number" min="1" max="9999" x-model="track.track_number" />
<div class="release-track-title" x-text="track.title"></div>
<div class="release-track-meta" x-text="track.artists || 'Unknown artist'"></div>
<div class="release-track-meta" x-text="releaseTrackOrigin(track)"></div>
<div class="release-track-meta" x-text="trackDuration(track.duration_seconds)"></div>
<button class="icon-btn" type="button" @click="removeReleaseTrack(track.id)" title="Remove from release">
<i data-lucide="x"></i>
</button>
</div>
</template>
</div>
<div class="empty" x-show="!releaseTracks().length">No tracks attached</div>
</div>
<div class="editor-grid" x-show="isTrackEditor()">
<div class="field">
<label>Track #</label>
@@ -2585,9 +2708,9 @@ tbody tr:hover {
<div class="toolbar">
<button class="btn primary" @click="saveLibraryItem()" :disabled="!editorCanSave()">
<i :data-lucide="editorSaving ? 'loader-circle' : 'save'"></i>
<span x-text="editorSaving ? 'Saving...' : 'Save'"></span>
<span x-text="editorSaving ? 'Saving...' : (editorIsNewRelease() ? 'Create' : 'Save')"></span>
</button>
<button class="btn danger" @click="deleteLibraryItem(activeLibraryItem)" :disabled="editorSaving || editorImageUploading">
<button class="btn danger" x-show="!editorIsNewRelease()" @click="deleteLibraryItem(activeLibraryItem)" :disabled="editorSaving || editorImageUploading">
<i data-lucide="trash-2"></i>
Delete
</button>
@@ -2682,8 +2805,12 @@ function adminV2() {
editorImageFile: null,
editorArtistToAdd: '',
editorReleaseToAdd: '',
releaseTrackSearch: '',
releaseTrackSearchResults: [],
releaseTrackSearchLoading: false,
releaseTrackSearchToken: 0,
editorDetail: null,
editorDraft: { title: '', hidden: 'false', release_type: 'album', year: '', release_id: null, track_number: '', disc_number: '', artist_ids: [] },
editorDraft: { title: '', hidden: 'false', release_type: 'album', year: '', release_id: null, track_number: '', disc_number: '', artist_ids: [], release_tracks: [] },
settings: { values: {}, sources: {}, lastfm_api_key_configured: false, lastfm_shared_secret_configured: false, lastfm_scrobbling_configured: false },
settingsDraft: {
auth_password_enabled: false,
@@ -3396,16 +3523,30 @@ function adminV2() {
release_id: null,
track_number: '',
disc_number: '',
artist_ids: []
artist_ids: [],
release_tracks: []
};
this.editorDetail = null;
this.editorImageFile = null;
this.editorArtistToAdd = '';
this.editorReleaseToAdd = '';
this.clearReleaseTrackSearch();
this.editorOpen = true;
this.loadEditorDetail(item);
},
openReleaseCreator() {
this.libraryKind = 'releases';
this.openEditor({
id: 0,
kind: 'releases',
title: '',
subtitle: 'New release',
is_hidden: false,
tags: []
});
},
async loadEditorDetail(item) {
const key = `${item.kind}:${item.id}`;
this.editorLoading = true;
@@ -3422,11 +3563,13 @@ function adminV2() {
release_id: detail.release_id || null,
track_number: detail.track_number || '',
disc_number: detail.disc_number || '',
artist_ids: Array.isArray(detail.selected_artist_ids) ? detail.selected_artist_ids.slice() : []
artist_ids: Array.isArray(detail.selected_artist_ids) ? detail.selected_artist_ids.slice() : [],
release_tracks: Array.isArray(detail.release_tracks) ? detail.release_tracks.map(track => this.normalizeReleaseTrack(track)) : []
};
this.editorImageFile = null;
this.editorArtistToAdd = '';
this.editorReleaseToAdd = '';
this.clearReleaseTrackSearch();
} catch (error) {
this.showToast(error.message);
} finally {
@@ -3449,8 +3592,22 @@ function adminV2() {
return this.activeLibraryItem && this.activeLibraryItem.kind === 'tracks';
},
editorIsNewRelease() {
return this.isReleaseEditor() && Number(this.activeLibraryItem.id || 0) === 0;
},
editorTitle() {
if (this.editorIsNewRelease()) return 'New release';
return this.activeLibraryItem?.title || 'Editor';
},
editorSubtitle() {
if (this.editorIsNewRelease()) return 'Create release and attach tracks';
return this.activeLibraryItem?.kind || 'Library entity';
},
canEditLibraryImage() {
return this.isArtistEditor() || this.isReleaseEditor();
return this.isArtistEditor() || (this.isReleaseEditor() && !this.editorIsNewRelease());
},
canShowMetadataTags() {
@@ -3497,6 +3654,7 @@ function adminV2() {
editorCanSave() {
if (!this.activeLibraryItem || !this.editorDetail || this.editorLoading || this.editorSaving) return false;
if (!String(this.editorDraft.title || '').trim()) return false;
if (this.isTrackEditor() && !this.editorDraft.release_id) return false;
return true;
},
@@ -3605,6 +3763,146 @@ function adminV2() {
return true;
},
normalizeReleaseTrack(track = {}) {
const trackNumber = track.track_number;
const discNumber = track.disc_number;
return {
id: Number(track.id),
title: track.title || `Track #${track.id}`,
artists: track.artists || '',
release_id: track.release_id == null ? null : Number(track.release_id),
release_title: track.release_title || '',
track_number: trackNumber == null ? '' : String(trackNumber),
disc_number: discNumber == null ? '' : String(discNumber),
duration_seconds: Number(track.duration_seconds || 0),
is_hidden: Boolean(track.is_hidden)
};
},
releaseTracks() {
if (!Array.isArray(this.editorDraft.release_tracks)) {
this.editorDraft.release_tracks = [];
}
return this.editorDraft.release_tracks;
},
releaseTrackPayload() {
return this.releaseTracks().map(track => ({
id: Number(track.id),
track_number: track.track_number || '',
disc_number: track.disc_number || ''
}));
},
releaseTrackIds() {
return new Set(this.releaseTracks().map(track => Number(track.id)));
},
releaseTrackSearchOpen() {
return this.isReleaseEditor() && String(this.releaseTrackSearch || '').trim().length > 0;
},
availableReleaseTrackResults() {
const selected = this.releaseTrackIds();
return (this.releaseTrackSearchResults || []).filter(track => !selected.has(Number(track.id)));
},
clearReleaseTrackSearch() {
this.releaseTrackSearch = '';
this.releaseTrackSearchResults = [];
this.releaseTrackSearchLoading = false;
this.releaseTrackSearchToken += 1;
},
async searchReleaseTracks() {
const query = String(this.releaseTrackSearch || '').trim();
if (!query) {
this.releaseTrackSearchResults = [];
this.releaseTrackSearchLoading = false;
return;
}
const token = this.releaseTrackSearchToken + 1;
this.releaseTrackSearchToken = token;
this.releaseTrackSearchLoading = true;
try {
const params = new URLSearchParams({ search: query, limit: '16' });
const rows = await this.request(`${this.apiBase}/library/tracks/search?${params.toString()}`);
if (this.releaseTrackSearchToken !== token) return;
this.releaseTrackSearchResults = Array.isArray(rows) ? rows.map(track => this.normalizeReleaseTrack(track)) : [];
} catch (error) {
if (this.releaseTrackSearchToken === token) this.showToast(error.message);
} finally {
if (this.releaseTrackSearchToken === token) {
this.releaseTrackSearchLoading = false;
this.icons();
}
}
},
async addBestReleaseTrack() {
if (!String(this.releaseTrackSearch || '').trim()) return;
if (!this.availableReleaseTrackResults().length && !this.releaseTrackSearchLoading) {
await this.searchReleaseTracks();
}
const track = this.availableReleaseTrackResults()[0];
if (!track) {
this.showToast('Choose a track from search results');
return;
}
this.addReleaseTrack(track);
},
addReleaseTrack(track) {
if (!track) return;
const normalized = this.normalizeReleaseTrack(track);
if (this.releaseTrackIds().has(Number(normalized.id))) {
this.showToast('Track already in release');
return;
}
this.editorDraft.release_tracks = this.releaseTracks().concat([normalized]);
this.clearReleaseTrackSearch();
this.$nextTick(() => this.icons());
},
removeReleaseTrack(id) {
this.editorDraft.release_tracks = this.releaseTracks().filter(track => Number(track.id) !== Number(id));
},
releaseTrackOrigin(track) {
const releaseId = Number(track && track.release_id ? track.release_id : 0);
const currentId = Number(this.activeLibraryItem && this.activeLibraryItem.id ? this.activeLibraryItem.id : 0);
if (releaseId && releaseId === currentId) return this.editorDraft.title || track.release_title || 'This release';
if (track && track.release_title) return track.release_title;
if (releaseId) return `Missing release #${releaseId}`;
return 'No release';
},
releaseTrackSearchMeta(track) {
const parts = [];
if (track.artists) parts.push(track.artists);
parts.push(this.releaseTrackOrigin(track));
const number = this.releaseTrackNumberLabel(track);
if (number) parts.push(number);
return parts.join(' / ');
},
releaseTrackNumberLabel(track) {
const disc = String((track && track.disc_number) || '').trim();
const number = String((track && track.track_number) || '').trim();
if (disc && number) return `D${disc} #${number}`;
if (number) return `#${number}`;
if (disc) return `D${disc}`;
return '';
},
trackDuration(seconds) {
const total = Math.round(Number(seconds || 0));
if (!total) return '-';
const minutes = Math.floor(total / 60);
const rest = String(total % 60).padStart(2, '0');
return `${minutes}:${rest}`;
},
setEditorImageFile(event) {
this.editorImageFile = event.target.files && event.target.files.length ? event.target.files[0] : null;
},
@@ -3729,6 +4027,7 @@ function adminV2() {
if (!this.editorCanSave()) return;
this.editorSaving = true;
try {
const wasNewRelease = this.editorIsNewRelease();
const updated = await this.request(`${this.apiBase}/library/item`, {
method: 'POST',
body: JSON.stringify({
@@ -3741,13 +4040,15 @@ function adminV2() {
release_id: this.editorDraft.release_id ? Number(this.editorDraft.release_id) : null,
track_number: this.editorDraft.track_number || '',
disc_number: this.editorDraft.disc_number || '',
artist_ids: this.editorDraft.artist_ids || []
artist_ids: this.editorDraft.artist_ids || [],
release_tracks: this.isReleaseEditor() ? this.releaseTrackPayload() : null
})
});
this.replaceLibraryItem(updated);
this.activeLibraryItem = updated;
if (this.editorDetail) this.editorDetail.item = updated;
this.showToast('Saved');
if (this.isReleaseEditor()) await this.loadEditorDetail(updated);
this.showToast(wasNewRelease ? 'Release created' : 'Saved');
await this.refreshCountsOnly();
} catch (error) {
this.showToast(error.message);
@@ -3768,9 +4069,18 @@ function adminV2() {
},
replaceLibraryItem(updated) {
this.library.items = this.library.items.map(item =>
item.kind === updated.kind && item.id === updated.id ? updated : item
);
let replaced = false;
this.library.items = this.library.items.map(item => {
if (item.kind === updated.kind && Number(item.id) === Number(updated.id)) {
replaced = true;
return updated;
}
return item;
});
if (!replaced && updated.kind === this.libraryKind) {
this.library.items = [updated].concat(this.library.items || []);
this.library.total = Number(this.library.total || 0) + 1;
}
},
async refreshCountsOnly() {
+54
View File
@@ -822,6 +822,7 @@ document.addEventListener('alpine:init', () => {
_playLocal(track, options = {}) {
this.currentTrack = track;
Alpine.store('queue')?.syncCurrentIndexToTrack(track);
this._localSourceTrackId = track.id;
this._historyRecorded = false;
this._resetPlaybackTracking();
@@ -1138,6 +1139,7 @@ document.addEventListener('alpine:init', () => {
_mirrorRemoteTrack(track, playing, positionSeconds = null) {
if (!track) return;
this.currentTrack = track;
Alpine.store('queue')?.syncCurrentIndexToTrack(track);
this.isPlaying = !!playing;
if (positionSeconds !== null) this.currentTime = Math.max(0, Number(positionSeconds || 0));
this.duration = Number(track.duration_seconds || this.duration || 0);
@@ -1158,6 +1160,7 @@ document.addEventListener('alpine:init', () => {
const track = state.track || queue?.tracks?.[queue.currentIndex] || null;
if (track) {
this.currentTrack = track;
queue?.syncCurrentIndexToTrack(track);
}
this.shuffle = !!state.shuffle;
this.repeatMode = state.repeat_mode || 'off';
@@ -1359,6 +1362,7 @@ document.addEventListener('alpine:init', () => {
: tracks[idx];
if (currentTrack) {
this.currentTrack = currentTrack;
queue.syncCurrentIndexToTrack(currentTrack);
this._localSourceTrackId = currentTrack.id;
this._historyRecorded = false;
this._resetPlaybackTracking();
@@ -1976,6 +1980,56 @@ document.addEventListener('alpine:init', () => {
return this.tracks.slice(start, start + limit);
},
effectiveCurrentIndex() {
const currentTrack = Alpine.store('player')?.currentTrack || null;
if (currentTrack?.id) {
return this.tracks.findIndex(track => Number(track?.id) === Number(currentTrack.id));
}
if (!this.tracks.length) return -1;
return Math.max(0, Math.min(Number(this.currentIndex || 0), this.tracks.length - 1));
},
queueItemState(index) {
const current = this.effectiveCurrentIndex();
if (current < 0) return 'upcoming';
if (index < current) return 'played';
if (index === current) return 'current';
return 'upcoming';
},
displayItems() {
const current = this.effectiveCurrentIndex();
const playerTrack = Alpine.store('player')?.currentTrack || null;
const items = this.tracks.map((track, index) => ({
track,
index,
key: `${index}-${track?.id || 'track'}`,
state: current >= 0
? (index < current ? 'played' : (index === current ? 'current' : 'upcoming'))
: 'upcoming',
synthetic: false,
}));
if (playerTrack?.id && current < 0) {
items.unshift({
track: playerTrack,
index: -1,
key: `current-${playerTrack.id}`,
state: 'current',
synthetic: true,
});
}
return items;
},
syncCurrentIndexToTrack(track) {
if (!track?.id || !this.tracks.length) return -1;
const index = this.tracks.findIndex(item => Number(item?.id) === Number(track.id));
if (index >= 0) this.currentIndex = index;
return index;
},
addToEnd(tracks) {
const items = this._tracksForQueueAdd(tracks);
if (!items.length) return;
+111 -33
View File
@@ -620,6 +620,83 @@
</div>
</div>
</div>
<template x-if="$store.library.currentArtist.top_tracks && $store.library.currentArtist.top_tracks.length > 0">
<section class="artist-release-group">
<h2 class="artist-release-group-title">{{ t.player_top_tracks }}</h2>
<div class="track-list-header">
<span>#</span>
<span>{{ t.player_title }}</span>
<span></span>
<span></span>
<span style="text-align:right">{{ t.player_duration }}</span>
</div>
<template x-for="(track, idx) in $store.library.currentArtist.top_tracks" :key="track.id">
<div class="track-row artist-appearance-row"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
@dblclick="$store.queue.playRelease($store.library.currentArtist.top_tracks, idx)">
<span class="track-num" x-text="idx + 1"></span>
<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">
<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>
</div>
<span></span>
<div class="track-actions">
<button class="track-action-btn info-btn popularity-info-btn"
:class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
:style="$store.library.popularityStyle(track)"
@click.stop="$store.library.openTrackInfo(track)"
:title="$store.library.trackInfoTitle(track)"
aria-label="{{ t.player_track_info }}">
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
</button>
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_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 queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(track, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
</button>
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_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>
<template x-for="group in $store.library.artistReleaseGroups()" :key="group.type">
<section class="artist-release-group">
<h2 class="artist-release-group-title" x-text="group.label"></h2>
@@ -959,42 +1036,43 @@
</div>
</div>
<div class="queue-tracks">
<template x-if="$store.queue.tracks.length === 0">
<template x-if="$store.queue.displayItems().length === 0">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<p>{{ t.player_queue_empty }}</p>
</div>
</template>
<template x-for="(track, idx) in $store.queue.tracks" :key="idx + '-' + track.id">
<template x-for="item in $store.queue.displayItems()" :key="item.key">
<div class="queue-track"
:data-queue-index="idx"
:class="{ active: idx === $store.queue.currentIndex, dragging: $store.queue._dragIdx === idx, 'foreign-jam-track': $store.queue.isForeignJamTrack(track) }"
:style="$store.queue.isForeignJamTrack(track) ? $store.queue.contributorStyle(track) : ''"
@click="$store.queue.playFromIndex(idx)"
draggable="true"
@dragstart="$store.queue._dragIdx = idx; $event.dataTransfer.effectAllowed = 'move'"
:data-queue-index="item.index"
:class="{ active: item.state === 'current', current: item.state === 'current', played: item.state === 'played', dragging: $store.queue._dragIdx === item.index, synthetic: item.synthetic, 'foreign-jam-track': $store.queue.isForeignJamTrack(item.track) }"
:style="$store.queue.isForeignJamTrack(item.track) ? $store.queue.contributorStyle(item.track) : ''"
@click="item.index >= 0 ? $store.queue.playFromIndex(item.index) : $store.player.play(item.track)"
:draggable="!item.synthetic"
@dragstart="if (item.synthetic) { $event.preventDefault(); } else { $store.queue._dragIdx = item.index; $event.dataTransfer.effectAllowed = 'move'; }"
@dragend="$store.queue._dragIdx = null; document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'))"
@dragover.prevent="$event.dataTransfer.dropEffect = 'move'; $event.currentTarget.classList.add('drag-over')"
@dragleave="$event.currentTarget.classList.remove('drag-over')"
@drop.prevent="$event.currentTarget.classList.remove('drag-over'); if ($store.queue._dragIdx !== null) { $store.queue.moveTrack($store.queue._dragIdx, idx); $store.queue._dragIdx = null; }">
@drop.prevent="$event.currentTarget.classList.remove('drag-over'); if (!item.synthetic && $store.queue._dragIdx !== null) { $store.queue.moveTrack($store.queue._dragIdx, item.index); $store.queue._dragIdx = null; }">
<div class="queue-drag-handle"
x-show="!item.synthetic"
@mousedown.stop
@click.stop
@pointerdown.stop="$store.queue.startPointerReorder($event, idx)">
@pointerdown.stop="$store.queue.startPointerReorder($event, item.index)">
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
</div>
<div class="queue-track-cover">
<template x-if="track.cover_url">
<img :src="track.cover_url" :alt="track.title" loading="lazy">
<template x-if="item.track.cover_url">
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
</template>
<template x-if="!track.cover_url">
<template x-if="!item.track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</template>
</div>
<div class="queue-track-info">
<div class="queue-track-title" x-text="track.title"></div>
<div class="queue-track-title" x-text="item.track.title"></div>
<div class="queue-track-artist">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.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>
@@ -1004,15 +1082,15 @@
</div>
<div class="queue-track-actions">
<button class="queue-track-remove info-btn popularity-info-btn"
:class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
:style="$store.library.popularityStyle(track)"
@click.stop="$store.library.openTrackInfo(track)"
:title="$store.library.trackInfoTitle(track)"
:class="{ 'has-popularity': $store.library.hasPopularity(item.track), 'no-popularity': !$store.library.hasPopularity(item.track) }"
:style="$store.library.popularityStyle(item.track)"
@click.stop="$store.library.openTrackInfo(item.track)"
:title="$store.library.trackInfoTitle(item.track)"
aria-label="{{ t.player_track_info }}">
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
<span x-show="$store.library.hasPopularity(item.track)" x-text="$store.library.popularityLabel(item.track)"></span>
<span x-show="!$store.library.hasPopularity(item.track)" class="info-letter">i</span>
</button>
<button class="queue-track-remove" @click.stop="$store.queue.remove(idx)" title="{{ t.player_remove }}">
<button class="queue-track-remove" x-show="!item.synthetic" @click.stop="$store.queue.remove(item.index)" title="{{ t.player_remove }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
@@ -1292,27 +1370,27 @@
</div>
<div class="mobile-expanded-queue">
<div class="mobile-expanded-queue-title">{{ t.player_queue }}</div>
<template x-if="$store.queue.upcoming().length === 0">
<template x-if="$store.queue.displayItems().length === 0">
<div class="mobile-expanded-queue-empty">{{ t.player_queue_empty }}</div>
</template>
<template x-for="(track, idx) in $store.queue.upcoming()" :key="'mobile-expanded-queue-' + track.id + '-' + idx">
<template x-for="item in $store.queue.displayItems()" :key="'mobile-expanded-queue-' + item.key">
<button class="mobile-expanded-queue-row"
:class="{ 'foreign-jam-track': $store.queue.isForeignJamTrack(track) }"
:style="$store.queue.isForeignJamTrack(track) ? $store.queue.contributorStyle(track) : ''"
:class="{ current: item.state === 'current', played: item.state === 'played', 'foreign-jam-track': $store.queue.isForeignJamTrack(item.track) }"
:style="$store.queue.isForeignJamTrack(item.track) ? $store.queue.contributorStyle(item.track) : ''"
type="button"
@click="$store.queue.playFromIndex($store.queue.currentIndex + idx + 1)">
@click="item.index >= 0 ? $store.queue.playFromIndex(item.index) : $store.player.play(item.track)">
<div class="mobile-expanded-queue-cover">
<template x-if="track.cover_url">
<img :src="track.cover_url" :alt="track.title" loading="lazy">
<template x-if="item.track.cover_url">
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
</template>
<template x-if="!track.cover_url">
<template x-if="!item.track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</template>
</div>
<div class="mobile-expanded-queue-info">
<div class="mobile-expanded-queue-name" x-text="track.title"></div>
<div class="mobile-expanded-queue-name" x-text="item.track.title"></div>
<div class="mobile-expanded-queue-artist">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<span x-text="artist.label"></span>
@@ -1320,7 +1398,7 @@
</template>
</div>
</div>
<span class="mobile-expanded-queue-time" x-text="formatTime(track.duration_seconds)"></span>
<span class="mobile-expanded-queue-time" x-text="formatTime(item.track.duration_seconds)"></span>
</button>
</template>
</div>
+41 -2
View File
@@ -1224,7 +1224,22 @@ button.user-stat:hover {
}
.queue-track:hover { background: var(--bg-hover); }
.queue-track.active { background: var(--bg-active); }
.queue-track.active,
.queue-track.current { background: var(--bg-active); }
.queue-track.played {
color: var(--text-subdued);
opacity: 0.58;
}
.queue-track.played:hover {
opacity: 0.78;
}
.queue-track.played .queue-track-cover {
filter: grayscale(1);
opacity: 0.72;
}
.queue-track.synthetic .queue-drag-handle {
display: none;
}
.queue-track.foreign-jam-track {
background: linear-gradient(90deg, var(--jam-contributor-bg, rgba(82,145,255,0.12)), transparent 78%);
}
@@ -1259,7 +1274,9 @@ button.user-stat:hover {
text-overflow: ellipsis;
}
.queue-track.active .queue-track-title { color: var(--accent); }
.queue-track.active .queue-track-title,
.queue-track.current .queue-track-title { color: var(--accent); }
.queue-track.played .queue-track-title { color: var(--text-subdued); }
.queue-track-artist {
font-size: 11px;
@@ -4925,6 +4942,28 @@ button.user-stat:hover {
background: var(--bg-hover);
}
.mobile-expanded-queue-row.current {
background: var(--bg-active);
}
.mobile-expanded-queue-row.current .mobile-expanded-queue-name {
color: var(--accent);
}
.mobile-expanded-queue-row.played {
color: var(--text-subdued);
opacity: 0.56;
}
.mobile-expanded-queue-row.played:active {
opacity: 0.74;
}
.mobile-expanded-queue-row.played .mobile-expanded-queue-cover {
filter: grayscale(1);
opacity: 0.72;
}
.mobile-expanded-queue-row.foreign-jam-track {
background: linear-gradient(90deg, var(--jam-contributor-bg, rgba(82,145,255,0.12)), transparent 82%);
}