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:
Generated
+841
-8
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ roxmltree = "0.20"
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] }
|
||||
tonic = "0.12"
|
||||
prost = "0.13"
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ services:
|
||||
POSTGRES_DB: music_aggregator
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
ports:
|
||||
- "5433:5432"
|
||||
healthcheck:
|
||||
|
||||
@@ -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"]');
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod indexer_controller;
|
||||
mod library_controller;
|
||||
mod metadata_controller;
|
||||
mod sync_controller;
|
||||
mod torrent_controller;
|
||||
@@ -29,6 +30,7 @@ pub fn routes(state: AppState) -> Router {
|
||||
.nest("/torrents", torrent_controller::routes())
|
||||
.nest("/metadata", metadata_controller::routes())
|
||||
.nest("/sync", sync_controller::routes())
|
||||
.nest("/library", library_controller::routes())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,46 @@
|
||||
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::services::{ArtistSyncResult, DownloadService};
|
||||
use crate::services::{DownloadService, SyncOptions, SyncResult};
|
||||
use crate::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/artist", post(sync_artist))
|
||||
Router::new().route("/", post(sync))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SyncArtistRequest {
|
||||
pub name: String,
|
||||
pub struct SyncRequest {
|
||||
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>,
|
||||
Json(req): Json<SyncArtistRequest>,
|
||||
) -> Result<Json<ArtistSyncResult>, (StatusCode, String)> {
|
||||
Json(req): Json<SyncRequest>,
|
||||
) -> Result<Json<SyncResult>, (StatusCode, String)> {
|
||||
let state = state.read().await;
|
||||
|
||||
let result = DownloadService::sync_artist(
|
||||
&req.name,
|
||||
let options = SyncOptions {
|
||||
artist: req.artist,
|
||||
album: req.album,
|
||||
download: req.download,
|
||||
store: req.store,
|
||||
};
|
||||
|
||||
let result = DownloadService::sync(
|
||||
options,
|
||||
&state.metadata_service,
|
||||
&state.indexer_service,
|
||||
&state.torrent_service,
|
||||
state.db_service.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct AppServices {
|
||||
pub indexer_service: services::IndexerService,
|
||||
pub torrent_service: services::TorrentService,
|
||||
pub metadata_service: services::MetadataService,
|
||||
pub db_service: Option<services::DbService>,
|
||||
config_path: String,
|
||||
}
|
||||
|
||||
@@ -22,6 +23,7 @@ impl AppServices {
|
||||
indexer_service: services::IndexerService,
|
||||
torrent_service: services::TorrentService,
|
||||
metadata_service: services::MetadataService,
|
||||
db_service: Option<services::DbService>,
|
||||
config_path: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -29,6 +31,7 @@ impl AppServices {
|
||||
indexer_service,
|
||||
torrent_service,
|
||||
metadata_service,
|
||||
db_service,
|
||||
config_path,
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -5,7 +5,7 @@ use axum::Router;
|
||||
use clap::Parser;
|
||||
use music_agregator::{
|
||||
api, config,
|
||||
services::{IndexerService, MetadataService, TorrentService},
|
||||
services::{DbService, IndexerService, MetadataService, TorrentService},
|
||||
AppServices, AppState,
|
||||
};
|
||||
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(
|
||||
indexer_service,
|
||||
torrent_service,
|
||||
metadata_service,
|
||||
db_service,
|
||||
args.config.clone(),
|
||||
)));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,17 +1,42 @@
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
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)]
|
||||
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_title: String,
|
||||
pub artist_name: String,
|
||||
pub status: DownloadStatus,
|
||||
pub stored: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub download_status: Option<DownloadStatus>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub torrent_hash: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indexer: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
@@ -24,89 +49,150 @@ pub enum DownloadStatus {
|
||||
Skipped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ArtistSyncResult {
|
||||
pub artist_id: String,
|
||||
pub artist_name: String,
|
||||
pub total_albums: usize,
|
||||
pub albums_added: usize,
|
||||
pub albums_failed: usize,
|
||||
pub albums_no_results: usize,
|
||||
pub results: Vec<AlbumDownloadResult>,
|
||||
struct DownloadResult {
|
||||
status: DownloadStatus,
|
||||
torrent_hash: Option<String>,
|
||||
indexer: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
pub struct DownloadService;
|
||||
|
||||
impl DownloadService {
|
||||
pub async fn sync_artist(
|
||||
artist_name: &str,
|
||||
pub async fn sync(
|
||||
options: SyncOptions,
|
||||
metadata: &MetadataService,
|
||||
indexers: &IndexerService,
|
||||
torrent: &TorrentService,
|
||||
) -> Result<ArtistSyncResult, String> {
|
||||
db: Option<&DbService>,
|
||||
) -> Result<SyncResult, String> {
|
||||
let search_result = metadata
|
||||
.search_artists(artist_name, Some(1), None)
|
||||
.search_artists(&options.artist, Some(1), None)
|
||||
.await
|
||||
.map_err(|e| format!("metadata search failed: {}", e))?;
|
||||
|
||||
let artist = search_result
|
||||
.artists
|
||||
.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
|
||||
.get_artist_albums(&artist.id, Some(100), None)
|
||||
.get_artist_albums(&artist.id, Some(500), None)
|
||||
.await
|
||||
.map_err(|e| format!("failed to get albums: {}", e))?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
let mut albums_added = 0;
|
||||
let mut albums_failed = 0;
|
||||
let mut albums_no_results = 0;
|
||||
let albums_to_process: Vec<_> = if let Some(ref album_filter) = options.album {
|
||||
let filter_lower = album_filter.to_lowercase();
|
||||
albums_response
|
||||
.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 result = Self::download_album(
|
||||
&artist.name,
|
||||
&album.id,
|
||||
&album.title,
|
||||
album
|
||||
let mut results = Vec::new();
|
||||
let mut albums_stored = 0;
|
||||
let mut albums_downloaded = 0;
|
||||
let mut albums_no_results = 0;
|
||||
let mut albums_failed = 0;
|
||||
|
||||
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
|
||||
.split('-')
|
||||
.next()
|
||||
.and_then(|y| y.parse().ok()),
|
||||
indexers,
|
||||
torrent,
|
||||
)
|
||||
.await;
|
||||
.and_then(|y| y.parse().ok());
|
||||
|
||||
match result.status {
|
||||
DownloadStatus::Added => albums_added += 1,
|
||||
let dl_result =
|
||||
Self::download_album(&artist.name, &album.title, year, indexers, torrent).await;
|
||||
|
||||
match dl_result.status {
|
||||
DownloadStatus::Added => albums_downloaded += 1,
|
||||
DownloadStatus::NoResults => albums_no_results += 1,
|
||||
DownloadStatus::Failed | DownloadStatus::Skipped => albums_failed += 1,
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
(
|
||||
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_name: artist.name.clone(),
|
||||
total_albums: albums_response.albums.len(),
|
||||
albums_added,
|
||||
albums_failed,
|
||||
total_albums: albums_to_process.len(),
|
||||
albums_stored,
|
||||
albums_downloaded,
|
||||
albums_no_results,
|
||||
albums_failed,
|
||||
results,
|
||||
})
|
||||
}
|
||||
|
||||
async fn download_album(
|
||||
artist_name: &str,
|
||||
album_id: &str,
|
||||
album_title: &str,
|
||||
year: Option<u32>,
|
||||
indexers: &IndexerService,
|
||||
torrent: &TorrentService,
|
||||
) -> AlbumDownloadResult {
|
||||
) -> DownloadResult {
|
||||
let criteria = crate::indexer::MusicSearchCriteria {
|
||||
artist: artist_name.to_string(),
|
||||
album: Some(album_title.to_string()),
|
||||
@@ -118,10 +204,7 @@ impl DownloadService {
|
||||
let search_results = match indexers.search(&criteria, None).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return AlbumDownloadResult {
|
||||
album_id: album_id.to_string(),
|
||||
album_title: album_title.to_string(),
|
||||
artist_name: artist_name.to_string(),
|
||||
return DownloadResult {
|
||||
status: DownloadStatus::Failed,
|
||||
torrent_hash: None,
|
||||
indexer: None,
|
||||
@@ -131,10 +214,7 @@ impl DownloadService {
|
||||
};
|
||||
|
||||
if search_results.is_empty() {
|
||||
return AlbumDownloadResult {
|
||||
album_id: album_id.to_string(),
|
||||
album_title: album_title.to_string(),
|
||||
artist_name: artist_name.to_string(),
|
||||
return DownloadResult {
|
||||
status: DownloadStatus::NoResults,
|
||||
torrent_hash: None,
|
||||
indexer: None,
|
||||
@@ -145,19 +225,13 @@ impl DownloadService {
|
||||
let best = Self::select_best_result(&search_results);
|
||||
|
||||
match torrent.add_torrent_url(&best.download_url, None).await {
|
||||
Ok(()) => AlbumDownloadResult {
|
||||
album_id: album_id.to_string(),
|
||||
album_title: album_title.to_string(),
|
||||
artist_name: artist_name.to_string(),
|
||||
Ok(()) => DownloadResult {
|
||||
status: DownloadStatus::Added,
|
||||
torrent_hash: best.infohash.clone(),
|
||||
indexer: Some(best.indexer.clone()),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => AlbumDownloadResult {
|
||||
album_id: album_id.to_string(),
|
||||
album_title: album_title.to_string(),
|
||||
artist_name: artist_name.to_string(),
|
||||
Err(e) => DownloadResult {
|
||||
status: DownloadStatus::Failed,
|
||||
torrent_hash: None,
|
||||
indexer: Some(best.indexer.clone()),
|
||||
|
||||
+3
-3
@@ -1,11 +1,11 @@
|
||||
mod db_service;
|
||||
mod download_service;
|
||||
mod indexer_service;
|
||||
mod metadata_service;
|
||||
mod torrent_service;
|
||||
|
||||
pub use download_service::{
|
||||
AlbumDownloadResult, ArtistSyncResult, DownloadService, DownloadStatus,
|
||||
};
|
||||
pub use db_service::{AlbumRow, AlbumWithArtistRow, ArtistMetadataRow, DbService};
|
||||
pub use download_service::{DownloadService, DownloadStatus, SyncOptions, SyncResult};
|
||||
pub use indexer_service::{IndexerInfo, IndexerService};
|
||||
pub use metadata_service::MetadataService;
|
||||
pub use torrent_service::TorrentService;
|
||||
|
||||
Reference in New Issue
Block a user