diff --git a/Cargo.lock b/Cargo.lock index 94cf6d7..b5e9cd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.2.5" +version = "0.2.6" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 6618d8d..be75896 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.2.6" +version = "0.2.7" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/admin/v2.rs b/src/admin/v2.rs index a9a65ba..0e7fb32 100644 --- a/src/admin/v2.rs +++ b/src/admin/v2.rs @@ -9,7 +9,7 @@ use cot::response::IntoResponse; use cot::session::Session; use cot::{Body, Template}; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use sqlx::{PgPool, Postgres, QueryBuilder}; use super::BUILD_INFO; @@ -68,9 +68,12 @@ pub(super) struct UpdateLibraryItemRequest { title: String, hidden: bool, release_type: Option, + #[serde(default, deserialize_with = "deserialize_optional_stringish")] year: Option, release_id: Option, + #[serde(default, deserialize_with = "deserialize_optional_stringish")] track_number: Option, + #[serde(default, deserialize_with = "deserialize_optional_stringish")] disc_number: Option, artist_ids: Option>, } @@ -1876,7 +1879,7 @@ async fn fetch_library_item( "releases" => { sqlx::query_as::<_, LibraryItemRow>( "SELECT r.id, r.title::text AS title, \ - CONCAT(r.release_type::text, COALESCE(' · ' || r.year::text, '')) AS subtitle, \ + (COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') || COALESCE(' / ' || r.year::text, '')) AS subtitle, \ r.is_hidden, COUNT(DISTINCT t.id)::bigint AS primary_count, \ COUNT(DISTINCT ra.artist_id)::bigint AS secondary_count, \ COUNT(DISTINCT ph.id)::bigint AS tertiary_count, \ @@ -1884,6 +1887,7 @@ async fn fetch_library_item( FROM furumusic__release r \ LEFT JOIN furumusic__track t ON t.release_id = r.id \ LEFT JOIN furumusic__release_artist ra ON ra.release_id = r.id \ + LEFT JOIN furumusic__artist a ON a.id = ra.artist_id \ LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \ WHERE r.id = $1 \ GROUP BY r.id", @@ -2055,8 +2059,11 @@ async fn load_artist_options(pool: &PgPool) -> anyhow::Result anyhow::Result> { let rows = sqlx::query_as::<_, (i64, String, Option)>( "SELECT r.id, r.title::text AS title, \ - CONCAT(r.release_type::text, COALESCE(' / ' || r.year::text, '')) AS subtitle \ + (COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') || COALESCE(' / ' || r.year::text, '')) AS subtitle \ FROM furumusic__release r \ + LEFT JOIN furumusic__release_artist ra ON ra.release_id = r.id \ + LEFT JOIN furumusic__artist a ON a.id = ra.artist_id \ + GROUP BY r.id \ ORDER BY r.title_sort ASC, r.year NULLS LAST, r.id ASC", ) .fetch_all(pool) @@ -2452,7 +2459,7 @@ async fn load_release_items( ) -> anyhow::Result> { let mut qb = QueryBuilder::::new( "SELECT r.id, r.title::text AS title, \ - CONCAT(r.release_type::text, COALESCE(' · ' || r.year::text, '')) AS subtitle, \ + (COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') || COALESCE(' / ' || r.year::text, '')) AS subtitle, \ r.is_hidden, COUNT(DISTINCT t.id)::bigint AS primary_count, \ COUNT(DISTINCT ra.artist_id)::bigint AS secondary_count, \ COUNT(DISTINCT ph.id)::bigint AS tertiary_count, \ @@ -2657,10 +2664,12 @@ fn optional_job_time(value: &str) -> Option { } fn normalize_library_kind(kind: Option<&str>) -> String { - match kind { - Some("releases") => "releases", - Some("tracks") => "tracks", - Some("playlists") => "playlists", + let kind = kind.unwrap_or_default().trim().to_ascii_lowercase(); + match kind.as_str() { + "release" | "releases" => "releases", + "track" | "tracks" => "tracks", + "playlist" | "playlists" => "playlists", + "artist" | "artists" => "artists", _ => "artists", } .to_owned() @@ -2696,6 +2705,23 @@ fn parse_optional_admin_i32(value: Option<&str>, min: i32, max: i32) -> Option(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let Some(value) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + match value { + serde_json::Value::Null => Ok(None), + serde_json::Value::String(value) => Ok(Some(value)), + serde_json::Value::Number(value) => Ok(Some(value.to_string())), + other => Err(serde::de::Error::custom(format!( + "expected string, number, or null, got {other}" + ))), + } +} + fn now_string() -> String { chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() } diff --git a/templates/admin/v2.html b/templates/admin/v2.html index 9514281..546dffe 100644 --- a/templates/admin/v2.html +++ b/templates/admin/v2.html @@ -1934,7 +1934,7 @@ tbody tr:hover {
Loading editor...
- +
@@ -2829,8 +2829,10 @@ function adminV2() { selectEditorRelease(release = null) { const candidates = this.filteredEditorReleases(); release = release || candidates[0]; - if (release) this.editorDraft.release_id = Number(release.id); + if (!release) return false; + this.editorDraft.release_id = Number(release.id); this.editorReleaseToAdd = ''; + return true; }, setEditorImageFile(event) { @@ -2948,6 +2950,12 @@ function adminV2() { }, async saveLibraryItem() { + if (this.isTrackEditor() && String(this.editorReleaseToAdd || '').trim()) { + if (!this.selectEditorRelease()) { + this.showToast('Choose a release from search results'); + return; + } + } if (!this.editorCanSave()) return; this.editorSaving = true; try { diff --git a/templates/player/modals.html b/templates/player/modals.html index ccecb10..e982abc 100644 --- a/templates/player/modals.html +++ b/templates/player/modals.html @@ -422,13 +422,13 @@ -
hidden
feat.
- + +
@@ -516,11 +516,6 @@