From 0ac59eb0cadfb55fc2bc3b95098b83e48ad64439 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Tue, 9 Jun 2026 15:06:01 +0100 Subject: [PATCH] Improved release editor --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/admin/mod.rs | 26 +++ src/admin/v2.rs | 344 +++++++++++++++++++++++++++++++++++++--- templates/admin/v2.html | 336 +++++++++++++++++++++++++++++++++++++-- 5 files changed, 671 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 386989e..a8af51c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.4.2" +version = "0.4.3" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 573c280..f71dc2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.4.3" +version = "0.4.4" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/admin/mod.rs b/src/admin/mod.rs index a0a58a7..4d78ab2 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -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| { + 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", { diff --git a/src/admin/v2.rs b/src/admin/v2.rs index ea50da4..8c383f4 100644 --- a/src/admin/v2.rs +++ b/src/admin/v2.rs @@ -110,6 +110,7 @@ pub(super) struct UpdateLibraryItemRequest { #[serde(default, deserialize_with = "deserialize_optional_stringish")] disc_number: Option, artist_ids: Option>, + release_tracks: Option>, } #[derive(Debug, Deserialize)] @@ -118,6 +119,21 @@ pub(super) struct LibraryItemDetailQuery { id: i64, } +#[derive(Debug, Deserialize)] +pub(super) struct TrackSearchQuery { + search: Option, + limit: Option, +} + +#[derive(Debug, Deserialize)] +struct ReleaseTrackUpdateRequest { + id: i64, + #[serde(default, deserialize_with = "deserialize_optional_stringish")] + track_number: Option, + #[serde(default, deserialize_with = "deserialize_optional_stringish")] + disc_number: Option, +} + #[derive(Debug, Deserialize)] pub(super) struct SetLibraryImageRequest { kind: String, @@ -538,6 +554,7 @@ struct LibraryItemDetailDto { selected_artist_ids: Vec, artists: Vec, releases: Vec, + release_tracks: Vec, available_covers: Vec, metadata_tags: Vec, } @@ -555,6 +572,19 @@ struct ReleaseOptionDto { subtitle: String, } +#[derive(Debug, Serialize, JsonSchema)] +struct ReleaseTrackDto { + id: i64, + title: String, + artists: String, + release_id: Option, + release_title: Option, + track_number: Option, + disc_number: Option, + 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, } +#[derive(Debug, sqlx::FromRow)] +struct ReleaseTrackRow { + id: i64, + title: String, + artists: String, + release_id: Option, + release_title: Option, + track_number: Option, + disc_number: Option, + duration_seconds: f64, + is_hidden: bool, +} + pub async fn page(admin: AuthenticatedUser, i18n: I18n) -> cot::Result { 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 { + 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::::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, Option, Option)> = sqlx::query_as( - "SELECT release_id, track_number, disc_number, year FROM furumusic__track WHERE id = $1", + let row: Option<(Option, Option, Option, Option)> = 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 anyhow::Result { + 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::().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::>(); + + 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> { + 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> { + 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::>(); + let selected_ids = selected.iter().map(|track| track.id).collect::>(); + + 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::::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::::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> { let mut qb = QueryBuilder::::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); diff --git a/templates/admin/v2.html b/templates/admin/v2.html index 2c12405..8e62618 100644 --- a/templates/admin/v2.html +++ b/templates/admin/v2.html @@ -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 { Edit + + +
+
+ Disc + Track # + Title + Artists + Current release + Time + +
+ +
+
No tracks attached
+ +
@@ -2585,9 +2708,9 @@ tbody tr:hover {
- @@ -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() {