This commit is contained in:
@@ -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<v2::MergeReleasesRequest>| {
|
||||
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(
|
||||
"/",
|
||||
|
||||
+231
@@ -69,6 +69,21 @@ pub(super) struct BulkLibraryRequest {
|
||||
filter: Option<LibraryFilter>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct MergeReleasesRequest {
|
||||
release_ids: Vec<i64>,
|
||||
target_release_id: i64,
|
||||
title: String,
|
||||
release_type: String,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
|
||||
year: Option<String>,
|
||||
hidden: bool,
|
||||
cover_file_id: Option<i64>,
|
||||
#[serde(default)]
|
||||
artist_ids: Vec<i64>,
|
||||
tracks: Vec<ReleaseTrackUpdateRequest>,
|
||||
}
|
||||
|
||||
#[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<i64>,
|
||||
track_number: Option<i32>,
|
||||
disc_number: Option<i32>,
|
||||
current_image_file_id: Option<i64>,
|
||||
current_image_url: Option<String>,
|
||||
selected_artist_ids: Vec<i64>,
|
||||
artists: Vec<ArtistOptionDto>,
|
||||
@@ -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<MergeReleasesRequest>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
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<i64> =
|
||||
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<i64> = 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::<Vec<_>>();
|
||||
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<i
|
||||
.map(|parsed| parsed.clamp(min, max))
|
||||
}
|
||||
|
||||
fn parse_merge_optional_i32(
|
||||
value: Option<&str>,
|
||||
min: i32,
|
||||
max: i32,
|
||||
field: &str,
|
||||
) -> Result<Option<i32>, String> {
|
||||
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let parsed = value
|
||||
.parse::<i32>()
|
||||
.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<Option<String>, 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<i32>,
|
||||
pub disc_number: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReleaseMergeSpec {
|
||||
pub release_ids: Vec<i64>,
|
||||
pub target_release_id: i64,
|
||||
pub title: String,
|
||||
pub title_sort: String,
|
||||
pub release_type: String,
|
||||
pub year: Option<i32>,
|
||||
pub hidden: bool,
|
||||
pub cover_file_id: Option<i64>,
|
||||
pub artist_ids: Vec<i64>,
|
||||
pub tracks: Vec<ReleaseMergeTrack>,
|
||||
}
|
||||
|
||||
#[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<ReleaseMergeResult> {
|
||||
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<i64> = 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<i64> =
|
||||
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::<Vec<_>>();
|
||||
let locked_track_ids: Vec<i64> = 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::<Vec<_>>();
|
||||
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<i64> = 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<i64> =
|
||||
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::<Vec<_>>();
|
||||
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<Vec<MediaFileRow>> {
|
||||
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<Vec<MediaFileRow>> {
|
||||
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?)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+575
-1
@@ -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 {
|
||||
<i data-lucide="plus"></i>
|
||||
New release
|
||||
</button>
|
||||
<button class="btn warn" @click="mockAction('Merge wizard will open from this action slot')">
|
||||
<button class="btn warn" x-show="libraryKind === 'releases'" @click="openReleaseMerge()" :disabled="!canOpenReleaseMerge()">
|
||||
<i data-lucide="git-merge"></i>
|
||||
Merge
|
||||
</button>
|
||||
@@ -3069,6 +3222,175 @@ tbody tr:hover {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" x-show="mergeOpen" x-transition @click.self="closeReleaseMerge()">
|
||||
<section class="modal merge-modal">
|
||||
<div class="modal-head">
|
||||
<div class="panel-title">
|
||||
<strong>Merge releases</strong>
|
||||
<span x-text="mergeSubtitle()"></span>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" @click="closeReleaseMerge()" :disabled="mergeSaving">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="empty" x-show="mergeLoading">Loading selected releases...</div>
|
||||
<div x-show="!mergeLoading">
|
||||
<div class="field">
|
||||
<label>Release to keep</label>
|
||||
<div class="merge-release-grid">
|
||||
<template x-for="release in mergeDetails" :key="release.item.id">
|
||||
<button class="merge-release-card" type="button" :class="{active: Number(mergeDraft.target_release_id) === Number(release.item.id)}" @click="chooseMergeTarget(release.item.id)">
|
||||
<template x-if="release.current_image_url">
|
||||
<img class="merge-release-cover" :src="release.current_image_url" alt="" />
|
||||
</template>
|
||||
<span class="merge-release-cover empty" x-show="!release.current_image_url">No cover</span>
|
||||
<span>
|
||||
<span class="merge-release-title" x-text="release.title"></span>
|
||||
<span class="merge-release-meta" x-text="mergeReleaseMeta(release)"></span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<p class="muted">This release keeps its ID. All tracks and genre tags are moved here, then the other release records are removed.</p>
|
||||
</div>
|
||||
|
||||
<div class="editor-grid">
|
||||
<div class="field">
|
||||
<label>Final title</label>
|
||||
<input x-model="mergeDraft.title" maxlength="255" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Release type</label>
|
||||
<select x-model="mergeDraft.release_type">
|
||||
<option value="album">Album</option>
|
||||
<option value="single">Single</option>
|
||||
<option value="ep">EP</option>
|
||||
<option value="compilation">Compilation</option>
|
||||
<option value="mixtape">Mixtape</option>
|
||||
<option value="soundtrack">Soundtrack</option>
|
||||
<option value="live">Live</option>
|
||||
<option value="remix">Remix</option>
|
||||
<option value="demo">Demo</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Year</label>
|
||||
<input type="number" min="0" max="3000" x-model="mergeDraft.year" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Visibility</label>
|
||||
<select x-model="mergeDraft.hidden">
|
||||
<option value="false">Visible in player</option>
|
||||
<option value="true">Hidden from player</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Release artists</label>
|
||||
<div class="artist-tags">
|
||||
<template x-for="artist in selectedMergeArtists()" :key="artist.id">
|
||||
<span class="tag relation">
|
||||
<span x-text="artist.name"></span>
|
||||
<button type="button" @click="removeMergeArtist(artist.id)">x</button>
|
||||
</span>
|
||||
</template>
|
||||
<span class="muted" x-show="selectedMergeArtists().length === 0">No artists attached</span>
|
||||
</div>
|
||||
<div class="artist-picker">
|
||||
<input class="search" placeholder="Search artist" x-model="mergeArtistSearch" @keydown.enter.prevent="addMergeArtist()" @keydown.escape="mergeArtistSearch = ''" />
|
||||
<div class="artist-results" x-show="mergeArtistSearchOpen()" x-transition>
|
||||
<template x-for="artist in filteredMergeArtists()" :key="artist.id">
|
||||
<button class="artist-result" type="button" @click="addMergeArtist(artist)" x-text="artist.name"></button>
|
||||
</template>
|
||||
<div class="artist-result muted" x-show="filteredMergeArtists().length === 0">No matching artists</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Final cover</label>
|
||||
<div class="merge-cover-grid">
|
||||
<button class="merge-cover-option" type="button" :class="{active: !mergeDraft.cover_file_id}" @click="mergeDraft.cover_file_id = null">
|
||||
<span class="merge-cover-empty">No cover</span>
|
||||
<span>None</span>
|
||||
</button>
|
||||
<template x-for="release in mergeCoverOptions()" :key="release.current_image_file_id">
|
||||
<button class="merge-cover-option" type="button" :class="{active: Number(mergeDraft.cover_file_id) === Number(release.current_image_file_id)}" @click="mergeDraft.cover_file_id = Number(release.current_image_file_id)">
|
||||
<img :src="release.current_image_url" alt="" />
|
||||
<span x-text="release.title"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="action-strip" style="padding:0 0 8px; border:0">
|
||||
<label style="margin:0">Tracks</label>
|
||||
<div class="toolbar">
|
||||
<button class="btn" type="button" @click="sortMergeTracks()">
|
||||
<i data-lucide="arrow-down-0-1"></i>
|
||||
Sort by number
|
||||
</button>
|
||||
<button class="btn" type="button" @click="renumberMergeTracks()">
|
||||
<i data-lucide="list-ordered"></i>
|
||||
Renumber 1..N
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow:auto" class="release-track-list" x-show="mergeDraft.tracks.length">
|
||||
<div class="merge-track-list">
|
||||
<div class="merge-track-head">
|
||||
<span>Order</span>
|
||||
<span>Disc</span>
|
||||
<span>Track #</span>
|
||||
<span>Title</span>
|
||||
<span>Artist</span>
|
||||
<span>From release</span>
|
||||
<span>Move</span>
|
||||
</div>
|
||||
<template x-for="(track, index) in mergeDraft.tracks" :key="track.id">
|
||||
<div class="merge-track-row">
|
||||
<span class="release-track-meta" x-text="index + 1"></span>
|
||||
<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>
|
||||
<div class="release-track-title" x-text="track.title"></div>
|
||||
<div class="release-track-meta" x-text="trackDuration(track.duration_seconds)"></div>
|
||||
</div>
|
||||
<div class="release-track-meta" x-text="track.artists || 'Unknown artist'"></div>
|
||||
<div class="release-track-meta" x-text="track.release_title || 'Unknown release'"></div>
|
||||
<div class="merge-order-actions">
|
||||
<button class="icon-btn" type="button" @click="moveMergeTrack(index, -1)" :disabled="index === 0" title="Move up">
|
||||
<i data-lucide="chevron-up"></i>
|
||||
</button>
|
||||
<button class="icon-btn" type="button" @click="moveMergeTrack(index, 1)" :disabled="index === mergeDraft.tracks.length - 1" title="Move down">
|
||||
<i data-lucide="chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="merge-footer">
|
||||
<span class="selection-summary" x-text="mergeConfirmationText()"></span>
|
||||
<div class="toolbar">
|
||||
<button class="btn" type="button" @click="closeReleaseMerge()" :disabled="mergeSaving">Cancel</button>
|
||||
<button class="btn warn" type="button" @click="saveReleaseMerge()" :disabled="!canSaveReleaseMerge()">
|
||||
<i :data-lucide="mergeSaving ? 'loader-circle' : 'git-merge'"></i>
|
||||
<span x-text="mergeSaving ? 'Merging...' : 'Merge releases'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" x-show="editorOpen && activeLibraryItem" x-transition @click.self="editorOpen = false">
|
||||
<section class="modal">
|
||||
<div class="modal-head">
|
||||
@@ -3390,6 +3712,12 @@ function adminV2() {
|
||||
releaseTrackSearchToken: 0,
|
||||
editorDetail: null,
|
||||
editorDraft: { title: '', hidden: 'false', release_type: 'album', year: '', release_id: null, track_number: '', disc_number: '', artist_ids: [], release_tracks: [] },
|
||||
mergeOpen: false,
|
||||
mergeLoading: false,
|
||||
mergeSaving: false,
|
||||
mergeDetails: [],
|
||||
mergeArtistSearch: '',
|
||||
mergeDraft: { release_ids: [], target_release_id: null, title: '', release_type: 'album', year: '', hidden: 'false', cover_file_id: null, artist_ids: [], tracks: [] },
|
||||
settings: { values: {}, sources: {}, lastfm_api_key_configured: false, lastfm_shared_secret_configured: false, lastfm_scrobbling_configured: false },
|
||||
settingsDraft: {
|
||||
auth_password_enabled: false,
|
||||
@@ -4761,6 +5089,252 @@ function adminV2() {
|
||||
this.setLibraryImage(null);
|
||||
},
|
||||
|
||||
canOpenReleaseMerge() {
|
||||
return this.libraryKind === 'releases'
|
||||
&& this.librarySelectionScope === 'ids'
|
||||
&& this.currentLibrarySelectionKeys().length >= 2;
|
||||
},
|
||||
|
||||
async openReleaseMerge() {
|
||||
if (!this.canOpenReleaseMerge()) {
|
||||
this.showToast(this.librarySelectionScope === 'filter'
|
||||
? 'Select individual releases to merge'
|
||||
: 'Select at least two releases');
|
||||
return;
|
||||
}
|
||||
const releaseIds = this.currentLibrarySelectionKeys()
|
||||
.map(key => Number(key.split(':')[1]))
|
||||
.filter(Boolean);
|
||||
this.mergeOpen = true;
|
||||
this.mergeLoading = true;
|
||||
this.mergeSaving = false;
|
||||
this.mergeDetails = [];
|
||||
this.mergeArtistSearch = '';
|
||||
try {
|
||||
const details = await Promise.all(releaseIds.map(async id => {
|
||||
const params = new URLSearchParams({ kind: 'releases', id: String(id) });
|
||||
return this.request(`${this.apiBase}/library/item/detail?${params.toString()}`);
|
||||
}));
|
||||
if (!this.mergeOpen) return;
|
||||
this.mergeDetails = details;
|
||||
const target = details.slice().sort((left, right) => {
|
||||
const leftAlbum = left.release_type === 'album' ? 1 : 0;
|
||||
const rightAlbum = right.release_type === 'album' ? 1 : 0;
|
||||
return (right.release_tracks || []).length - (left.release_tracks || []).length
|
||||
|| rightAlbum - leftAlbum
|
||||
|| Number(left.item.id) - Number(right.item.id);
|
||||
})[0];
|
||||
const artistIds = [];
|
||||
const seenArtists = new Set();
|
||||
const tracks = [];
|
||||
for (const detail of details) {
|
||||
for (const artistId of detail.selected_artist_ids || []) {
|
||||
const id = Number(artistId);
|
||||
if (id && !seenArtists.has(id)) {
|
||||
seenArtists.add(id);
|
||||
artistIds.push(id);
|
||||
}
|
||||
}
|
||||
for (const row of detail.release_tracks || []) {
|
||||
tracks.push(this.normalizeReleaseTrack(row));
|
||||
}
|
||||
}
|
||||
this.mergeDraft = {
|
||||
release_ids: releaseIds,
|
||||
target_release_id: Number(target.item.id),
|
||||
title: target.title || '',
|
||||
release_type: target.release_type || 'album',
|
||||
year: target.year || '',
|
||||
hidden: target.hidden ? 'true' : 'false',
|
||||
cover_file_id: target.current_image_file_id == null ? null : Number(target.current_image_file_id),
|
||||
artist_ids: artistIds,
|
||||
tracks
|
||||
};
|
||||
this.sortMergeTracks();
|
||||
} catch (error) {
|
||||
this.mergeOpen = false;
|
||||
this.showToast(error.message);
|
||||
} finally {
|
||||
this.mergeLoading = false;
|
||||
this.icons();
|
||||
}
|
||||
},
|
||||
|
||||
closeReleaseMerge() {
|
||||
if (this.mergeSaving) return;
|
||||
this.mergeOpen = false;
|
||||
this.mergeDetails = [];
|
||||
this.mergeArtistSearch = '';
|
||||
},
|
||||
|
||||
mergeSubtitle() {
|
||||
if (this.mergeLoading) return 'Preparing merge workspace';
|
||||
return `${this.mergeDetails.length} releases / ${this.mergeDraft.tracks.length} tracks`;
|
||||
},
|
||||
|
||||
mergeReleaseMeta(release) {
|
||||
const parts = [`${(release.release_tracks || []).length} tracks`];
|
||||
if (release.release_type) parts.push(release.release_type);
|
||||
if (release.year) parts.push(release.year);
|
||||
return parts.join(' / ');
|
||||
},
|
||||
|
||||
chooseMergeTarget(id) {
|
||||
const release = this.mergeDetails.find(row => Number(row.item.id) === Number(id));
|
||||
if (!release) return;
|
||||
this.mergeDraft.target_release_id = Number(id);
|
||||
this.mergeDraft.title = release.title || '';
|
||||
this.mergeDraft.release_type = release.release_type || 'album';
|
||||
this.mergeDraft.year = release.year || '';
|
||||
this.mergeDraft.hidden = release.hidden ? 'true' : 'false';
|
||||
this.mergeDraft.cover_file_id = release.current_image_file_id == null
|
||||
? null
|
||||
: Number(release.current_image_file_id);
|
||||
},
|
||||
|
||||
mergeCoverOptions() {
|
||||
const seen = new Set();
|
||||
return this.mergeDetails.filter(release => {
|
||||
const id = Number(release.current_image_file_id || 0);
|
||||
if (!id || !release.current_image_url || seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
mergeArtists() {
|
||||
return this.mergeDetails.length && Array.isArray(this.mergeDetails[0].artists)
|
||||
? this.mergeDetails[0].artists
|
||||
: [];
|
||||
},
|
||||
|
||||
selectedMergeArtists() {
|
||||
const selected = this.mergeDraft.artist_ids || [];
|
||||
const artists = this.mergeArtists();
|
||||
return selected.map(id => artists.find(row => Number(row.id) === Number(id)) || { id, name: `Artist #${id}` });
|
||||
},
|
||||
|
||||
filteredMergeArtists() {
|
||||
const selected = new Set((this.mergeDraft.artist_ids || []).map(Number));
|
||||
const query = String(this.mergeArtistSearch || '').trim().toLowerCase();
|
||||
return this.mergeArtists()
|
||||
.filter(artist => !selected.has(Number(artist.id)))
|
||||
.filter(artist => !query || String(artist.name || '').toLowerCase().includes(query))
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.slice(0, 12);
|
||||
},
|
||||
|
||||
mergeArtistSearchOpen() {
|
||||
return String(this.mergeArtistSearch || '').trim().length > 0;
|
||||
},
|
||||
|
||||
addMergeArtist(artist = null) {
|
||||
artist = artist || this.filteredMergeArtists()[0];
|
||||
if (!artist) return;
|
||||
const id = Number(artist.id);
|
||||
if (!(this.mergeDraft.artist_ids || []).map(Number).includes(id)) {
|
||||
this.mergeDraft.artist_ids = (this.mergeDraft.artist_ids || []).concat([id]);
|
||||
}
|
||||
this.mergeArtistSearch = '';
|
||||
},
|
||||
|
||||
removeMergeArtist(id) {
|
||||
this.mergeDraft.artist_ids = (this.mergeDraft.artist_ids || []).filter(value => Number(value) !== Number(id));
|
||||
},
|
||||
|
||||
sortMergeTracks() {
|
||||
this.mergeDraft.tracks = (this.mergeDraft.tracks || []).slice().sort((left, right) => {
|
||||
const leftDisc = Number(left.disc_number || 1);
|
||||
const rightDisc = Number(right.disc_number || 1);
|
||||
const leftTrack = left.track_number === '' ? Number.MAX_SAFE_INTEGER : Number(left.track_number || Number.MAX_SAFE_INTEGER);
|
||||
const rightTrack = right.track_number === '' ? Number.MAX_SAFE_INTEGER : Number(right.track_number || Number.MAX_SAFE_INTEGER);
|
||||
return leftDisc - rightDisc
|
||||
|| leftTrack - rightTrack
|
||||
|| String(left.release_title || '').localeCompare(String(right.release_title || ''))
|
||||
|| Number(left.id) - Number(right.id);
|
||||
});
|
||||
this.$nextTick(() => this.icons());
|
||||
},
|
||||
|
||||
renumberMergeTracks() {
|
||||
this.mergeDraft.tracks = (this.mergeDraft.tracks || []).map((track, index) => ({
|
||||
...track,
|
||||
disc_number: '1',
|
||||
track_number: String(index + 1)
|
||||
}));
|
||||
},
|
||||
|
||||
moveMergeTrack(index, direction) {
|
||||
const target = index + direction;
|
||||
const tracks = (this.mergeDraft.tracks || []).slice();
|
||||
if (target < 0 || target >= tracks.length) return;
|
||||
const moving = { ...tracks[index] };
|
||||
const displaced = { ...tracks[target] };
|
||||
const movingPosition = {
|
||||
disc_number: moving.disc_number,
|
||||
track_number: moving.track_number
|
||||
};
|
||||
moving.disc_number = displaced.disc_number;
|
||||
moving.track_number = displaced.track_number;
|
||||
displaced.disc_number = movingPosition.disc_number;
|
||||
displaced.track_number = movingPosition.track_number;
|
||||
tracks[index] = displaced;
|
||||
tracks[target] = moving;
|
||||
this.mergeDraft.tracks = tracks;
|
||||
this.$nextTick(() => this.icons());
|
||||
},
|
||||
|
||||
mergeConfirmationText() {
|
||||
const removed = Math.max(0, this.mergeDetails.length - 1);
|
||||
return `${this.mergeDraft.tracks.length} tracks will remain; ${removed} source release${removed === 1 ? '' : 's'} will be removed`;
|
||||
},
|
||||
|
||||
canSaveReleaseMerge() {
|
||||
return this.mergeOpen
|
||||
&& !this.mergeLoading
|
||||
&& !this.mergeSaving
|
||||
&& this.mergeDetails.length >= 2
|
||||
&& Boolean(this.mergeDraft.target_release_id)
|
||||
&& Boolean(String(this.mergeDraft.title || '').trim());
|
||||
},
|
||||
|
||||
async saveReleaseMerge() {
|
||||
if (!this.canSaveReleaseMerge()) return;
|
||||
this.mergeSaving = true;
|
||||
try {
|
||||
const result = await this.request(`${this.apiBase}/library/releases/merge`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
release_ids: this.mergeDraft.release_ids.map(Number),
|
||||
target_release_id: Number(this.mergeDraft.target_release_id),
|
||||
title: String(this.mergeDraft.title || '').trim(),
|
||||
release_type: this.mergeDraft.release_type || 'album',
|
||||
year: this.mergeDraft.year || '',
|
||||
hidden: this.mergeDraft.hidden === 'true',
|
||||
cover_file_id: this.mergeDraft.cover_file_id == null ? null : Number(this.mergeDraft.cover_file_id),
|
||||
artist_ids: (this.mergeDraft.artist_ids || []).map(Number),
|
||||
tracks: (this.mergeDraft.tracks || []).map(track => ({
|
||||
id: Number(track.id),
|
||||
track_number: track.track_number || '',
|
||||
disc_number: track.disc_number || ''
|
||||
}))
|
||||
})
|
||||
});
|
||||
this.mergeSaving = false;
|
||||
this.closeReleaseMerge();
|
||||
this.clearLibrarySelection();
|
||||
await this.loadLibrary(false);
|
||||
await this.refreshCountsOnly();
|
||||
this.showToast(`${result.moved_tracks} tracks merged into “${result.item.title}”`);
|
||||
this.openEditor(result.item);
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
} finally {
|
||||
this.mergeSaving = false;
|
||||
this.icons();
|
||||
}
|
||||
},
|
||||
|
||||
toggleLibrary(item) {
|
||||
this.librarySelectionScope = 'ids';
|
||||
const key = `${item.kind}:${item.id}`;
|
||||
|
||||
Reference in New Issue
Block a user