1139 lines
39 KiB
Rust
1139 lines
39 KiB
Rust
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,
|
|
pub hidden: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
|
pub struct Album {
|
|
pub id: i64,
|
|
pub artist_id: i64,
|
|
pub name: String,
|
|
pub year: Option<i32>,
|
|
pub release_type: String,
|
|
pub hidden: bool,
|
|
}
|
|
|
|
#[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
|
|
pub norm_release_type: Option<String>,
|
|
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,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[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,
|
|
norm_release_type = $13,
|
|
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)
|
|
.bind(&norm.release_type)
|
|
.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)
|
|
}
|
|
|
|
pub async fn upsert_album(pool: &PgPool, artist_id: i64, name: &str, year: Option<i32>, release_type: &str) -> Result<i64, sqlx::Error> {
|
|
let slug = generate_slug();
|
|
let row: (i64,) = sqlx::query_as(
|
|
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
|
|
RETURNING id"#
|
|
)
|
|
.bind(artist_id)
|
|
.bind(name)
|
|
.bind(year)
|
|
.bind(&slug)
|
|
.bind(release_type)
|
|
.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?;
|
|
|
|
// Check if track already exists (e.g. previously approved but pending not cleaned up)
|
|
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 {
|
|
// Already finalized — just mark pending as approved
|
|
update_pending_status(pool, pending_id, "approved", None).await?;
|
|
return Ok(track_id);
|
|
}
|
|
|
|
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() {
|
|
Some(album_name) => Some(upsert_album(pool, artist_id, album_name, pt.norm_year, pt.norm_release_type.as_deref().unwrap_or("album")).await?),
|
|
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)
|
|
}
|
|
|
|
#[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)
|
|
.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))
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
// --- 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>,
|
|
pub release_type: Option<String>,
|
|
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> {
|
|
sqlx::query_as::<_, Artist>("SELECT id, name, hidden FROM artists ORDER BY name")
|
|
.fetch_all(pool)
|
|
.await
|
|
}
|
|
|
|
pub async fn list_albums_by_artist(pool: &PgPool, artist_id: i64) -> Result<Vec<Album>, sqlx::Error> {
|
|
sqlx::query_as::<_, Album>("SELECT id, artist_id, name, year, release_type, hidden FROM albums WHERE artist_id = $1 ORDER BY year, name")
|
|
.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,
|
|
pub merged_count: i64,
|
|
pub active_merges: i64,
|
|
}
|
|
|
|
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?;
|
|
let (merged_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM pending_tracks WHERE status = 'merged'").fetch_one(pool).await?;
|
|
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,
|
|
pub album_id: Option<i64>,
|
|
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,
|
|
pub release_type: String,
|
|
pub hidden: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
|
pub struct ArtistRow {
|
|
pub id: i64,
|
|
pub name: String,
|
|
pub album_count: i64,
|
|
pub single_count: i64,
|
|
pub ep_count: i64,
|
|
pub compilation_count: i64,
|
|
pub live_count: i64,
|
|
pub track_count: i64,
|
|
pub hidden: bool,
|
|
}
|
|
|
|
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>(
|
|
r#"SELECT t.id, t.title, ar.name AS artist_name, t.album_id, al.name AS album_name,
|
|
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,
|
|
COUNT(t.id) AS track_count, a.release_type, a.hidden
|
|
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 || '%')
|
|
GROUP BY a.id, a.name, ar.name, a.year, a.release_type, a.hidden
|
|
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,
|
|
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
|
|
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 || '%')
|
|
GROUP BY ar.id, ar.name, ar.hidden
|
|
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)
|
|
}
|
|
|
|
// --- 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>,
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/// 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))
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// =================== 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, hidden 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 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(())
|
|
}
|