use serde::Serialize; use sqlx::PgPool; use sqlx::postgres::PgPoolOptions; pub async fn connect(database_url: &str) -> Result { PgPoolOptions::new() .max_connections(10) .connect(database_url) .await } // --- Models --- #[derive(Debug, Serialize, sqlx::FromRow)] pub struct ArtistListItem { pub slug: String, pub name: String, pub album_count: i64, pub track_count: i64, } #[derive(Debug, Serialize, sqlx::FromRow)] pub struct ArtistDetail { pub slug: String, pub name: String, } #[derive(Debug, Serialize, sqlx::FromRow)] pub struct AlbumListItem { pub slug: String, pub name: String, pub year: Option, pub track_count: i64, pub has_cover: bool, } #[derive(Debug, Serialize, sqlx::FromRow)] pub struct TrackListItem { pub slug: String, pub title: String, pub track_number: Option, pub duration_secs: Option, pub artist_name: String, pub album_name: Option, pub album_slug: Option, pub genre: Option, } #[derive(Debug, Serialize, sqlx::FromRow)] pub struct TrackDetail { pub slug: String, pub title: String, pub track_number: Option, pub duration_secs: Option, pub genre: Option, pub storage_path: String, pub artist_name: String, pub artist_slug: String, pub album_name: Option, pub album_slug: Option, pub album_year: Option, } #[derive(Debug, sqlx::FromRow)] pub struct CoverInfo { pub file_path: String, pub mime_type: String, } #[derive(Debug, sqlx::FromRow)] pub struct TrackCoverLookup { pub storage_path: String, pub album_id: Option, } #[derive(Debug, Serialize, sqlx::FromRow)] pub struct SearchResult { pub result_type: String, // "artist", "album", "track" pub slug: String, pub name: String, pub detail: Option, // artist name for albums/tracks } // --- Queries --- pub async fn list_artists(pool: &PgPool) -> Result, sqlx::Error> { sqlx::query_as::<_, ArtistListItem>( r#"SELECT ar.slug, ar.name, COUNT(DISTINCT al.id) AS album_count, COUNT(DISTINCT t.id) AS track_count FROM artists ar LEFT JOIN albums al ON al.artist_id = ar.id LEFT JOIN tracks t ON t.artist_id = ar.id GROUP BY ar.id, ar.slug, ar.name HAVING COUNT(DISTINCT t.id) > 0 ORDER BY ar.name"# ) .fetch_all(pool) .await } pub async fn get_artist(pool: &PgPool, slug: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, ArtistDetail>( "SELECT slug, name FROM artists WHERE slug = $1" ) .bind(slug) .fetch_optional(pool) .await } pub async fn list_albums_by_artist(pool: &PgPool, artist_slug: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, AlbumListItem>( r#"SELECT al.slug, al.name, al.year, COUNT(t.id) AS track_count, EXISTS(SELECT 1 FROM album_images ai WHERE ai.album_id = al.id AND ai.image_type = 'cover') AS has_cover FROM albums al JOIN artists ar ON al.artist_id = ar.id LEFT JOIN tracks t ON t.album_id = al.id WHERE ar.slug = $1 GROUP BY al.id, al.slug, al.name, al.year ORDER BY al.year NULLS LAST, al.name"# ) .bind(artist_slug) .fetch_all(pool) .await } pub async fn list_tracks_by_album(pool: &PgPool, album_slug: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, TrackListItem>( r#"SELECT t.slug, t.title, t.track_number, t.duration_secs, ar.name AS artist_name, al.name AS album_name, al.slug AS album_slug, t.genre FROM tracks t JOIN artists ar ON t.artist_id = ar.id LEFT JOIN albums al ON t.album_id = al.id WHERE al.slug = $1 ORDER BY t.track_number NULLS LAST, t.title"# ) .bind(album_slug) .fetch_all(pool) .await } pub async fn get_track(pool: &PgPool, slug: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, TrackDetail>( r#"SELECT t.slug, t.title, t.track_number, t.duration_secs, t.genre, t.storage_path, ar.name AS artist_name, ar.slug AS artist_slug, al.name AS album_name, al.slug AS album_slug, al.year AS album_year FROM tracks t JOIN artists ar ON t.artist_id = ar.id LEFT JOIN albums al ON t.album_id = al.id WHERE t.slug = $1"# ) .bind(slug) .fetch_optional(pool) .await } pub async fn get_track_cover_lookup(pool: &PgPool, track_slug: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, TrackCoverLookup>( "SELECT storage_path, album_id FROM tracks WHERE slug = $1" ) .bind(track_slug) .fetch_optional(pool) .await } pub async fn get_album_cover_by_id(pool: &PgPool, album_id: i64) -> Result, sqlx::Error> { sqlx::query_as::<_, CoverInfo>( r#"SELECT file_path, mime_type FROM album_images WHERE album_id = $1 AND image_type = 'cover' LIMIT 1"# ) .bind(album_id) .fetch_optional(pool) .await } pub async fn get_album_cover(pool: &PgPool, album_slug: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, CoverInfo>( r#"SELECT ai.file_path, ai.mime_type FROM album_images ai JOIN albums al ON ai.album_id = al.id WHERE al.slug = $1 AND ai.image_type = 'cover' LIMIT 1"# ) .bind(album_slug) .fetch_optional(pool) .await } pub async fn search(pool: &PgPool, query: &str, limit: i32) -> Result, sqlx::Error> { let pattern = format!("%{}%", query); sqlx::query_as::<_, SearchResult>( r#"SELECT * FROM ( SELECT 'artist' AS result_type, slug, name, NULL AS detail FROM artists WHERE name ILIKE $1 UNION ALL SELECT 'album' AS result_type, al.slug, al.name, ar.name AS detail FROM albums al JOIN artists ar ON al.artist_id = ar.id WHERE al.name ILIKE $1 UNION ALL SELECT 'track' AS result_type, t.slug, t.title AS name, ar.name AS detail FROM tracks t JOIN artists ar ON t.artist_id = ar.id WHERE t.title ILIKE $1 ) sub ORDER BY result_type, name LIMIT $2"# ) .bind(&pattern) .bind(limit) .fetch_all(pool) .await } pub async fn list_all_tracks_by_artist(pool: &PgPool, artist_slug: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, TrackListItem>( r#"SELECT t.slug, t.title, t.track_number, t.duration_secs, ar.name AS artist_name, al.name AS album_name, al.slug AS album_slug, t.genre FROM tracks t JOIN artists ar ON t.artist_id = ar.id LEFT JOIN albums al ON t.album_id = al.id WHERE ar.slug = $1 ORDER BY al.year NULLS LAST, al.name, t.track_number NULLS LAST, t.title"# ) .bind(artist_slug) .fetch_all(pool) .await }