feat: add PostgreSQL persistence and unified sync endpoint

- Add sqlx with PostgreSQL support for database operations
- Create DbService with artist/album upsert and listing methods
- Add database schema (14 tables) in containers/init.sql
- Add library controller (GET /api/library/artists, /albums, /stats)
- Merge sync_artist + ingest into single POST /api/sync endpoint
- Support configurable sync: download (bool), store (bool), album filter
- Connect to database at startup with graceful fallback
This commit is contained in:
Alexander
2026-04-29 10:06:01 +02:00
parent 3aaeade4d3
commit f24543f401
12 changed files with 1590 additions and 84 deletions
Generated
+841 -8
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -25,6 +25,7 @@ roxmltree = "0.20"
base64 = "0.22" base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] }
tonic = "0.12" tonic = "0.12"
prost = "0.13" prost = "0.13"
+1
View File
@@ -9,6 +9,7 @@ services:
POSTGRES_DB: music_aggregator POSTGRES_DB: music_aggregator
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports: ports:
- "5433:5432" - "5433:5432"
healthcheck: healthcheck:
+225
View File
@@ -0,0 +1,225 @@
-- Music Aggregator Database Schema
-- Based on docs/erd.puml
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ══════════════════════════════════════════════════════════════
-- CONFIGURATION
-- ══════════════════════════════════════════════════════════════
CREATE TABLE quality_profiles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL UNIQUE,
cutoff INT NOT NULL DEFAULT 0,
items JSONB NOT NULL DEFAULT '[]',
upgrade_allowed BOOLEAN NOT NULL DEFAULT true
);
CREATE TABLE metadata_profiles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL UNIQUE,
primary_album_types JSONB NOT NULL DEFAULT '["Album", "EP"]',
secondary_album_types JSONB NOT NULL DEFAULT '[]',
release_statuses JSONB NOT NULL DEFAULT '["Official"]'
);
CREATE TABLE root_folders (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
default_quality_profile_id UUID REFERENCES quality_profiles(id),
default_metadata_profile_id UUID REFERENCES metadata_profiles(id)
);
CREATE TABLE indexers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
implementation TEXT NOT NULL,
settings JSONB NOT NULL DEFAULT '{}',
enable_rss BOOLEAN NOT NULL DEFAULT true,
enable_search BOOLEAN NOT NULL DEFAULT true,
priority INT NOT NULL DEFAULT 25
);
CREATE TABLE download_clients (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
implementation TEXT NOT NULL,
settings JSONB NOT NULL DEFAULT '{}',
protocol TEXT NOT NULL DEFAULT 'torrent',
priority INT NOT NULL DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT true
);
-- ══════════════════════════════════════════════════════════════
-- CORE MUSIC ENTITIES
-- ══════════════════════════════════════════════════════════════
CREATE TABLE artist_metadata (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
foreign_artist_id TEXT UNIQUE,
name TEXT NOT NULL,
sort_name TEXT,
disambiguation TEXT,
artist_type TEXT,
status TEXT,
overview TEXT,
images JSONB NOT NULL DEFAULT '[]',
links JSONB NOT NULL DEFAULT '[]',
genres JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE artists (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
metadata_id UUID NOT NULL REFERENCES artist_metadata(id) ON DELETE CASCADE,
quality_profile_id UUID REFERENCES quality_profiles(id),
metadata_profile_id UUID REFERENCES metadata_profiles(id),
root_folder_id UUID REFERENCES root_folders(id),
path TEXT,
monitored BOOLEAN NOT NULL DEFAULT true,
monitor_new_items TEXT NOT NULL DEFAULT 'all',
last_info_sync TIMESTAMPTZ,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE albums (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
artist_metadata_id UUID NOT NULL REFERENCES artist_metadata(id) ON DELETE CASCADE,
foreign_album_id TEXT UNIQUE,
title TEXT NOT NULL,
clean_title TEXT,
disambiguation TEXT,
overview TEXT,
album_type TEXT,
release_date DATE,
images JSONB NOT NULL DEFAULT '[]',
genres JSONB NOT NULL DEFAULT '[]',
monitored BOOLEAN NOT NULL DEFAULT true,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE album_releases (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
foreign_release_id TEXT UNIQUE,
title TEXT NOT NULL,
status TEXT,
duration_ms INT,
release_date DATE,
country TEXT[],
label TEXT[],
format TEXT,
track_count INT,
monitored BOOLEAN NOT NULL DEFAULT true
);
CREATE TABLE track_files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
album_id UUID NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
path TEXT NOT NULL,
relative_path TEXT NOT NULL,
size BIGINT NOT NULL DEFAULT 0,
file_hash TEXT,
audio_hash TEXT,
quality JSONB NOT NULL DEFAULT '{}',
media_info JSONB NOT NULL DEFAULT '{}',
scene_name TEXT,
release_group TEXT,
date_added TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE tracks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
album_release_id UUID NOT NULL REFERENCES album_releases(id) ON DELETE CASCADE,
artist_metadata_id UUID NOT NULL REFERENCES artist_metadata(id) ON DELETE CASCADE,
track_file_id UUID REFERENCES track_files(id) ON DELETE SET NULL,
foreign_track_id TEXT UNIQUE,
title TEXT NOT NULL,
track_number INT NOT NULL DEFAULT 1,
disc_number INT NOT NULL DEFAULT 1,
duration_ms INT,
explicit BOOLEAN NOT NULL DEFAULT false
);
-- ══════════════════════════════════════════════════════════════
-- DOWNLOAD TRACKING
-- ══════════════════════════════════════════════════════════════
CREATE TABLE wanted_albums (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
album_id UUID NOT NULL UNIQUE REFERENCES albums(id) ON DELETE CASCADE,
priority INT NOT NULL DEFAULT 0,
search_count INT NOT NULL DEFAULT 0,
last_searched_at TIMESTAMPTZ,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE download_queue (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
artist_id UUID REFERENCES artists(id) ON DELETE SET NULL,
album_id UUID REFERENCES albums(id) ON DELETE SET NULL,
download_id TEXT,
title TEXT NOT NULL,
size BIGINT NOT NULL DEFAULT 0,
size_left BIGINT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'queued',
progress REAL NOT NULL DEFAULT 0.0,
error_message TEXT,
protocol TEXT NOT NULL DEFAULT 'torrent',
indexer TEXT,
download_client TEXT,
torrent_hash TEXT,
output_path TEXT,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE TABLE blocklist (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
artist_id UUID NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
album_id UUID REFERENCES albums(id) ON DELETE CASCADE,
source_title TEXT NOT NULL,
quality JSONB NOT NULL DEFAULT '{}',
size BIGINT NOT NULL DEFAULT 0,
protocol TEXT,
indexer TEXT,
message TEXT,
torrent_hash TEXT,
date TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ══════════════════════════════════════════════════════════════
-- INDEXES
-- ══════════════════════════════════════════════════════════════
CREATE INDEX idx_artist_metadata_name ON artist_metadata(name);
CREATE INDEX idx_artist_metadata_foreign_id ON artist_metadata(foreign_artist_id);
CREATE INDEX idx_albums_artist ON albums(artist_metadata_id);
CREATE INDEX idx_albums_foreign_id ON albums(foreign_album_id);
CREATE INDEX idx_albums_release_date ON albums(release_date);
CREATE INDEX idx_album_releases_album ON album_releases(album_id);
CREATE INDEX idx_tracks_release ON tracks(album_release_id);
CREATE INDEX idx_tracks_artist ON tracks(artist_metadata_id);
CREATE INDEX idx_track_files_album ON track_files(album_id);
CREATE INDEX idx_track_files_hash ON track_files(file_hash);
CREATE INDEX idx_track_files_audio_hash ON track_files(audio_hash);
CREATE INDEX idx_wanted_albums_priority ON wanted_albums(priority DESC);
CREATE INDEX idx_download_queue_status ON download_queue(status);
CREATE INDEX idx_download_queue_album ON download_queue(album_id);
CREATE INDEX idx_blocklist_artist ON blocklist(artist_id);
CREATE INDEX idx_blocklist_torrent ON blocklist(torrent_hash);
-- ══════════════════════════════════════════════════════════════
-- DEFAULT DATA
-- ══════════════════════════════════════════════════════════════
INSERT INTO quality_profiles (name, cutoff, items, upgrade_allowed) VALUES
('Any', 0, '[]', true),
('Lossless', 1, '[{"quality": "FLAC", "allowed": true}, {"quality": "ALAC", "allowed": true}]', true),
('Standard', 2, '[{"quality": "MP3-320", "allowed": true}, {"quality": "MP3-VBR-V0", "allowed": true}]', true);
INSERT INTO metadata_profiles (name, primary_album_types, secondary_album_types, release_statuses) VALUES
('Standard', '["Album", "EP"]', '[]', '["Official"]'),
('All', '["Album", "EP", "Single", "Broadcast", "Other"]', '["Compilation", "Soundtrack", "Spokenword", "Interview", "Audiobook", "Live", "Remix", "DJ-mix", "Mixtape/Street", "Demo"]', '["Official", "Promotional", "Bootleg"]');
+124
View File
@@ -0,0 +1,124 @@
use axum::{
extract::{Path, State},
http::StatusCode,
routing::get,
Json, Router,
};
use serde::Serialize;
use uuid::Uuid;
use crate::services::{AlbumRow, AlbumWithArtistRow, ArtistMetadataRow};
use crate::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/artists", get(list_artists))
.route("/artists/{id}/albums", get(list_artist_albums))
.route("/albums", get(list_albums))
.route("/stats", get(library_stats))
}
#[derive(Serialize)]
struct ArtistsResponse {
artists: Vec<ArtistMetadataRow>,
total: usize,
}
async fn list_artists(
State(state): State<AppState>,
) -> Result<Json<ArtistsResponse>, (StatusCode, String)> {
let state = state.read().await;
let db = state.db_service.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"database not connected".to_string(),
))?;
let artists = db
.list_artists()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let total = artists.len();
Ok(Json(ArtistsResponse { artists, total }))
}
#[derive(Serialize)]
struct ArtistAlbumsResponse {
albums: Vec<AlbumRow>,
total: usize,
}
async fn list_artist_albums(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<ArtistAlbumsResponse>, (StatusCode, String)> {
let state = state.read().await;
let db = state.db_service.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"database not connected".to_string(),
))?;
let albums = db
.list_albums_by_artist(id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let total = albums.len();
Ok(Json(ArtistAlbumsResponse { albums, total }))
}
#[derive(Serialize)]
struct AlbumsResponse {
albums: Vec<AlbumWithArtistRow>,
total: usize,
}
async fn list_albums(
State(state): State<AppState>,
) -> Result<Json<AlbumsResponse>, (StatusCode, String)> {
let state = state.read().await;
let db = state.db_service.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"database not connected".to_string(),
))?;
let albums = db
.list_all_albums()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let total = albums.len();
Ok(Json(AlbumsResponse { albums, total }))
}
#[derive(Serialize)]
struct LibraryStats {
artists: i64,
albums: i64,
}
async fn library_stats(
State(state): State<AppState>,
) -> Result<Json<LibraryStats>, (StatusCode, String)> {
let state = state.read().await;
let db = state.db_service.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"database not connected".to_string(),
))?;
let artists = db
.count_artists()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let albums = db
.count_albums()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(LibraryStats { artists, albums }))
}
+2
View File
@@ -1,4 +1,5 @@
mod indexer_controller; mod indexer_controller;
mod library_controller;
mod metadata_controller; mod metadata_controller;
mod sync_controller; mod sync_controller;
mod torrent_controller; mod torrent_controller;
@@ -29,6 +30,7 @@ pub fn routes(state: AppState) -> Router {
.nest("/torrents", torrent_controller::routes()) .nest("/torrents", torrent_controller::routes())
.nest("/metadata", metadata_controller::routes()) .nest("/metadata", metadata_controller::routes())
.nest("/sync", sync_controller::routes()) .nest("/sync", sync_controller::routes())
.nest("/library", library_controller::routes())
.with_state(state) .with_state(state)
} }
+26 -9
View File
@@ -1,29 +1,46 @@
use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use serde::Deserialize; use serde::Deserialize;
use crate::services::{ArtistSyncResult, DownloadService}; use crate::services::{DownloadService, SyncOptions, SyncResult};
use crate::AppState; use crate::AppState;
pub fn routes() -> Router<AppState> { pub fn routes() -> Router<AppState> {
Router::new().route("/artist", post(sync_artist)) Router::new().route("/", post(sync))
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct SyncArtistRequest { pub struct SyncRequest {
pub name: String, pub artist: String,
pub album: Option<String>,
#[serde(default = "default_true")]
pub download: bool,
#[serde(default = "default_true")]
pub store: bool,
} }
async fn sync_artist( fn default_true() -> bool {
true
}
async fn sync(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<SyncArtistRequest>, Json(req): Json<SyncRequest>,
) -> Result<Json<ArtistSyncResult>, (StatusCode, String)> { ) -> Result<Json<SyncResult>, (StatusCode, String)> {
let state = state.read().await; let state = state.read().await;
let result = DownloadService::sync_artist( let options = SyncOptions {
&req.name, artist: req.artist,
album: req.album,
download: req.download,
store: req.store,
};
let result = DownloadService::sync(
options,
&state.metadata_service, &state.metadata_service,
&state.indexer_service, &state.indexer_service,
&state.torrent_service, &state.torrent_service,
state.db_service.as_ref(),
) )
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
+3
View File
@@ -14,6 +14,7 @@ pub struct AppServices {
pub indexer_service: services::IndexerService, pub indexer_service: services::IndexerService,
pub torrent_service: services::TorrentService, pub torrent_service: services::TorrentService,
pub metadata_service: services::MetadataService, pub metadata_service: services::MetadataService,
pub db_service: Option<services::DbService>,
config_path: String, config_path: String,
} }
@@ -22,6 +23,7 @@ impl AppServices {
indexer_service: services::IndexerService, indexer_service: services::IndexerService,
torrent_service: services::TorrentService, torrent_service: services::TorrentService,
metadata_service: services::MetadataService, metadata_service: services::MetadataService,
db_service: Option<services::DbService>,
config_path: String, config_path: String,
) -> Self { ) -> Self {
Self { Self {
@@ -29,6 +31,7 @@ impl AppServices {
indexer_service, indexer_service,
torrent_service, torrent_service,
metadata_service, metadata_service,
db_service,
config_path, config_path,
} }
} }
+16 -1
View File
@@ -5,7 +5,7 @@ use axum::Router;
use clap::Parser; use clap::Parser;
use music_agregator::{ use music_agregator::{
api, config, api, config,
services::{IndexerService, MetadataService, TorrentService}, services::{DbService, IndexerService, MetadataService, TorrentService},
AppServices, AppState, AppServices, AppState,
}; };
use tower_http::cors::{Any, CorsLayer}; use tower_http::cors::{Any, CorsLayer};
@@ -91,10 +91,25 @@ async fn main() {
} }
} }
let db_service = match DbService::new(&config.database.url).await {
Ok(svc) => {
tracing::info!("connected to database");
Some(svc)
}
Err(e) => {
tracing::warn!(
"failed to connect to database: {} (continuing without db)",
e
);
None
}
};
let state: AppState = Arc::new(RwLock::new(AppServices::new( let state: AppState = Arc::new(RwLock::new(AppServices::new(
indexer_service, indexer_service,
torrent_service, torrent_service,
metadata_service, metadata_service,
db_service,
args.config.clone(), args.config.clone(),
))); )));
+211
View File
@@ -0,0 +1,211 @@
use sqlx::{postgres::PgPoolOptions, FromRow, PgPool};
use uuid::Uuid;
use crate::metadata::proto::{Album, Artist};
#[derive(Clone)]
pub struct DbService {
pool: PgPool,
}
impl DbService {
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(database_url)
.await?;
Ok(Self { pool })
}
pub async fn upsert_artist_metadata(&self, artist: &Artist) -> Result<Uuid, sqlx::Error> {
let id = Uuid::parse_str(&artist.id).unwrap_or_else(|_| Uuid::new_v4());
let genres: serde_json::Value = serde_json::json!(artist
.genres
.iter()
.map(|g| serde_json::json!({"id": g.id, "name": g.name}))
.collect::<Vec<_>>());
let links: serde_json::Value = serde_json::json!(artist
.external_ids
.iter()
.map(
|e| serde_json::json!({"source": e.source, "source_id": e.source_id, "url": e.url})
)
.collect::<Vec<_>>());
let row: (Uuid,) = sqlx::query_as(
r#"
INSERT INTO artist_metadata (
id, foreign_artist_id, name, sort_name, disambiguation,
artist_type, status, overview, genres, links, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
ON CONFLICT (foreign_artist_id) DO UPDATE SET
name = EXCLUDED.name,
sort_name = EXCLUDED.sort_name,
artist_type = EXCLUDED.artist_type,
overview = EXCLUDED.overview,
genres = EXCLUDED.genres,
links = EXCLUDED.links,
updated_at = NOW()
RETURNING id
"#,
)
.bind(id)
.bind(&artist.id)
.bind(&artist.name)
.bind(&artist.sort_name)
.bind(&artist.description)
.bind(&artist.artist_type)
.bind("active")
.bind(&artist.description)
.bind(&genres)
.bind(&links)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
pub async fn upsert_album(
&self,
album: &Album,
artist_metadata_id: Uuid,
) -> Result<Uuid, sqlx::Error> {
let id = Uuid::parse_str(&album.id).unwrap_or_else(|_| Uuid::new_v4());
let genres: serde_json::Value = serde_json::json!(album
.genres
.iter()
.map(|g| serde_json::json!({"id": g.id, "name": g.name}))
.collect::<Vec<_>>());
let images: serde_json::Value = serde_json::json!([]);
let release_date = chrono::NaiveDate::parse_from_str(&album.release_date, "%Y-%m-%d").ok();
let clean_title = album
.title
.to_lowercase()
.replace(|c: char| !c.is_alphanumeric(), "");
let row: (Uuid,) = sqlx::query_as(
r#"
INSERT INTO albums (
id, artist_metadata_id, foreign_album_id, title, clean_title,
overview, album_type, release_date, images, genres
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (foreign_album_id) DO UPDATE SET
title = EXCLUDED.title,
album_type = EXCLUDED.album_type,
release_date = EXCLUDED.release_date,
genres = EXCLUDED.genres
RETURNING id
"#,
)
.bind(id)
.bind(artist_metadata_id)
.bind(&album.id)
.bind(&album.title)
.bind(&clean_title)
.bind("")
.bind(&album.album_type)
.bind(release_date)
.bind(&images)
.bind(&genres)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
pub async fn list_artists(&self) -> Result<Vec<ArtistMetadataRow>, sqlx::Error> {
sqlx::query_as(
r#"
SELECT id, foreign_artist_id, name, sort_name, artist_type, genres, created_at, updated_at
FROM artist_metadata
ORDER BY name
"#,
)
.fetch_all(&self.pool)
.await
}
pub async fn list_albums_by_artist(
&self,
artist_metadata_id: Uuid,
) -> Result<Vec<AlbumRow>, sqlx::Error> {
sqlx::query_as(
r#"
SELECT id, artist_metadata_id, foreign_album_id, title, album_type, release_date, monitored, added_at
FROM albums
WHERE artist_metadata_id = $1
ORDER BY release_date DESC NULLS LAST
"#,
)
.bind(artist_metadata_id)
.fetch_all(&self.pool)
.await
}
pub async fn list_all_albums(&self) -> Result<Vec<AlbumWithArtistRow>, sqlx::Error> {
sqlx::query_as(
r#"
SELECT
a.id, a.foreign_album_id, a.title, a.album_type, a.release_date, a.monitored, a.added_at,
am.id as artist_id, am.name as artist_name
FROM albums a
JOIN artist_metadata am ON a.artist_metadata_id = am.id
ORDER BY a.added_at DESC
"#,
)
.fetch_all(&self.pool)
.await
}
pub async fn count_artists(&self) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM artist_metadata")
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
pub async fn count_albums(&self) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM albums")
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
}
#[derive(Debug, serde::Serialize, FromRow)]
pub struct ArtistMetadataRow {
pub id: Uuid,
pub foreign_artist_id: Option<String>,
pub name: String,
pub sort_name: Option<String>,
pub artist_type: Option<String>,
pub genres: Option<serde_json::Value>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, serde::Serialize, FromRow)]
pub struct AlbumRow {
pub id: Uuid,
pub artist_metadata_id: Uuid,
pub foreign_album_id: Option<String>,
pub title: String,
pub album_type: Option<String>,
pub release_date: Option<chrono::NaiveDate>,
pub monitored: bool,
pub added_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, serde::Serialize, FromRow)]
pub struct AlbumWithArtistRow {
pub id: Uuid,
pub foreign_album_id: Option<String>,
pub title: String,
pub album_type: Option<String>,
pub release_date: Option<chrono::NaiveDate>,
pub monitored: bool,
pub added_at: chrono::DateTime<chrono::Utc>,
pub artist_id: Uuid,
pub artist_name: String,
}
+137 -63
View File
@@ -1,17 +1,42 @@
use serde::Serialize; use serde::{Deserialize, Serialize};
use crate::indexer::SearchResult; use crate::indexer::SearchResult;
use super::{IndexerService, MetadataService, TorrentService}; use super::{DbService, IndexerService, MetadataService, TorrentService};
#[derive(Debug, Deserialize)]
pub struct SyncOptions {
pub artist: String,
pub album: Option<String>,
pub download: bool,
pub store: bool,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct AlbumDownloadResult { pub struct SyncResult {
pub artist_id: String,
pub artist_name: String,
pub total_albums: usize,
pub albums_stored: usize,
pub albums_downloaded: usize,
pub albums_no_results: usize,
pub albums_failed: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub results: Vec<AlbumSyncResult>,
}
#[derive(Debug, Serialize)]
pub struct AlbumSyncResult {
pub album_id: String, pub album_id: String,
pub album_title: String, pub album_title: String,
pub artist_name: String, pub stored: bool,
pub status: DownloadStatus, #[serde(skip_serializing_if = "Option::is_none")]
pub download_status: Option<DownloadStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub torrent_hash: Option<String>, pub torrent_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub indexer: Option<String>, pub indexer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>, pub error: Option<String>,
} }
@@ -24,89 +49,150 @@ pub enum DownloadStatus {
Skipped, Skipped,
} }
#[derive(Debug, Serialize)] struct DownloadResult {
pub struct ArtistSyncResult { status: DownloadStatus,
pub artist_id: String, torrent_hash: Option<String>,
pub artist_name: String, indexer: Option<String>,
pub total_albums: usize, error: Option<String>,
pub albums_added: usize,
pub albums_failed: usize,
pub albums_no_results: usize,
pub results: Vec<AlbumDownloadResult>,
} }
pub struct DownloadService; pub struct DownloadService;
impl DownloadService { impl DownloadService {
pub async fn sync_artist( pub async fn sync(
artist_name: &str, options: SyncOptions,
metadata: &MetadataService, metadata: &MetadataService,
indexers: &IndexerService, indexers: &IndexerService,
torrent: &TorrentService, torrent: &TorrentService,
) -> Result<ArtistSyncResult, String> { db: Option<&DbService>,
) -> Result<SyncResult, String> {
let search_result = metadata let search_result = metadata
.search_artists(artist_name, Some(1), None) .search_artists(&options.artist, Some(1), None)
.await .await
.map_err(|e| format!("metadata search failed: {}", e))?; .map_err(|e| format!("metadata search failed: {}", e))?;
let artist = search_result let artist = search_result
.artists .artists
.first() .first()
.ok_or_else(|| format!("artist '{}' not found", artist_name))?; .ok_or_else(|| format!("artist '{}' not found", options.artist))?;
let artist_metadata_id = if options.store {
if let Some(db) = db {
match db.upsert_artist_metadata(artist).await {
Ok(id) => {
tracing::info!("stored artist metadata: {} ({})", artist.name, id);
Some(id)
}
Err(e) => {
tracing::warn!("failed to store artist metadata: {}", e);
None
}
}
} else {
None
}
} else {
None
};
let albums_response = metadata let albums_response = metadata
.get_artist_albums(&artist.id, Some(100), None) .get_artist_albums(&artist.id, Some(500), None)
.await .await
.map_err(|e| format!("failed to get albums: {}", e))?; .map_err(|e| format!("failed to get albums: {}", e))?;
let mut results = Vec::new(); let albums_to_process: Vec<_> = if let Some(ref album_filter) = options.album {
let mut albums_added = 0; let filter_lower = album_filter.to_lowercase();
let mut albums_failed = 0; albums_response
let mut albums_no_results = 0; .albums
.iter()
.filter(|a| a.title.to_lowercase().contains(&filter_lower))
.collect()
} else {
albums_response.albums.iter().collect()
};
for album in &albums_response.albums { let mut results = Vec::new();
let result = Self::download_album( let mut albums_stored = 0;
&artist.name, let mut albums_downloaded = 0;
&album.id, let mut albums_no_results = 0;
&album.title, let mut albums_failed = 0;
album
for album in albums_to_process.iter() {
let stored = if options.store {
if let (Some(db), Some(artist_id)) = (db, artist_metadata_id) {
match db.upsert_album(album, artist_id).await {
Ok(_) => {
albums_stored += 1;
true
}
Err(e) => {
tracing::warn!("failed to store album {}: {}", album.title, e);
false
}
}
} else {
false
}
} else {
false
};
let (download_status, torrent_hash, indexer, error) = if options.download {
let year = album
.release_date .release_date
.split('-') .split('-')
.next() .next()
.and_then(|y| y.parse().ok()), .and_then(|y| y.parse().ok());
indexers,
torrent,
)
.await;
match result.status { let dl_result =
DownloadStatus::Added => albums_added += 1, Self::download_album(&artist.name, &album.title, year, indexers, torrent).await;
DownloadStatus::NoResults => albums_no_results += 1,
DownloadStatus::Failed | DownloadStatus::Skipped => albums_failed += 1,
}
results.push(result); match dl_result.status {
DownloadStatus::Added => albums_downloaded += 1,
DownloadStatus::NoResults => albums_no_results += 1,
DownloadStatus::Failed | DownloadStatus::Skipped => albums_failed += 1,
}
(
Some(dl_result.status),
dl_result.torrent_hash,
dl_result.indexer,
dl_result.error,
)
} else {
(None, None, None, None)
};
results.push(AlbumSyncResult {
album_id: album.id.clone(),
album_title: album.title.clone(),
stored,
download_status,
torrent_hash,
indexer,
error,
});
} }
Ok(ArtistSyncResult { Ok(SyncResult {
artist_id: artist.id.clone(), artist_id: artist.id.clone(),
artist_name: artist.name.clone(), artist_name: artist.name.clone(),
total_albums: albums_response.albums.len(), total_albums: albums_to_process.len(),
albums_added, albums_stored,
albums_failed, albums_downloaded,
albums_no_results, albums_no_results,
albums_failed,
results, results,
}) })
} }
async fn download_album( async fn download_album(
artist_name: &str, artist_name: &str,
album_id: &str,
album_title: &str, album_title: &str,
year: Option<u32>, year: Option<u32>,
indexers: &IndexerService, indexers: &IndexerService,
torrent: &TorrentService, torrent: &TorrentService,
) -> AlbumDownloadResult { ) -> DownloadResult {
let criteria = crate::indexer::MusicSearchCriteria { let criteria = crate::indexer::MusicSearchCriteria {
artist: artist_name.to_string(), artist: artist_name.to_string(),
album: Some(album_title.to_string()), album: Some(album_title.to_string()),
@@ -118,10 +204,7 @@ impl DownloadService {
let search_results = match indexers.search(&criteria, None).await { let search_results = match indexers.search(&criteria, None).await {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
return AlbumDownloadResult { return DownloadResult {
album_id: album_id.to_string(),
album_title: album_title.to_string(),
artist_name: artist_name.to_string(),
status: DownloadStatus::Failed, status: DownloadStatus::Failed,
torrent_hash: None, torrent_hash: None,
indexer: None, indexer: None,
@@ -131,10 +214,7 @@ impl DownloadService {
}; };
if search_results.is_empty() { if search_results.is_empty() {
return AlbumDownloadResult { return DownloadResult {
album_id: album_id.to_string(),
album_title: album_title.to_string(),
artist_name: artist_name.to_string(),
status: DownloadStatus::NoResults, status: DownloadStatus::NoResults,
torrent_hash: None, torrent_hash: None,
indexer: None, indexer: None,
@@ -145,19 +225,13 @@ impl DownloadService {
let best = Self::select_best_result(&search_results); let best = Self::select_best_result(&search_results);
match torrent.add_torrent_url(&best.download_url, None).await { match torrent.add_torrent_url(&best.download_url, None).await {
Ok(()) => AlbumDownloadResult { Ok(()) => DownloadResult {
album_id: album_id.to_string(),
album_title: album_title.to_string(),
artist_name: artist_name.to_string(),
status: DownloadStatus::Added, status: DownloadStatus::Added,
torrent_hash: best.infohash.clone(), torrent_hash: best.infohash.clone(),
indexer: Some(best.indexer.clone()), indexer: Some(best.indexer.clone()),
error: None, error: None,
}, },
Err(e) => AlbumDownloadResult { Err(e) => DownloadResult {
album_id: album_id.to_string(),
album_title: album_title.to_string(),
artist_name: artist_name.to_string(),
status: DownloadStatus::Failed, status: DownloadStatus::Failed,
torrent_hash: None, torrent_hash: None,
indexer: Some(best.indexer.clone()), indexer: Some(best.indexer.clone()),
+3 -3
View File
@@ -1,11 +1,11 @@
mod db_service;
mod download_service; mod download_service;
mod indexer_service; mod indexer_service;
mod metadata_service; mod metadata_service;
mod torrent_service; mod torrent_service;
pub use download_service::{ pub use db_service::{AlbumRow, AlbumWithArtistRow, ArtistMetadataRow, DbService};
AlbumDownloadResult, ArtistSyncResult, DownloadService, DownloadStatus, pub use download_service::{DownloadService, DownloadStatus, SyncOptions, SyncResult};
};
pub use indexer_service::{IndexerInfo, IndexerService}; pub use indexer_service::{IndexerInfo, IndexerService};
pub use metadata_service::MetadataService; pub use metadata_service::MetadataService;
pub use torrent_service::TorrentService; pub use torrent_service::TorrentService;