From 34e90b33f60b358a6181a2952b6e55b777aea366 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Fri, 14 Aug 2026 18:15:03 +0100 Subject: [PATCH] Fixed merge feature --- src/admin/mod.rs | 28 ++ src/admin/v2.rs | 231 ++++++++++++++++ src/library_cleanup.rs | 245 +++++++++++++++++ src/metrics.rs | 1 + templates/admin/v2.html | 576 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 1080 insertions(+), 1 deletion(-) diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 80d76cd..7dd7c31 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -711,6 +711,34 @@ impl App for AdminApp { }, "admin_v2_library_bulk", ), + Route::with_handler_and_name( + "/v2/api/library/releases/merge", + { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + cot::router::method::post( + move |session: Session, + db: Database, + json: Json| { + 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::merge_releases(session, db, pg_pool, json).await + } + }, + ) + }, + "admin_v2_library_releases_merge", + ), // -- Dashboard ---------------------------------------------------- Route::with_handler_and_name( "/", diff --git a/src/admin/v2.rs b/src/admin/v2.rs index eef8d2a..432e0f5 100644 --- a/src/admin/v2.rs +++ b/src/admin/v2.rs @@ -69,6 +69,21 @@ pub(super) struct BulkLibraryRequest { filter: Option, } +#[derive(Debug, Deserialize)] +pub(super) struct MergeReleasesRequest { + release_ids: Vec, + target_release_id: i64, + title: String, + release_type: String, + #[serde(default, deserialize_with = "deserialize_optional_stringish")] + year: Option, + hidden: bool, + cover_file_id: Option, + #[serde(default)] + artist_ids: Vec, + tracks: Vec, +} + #[derive(Debug, Deserialize)] pub struct MetadataBackfillRunRequest { #[serde(default = "default_true")] @@ -417,6 +432,14 @@ struct MutationResponse { affected: u64, } +#[derive(Debug, Serialize, JsonSchema)] +struct MergeReleasesResponse { + ok: bool, + merged_releases: u64, + moved_tracks: u64, + item: LibraryItemDto, +} + #[derive(Debug, Serialize, JsonSchema)] struct AdminSettingsDto { values: AdminSettingsValues, @@ -659,6 +682,7 @@ struct LibraryItemDetailDto { release_id: Option, track_number: Option, disc_number: Option, + current_image_file_id: Option, current_image_url: Option, selected_artist_ids: Vec, artists: Vec, @@ -2075,6 +2099,173 @@ pub async fn bulk_library( Json(MutationResponse { ok: true, affected }).into_response() } +pub async fn merge_releases( + session: Session, + db: Database, + pool: &PgPool, + Json(body): Json, +) -> cot::Result { + if let Err(response) = require_admin_json(&session, &db).await { + return Ok(response); + } + + let mut release_ids = body.release_ids; + release_ids.retain(|id| *id > 0); + release_ids.sort_unstable(); + release_ids.dedup(); + if release_ids.len() < 2 { + return Ok(json_error( + StatusCode::BAD_REQUEST, + "select at least two releases to merge", + )); + } + if release_ids.len() > 250 { + return Ok(json_error( + StatusCode::BAD_REQUEST, + "at most 250 releases can be merged at once", + )); + } + if !release_ids.contains(&body.target_release_id) { + return Ok(json_error( + StatusCode::BAD_REQUEST, + "destination release must be part of the selection", + )); + } + + let title = body.title.trim(); + if title.is_empty() { + return Ok(json_error(StatusCode::BAD_REQUEST, "title cannot be empty")); + } + if title.chars().count() > 255 { + return Ok(json_error( + StatusCode::BAD_REQUEST, + "title cannot exceed 255 characters", + )); + } + let release_type = body.release_type.trim().to_lowercase(); + if !matches!( + release_type.as_str(), + "album" + | "single" + | "ep" + | "compilation" + | "mixtape" + | "live" + | "soundtrack" + | "remix" + | "demo" + | "unknown" + ) { + return Ok(json_error(StatusCode::BAD_REQUEST, "invalid release type")); + } + let year = match parse_merge_optional_i32(body.year.as_deref(), 0, 3000, "year") { + Ok(year) => year, + Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, &message)), + }; + + let mut tracks = Vec::with_capacity(body.tracks.len()); + for track in body.tracks { + if track.id <= 0 { + return Ok(json_error(StatusCode::BAD_REQUEST, "invalid track id")); + } + let track_number = match parse_merge_optional_i32( + track.track_number.as_deref(), + 1, + 9999, + "track number", + ) { + Ok(value) => value, + Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, &message)), + }; + let disc_number = + match parse_merge_optional_i32(track.disc_number.as_deref(), 1, 999, "disc number") { + Ok(value) => value, + Err(message) => return Ok(json_error(StatusCode::BAD_REQUEST, &message)), + }; + tracks.push(crate::library_cleanup::ReleaseMergeTrack { + id: track.id, + track_number, + disc_number, + }); + } + + let existing_release_ids: Vec = + sqlx::query_scalar("SELECT id FROM furumusic__release WHERE id = ANY($1) ORDER BY id") + .bind(&release_ids) + .fetch_all(pool) + .await + .map_err(|error| cot::Error::internal(error.to_string()))?; + if existing_release_ids != release_ids { + return Ok(json_error( + StatusCode::BAD_REQUEST, + "one or more selected releases no longer exist", + )); + } + let selected_track_ids: Vec = sqlx::query_scalar( + "SELECT id FROM furumusic__track WHERE release_id = ANY($1) ORDER BY id", + ) + .bind(&release_ids) + .fetch_all(pool) + .await + .map_err(|error| cot::Error::internal(error.to_string()))?; + let mut requested_track_ids = tracks.iter().map(|track| track.id).collect::>(); + requested_track_ids.sort_unstable(); + if requested_track_ids.windows(2).any(|ids| ids[0] == ids[1]) + || requested_track_ids != selected_track_ids + { + return Ok(json_error( + StatusCode::BAD_REQUEST, + "track list does not match the selected releases; reopen the merge wizard", + )); + } + + let storage_dir = AppConfig::load_with_db(&db).await.0.agent_storage_dir; + let result = match crate::library_cleanup::merge_releases( + pool, + crate::library_cleanup::ReleaseMergeSpec { + release_ids, + target_release_id: body.target_release_id, + title: title.to_owned(), + title_sort: normalize_name(title), + release_type, + year, + hidden: body.hidden, + cover_file_id: body.cover_file_id, + artist_ids: body.artist_ids, + tracks, + }, + &storage_dir, + ) + .await + { + Ok(result) => result, + Err(error) => { + tracing::error!(?error, "release merge failed"); + return Ok(json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "release merge failed; no changes were committed", + )); + } + }; + + let Some(item) = fetch_library_item(pool, "releases", body.target_release_id) + .await + .map_err(|error| cot::Error::internal(error.to_string()))? + else { + return Ok(json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "merged release could not be loaded", + )); + }; + Json(MergeReleasesResponse { + ok: true, + merged_releases: result.merged_releases, + moved_tracks: result.moved_tracks, + item, + }) + .into_response() +} + async fn require_admin_json( session: &Session, db: &Database, @@ -3144,6 +3335,7 @@ async fn load_library_item_detail( release_id: None, track_number: None, disc_number: None, + current_image_file_id: None, current_image_url: None, selected_artist_ids: Vec::new(), artists: Vec::new(), @@ -3164,6 +3356,7 @@ async fn load_library_item_detail( .flatten(); detail.current_image_url = image_file_id.map(|id| format!("/api/player/cover/{id}/large")); + detail.current_image_file_id = image_file_id; detail.available_covers = artist_available_covers(pool, detail.item.id).await?; } "releases" => { @@ -3178,6 +3371,7 @@ async fn load_library_item_detail( detail.year = year; detail.current_image_url = cover_file_id.map(|id| format!("/api/player/cover/{id}/large")); + detail.current_image_file_id = cover_file_id; } detail.selected_artist_ids = sqlx::query_as::<_, IdRow>( "SELECT artist_id AS id FROM furumusic__release_artist WHERE release_id = $1 ORDER BY position, artist_id", @@ -4114,6 +4308,24 @@ fn parse_optional_admin_i32(value: Option<&str>, min: i32, max: i32) -> Option, + min: i32, + max: i32, + field: &str, +) -> Result, String> { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + let parsed = value + .parse::() + .map_err(|_| format!("{field} must be an integer from {min} to {max}"))?; + if !(min..=max).contains(&parsed) { + return Err(format!("{field} must be from {min} to {max}")); + } + Ok(Some(parsed)) +} + fn deserialize_optional_stringish<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, @@ -4187,3 +4399,22 @@ fn size_display(bytes: i64) -> String { format!("{bytes} B") } } + +#[cfg(test)] +mod tests { + use super::parse_merge_optional_i32; + + #[test] + fn merge_numbers_preserve_empty_values_and_reject_invalid_input() { + assert_eq!( + parse_merge_optional_i32(None, 1, 9999, "track number").unwrap(), + None + ); + assert_eq!( + parse_merge_optional_i32(Some(" 38 "), 1, 9999, "track number").unwrap(), + Some(38) + ); + assert!(parse_merge_optional_i32(Some("0"), 1, 9999, "track number").is_err()); + assert!(parse_merge_optional_i32(Some("nope"), 1, 9999, "track number").is_err()); + } +} diff --git a/src/library_cleanup.rs b/src/library_cleanup.rs index 9ef7c27..8f77a26 100644 --- a/src/library_cleanup.rs +++ b/src/library_cleanup.rs @@ -68,6 +68,239 @@ pub async fn delete_releases( delete_locked_scope(transaction, track_ids, release_ids, storage_dir, true).await } +#[derive(Debug)] +pub struct ReleaseMergeTrack { + pub id: i64, + pub track_number: Option, + pub disc_number: Option, +} + +#[derive(Debug)] +pub struct ReleaseMergeSpec { + pub release_ids: Vec, + pub target_release_id: i64, + pub title: String, + pub title_sort: String, + pub release_type: String, + pub year: Option, + pub hidden: bool, + pub cover_file_id: Option, + pub artist_ids: Vec, + pub tracks: Vec, +} + +#[derive(Debug)] +pub struct ReleaseMergeResult { + pub merged_releases: u64, + pub moved_tracks: u64, +} + +/// Merge several releases into one while preserving their tracks and media. +/// +/// Source cover files are quarantined before the database transaction commits, +/// just like normal library deletion. The cover selected for the destination +/// and any media still referenced elsewhere are retained. +pub async fn merge_releases( + pool: &PgPool, + mut spec: ReleaseMergeSpec, + storage_dir: &str, +) -> anyhow::Result { + spec.release_ids.retain(|id| *id > 0); + spec.release_ids.sort_unstable(); + spec.release_ids.dedup(); + if spec.release_ids.len() < 2 { + bail!("select at least two releases to merge"); + } + if !spec.release_ids.contains(&spec.target_release_id) { + bail!("destination release must be part of the selection"); + } + + let mut transaction = pool.begin().await?; + let locked_release_ids: Vec = sqlx::query_scalar( + "SELECT id FROM furumusic__release WHERE id = ANY($1) ORDER BY id FOR UPDATE", + ) + .bind(&spec.release_ids) + .fetch_all(&mut *transaction) + .await?; + if locked_release_ids != spec.release_ids { + bail!("one or more selected releases no longer exist; reopen the merge wizard"); + } + let original_target_cover: Option = + sqlx::query_scalar("SELECT cover_file_id FROM furumusic__release WHERE id = $1") + .bind(spec.target_release_id) + .fetch_one(&mut *transaction) + .await?; + + let source_release_ids = spec + .release_ids + .iter() + .copied() + .filter(|id| *id != spec.target_release_id) + .collect::>(); + let locked_track_ids: Vec = sqlx::query_scalar( + "SELECT id FROM furumusic__track WHERE release_id = ANY($1) ORDER BY id FOR UPDATE", + ) + .bind(&spec.release_ids) + .fetch_all(&mut *transaction) + .await?; + let mut requested_track_ids = spec.tracks.iter().map(|track| track.id).collect::>(); + requested_track_ids.sort_unstable(); + if requested_track_ids.windows(2).any(|ids| ids[0] == ids[1]) { + bail!("the merge track list contains duplicates"); + } + if requested_track_ids != locked_track_ids { + bail!("the selected releases changed; reopen the merge wizard before merging"); + } + + if let Some(cover_file_id) = spec.cover_file_id { + let valid_cover: Option = sqlx::query_scalar( + r#"SELECT r.cover_file_id + FROM furumusic__release r + JOIN furumusic__media_file mf ON mf.id = r.cover_file_id + WHERE r.id = ANY($1) + AND r.cover_file_id = $2 + AND mf.file_type = 'cover_art' + LIMIT 1"#, + ) + .bind(&spec.release_ids) + .bind(cover_file_id) + .fetch_optional(&mut *transaction) + .await?; + if valid_cover.is_none() { + bail!("selected cover does not belong to one of the merged releases"); + } + } + + let mut seen_artist_ids = HashSet::new(); + spec.artist_ids + .retain(|id| *id > 0 && seen_artist_ids.insert(*id)); + if !spec.artist_ids.is_empty() { + let existing_artist_ids: Vec = + sqlx::query_scalar("SELECT id FROM furumusic__artist WHERE id = ANY($1) ORDER BY id") + .bind(&spec.artist_ids) + .fetch_all(&mut *transaction) + .await?; + let mut requested_artist_ids = spec.artist_ids.clone(); + requested_artist_ids.sort_unstable(); + if existing_artist_ids != requested_artist_ids { + bail!("one or more selected artists no longer exist"); + } + } + + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + let total_discs = spec + .tracks + .iter() + .filter_map(|track| track.disc_number) + .max(); + sqlx::query( + r#"UPDATE furumusic__release + SET title = $2, title_sort = $3, release_type = $4, year = $5, + cover_file_id = $6, total_tracks = $7, total_discs = $8, + is_hidden = $9, model_name = NULL, updated_at = $10 + WHERE id = $1"#, + ) + .bind(spec.target_release_id) + .bind(&spec.title) + .bind(&spec.title_sort) + .bind(&spec.release_type) + .bind(spec.year) + .bind(spec.cover_file_id) + .bind(i32::try_from(spec.tracks.len()).unwrap_or(i32::MAX)) + .bind(total_discs) + .bind(spec.hidden) + .bind(&now) + .execute(&mut *transaction) + .await?; + + sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = $1") + .bind(spec.target_release_id) + .execute(&mut *transaction) + .await?; + for (position, artist_id) in spec.artist_ids.iter().enumerate() { + sqlx::query( + "INSERT INTO furumusic__release_artist (release_id, artist_id, position) VALUES ($1, $2, $3)", + ) + .bind(spec.target_release_id) + .bind(*artist_id) + .bind(i32::try_from(position).unwrap_or(i32::MAX)) + .execute(&mut *transaction) + .await?; + } + + for track in &spec.tracks { + sqlx::query( + r#"UPDATE furumusic__track + SET release_id = $1, track_number = $2, disc_number = $3, + updated_at = $4 + WHERE id = $5"#, + ) + .bind(spec.target_release_id) + .bind(track.track_number) + .bind(track.disc_number) + .bind(&now) + .bind(track.id) + .execute(&mut *transaction) + .await?; + } + + sqlx::query( + r#"INSERT INTO furumusic__entity_genre_tag + (entity_kind, entity_id, genre_id, source, weight, updated_at) + SELECT 'release', $1, genre_id, source, weight, $3 + FROM furumusic__entity_genre_tag + WHERE entity_kind = 'release' AND entity_id = ANY($2) + ON CONFLICT (entity_kind, entity_id, genre_id, source) DO UPDATE + SET weight = GREATEST(furumusic__entity_genre_tag.weight, EXCLUDED.weight), + updated_at = EXCLUDED.updated_at"#, + ) + .bind(spec.target_release_id) + .bind(&spec.release_ids) + .bind(&now) + .execute(&mut *transaction) + .await?; + + let extra_media_ids = original_target_cover.into_iter().collect::>(); + let media_files = deletable_media_files_with_extra( + &mut transaction, + &[], + &source_release_ids, + &extra_media_ids, + ) + .await?; + let quarantine = match quarantine_media_files(storage_dir, &media_files).await { + Ok(quarantine) => quarantine, + Err(error) => { + transaction.rollback().await?; + return Err(error); + } + }; + let deletion = delete_database_rows( + &mut transaction, + &[], + &source_release_ids, + &media_files, + true, + ) + .await; + if let Err(error) = deletion { + transaction.rollback().await?; + restore_quarantine(&quarantine).await; + return Err(error); + } + if let Err(error) = transaction.commit().await { + restore_quarantine(&quarantine).await; + return Err(error.into()); + } + purge_quarantine(&quarantine).await; + remove_empty_storage_parents(storage_dir, &quarantine.files).await; + + Ok(ReleaseMergeResult { + merged_releases: u64::try_from(spec.release_ids.len()).unwrap_or(u64::MAX), + moved_tracks: u64::try_from(spec.tracks.len()).unwrap_or(u64::MAX), + }) +} + async fn delete_scope( pool: &PgPool, requested_track_ids: &[i64], @@ -141,6 +374,15 @@ async fn deletable_media_files( transaction: &mut Transaction<'_, Postgres>, track_ids: &[i64], release_ids: &[i64], +) -> anyhow::Result> { + deletable_media_files_with_extra(transaction, track_ids, release_ids, &[]).await +} + +async fn deletable_media_files_with_extra( + transaction: &mut Transaction<'_, Postgres>, + track_ids: &[i64], + release_ids: &[i64], + extra_media_ids: &[i64], ) -> anyhow::Result> { Ok(sqlx::query_as( r#"WITH seed_media(id) AS ( @@ -151,6 +393,8 @@ async fn deletable_media_files( UNION SELECT cover_file_id FROM furumusic__release WHERE id = ANY($2) AND cover_file_id IS NOT NULL + UNION + SELECT UNNEST($3::bigint[]) ), candidate_media(id) AS ( SELECT id FROM seed_media UNION @@ -205,6 +449,7 @@ async fn deletable_media_files( ) .bind(track_ids) .bind(release_ids) + .bind(extra_media_ids) .fetch_all(&mut **transaction) .await?) } diff --git a/src/metrics.rs b/src/metrics.rs index 1003118..6c36cc9 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -841,6 +841,7 @@ const KNOWN_HTTP_ROUTES: &[&str] = &[ "/admin/v2/api/library/item/image", "/admin/v2/api/library/item/upload-image", "/admin/v2/api/library/bulk", + "/admin/v2/api/library/releases/merge", "/admin/debug", "/admin/settings", "/admin/settings/probe", diff --git a/templates/admin/v2.html b/templates/admin/v2.html index 271439b..1b331a2 100644 --- a/templates/admin/v2.html +++ b/templates/admin/v2.html @@ -1519,6 +1519,159 @@ tbody tr:hover { font-size: 11px; } +.merge-modal { + width: min(1120px, calc(100vw - 56px)); +} + +.merge-release-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 8px; + margin-bottom: 14px; +} + +.merge-release-card { + display: grid; + grid-template-columns: 48px minmax(0, 1fr); + gap: 10px; + align-items: center; + min-height: 68px; + padding: 9px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-primary); + text-align: left; + cursor: pointer; +} + +.merge-release-card:hover, +.merge-release-card.active { + border-color: rgba(29, 185, 84, 0.78); + background: rgba(29, 185, 84, 0.08); +} + +.merge-release-cover { + width: 48px; + height: 48px; + border-radius: 6px; + background: var(--bg-elevated); + object-fit: cover; +} + +.merge-release-cover.empty { + display: grid; + place-items: center; + padding: 0; + font-size: 10px; +} + +.merge-release-title, +.merge-release-meta { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.merge-release-title { + font-size: 12px; + font-weight: 800; +} + +.merge-release-meta { + margin-top: 4px; + color: var(--text-subdued); + font-size: 10px; +} + +.merge-cover-grid { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.merge-cover-option { + width: 82px; + padding: 6px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-subdued); + cursor: pointer; +} + +.merge-cover-option.active { + border-color: rgba(29, 185, 84, 0.78); + color: var(--text-primary); +} + +.merge-cover-option img, +.merge-cover-empty { + display: grid; + place-items: center; + width: 68px; + height: 68px; + margin-bottom: 5px; + border-radius: 5px; + background: var(--bg-elevated); + object-fit: cover; +} + +.merge-track-list { + min-width: 760px; +} + +.merge-track-head, +.merge-track-row { + display: grid; + grid-template-columns: 52px 70px 74px minmax(180px, 1.5fr) minmax(130px, 1fr) minmax(130px, 1fr) 62px; + gap: 8px; + align-items: center; + padding: 8px; +} + +.merge-track-head { + border-bottom: 1px solid var(--border-color); + color: var(--text-subdued); + font-size: 10px; + font-weight: 850; + text-transform: uppercase; +} + +.merge-track-row { + border-bottom: 1px solid rgba(255, 255, 255, 0.055); +} + +.merge-track-row:last-child { + border-bottom: 0; +} + +.merge-track-row input { + width: 100%; + height: 30px; + min-height: 30px; + padding: 0 7px; +} + +.merge-order-actions { + display: flex; + gap: 3px; +} + +.merge-footer { + position: sticky; + bottom: -14px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin: 14px -14px -14px; + padding: 12px 14px; + border-top: 1px solid var(--border-color); + background: var(--bg-secondary); +} + .image-actions { display: flex; align-items: center; @@ -2166,7 +2319,7 @@ tbody tr:hover { New release - @@ -3069,6 +3222,175 @@ tbody tr:hover { + +