Added merge
All checks were successful
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m7s
Publish Web Player Image / build-and-push-image (push) Successful in 1m11s
Publish Server Image / build-and-push-image (push) Successful in 2m14s

This commit is contained in:
2026-03-19 00:55:49 +00:00
parent 4a272f373d
commit e1782a6e3b
13 changed files with 949 additions and 23 deletions

View File

@@ -88,6 +88,7 @@ pub struct SimilarAlbum {
pub similarity: f32,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct AlbumImage {
pub id: i64,
@@ -416,6 +417,7 @@ pub async fn insert_album_image(
Ok(row.0)
}
#[allow(dead_code)]
pub async fn get_album_images(pool: &PgPool, album_id: i64) -> Result<Vec<AlbumImage>, sqlx::Error> {
sqlx::query_as::<_, AlbumImage>("SELECT * FROM album_images WHERE album_id = $1 ORDER BY image_type")
.bind(album_id)
@@ -563,6 +565,7 @@ pub struct Stats {
pub pending_count: i64,
pub review_count: i64,
pub error_count: i64,
pub merged_count: i64,
}
pub async fn get_stats(pool: &PgPool) -> Result<Stats, sqlx::Error> {
@@ -572,5 +575,200 @@ pub async fn get_stats(pool: &PgPool) -> Result<Stats, sqlx::Error> {
let (pending_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM pending_tracks WHERE status = 'pending'").fetch_one(pool).await?;
let (review_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM pending_tracks WHERE status = 'review'").fetch_one(pool).await?;
let (error_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM pending_tracks WHERE status = 'error'").fetch_one(pool).await?;
Ok(Stats { total_tracks, total_artists, total_albums, pending_count, review_count, error_count })
let (merged_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM pending_tracks WHERE status = 'merged'").fetch_one(pool).await?;
Ok(Stats { total_tracks, total_artists, total_albums, pending_count, review_count, error_count, merged_count })
}
// =================== Artist Merges ===================
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct ArtistMerge {
pub id: Uuid,
pub status: String,
pub source_artist_ids: String,
pub proposal: Option<String>,
pub llm_notes: Option<String>,
pub error_message: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize)]
pub struct ArtistFullData {
pub id: i64,
pub name: String,
pub albums: Vec<AlbumFullData>,
}
#[derive(Debug, Serialize)]
pub struct AlbumFullData {
pub id: i64,
pub name: String,
pub year: Option<i32>,
pub tracks: Vec<TrackBasic>,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct TrackBasic {
pub id: i64,
pub title: String,
pub track_number: Option<i32>,
pub storage_path: String,
}
#[derive(Debug, sqlx::FromRow)]
pub struct TrackWithAlbum {
pub id: i64,
pub storage_path: String,
pub album_name: Option<String>,
}
pub async fn insert_artist_merge(pool: &PgPool, source_artist_ids: &[i64]) -> Result<Uuid, sqlx::Error> {
let ids_json = serde_json::to_string(source_artist_ids).unwrap_or_default();
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO artist_merges (source_artist_ids) VALUES ($1) RETURNING id"
).bind(&ids_json).fetch_one(pool).await?;
Ok(row.0)
}
pub async fn list_artist_merges(pool: &PgPool) -> Result<Vec<ArtistMerge>, sqlx::Error> {
sqlx::query_as::<_, ArtistMerge>("SELECT * FROM artist_merges ORDER BY created_at DESC")
.fetch_all(pool).await
}
pub async fn get_artist_merge(pool: &PgPool, id: Uuid) -> Result<Option<ArtistMerge>, sqlx::Error> {
sqlx::query_as::<_, ArtistMerge>("SELECT * FROM artist_merges WHERE id = $1")
.bind(id).fetch_optional(pool).await
}
pub async fn update_merge_status(pool: &PgPool, id: Uuid, status: &str, error: Option<&str>) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE artist_merges SET status = $2, error_message = $3, updated_at = NOW() WHERE id = $1")
.bind(id).bind(status).bind(error).execute(pool).await?;
Ok(())
}
pub async fn update_merge_proposal(pool: &PgPool, id: Uuid, proposal_json: &str, notes: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE artist_merges SET proposal = $2, llm_notes = $3, status = 'review', error_message = NULL, updated_at = NOW() WHERE id = $1")
.bind(id).bind(proposal_json).bind(notes).execute(pool).await?;
Ok(())
}
pub async fn get_pending_merges_for_processing(pool: &PgPool) -> Result<Vec<Uuid>, sqlx::Error> {
let rows: Vec<(Uuid,)> = sqlx::query_as(
"SELECT id FROM artist_merges WHERE status = 'pending' ORDER BY created_at ASC LIMIT 5"
).fetch_all(pool).await?;
Ok(rows.into_iter().map(|(id,)| id).collect())
}
pub async fn get_artists_full_data(pool: &PgPool, ids: &[i64]) -> Result<Vec<ArtistFullData>, sqlx::Error> {
let mut result = Vec::new();
for &id in ids {
let artist: Artist = sqlx::query_as("SELECT id, name FROM artists WHERE id = $1")
.bind(id).fetch_one(pool).await?;
let albums: Vec<Album> = sqlx::query_as("SELECT * FROM albums WHERE artist_id = $1 ORDER BY year NULLS LAST, name")
.bind(id).fetch_all(pool).await?;
let mut album_data = Vec::new();
for album in albums {
let tracks: Vec<TrackBasic> = sqlx::query_as(
"SELECT id, title, track_number, storage_path FROM tracks WHERE album_id = $1 ORDER BY track_number NULLS LAST, title"
).bind(album.id).fetch_all(pool).await?;
album_data.push(AlbumFullData { id: album.id, name: album.name, year: album.year, tracks });
}
result.push(ArtistFullData { id, name: artist.name, albums: album_data });
}
Ok(result)
}
pub async fn get_tracks_with_albums_for_artist(pool: &PgPool, artist_id: i64) -> Result<Vec<TrackWithAlbum>, sqlx::Error> {
sqlx::query_as::<_, TrackWithAlbum>(
r#"SELECT t.id, t.storage_path, a.name as album_name
FROM tracks t
LEFT JOIN albums a ON a.id = t.album_id
WHERE t.id IN (
SELECT track_id FROM track_artists WHERE artist_id = $1 AND role = 'primary'
)"#
).bind(artist_id).fetch_all(pool).await
}
pub async fn rename_artist(pool: &PgPool, id: i64, new_name: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE artists SET name = $2 WHERE id = $1")
.bind(id).bind(new_name).execute(pool).await?;
Ok(())
}
pub async fn delete_artist(pool: &PgPool, id: i64) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM artists WHERE id = $1")
.bind(id).execute(pool).await?;
Ok(())
}
pub async fn rename_album(pool: &PgPool, id: i64, new_name: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE albums SET name = $2 WHERE id = $1")
.bind(id).bind(new_name).execute(pool).await?;
Ok(())
}
pub async fn set_album_artist(pool: &PgPool, album_id: i64, artist_id: i64) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE albums SET artist_id = $2 WHERE id = $1")
.bind(album_id).bind(artist_id).execute(pool).await?;
Ok(())
}
pub async fn move_albums_to_artist(pool: &PgPool, from_artist_id: i64, to_artist_id: i64) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE albums SET artist_id = $2 WHERE artist_id = $1")
.bind(from_artist_id).bind(to_artist_id).execute(pool).await?;
Ok(())
}
pub async fn move_track_artists(pool: &PgPool, from_artist_id: i64, to_artist_id: i64) -> Result<(), sqlx::Error> {
// Update, but avoid duplicate (track_id, artist_id, role) - delete first any conflicting rows
sqlx::query(
r#"DELETE FROM track_artists
WHERE artist_id = $2
AND (track_id, role) IN (
SELECT track_id, role FROM track_artists WHERE artist_id = $1
)"#
).bind(from_artist_id).bind(to_artist_id).execute(pool).await?;
sqlx::query("UPDATE track_artists SET artist_id = $2 WHERE artist_id = $1")
.bind(from_artist_id).bind(to_artist_id).execute(pool).await?;
Ok(())
}
pub async fn get_duplicate_track_ids_in_albums(pool: &PgPool, source_album_id: i64, target_album_id: i64) -> Result<Vec<i64>, sqlx::Error> {
let rows: Vec<(i64,)> = sqlx::query_as(
r#"SELECT t1.id FROM tracks t1
JOIN tracks t2 ON t1.file_hash = t2.file_hash AND t2.album_id = $2
WHERE t1.album_id = $1"#
).bind(source_album_id).bind(target_album_id).fetch_all(pool).await?;
Ok(rows.into_iter().map(|(id,)| id).collect())
}
pub async fn get_track_storage_path(pool: &PgPool, track_id: i64) -> Result<Option<String>, sqlx::Error> {
let row: Option<(String,)> = sqlx::query_as("SELECT storage_path FROM tracks WHERE id = $1")
.bind(track_id).fetch_optional(pool).await?;
Ok(row.map(|(p,)| p))
}
pub async fn delete_track(pool: &PgPool, track_id: i64) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM track_artists WHERE track_id = $1").bind(track_id).execute(pool).await?;
sqlx::query("DELETE FROM tracks WHERE id = $1").bind(track_id).execute(pool).await?;
Ok(())
}
pub async fn move_tracks_to_album(pool: &PgPool, from_album_id: i64, to_album_id: i64) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE tracks SET album_id = $2 WHERE album_id = $1")
.bind(from_album_id).bind(to_album_id).execute(pool).await?;
Ok(())
}
pub async fn delete_album(pool: &PgPool, id: i64) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM albums WHERE id = $1")
.bind(id).execute(pool).await?;
Ok(())
}
pub async fn update_track_storage_path(pool: &PgPool, track_id: i64, new_path: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE tracks SET storage_path = $2 WHERE id = $1")
.bind(track_id).bind(new_path).execute(pool).await?;
Ok(())
}