2026-03-18 02:21:00 +00:00
use serde ::{ Deserialize , Serialize } ;
use sqlx ::PgPool ;
use sqlx ::postgres ::PgPoolOptions ;
use uuid ::Uuid ;
/// Generate a short URL-safe slug from a UUID v4.
fn generate_slug ( ) -> String {
Uuid ::new_v4 ( ) . simple ( ) . to_string ( ) [ .. 12 ] . to_owned ( )
}
pub async fn connect ( database_url : & str ) -> Result < PgPool , sqlx ::Error > {
PgPoolOptions ::new ( )
. max_connections ( 5 )
. connect ( database_url )
. await
}
pub async fn migrate ( pool : & PgPool ) -> Result < ( ) , sqlx ::migrate ::MigrateError > {
sqlx ::migrate! ( " ./migrations " ) . run ( pool ) . await
}
// --- Models ---
#[ derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow) ]
pub struct Artist {
pub id : i64 ,
pub name : String ,
2026-03-19 13:24:48 +00:00
pub hidden : bool ,
2026-03-18 02:21:00 +00:00
}
#[ derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow) ]
pub struct Album {
pub id : i64 ,
pub artist_id : i64 ,
pub name : String ,
pub year : Option < i32 > ,
2026-03-19 13:24:48 +00:00
pub release_type : String ,
pub hidden : bool ,
2026-03-18 02:21:00 +00:00
}
#[ derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow) ]
pub struct PendingTrack {
pub id : Uuid ,
pub status : String ,
pub inbox_path : String ,
pub file_hash : String ,
pub file_size : i64 ,
// Raw metadata from file tags
pub raw_title : Option < String > ,
pub raw_artist : Option < String > ,
pub raw_album : Option < String > ,
pub raw_year : Option < i32 > ,
pub raw_track_number : Option < i32 > ,
pub raw_genre : Option < String > ,
pub duration_secs : Option < f64 > ,
// Path-derived hints
pub path_artist : Option < String > ,
pub path_album : Option < String > ,
pub path_year : Option < i32 > ,
pub path_track_number : Option < i32 > ,
pub path_title : Option < String > ,
// Normalized (LLM output)
pub norm_title : Option < String > ,
pub norm_artist : Option < String > ,
pub norm_album : Option < String > ,
pub norm_year : Option < i32 > ,
pub norm_track_number : Option < i32 > ,
pub norm_genre : Option < String > ,
pub norm_featured_artists : Option < String > , // JSON array
2026-03-19 13:24:48 +00:00
pub norm_release_type : Option < String > ,
2026-03-18 02:21:00 +00:00
pub confidence : Option < f64 > ,
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, Clone, Serialize, Deserialize, sqlx::FromRow) ]
pub struct SimilarArtist {
pub id : i64 ,
pub name : String ,
pub similarity : f32 ,
}
#[ derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow) ]
pub struct SimilarAlbum {
pub id : i64 ,
pub artist_id : i64 ,
pub name : String ,
pub year : Option < i32 > ,
pub similarity : f32 ,
}
2026-03-19 00:55:49 +00:00
#[ allow(dead_code) ]
2026-03-18 02:21:00 +00:00
#[ derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow) ]
pub struct AlbumImage {
pub id : i64 ,
pub album_id : i64 ,
pub image_type : String ,
pub file_path : String ,
pub file_hash : String ,
pub mime_type : String ,
pub width : Option < i32 > ,
pub height : Option < i32 > ,
pub file_size : i64 ,
}
// --- Queries ---
pub async fn file_hash_exists ( pool : & PgPool , hash : & str ) -> Result < bool , sqlx ::Error > {
let row : ( bool , ) = sqlx ::query_as (
" SELECT EXISTS(SELECT 1 FROM tracks WHERE file_hash = $1) OR EXISTS(SELECT 1 FROM pending_tracks WHERE file_hash = $1 AND status NOT IN ('rejected', 'error')) "
)
. bind ( hash )
. fetch_one ( pool )
. await ? ;
Ok ( row . 0 )
}
pub async fn insert_pending (
pool : & PgPool ,
inbox_path : & str ,
file_hash : & str ,
file_size : i64 ,
raw : & RawFields ,
path_hints : & PathHints ,
duration_secs : Option < f64 > ,
) -> Result < Uuid , sqlx ::Error > {
let row : ( Uuid , ) = sqlx ::query_as (
r #" INSERT INTO pending_tracks
( inbox_path , file_hash , file_size ,
raw_title , raw_artist , raw_album , raw_year , raw_track_number , raw_genre ,
path_title , path_artist , path_album , path_year , path_track_number ,
duration_secs , status )
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , $ 5 , $ 6 , $ 7 , $ 8 , $ 9 , $ 10 , $ 11 , $ 12 , $ 13 , $ 14 , $ 15 , ' pending ' )
RETURNING id " #,
)
. bind ( inbox_path )
. bind ( file_hash )
. bind ( file_size )
. bind ( & raw . title )
. bind ( & raw . artist )
. bind ( & raw . album )
. bind ( raw . year )
. bind ( raw . track_number )
. bind ( & raw . genre )
. bind ( & path_hints . title )
. bind ( & path_hints . artist )
. bind ( & path_hints . album )
. bind ( path_hints . year )
. bind ( path_hints . track_number )
. bind ( duration_secs )
. fetch_one ( pool )
. await ? ;
Ok ( row . 0 )
}
pub async fn update_pending_normalized (
pool : & PgPool ,
id : Uuid ,
status : & str ,
norm : & NormalizedFields ,
error_message : Option < & str > ,
) -> Result < ( ) , sqlx ::Error > {
let featured_json = if norm . featured_artists . is_empty ( ) {
None
} else {
Some ( serde_json ::to_string ( & norm . featured_artists ) . unwrap_or_default ( ) )
} ;
sqlx ::query (
r #" UPDATE pending_tracks SET
status = $ 2 ,
norm_title = $ 3 , norm_artist = $ 4 , norm_album = $ 5 ,
norm_year = $ 6 , norm_track_number = $ 7 , norm_genre = $ 8 ,
norm_featured_artists = $ 9 ,
confidence = $ 10 , llm_notes = $ 11 , error_message = $ 12 ,
2026-03-19 13:24:48 +00:00
norm_release_type = $ 13 ,
2026-03-18 02:21:00 +00:00
updated_at = NOW ( )
WHERE id = $ 1 " #,
)
. bind ( id )
. bind ( status )
. bind ( & norm . title )
. bind ( & norm . artist )
. bind ( & norm . album )
. bind ( norm . year )
. bind ( norm . track_number )
. bind ( & norm . genre )
. bind ( & featured_json )
. bind ( norm . confidence )
. bind ( & norm . notes )
. bind ( error_message )
2026-03-19 13:24:48 +00:00
. bind ( & norm . release_type )
2026-03-18 02:21:00 +00:00
. execute ( pool )
. await ? ;
Ok ( ( ) )
}
pub async fn update_pending_status (
pool : & PgPool ,
id : Uuid ,
status : & str ,
error_message : Option < & str > ,
) -> Result < ( ) , sqlx ::Error > {
sqlx ::query ( " UPDATE pending_tracks SET status = $2, error_message = $3, updated_at = NOW() WHERE id = $1 " )
. bind ( id )
. bind ( status )
. bind ( error_message )
. execute ( pool )
. await ? ;
Ok ( ( ) )
}
pub async fn find_similar_artists ( pool : & PgPool , name : & str , limit : i32 ) -> Result < Vec < SimilarArtist > , sqlx ::Error > {
// pg_trgm needs at least 3 chars to produce trigrams; for shorter queries use ILIKE prefix
if name . chars ( ) . count ( ) < 3 {
sqlx ::query_as ::< _ , SimilarArtist > (
" SELECT id, name, 1.0::real AS similarity FROM artists WHERE name ILIKE $1 || '%' ORDER BY name LIMIT $2 "
)
. bind ( name )
. bind ( limit )
. fetch_all ( pool )
. await
} else {
sqlx ::query_as ::< _ , SimilarArtist > (
r #" SELECT id, name, MAX(sim) AS similarity FROM (
SELECT id , name , similarity ( name , $ 1 ) AS sim FROM artists WHERE name % $ 1
UNION ALL
SELECT id , name , 0.01 ::real AS sim FROM artists WHERE name ILIKE '%' | | $ 1 | | '%'
) sub GROUP BY id , name ORDER BY similarity DESC LIMIT $ 2 " #
)
. bind ( name )
. bind ( limit )
. fetch_all ( pool )
. await
}
}
pub async fn find_similar_albums ( pool : & PgPool , name : & str , limit : i32 ) -> Result < Vec < SimilarAlbum > , sqlx ::Error > {
sqlx ::query_as ::< _ , SimilarAlbum > (
" SELECT id, artist_id, name, year, similarity(name, $1) AS similarity FROM albums WHERE name % $1 ORDER BY similarity DESC LIMIT $2 "
)
. bind ( name )
. bind ( limit )
. fetch_all ( pool )
. await
}
pub async fn upsert_artist ( pool : & PgPool , name : & str ) -> Result < i64 , sqlx ::Error > {
let slug = generate_slug ( ) ;
let row : ( i64 , ) = sqlx ::query_as (
" INSERT INTO artists (name, slug) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id "
)
. bind ( name )
. bind ( & slug )
. fetch_one ( pool )
. await ? ;
Ok ( row . 0 )
}
2026-03-19 13:24:48 +00:00
pub async fn upsert_album ( pool : & PgPool , artist_id : i64 , name : & str , year : Option < i32 > , release_type : & str ) -> Result < i64 , sqlx ::Error > {
2026-03-18 02:21:00 +00:00
let slug = generate_slug ( ) ;
let row : ( i64 , ) = sqlx ::query_as (
2026-03-19 13:24:48 +00:00
r #" INSERT INTO albums (artist_id, name, year, slug, release_type)
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , $ 5 )
ON CONFLICT ( artist_id , name ) DO UPDATE SET year = COALESCE ( EXCLUDED . year , albums . year ) , release_type = EXCLUDED . release_type
2026-03-18 02:21:00 +00:00
RETURNING id " #
)
. bind ( artist_id )
. bind ( name )
. bind ( year )
. bind ( & slug )
2026-03-19 13:24:48 +00:00
. bind ( release_type )
2026-03-18 02:21:00 +00:00
. fetch_one ( pool )
. await ? ;
Ok ( row . 0 )
}
pub async fn insert_track (
pool : & PgPool ,
artist_id : i64 ,
album_id : Option < i64 > ,
title : & str ,
track_number : Option < i32 > ,
genre : Option < & str > ,
duration_secs : Option < f64 > ,
file_hash : & str ,
file_size : i64 ,
storage_path : & str ,
) -> Result < i64 , sqlx ::Error > {
let slug = generate_slug ( ) ;
let row : ( i64 , ) = sqlx ::query_as (
r #" INSERT INTO tracks
( artist_id , album_id , title , track_number , genre , duration_secs , file_hash , file_size , storage_path , slug )
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , $ 5 , $ 6 , $ 7 , $ 8 , $ 9 , $ 10 )
RETURNING id " #
)
. bind ( artist_id )
. bind ( album_id )
. bind ( title )
. bind ( track_number )
. bind ( genre )
. bind ( duration_secs )
. bind ( file_hash )
. bind ( file_size )
. bind ( storage_path )
. bind ( & slug )
. fetch_one ( pool )
. await ? ;
Ok ( row . 0 )
}
pub async fn link_track_artist ( pool : & PgPool , track_id : i64 , artist_id : i64 , role : & str ) -> Result < ( ) , sqlx ::Error > {
sqlx ::query (
" INSERT INTO track_artists (track_id, artist_id, role) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING "
)
. bind ( track_id )
. bind ( artist_id )
. bind ( role )
. execute ( pool )
. await ? ;
Ok ( ( ) )
}
pub async fn approve_and_finalize (
pool : & PgPool ,
pending_id : Uuid ,
storage_path : & str ,
) -> Result < i64 , sqlx ::Error > {
let pt : PendingTrack = sqlx ::query_as ( " SELECT * FROM pending_tracks WHERE id = $1 " )
. bind ( pending_id )
. fetch_one ( pool )
. await ? ;
2026-03-19 23:37:33 +00:00
// Check if track already exists by file_hash (re-approval of same file)
2026-03-18 04:05:47 +00:00
let existing : Option < ( i64 , ) > = sqlx ::query_as ( " SELECT id FROM tracks WHERE file_hash = $1 " )
. bind ( & pt . file_hash )
. fetch_optional ( pool )
. await ? ;
if let Some ( ( track_id , ) ) = existing {
update_pending_status ( pool , pending_id , " approved " , None ) . await ? ;
return Ok ( track_id ) ;
}
2026-03-19 23:37:33 +00:00
// Check if track already exists by storage_path (Merged: different quality file landed
// at the same destination, source was deleted — don't create a phantom duplicate)
let existing_path : Option < ( i64 , ) > = sqlx ::query_as (
" SELECT id FROM tracks WHERE storage_path = $1 AND NOT hidden "
)
. bind ( storage_path )
. fetch_optional ( pool )
. await ? ;
if let Some ( ( track_id , ) ) = existing_path {
update_pending_status ( pool , pending_id , " merged " , None ) . await ? ;
return Ok ( track_id ) ;
}
2026-03-18 02:21:00 +00:00
let artist_name = pt . norm_artist . as_deref ( ) . unwrap_or ( " Unknown Artist " ) ;
let artist_id = upsert_artist ( pool , artist_name ) . await ? ;
let album_id = match pt . norm_album . as_deref ( ) {
2026-03-19 13:24:48 +00:00
Some ( album_name ) = > Some ( upsert_album ( pool , artist_id , album_name , pt . norm_year , pt . norm_release_type . as_deref ( ) . unwrap_or ( " album " ) ) . await ? ) ,
2026-03-18 02:21:00 +00:00
None = > None ,
} ;
let title = pt . norm_title . as_deref ( ) . unwrap_or ( " Unknown Title " ) ;
let track_id = insert_track (
pool ,
artist_id ,
album_id ,
title ,
pt . norm_track_number ,
pt . norm_genre . as_deref ( ) ,
pt . duration_secs ,
& pt . file_hash ,
pt . file_size ,
storage_path ,
)
. await ? ;
// Link primary artist
link_track_artist ( pool , track_id , artist_id , " primary " ) . await ? ;
// Link featured artists
if let Some ( featured_json ) = & pt . norm_featured_artists {
if let Ok ( featured ) = serde_json ::from_str ::< Vec < String > > ( featured_json ) {
for feat_name in & featured {
let feat_id = upsert_artist ( pool , feat_name ) . await ? ;
link_track_artist ( pool , track_id , feat_id , " featured " ) . await ? ;
}
}
}
update_pending_status ( pool , pending_id , " approved " , None ) . await ? ;
Ok ( track_id )
}
// --- Album images ---
pub async fn image_hash_exists ( pool : & PgPool , hash : & str ) -> Result < bool , sqlx ::Error > {
let row : ( bool , ) = sqlx ::query_as ( " SELECT EXISTS(SELECT 1 FROM album_images WHERE file_hash = $1) " )
. bind ( hash )
. fetch_one ( pool )
. await ? ;
Ok ( row . 0 )
}
pub async fn insert_album_image (
pool : & PgPool ,
album_id : i64 ,
image_type : & str ,
file_path : & str ,
file_hash : & str ,
mime_type : & str ,
file_size : i64 ,
) -> Result < i64 , sqlx ::Error > {
let row : ( i64 , ) = sqlx ::query_as (
r #" INSERT INTO album_images (album_id, image_type, file_path, file_hash, mime_type, file_size)
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , $ 5 , $ 6 )
ON CONFLICT ( file_hash ) DO NOTHING
RETURNING id " #
)
. bind ( album_id )
. bind ( image_type )
. bind ( file_path )
. bind ( file_hash )
. bind ( mime_type )
. bind ( file_size )
. fetch_one ( pool )
. await ? ;
Ok ( row . 0 )
}
2026-03-19 00:55:49 +00:00
#[ allow(dead_code) ]
2026-03-18 02:21:00 +00:00
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 )
. fetch_all ( pool )
. await
}
/// Find album_id by artist+album name (used when linking covers to already-finalized albums)
pub async fn find_album_id ( pool : & PgPool , artist_name : & str , album_name : & str ) -> Result < Option < i64 > , sqlx ::Error > {
let row : Option < ( i64 , ) > = sqlx ::query_as (
r #" SELECT a.id FROM albums a
JOIN artists ar ON a . artist_id = ar . id
WHERE ar . name = $ 1 AND a . name = $ 2 " #
)
. bind ( artist_name )
. bind ( album_name )
. fetch_optional ( pool )
. await ? ;
Ok ( row . map ( | r | r . 0 ) )
}
2026-03-18 04:05:47 +00:00
/// Fetch pending tracks that need (re-)processing by the LLM pipeline.
pub async fn list_pending_for_processing ( pool : & PgPool , limit : i64 ) -> Result < Vec < PendingTrack > , sqlx ::Error > {
sqlx ::query_as ::< _ , PendingTrack > (
" SELECT * FROM pending_tracks WHERE status = 'pending' ORDER BY created_at ASC LIMIT $1 "
)
. bind ( limit )
. fetch_all ( pool )
. await
}
2026-03-18 02:21:00 +00:00
// --- DTOs for insert helpers ---
#[ derive(Debug, Default) ]
pub struct RawFields {
pub title : Option < String > ,
pub artist : Option < String > ,
pub album : Option < String > ,
pub year : Option < i32 > ,
pub track_number : Option < i32 > ,
pub genre : Option < String > ,
}
#[ derive(Debug, Default) ]
pub struct PathHints {
pub title : Option < String > ,
pub artist : Option < String > ,
pub album : Option < String > ,
pub year : Option < i32 > ,
pub track_number : Option < i32 > ,
}
#[ derive(Debug, Default, Serialize, Deserialize) ]
pub struct NormalizedFields {
pub title : Option < String > ,
pub artist : Option < String > ,
pub album : Option < String > ,
pub year : Option < i32 > ,
pub track_number : Option < i32 > ,
pub genre : Option < String > ,
#[ serde(default) ]
pub featured_artists : Vec < String > ,
2026-03-19 13:24:48 +00:00
pub release_type : Option < String > ,
2026-03-18 02:21:00 +00:00
pub confidence : Option < f64 > ,
pub notes : Option < String > ,
}
// --- Admin queries ---
pub async fn list_pending ( pool : & PgPool , status_filter : Option < & str > , limit : i64 , offset : i64 ) -> Result < Vec < PendingTrack > , sqlx ::Error > {
match status_filter {
Some ( status ) = > {
sqlx ::query_as ::< _ , PendingTrack > (
" SELECT * FROM pending_tracks WHERE status = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 "
)
. bind ( status )
. bind ( limit )
. bind ( offset )
. fetch_all ( pool )
. await
}
None = > {
sqlx ::query_as ::< _ , PendingTrack > (
" SELECT * FROM pending_tracks ORDER BY created_at DESC LIMIT $1 OFFSET $2 "
)
. bind ( limit )
. bind ( offset )
. fetch_all ( pool )
. await
}
}
}
pub async fn get_pending ( pool : & PgPool , id : Uuid ) -> Result < Option < PendingTrack > , sqlx ::Error > {
sqlx ::query_as ::< _ , PendingTrack > ( " SELECT * FROM pending_tracks WHERE id = $1 " )
. bind ( id )
. fetch_optional ( pool )
. await
}
pub async fn delete_pending ( pool : & PgPool , id : Uuid ) -> Result < bool , sqlx ::Error > {
let result = sqlx ::query ( " DELETE FROM pending_tracks WHERE id = $1 " )
. bind ( id )
. execute ( pool )
. await ? ;
Ok ( result . rows_affected ( ) > 0 )
}
pub async fn list_artists_all ( pool : & PgPool ) -> Result < Vec < Artist > , sqlx ::Error > {
2026-03-19 13:24:48 +00:00
sqlx ::query_as ::< _ , Artist > ( " SELECT id, name, hidden FROM artists ORDER BY name " )
2026-03-18 02:21:00 +00:00
. fetch_all ( pool )
. await
}
pub async fn list_albums_by_artist ( pool : & PgPool , artist_id : i64 ) -> Result < Vec < Album > , sqlx ::Error > {
2026-03-19 13:24:48 +00:00
sqlx ::query_as ::< _ , Album > ( " SELECT id, artist_id, name, year, release_type, hidden FROM albums WHERE artist_id = $1 ORDER BY year, name " )
2026-03-18 02:21:00 +00:00
. bind ( artist_id )
. fetch_all ( pool )
. await
}
pub async fn update_artist_name ( pool : & PgPool , id : i64 , name : & str ) -> Result < bool , sqlx ::Error > {
let result = sqlx ::query ( " UPDATE artists SET name = $2 WHERE id = $1 " )
. bind ( id )
. bind ( name )
. execute ( pool )
. await ? ;
Ok ( result . rows_affected ( ) > 0 )
}
pub async fn update_album ( pool : & PgPool , id : i64 , name : & str , year : Option < i32 > ) -> Result < bool , sqlx ::Error > {
let result = sqlx ::query ( " UPDATE albums SET name = $2, year = $3 WHERE id = $1 " )
. bind ( id )
. bind ( name )
. bind ( year )
. execute ( pool )
. await ? ;
Ok ( result . rows_affected ( ) > 0 )
}
#[ derive(Debug, Serialize) ]
pub struct Stats {
pub total_tracks : i64 ,
pub total_artists : i64 ,
pub total_albums : i64 ,
pub pending_count : i64 ,
pub review_count : i64 ,
pub error_count : i64 ,
2026-03-19 00:55:49 +00:00
pub merged_count : i64 ,
2026-03-19 02:09:04 +00:00
pub active_merges : i64 ,
2026-03-18 02:21:00 +00:00
}
pub async fn get_stats ( pool : & PgPool ) -> Result < Stats , sqlx ::Error > {
let ( total_tracks , ) : ( i64 , ) = sqlx ::query_as ( " SELECT COUNT(*) FROM tracks " ) . fetch_one ( pool ) . await ? ;
let ( total_artists , ) : ( i64 , ) = sqlx ::query_as ( " SELECT COUNT(*) FROM artists " ) . fetch_one ( pool ) . await ? ;
let ( total_albums , ) : ( i64 , ) = sqlx ::query_as ( " SELECT COUNT(*) FROM albums " ) . fetch_one ( pool ) . await ? ;
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 ? ;
2026-03-19 00:55:49 +00:00
let ( merged_count , ) : ( i64 , ) = sqlx ::query_as ( " SELECT COUNT(*) FROM pending_tracks WHERE status = 'merged' " ) . fetch_one ( pool ) . await ? ;
2026-03-19 02:09:04 +00:00
let ( active_merges , ) : ( i64 , ) = sqlx ::query_as ( " SELECT COUNT(*) FROM artist_merges WHERE status IN ('pending','processing') " ) . fetch_one ( pool ) . await ? ;
Ok ( Stats { total_tracks , total_artists , total_albums , pending_count , review_count , error_count , merged_count , active_merges } )
}
// =================== Library search ===================
#[ derive(Debug, Serialize, sqlx::FromRow) ]
pub struct TrackRow {
pub id : i64 ,
pub title : String ,
pub artist_name : String ,
2026-03-19 02:36:27 +00:00
pub album_id : Option < i64 > ,
2026-03-19 02:09:04 +00:00
pub album_name : Option < String > ,
pub year : Option < i32 > ,
pub track_number : Option < i32 > ,
pub duration_secs : Option < f64 > ,
pub genre : Option < String > ,
}
#[ derive(Debug, Serialize, sqlx::FromRow) ]
pub struct AlbumRow {
pub id : i64 ,
pub name : String ,
pub artist_name : String ,
pub year : Option < i32 > ,
pub track_count : i64 ,
2026-03-19 13:24:48 +00:00
pub release_type : String ,
pub hidden : bool ,
2026-03-19 02:09:04 +00:00
}
#[ derive(Debug, Serialize, sqlx::FromRow) ]
pub struct ArtistRow {
pub id : i64 ,
pub name : String ,
pub album_count : i64 ,
2026-03-19 13:24:48 +00:00
pub single_count : i64 ,
pub ep_count : i64 ,
pub compilation_count : i64 ,
pub live_count : i64 ,
2026-03-19 02:09:04 +00:00
pub track_count : i64 ,
2026-03-19 13:24:48 +00:00
pub hidden : bool ,
2026-03-19 02:09:04 +00:00
}
pub async fn search_tracks (
pool : & PgPool ,
q : & str , artist : & str , album : & str ,
limit : i64 , offset : i64 ,
) -> Result < Vec < TrackRow > , sqlx ::Error > {
sqlx ::query_as ::< _ , TrackRow > (
2026-03-19 02:36:27 +00:00
r #" SELECT t.id, t.title, ar.name AS artist_name, t.album_id, al.name AS album_name,
2026-03-19 02:09:04 +00:00
al . year , t . track_number , t . duration_secs , t . genre
FROM tracks t
JOIN track_artists ta ON ta . track_id = t . id AND ta . role = ' primary '
JOIN artists ar ON ar . id = ta . artist_id
LEFT JOIN albums al ON al . id = t . album_id
WHERE ( $ 1 = ' ' OR t . title ILIKE '%' | | $ 1 | | '%' )
AND ( $ 2 = ' ' OR ar . name ILIKE '%' | | $ 2 | | '%' )
AND ( $ 3 = ' ' OR al . name ILIKE '%' | | $ 3 | | '%' )
ORDER BY ar . name , al . name NULLS LAST , t . track_number NULLS LAST , t . title
LIMIT $ 4 OFFSET $ 5 " #,
)
. bind ( q ) . bind ( artist ) . bind ( album ) . bind ( limit ) . bind ( offset )
. fetch_all ( pool ) . await
}
pub async fn count_tracks ( pool : & PgPool , q : & str , artist : & str , album : & str ) -> Result < i64 , sqlx ::Error > {
let ( n , ) : ( i64 , ) = sqlx ::query_as (
r #" SELECT COUNT(*) FROM tracks t
JOIN track_artists ta ON ta . track_id = t . id AND ta . role = ' primary '
JOIN artists ar ON ar . id = ta . artist_id
LEFT JOIN albums al ON al . id = t . album_id
WHERE ( $ 1 = ' ' OR t . title ILIKE '%' | | $ 1 | | '%' )
AND ( $ 2 = ' ' OR ar . name ILIKE '%' | | $ 2 | | '%' )
AND ( $ 3 = ' ' OR al . name ILIKE '%' | | $ 3 | | '%' ) " #,
)
. bind ( q ) . bind ( artist ) . bind ( album )
. fetch_one ( pool ) . await ? ;
Ok ( n )
}
pub async fn search_albums (
pool : & PgPool ,
q : & str , artist : & str ,
limit : i64 , offset : i64 ,
) -> Result < Vec < AlbumRow > , sqlx ::Error > {
sqlx ::query_as ::< _ , AlbumRow > (
r #" SELECT a.id, a.name, ar.name AS artist_name, a.year,
2026-03-19 13:24:48 +00:00
COUNT ( t . id ) AS track_count , a . release_type , a . hidden
2026-03-19 02:09:04 +00:00
FROM albums a
JOIN artists ar ON ar . id = a . artist_id
LEFT JOIN tracks t ON t . album_id = a . id
WHERE ( $ 1 = ' ' OR a . name ILIKE '%' | | $ 1 | | '%' )
AND ( $ 2 = ' ' OR ar . name ILIKE '%' | | $ 2 | | '%' )
2026-03-19 13:24:48 +00:00
GROUP BY a . id , a . name , ar . name , a . year , a . release_type , a . hidden
2026-03-19 02:09:04 +00:00
ORDER BY ar . name , a . year NULLS LAST , a . name
LIMIT $ 3 OFFSET $ 4 " #,
)
. bind ( q ) . bind ( artist ) . bind ( limit ) . bind ( offset )
. fetch_all ( pool ) . await
}
pub async fn count_albums ( pool : & PgPool , q : & str , artist : & str ) -> Result < i64 , sqlx ::Error > {
let ( n , ) : ( i64 , ) = sqlx ::query_as (
r #" SELECT COUNT(*) FROM albums a
JOIN artists ar ON ar . id = a . artist_id
WHERE ( $ 1 = ' ' OR a . name ILIKE '%' | | $ 1 | | '%' )
AND ( $ 2 = ' ' OR ar . name ILIKE '%' | | $ 2 | | '%' ) " #,
)
. bind ( q ) . bind ( artist )
. fetch_one ( pool ) . await ? ;
Ok ( n )
}
pub async fn search_artists_lib (
pool : & PgPool ,
q : & str ,
limit : i64 , offset : i64 ,
) -> Result < Vec < ArtistRow > , sqlx ::Error > {
sqlx ::query_as ::< _ , ArtistRow > (
r #" SELECT ar.id, ar.name,
2026-03-19 13:24:48 +00:00
COUNT ( DISTINCT CASE WHEN al . release_type = ' album ' THEN al . id END ) AS album_count ,
COUNT ( DISTINCT CASE WHEN al . release_type = ' single ' THEN al . id END ) AS single_count ,
COUNT ( DISTINCT CASE WHEN al . release_type = ' ep ' THEN al . id END ) AS ep_count ,
COUNT ( DISTINCT CASE WHEN al . release_type = ' compilation ' THEN al . id END ) AS compilation_count ,
COUNT ( DISTINCT CASE WHEN al . release_type = ' live ' THEN al . id END ) AS live_count ,
COUNT ( DISTINCT ta . track_id ) AS track_count ,
ar . hidden
2026-03-19 02:09:04 +00:00
FROM artists ar
LEFT JOIN albums al ON al . artist_id = ar . id
LEFT JOIN track_artists ta ON ta . artist_id = ar . id AND ta . role = ' primary '
WHERE ( $ 1 = ' ' OR ar . name ILIKE '%' | | $ 1 | | '%' )
2026-03-19 13:24:48 +00:00
GROUP BY ar . id , ar . name , ar . hidden
2026-03-19 02:09:04 +00:00
ORDER BY ar . name
LIMIT $ 2 OFFSET $ 3 " #,
)
. bind ( q ) . bind ( limit ) . bind ( offset )
. fetch_all ( pool ) . await
}
pub async fn count_artists_lib ( pool : & PgPool , q : & str ) -> Result < i64 , sqlx ::Error > {
let ( n , ) : ( i64 , ) = sqlx ::query_as (
" SELECT COUNT(*) FROM artists WHERE ($1 = '' OR name ILIKE '%' || $1 || '%') "
)
. bind ( q )
. fetch_one ( pool ) . await ? ;
Ok ( n )
2026-03-19 00:55:49 +00:00
}
2026-03-19 02:36:27 +00:00
// --- Track full details ---
#[ derive(Debug, Serialize) ]
pub struct TrackFull {
pub id : i64 ,
pub title : String ,
pub artist_id : i64 ,
pub artist_name : String ,
pub album_id : Option < i64 > ,
pub album_name : Option < String > ,
pub track_number : Option < i32 > ,
pub duration_secs : Option < f64 > ,
pub genre : Option < String > ,
pub file_hash : String ,
pub file_size : i64 ,
pub storage_path : String ,
pub featured_artists : Vec < String > ,
}
pub async fn get_track_full ( pool : & PgPool , id : i64 ) -> Result < Option < TrackFull > , sqlx ::Error > {
#[ derive(sqlx::FromRow) ]
struct Row {
id : i64 , title : String , artist_id : i64 , artist_name : String ,
album_id : Option < i64 > , album_name : Option < String > ,
track_number : Option < i32 > , duration_secs : Option < f64 > ,
genre : Option < String > , file_hash : String , file_size : i64 , storage_path : String ,
}
let row : Option < Row > = sqlx ::query_as (
r #" SELECT t.id, t.title,
ta_p . artist_id , ar . name AS artist_name ,
t . album_id , al . name AS album_name ,
t . track_number , t . duration_secs , t . genre ,
t . file_hash , t . file_size , t . storage_path
FROM tracks t
JOIN track_artists ta_p ON ta_p . track_id = t . id AND ta_p . role = ' primary '
JOIN artists ar ON ar . id = ta_p . artist_id
LEFT JOIN albums al ON al . id = t . album_id
WHERE t . id = $ 1 " #,
) . bind ( id ) . fetch_optional ( pool ) . await ? ;
let row = match row { Some ( r ) = > r , None = > return Ok ( None ) } ;
let feat : Vec < ( String , ) > = sqlx ::query_as (
" SELECT ar.name FROM track_artists ta JOIN artists ar ON ar.id=ta.artist_id WHERE ta.track_id=$1 AND ta.role='featured' ORDER BY ta.id "
) . bind ( id ) . fetch_all ( pool ) . await ? ;
Ok ( Some ( TrackFull {
id : row . id , title : row . title , artist_id : row . artist_id , artist_name : row . artist_name ,
album_id : row . album_id , album_name : row . album_name , track_number : row . track_number ,
duration_secs : row . duration_secs , genre : row . genre , file_hash : row . file_hash ,
file_size : row . file_size , storage_path : row . storage_path ,
featured_artists : feat . into_iter ( ) . map ( | ( n , ) | n ) . collect ( ) ,
} ) )
}
#[ derive(Deserialize) ]
pub struct TrackUpdateFields {
pub title : String ,
pub artist_id : i64 ,
pub album_id : Option < i64 > ,
pub track_number : Option < i32 > ,
pub genre : Option < String > ,
#[ serde(default) ]
pub featured_artists : Vec < String > ,
}
pub async fn update_track_metadata ( pool : & PgPool , id : i64 , f : & TrackUpdateFields ) -> Result < ( ) , sqlx ::Error > {
sqlx ::query ( " UPDATE tracks SET title=$2, album_id=$3, track_number=$4, genre=$5 WHERE id=$1 " )
. bind ( id ) . bind ( & f . title ) . bind ( f . album_id ) . bind ( f . track_number ) . bind ( & f . genre )
. execute ( pool ) . await ? ;
sqlx ::query ( " UPDATE track_artists SET artist_id=$2 WHERE track_id=$1 AND role='primary' " )
. bind ( id ) . bind ( f . artist_id ) . execute ( pool ) . await ? ;
// Rebuild featured artists
sqlx ::query ( " DELETE FROM track_artists WHERE track_id=$1 AND role='featured' " )
. bind ( id ) . execute ( pool ) . await ? ;
for name in & f . featured_artists {
let feat_id = upsert_artist ( pool , name ) . await ? ;
link_track_artist ( pool , id , feat_id , " featured " ) . await ? ;
}
Ok ( ( ) )
}
// --- Album full details ---
#[ derive(Debug, Serialize) ]
pub struct AlbumDetails {
pub id : i64 ,
pub name : String ,
pub year : Option < i32 > ,
pub artist_id : i64 ,
pub artist_name : String ,
pub tracks : Vec < AlbumTrackRow > ,
}
#[ derive(Debug, Serialize, sqlx::FromRow) ]
pub struct AlbumTrackRow {
pub id : i64 ,
pub title : String ,
pub track_number : Option < i32 > ,
pub duration_secs : Option < f64 > ,
pub artist_name : String ,
pub genre : Option < String > ,
}
2026-03-19 15:28:25 +00:00
pub async fn set_album_tracks_genre ( pool : & PgPool , album_id : i64 , genre : & str ) -> Result < ( ) , sqlx ::Error > {
sqlx ::query ( " UPDATE tracks SET genre = $2 WHERE album_id = $1 " )
. bind ( album_id ) . bind ( genre ) . execute ( pool ) . await ? ;
Ok ( ( ) )
}
2026-03-19 02:36:27 +00:00
pub async fn get_album_details ( pool : & PgPool , id : i64 ) -> Result < Option < AlbumDetails > , sqlx ::Error > {
let row : Option < ( i64 , String , Option < i32 > , i64 , String ) > = sqlx ::query_as (
" SELECT a.id, a.name, a.year, ar.id, ar.name FROM albums a JOIN artists ar ON ar.id=a.artist_id WHERE a.id=$1 "
) . bind ( id ) . fetch_optional ( pool ) . await ? ;
let ( aid , aname , ayear , artist_id , artist_name ) = match row { Some ( r ) = > r , None = > return Ok ( None ) } ;
let tracks : Vec < AlbumTrackRow > = sqlx ::query_as (
r #" SELECT t.id, t.title, t.track_number, t.duration_secs, ar.name AS artist_name, t.genre
FROM tracks t
JOIN track_artists ta ON ta . track_id = t . id AND ta . role = ' primary '
JOIN artists ar ON ar . id = ta . artist_id
WHERE t . album_id = $ 1 ORDER BY t . track_number NULLS LAST , t . title " #
) . bind ( id ) . fetch_all ( pool ) . await ? ;
Ok ( Some ( AlbumDetails { id : aid , name : aname , year : ayear , artist_id , artist_name , tracks } ) )
}
pub async fn update_album_full ( pool : & PgPool , id : i64 , name : & str , year : Option < i32 > , artist_id : i64 ) -> Result < ( ) , sqlx ::Error > {
sqlx ::query ( " UPDATE albums SET name=$2, year=$3, artist_id=$4 WHERE id=$1 " )
. bind ( id ) . bind ( name ) . bind ( year ) . bind ( artist_id ) . execute ( pool ) . await ? ;
Ok ( ( ) )
}
pub async fn reorder_tracks ( pool : & PgPool , orders : & [ ( i64 , i32 ) ] ) -> Result < ( ) , sqlx ::Error > {
for & ( track_id , track_number ) in orders {
sqlx ::query ( " UPDATE tracks SET track_number=$2 WHERE id=$1 " )
. bind ( track_id ) . bind ( track_number ) . execute ( pool ) . await ? ;
}
Ok ( ( ) )
}
pub async fn get_album_cover ( pool : & PgPool , album_id : i64 ) -> Result < Option < ( String , String ) > , sqlx ::Error > {
let row : Option < ( String , String ) > = sqlx ::query_as (
" SELECT file_path, mime_type FROM album_images WHERE album_id=$1 LIMIT 1 "
) . bind ( album_id ) . fetch_optional ( pool ) . await ? ;
Ok ( row )
}
2026-03-19 15:28:25 +00:00
/// Returns the storage_path of the first track in an album (for embedded cover fallback).
pub async fn get_album_first_track_path ( pool : & PgPool , album_id : i64 ) -> Result < Option < String > , sqlx ::Error > {
let row : Option < ( String , ) > = sqlx ::query_as (
" SELECT storage_path FROM tracks WHERE album_id=$1 ORDER BY track_number NULLS LAST, title LIMIT 1 "
) . bind ( album_id ) . fetch_optional ( pool ) . await ? ;
Ok ( row . map ( | ( p , ) | p ) )
}
2026-03-19 13:24:48 +00:00
pub async fn get_artist_by_id ( pool : & PgPool , id : i64 ) -> Result < Option < Artist > , sqlx ::Error > {
sqlx ::query_as ::< _ , Artist > ( " SELECT id, name, hidden FROM artists WHERE id=$1 " )
. bind ( id ) . fetch_optional ( pool ) . await
}
2026-03-19 02:36:27 +00:00
pub async fn search_albums_for_artist ( pool : & PgPool , q : & str , artist_id : Option < i64 > ) -> Result < Vec < ( i64 , String ) > , sqlx ::Error > {
if let Some ( aid ) = artist_id {
let rows : Vec < ( i64 , String ) > = sqlx ::query_as (
" SELECT id, name FROM albums WHERE artist_id=$1 AND ($2='' OR name ILIKE '%'||$2||'%') ORDER BY year NULLS LAST, name LIMIT 15 "
) . bind ( aid ) . bind ( q ) . fetch_all ( pool ) . await ? ;
Ok ( rows )
} else {
let rows : Vec < ( i64 , String ) > = sqlx ::query_as (
" SELECT id, name FROM albums WHERE $1='' OR name ILIKE '%'||$1||'%' ORDER BY name LIMIT 15 "
) . bind ( q ) . fetch_all ( pool ) . await ? ;
Ok ( rows )
}
}
2026-03-19 13:24:48 +00:00
pub async fn set_track_hidden ( pool : & PgPool , id : i64 , hidden : bool ) -> Result < ( ) , sqlx ::Error > {
sqlx ::query ( " UPDATE tracks SET hidden=$2 WHERE id=$1 " ) . bind ( id ) . bind ( hidden ) . execute ( pool ) . await ? ;
Ok ( ( ) )
}
pub async fn set_album_hidden ( pool : & PgPool , id : i64 , hidden : bool ) -> Result < ( ) , sqlx ::Error > {
sqlx ::query ( " UPDATE albums SET hidden=$2 WHERE id=$1 " ) . bind ( id ) . bind ( hidden ) . execute ( pool ) . await ? ;
Ok ( ( ) )
}
pub async fn set_artist_hidden ( pool : & PgPool , id : i64 , hidden : bool ) -> Result < ( ) , sqlx ::Error > {
sqlx ::query ( " UPDATE artists SET hidden=$2 WHERE id=$1 " ) . bind ( id ) . bind ( hidden ) . execute ( pool ) . await ? ;
Ok ( ( ) )
}
pub async fn set_album_release_type ( pool : & PgPool , id : i64 , release_type : & str ) -> Result < ( ) , sqlx ::Error > {
sqlx ::query ( " UPDATE albums SET release_type=$2 WHERE id=$1 " ) . bind ( id ) . bind ( release_type ) . execute ( pool ) . await ? ;
Ok ( ( ) )
}
pub async fn rename_artist_name ( pool : & PgPool , id : i64 , name : & str ) -> Result < ( ) , sqlx ::Error > {
sqlx ::query ( " UPDATE artists SET name=$2 WHERE id=$1 " ) . bind ( id ) . bind ( name ) . execute ( pool ) . await ? ;
Ok ( ( ) )
}
// Full artist data for admin form
#[ derive(Debug, Serialize, sqlx::FromRow) ]
pub struct ArtistAlbumRow {
pub id : i64 ,
pub name : String ,
pub year : Option < i32 > ,
pub release_type : String ,
pub hidden : bool ,
pub track_count : i64 ,
}
#[ derive(Debug, Serialize, sqlx::FromRow) ]
pub struct ArtistAlbumTrack {
pub id : i64 ,
pub title : String ,
pub track_number : Option < i32 > ,
pub duration_secs : Option < f64 > ,
pub hidden : bool ,
}
#[ derive(Debug, Serialize, sqlx::FromRow) ]
pub struct AppearanceRow {
pub track_id : i64 ,
pub track_title : String ,
pub primary_artist_id : i64 ,
pub primary_artist_name : String ,
pub album_id : Option < i64 > ,
pub album_name : Option < String > ,
}
pub async fn get_artist_albums ( pool : & PgPool , artist_id : i64 ) -> Result < Vec < ArtistAlbumRow > , sqlx ::Error > {
sqlx ::query_as ::< _ , ArtistAlbumRow > (
r #" SELECT a.id, a.name, a.year, a.release_type, a.hidden,
COUNT ( t . id ) AS track_count
FROM albums a
LEFT JOIN tracks t ON t . album_id = a . id
WHERE a . artist_id = $ 1
GROUP BY a . id , a . name , a . year , a . release_type , a . hidden
ORDER BY a . year NULLS LAST , a . name " #
) . bind ( artist_id ) . fetch_all ( pool ) . await
}
pub async fn get_album_tracks_admin ( pool : & PgPool , album_id : i64 ) -> Result < Vec < ArtistAlbumTrack > , sqlx ::Error > {
sqlx ::query_as ::< _ , ArtistAlbumTrack > (
" SELECT id, title, track_number, duration_secs, hidden FROM tracks WHERE album_id=$1 ORDER BY track_number NULLS LAST, title "
) . bind ( album_id ) . fetch_all ( pool ) . await
}
pub async fn get_artist_appearances ( pool : & PgPool , artist_id : i64 ) -> Result < Vec < AppearanceRow > , sqlx ::Error > {
sqlx ::query_as ::< _ , AppearanceRow > (
r #" SELECT ta.track_id, t.title AS track_title,
ta_p . artist_id AS primary_artist_id , ar_p . name AS primary_artist_name ,
t . album_id , al . name AS album_name
FROM track_artists ta
JOIN tracks t ON t . id = ta . track_id
JOIN track_artists ta_p ON ta_p . track_id = t . id AND ta_p . role = ' primary '
JOIN artists ar_p ON ar_p . id = ta_p . artist_id
LEFT JOIN albums al ON al . id = t . album_id
WHERE ta . artist_id = $ 1 AND ta . role = ' featured '
ORDER BY ar_p . name , al . name NULLS LAST , t . title " #
) . bind ( artist_id ) . fetch_all ( pool ) . await
}
pub async fn add_track_appearance ( pool : & PgPool , track_id : i64 , artist_id : i64 ) -> Result < ( ) , sqlx ::Error > {
sqlx ::query (
" INSERT INTO track_artists (track_id, artist_id, role) VALUES ($1, $2, 'featured') ON CONFLICT DO NOTHING "
) . bind ( track_id ) . bind ( artist_id ) . execute ( pool ) . await ? ;
Ok ( ( ) )
}
pub async fn remove_track_appearance ( pool : & PgPool , track_id : i64 , artist_id : i64 ) -> Result < ( ) , sqlx ::Error > {
sqlx ::query (
" DELETE FROM track_artists WHERE track_id=$1 AND artist_id=$2 AND role='featured' "
) . bind ( track_id ) . bind ( artist_id ) . execute ( pool ) . await ? ;
Ok ( ( ) )
}
pub async fn search_tracks_for_feat ( pool : & PgPool , q : & str ) -> Result < Vec < ( i64 , String , String ) > , sqlx ::Error > {
// Returns (track_id, track_title, primary_artist_name)
sqlx ::query_as ::< _ , ( i64 , String , String ) > (
r #" SELECT t.id, t.title, ar.name FROM tracks t
JOIN track_artists ta ON ta . track_id = t . id AND ta . role = ' primary '
JOIN artists ar ON ar . id = ta . artist_id
WHERE t . title ILIKE '%' | | $ 1 | | '%' OR ar . name ILIKE '%' | | $ 1 | | '%'
ORDER BY ar . name , t . title LIMIT 15 " #
) . bind ( q ) . fetch_all ( pool ) . await
}
2026-03-19 00:55:49 +00:00
// =================== 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 {
2026-03-19 13:24:48 +00:00
let artist : Artist = sqlx ::query_as ( " SELECT id, name, hidden FROM artists WHERE id = $1 " )
2026-03-19 00:55:49 +00:00
. 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 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 ( ( ) )
2026-03-18 02:21:00 +00:00
}