commit 50a0ddcdbc7cf79f579ffc48c6a26272e748c028 Author: Connor Johnstone Date: Tue Mar 17 21:56:12 2026 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e6892f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +.env +*.db +*.db-journal +static/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..da3fa80 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "shanty-web" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Web interface backend for Shanty" +repository = "ssh://connor@git.rcjohnstone.com:2222/Shanty/web.git" + +[dependencies] +shanty-db = { path = "../shanty-db" } +shanty-index = { path = "../shanty-index" } +shanty-tag = { path = "../shanty-tag" } +shanty-org = { path = "../shanty-org" } +shanty-watch = { path = "../shanty-watch" } +shanty-dl = { path = "../shanty-dl" } +shanty-search = { path = "../shanty-search" } +sea-orm = { version = "1", features = ["sqlx-sqlite", "runtime-tokio-native-tls"] } +actix-web = "4" +actix-cors = "0.7" +actix-files = "0.6" +thiserror = "2" +anyhow = "1" +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-actix-web = "0.7" +chrono = { version = "0.4", features = ["serde"] } +dirs = "6" +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +actix-rt = "2" +tokio = { version = "1", features = ["full", "test-util"] } diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..0d1c2bd --- /dev/null +++ b/readme.md @@ -0,0 +1,30 @@ +# shanty-web + +Web interface backend for [Shanty](ssh://connor@git.rcjohnstone.com:2222/Shanty/shanty.git). + +Actix-web server exposing a REST API that ties together all Shanty components. +Serves the Elm frontend and orchestrates indexing, tagging, downloading, and organization. + +## Usage + +```sh +shanty-web # start with default config +shanty-web --config /path/to/config.yaml +shanty-web --port 9090 # override port +``` + +## Configuration + +Create `~/.config/shanty/config.yaml`: + +```yaml +library_path: ~/Music +download_path: ~/.local/share/shanty/downloads +web: + port: 8085 + bind: 0.0.0.0 +``` + +## API + +All endpoints under `/api/`. See the issue tracker for full API documentation. diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..4e703ea --- /dev/null +++ b/src/config.rs @@ -0,0 +1,204 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + #[serde(default = "default_library_path")] + pub library_path: PathBuf, + + #[serde(default = "default_database_url")] + pub database_url: String, + + #[serde(default = "default_download_path")] + pub download_path: PathBuf, + + #[serde(default = "default_organization_format")] + pub organization_format: String, + + #[serde(default)] + pub web: WebConfig, + + #[serde(default)] + pub tagging: TaggingConfig, + + #[serde(default)] + pub download: DownloadConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebConfig { + #[serde(default = "default_port")] + pub port: u16, + + #[serde(default = "default_bind")] + pub bind: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaggingConfig { + #[serde(default)] + pub auto_tag: bool, + + #[serde(default = "default_true")] + pub write_tags: bool, + + #[serde(default = "default_confidence")] + pub confidence: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DownloadConfig { + #[serde(default = "default_format")] + pub format: String, + + #[serde(default = "default_search_source")] + pub search_source: String, + + #[serde(default)] + pub cookies_path: Option, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + library_path: default_library_path(), + database_url: default_database_url(), + download_path: default_download_path(), + organization_format: default_organization_format(), + web: WebConfig::default(), + tagging: TaggingConfig::default(), + download: DownloadConfig::default(), + } + } +} + +impl Default for WebConfig { + fn default() -> Self { + Self { + port: default_port(), + bind: default_bind(), + } + } +} + +impl Default for TaggingConfig { + fn default() -> Self { + Self { + auto_tag: false, + write_tags: true, + confidence: default_confidence(), + } + } +} + +impl Default for DownloadConfig { + fn default() -> Self { + Self { + format: default_format(), + search_source: default_search_source(), + cookies_path: None, + } + } +} + +fn default_library_path() -> PathBuf { + dirs::audio_dir().unwrap_or_else(|| PathBuf::from("~/Music")) +} + +fn default_database_url() -> String { + let data_dir = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("shanty"); + std::fs::create_dir_all(&data_dir).ok(); + format!("sqlite://{}?mode=rwc", data_dir.join("shanty.db").display()) +} + +fn default_download_path() -> PathBuf { + let dir = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("shanty") + .join("downloads"); + std::fs::create_dir_all(&dir).ok(); + dir +} + +fn default_organization_format() -> String { + shanty_org::DEFAULT_FORMAT.to_string() +} + +fn default_port() -> u16 { + 8085 +} +fn default_bind() -> String { + "0.0.0.0".to_string() +} +fn default_confidence() -> f64 { + 0.8 +} +fn default_format() -> String { + "opus".to_string() +} +fn default_search_source() -> String { + "ytmusic".to_string() +} +fn default_true() -> bool { + true +} + +impl AppConfig { + /// Load config from file, falling back to defaults. + pub fn load(path: Option<&str>) -> Self { + let config_path = path + .map(PathBuf::from) + .or_else(|| std::env::var("SHANTY_CONFIG").ok().map(PathBuf::from)) + .unwrap_or_else(|| { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("shanty") + .join("config.yaml") + }); + + if config_path.exists() { + match std::fs::read_to_string(&config_path) { + Ok(contents) => match serde_yaml::from_str(&contents) { + Ok(config) => { + tracing::info!(path = %config_path.display(), "loaded config"); + return Self::apply_env_overrides(config); + } + Err(e) => { + tracing::warn!(path = %config_path.display(), error = %e, "failed to parse config, using defaults"); + } + }, + Err(e) => { + tracing::warn!(path = %config_path.display(), error = %e, "failed to read config, using defaults"); + } + } + } else { + tracing::info!(path = %config_path.display(), "no config file found, using defaults"); + } + + Self::apply_env_overrides(AppConfig::default()) + } + + fn apply_env_overrides(mut config: Self) -> Self { + if let Ok(v) = std::env::var("SHANTY_DATABASE_URL") { + config.database_url = v; + } + if let Ok(v) = std::env::var("SHANTY_LIBRARY_PATH") { + config.library_path = PathBuf::from(v); + } + if let Ok(v) = std::env::var("SHANTY_DOWNLOAD_PATH") { + config.download_path = PathBuf::from(v); + } + if let Ok(v) = std::env::var("SHANTY_WEB_PORT") { + if let Ok(port) = v.parse() { + config.web.port = port; + } + } + if let Ok(v) = std::env::var("SHANTY_WEB_BIND") { + config.web.bind = v; + } + config + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..99c47b8 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,68 @@ +use actix_web::{HttpResponse, ResponseError}; +use serde::Serialize; + +#[derive(Debug, Serialize)] +struct ErrorResponse { + error: String, + status: u16, +} + +#[derive(Debug, thiserror::Error)] +pub enum ApiError { + #[error("not found: {0}")] + NotFound(String), + + #[error("bad request: {0}")] + BadRequest(String), + + #[error("internal error: {0}")] + Internal(String), +} + +impl ResponseError for ApiError { + fn error_response(&self) -> HttpResponse { + let (status, message) = match self { + ApiError::NotFound(msg) => (actix_web::http::StatusCode::NOT_FOUND, msg.clone()), + ApiError::BadRequest(msg) => (actix_web::http::StatusCode::BAD_REQUEST, msg.clone()), + ApiError::Internal(msg) => { + tracing::error!(error = %msg, "internal error"); + ( + actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, + msg.clone(), + ) + } + }; + + HttpResponse::build(status).json(ErrorResponse { + error: message, + status: status.as_u16(), + }) + } +} + +impl From for ApiError { + fn from(e: shanty_db::DbError) -> Self { + match e { + shanty_db::DbError::NotFound(msg) => ApiError::NotFound(msg), + other => ApiError::Internal(other.to_string()), + } + } +} + +impl From for ApiError { + fn from(e: shanty_watch::WatchError) -> Self { + ApiError::Internal(e.to_string()) + } +} + +impl From for ApiError { + fn from(e: shanty_search::SearchError) -> Self { + ApiError::Internal(e.to_string()) + } +} + +impl From for ApiError { + fn from(e: shanty_dl::DlError) -> Self { + ApiError::Internal(e.to_string()) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..08e6138 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,11 @@ +//! Web interface backend for Shanty. +//! +//! An Actix-web server that ties all Shanty components together, exposing a REST +//! API consumed by the Elm frontend. Handles background tasks, configuration, +//! and orchestration of indexing, tagging, downloading, and more. + +pub mod config; +pub mod error; +pub mod routes; +pub mod state; +pub mod tasks; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9a297fb --- /dev/null +++ b/src/main.rs @@ -0,0 +1,88 @@ +use actix_cors::Cors; +use actix_web::{web, App, HttpServer}; +use clap::Parser; +use tracing_actix_web::TracingLogger; +use tracing_subscriber::EnvFilter; + +use shanty_db::Database; +use shanty_search::MusicBrainzSearch; +use shanty_tag::MusicBrainzClient; + +use shanty_web::config::AppConfig; +use shanty_web::routes; +use shanty_web::state::AppState; +use shanty_web::tasks::TaskManager; + +#[derive(Parser)] +#[command(name = "shanty-web", about = "Shanty web interface backend")] +struct Cli { + /// Path to config file. + #[arg(long, env = "SHANTY_CONFIG")] + config: Option, + + /// Override the port. + #[arg(long)] + port: Option, + + /// Increase verbosity (-v info, -vv debug, -vvv trace). + #[arg(short, long, action = clap::ArgAction::Count)] + verbose: u8, +} + +#[actix_web::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + let filter = match cli.verbose { + 0 => "info,shanty_web=info", + 1 => "info,shanty_web=debug", + _ => "debug,shanty_web=trace", + }; + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(filter)), + ) + .init(); + + let mut config = AppConfig::load(cli.config.as_deref()); + if let Some(port) = cli.port { + config.web.port = port; + } + + tracing::info!(url = %config.database_url, "connecting to database"); + let db = Database::new(&config.database_url).await?; + + let mb_client = MusicBrainzClient::new()?; + let search = MusicBrainzSearch::new()?; + + let bind = format!("{}:{}", config.web.bind, config.web.port); + tracing::info!(bind = %bind, "starting server"); + + let state = web::Data::new(AppState { + db, + mb_client, + search, + config, + tasks: TaskManager::new(), + }); + + HttpServer::new(move || { + let cors = Cors::permissive(); + + App::new() + .wrap(cors) + .wrap(TracingLogger::default()) + .app_data(state.clone()) + .configure(routes::configure) + .service( + actix_files::Files::new("/", "static/") + .index_file("index.html") + .prefer_utf8(true), + ) + }) + .bind(&bind)? + .run() + .await?; + + Ok(()) +} diff --git a/src/routes/albums.rs b/src/routes/albums.rs new file mode 100644 index 0000000..e126c2a --- /dev/null +++ b/src/routes/albums.rs @@ -0,0 +1,78 @@ +use actix_web::{web, HttpResponse}; +use serde::Deserialize; + +use shanty_db::queries; + +use crate::error::ApiError; +use crate::state::AppState; + +#[derive(Deserialize)] +pub struct PaginationParams { + #[serde(default = "default_limit")] + limit: u64, + #[serde(default)] + offset: u64, +} +fn default_limit() -> u64 { 50 } + +#[derive(Deserialize)] +pub struct AddAlbumRequest { + artist: Option, + album: Option, + mbid: Option, +} + +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service( + web::resource("/albums") + .route(web::get().to(list_albums)) + .route(web::post().to(add_album)), + ) + .service( + web::resource("/albums/{id}") + .route(web::get().to(get_album)), + ); +} + +async fn list_albums( + state: web::Data, + query: web::Query, +) -> Result { + let albums = queries::albums::list(state.db.conn(), query.limit, query.offset).await?; + Ok(HttpResponse::Ok().json(albums)) +} + +async fn get_album( + state: web::Data, + path: web::Path, +) -> Result { + let id = path.into_inner(); + let album = queries::albums::get_by_id(state.db.conn(), id).await?; + let tracks = queries::tracks::get_by_album(state.db.conn(), id).await?; + Ok(HttpResponse::Ok().json(serde_json::json!({ + "album": album, + "tracks": tracks, + }))) +} + +async fn add_album( + state: web::Data, + body: web::Json, +) -> Result { + if body.artist.is_none() && body.album.is_none() && body.mbid.is_none() { + return Err(ApiError::BadRequest("provide artist+album or mbid".into())); + } + let summary = shanty_watch::add_album( + state.db.conn(), + body.artist.as_deref(), + body.album.as_deref(), + body.mbid.as_deref(), + &state.mb_client, + ) + .await?; + Ok(HttpResponse::Ok().json(serde_json::json!({ + "tracks_added": summary.tracks_added, + "tracks_already_owned": summary.tracks_already_owned, + "errors": summary.errors, + }))) +} diff --git a/src/routes/artists.rs b/src/routes/artists.rs new file mode 100644 index 0000000..f4f9a27 --- /dev/null +++ b/src/routes/artists.rs @@ -0,0 +1,86 @@ +use actix_web::{web, HttpResponse}; +use serde::Deserialize; + +use shanty_db::queries; + +use crate::error::ApiError; +use crate::state::AppState; + +#[derive(Deserialize)] +pub struct PaginationParams { + #[serde(default = "default_limit")] + limit: u64, + #[serde(default)] + offset: u64, +} +fn default_limit() -> u64 { 50 } + +#[derive(Deserialize)] +pub struct AddArtistRequest { + name: Option, + mbid: Option, +} + +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service( + web::resource("/artists") + .route(web::get().to(list_artists)) + .route(web::post().to(add_artist)), + ) + .service( + web::resource("/artists/{id}") + .route(web::get().to(get_artist)) + .route(web::delete().to(delete_artist)), + ); +} + +async fn list_artists( + state: web::Data, + query: web::Query, +) -> Result { + let artists = queries::artists::list(state.db.conn(), query.limit, query.offset).await?; + Ok(HttpResponse::Ok().json(artists)) +} + +async fn get_artist( + state: web::Data, + path: web::Path, +) -> Result { + let id = path.into_inner(); + let artist = queries::artists::get_by_id(state.db.conn(), id).await?; + let albums = queries::albums::get_by_artist(state.db.conn(), id).await?; + Ok(HttpResponse::Ok().json(serde_json::json!({ + "artist": artist, + "albums": albums, + }))) +} + +async fn add_artist( + state: web::Data, + body: web::Json, +) -> Result { + if body.name.is_none() && body.mbid.is_none() { + return Err(ApiError::BadRequest("provide name or mbid".into())); + } + let summary = shanty_watch::add_artist( + state.db.conn(), + body.name.as_deref(), + body.mbid.as_deref(), + &state.mb_client, + ) + .await?; + Ok(HttpResponse::Ok().json(serde_json::json!({ + "tracks_added": summary.tracks_added, + "tracks_already_owned": summary.tracks_already_owned, + "errors": summary.errors, + }))) +} + +async fn delete_artist( + state: web::Data, + path: web::Path, +) -> Result { + let id = path.into_inner(); + queries::artists::delete(state.db.conn(), id).await?; + Ok(HttpResponse::NoContent().finish()) +} diff --git a/src/routes/downloads.rs b/src/routes/downloads.rs new file mode 100644 index 0000000..c20376e --- /dev/null +++ b/src/routes/downloads.rs @@ -0,0 +1,125 @@ +use actix_web::{web, HttpResponse}; +use serde::Deserialize; + +use shanty_db::entities::download_queue::DownloadStatus; +use shanty_db::queries; + +use crate::error::ApiError; +use crate::state::AppState; + +#[derive(Deserialize)] +pub struct QueueParams { + status: Option, +} + +#[derive(Deserialize)] +pub struct EnqueueRequest { + query: String, +} + +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service( + web::resource("/downloads/queue") + .route(web::get().to(list_queue)), + ) + .service( + web::resource("/downloads") + .route(web::post().to(enqueue_download)), + ) + .service( + web::resource("/downloads/sync") + .route(web::post().to(sync_downloads)), + ) + .service( + web::resource("/downloads/process") + .route(web::post().to(trigger_process)), + ) + .service( + web::resource("/downloads/retry/{id}") + .route(web::post().to(retry_download)), + ) + .service( + web::resource("/downloads/{id}") + .route(web::delete().to(cancel_download)), + ); +} + +async fn list_queue( + state: web::Data, + query: web::Query, +) -> Result { + let filter = match query.status.as_deref() { + Some("pending") => Some(DownloadStatus::Pending), + Some("downloading") => Some(DownloadStatus::Downloading), + Some("completed") => Some(DownloadStatus::Completed), + Some("failed") => Some(DownloadStatus::Failed), + Some("cancelled") => Some(DownloadStatus::Cancelled), + _ => None, + }; + let items = queries::downloads::list(state.db.conn(), filter).await?; + Ok(HttpResponse::Ok().json(items)) +} + +async fn enqueue_download( + state: web::Data, + body: web::Json, +) -> Result { + let item = queries::downloads::enqueue(state.db.conn(), &body.query, None, "ytdlp").await?; + Ok(HttpResponse::Ok().json(item)) +} + +async fn sync_downloads( + state: web::Data, +) -> Result { + let stats = shanty_dl::sync_wanted_to_queue(state.db.conn(), false).await?; + Ok(HttpResponse::Ok().json(serde_json::json!({ + "found": stats.found, + "enqueued": stats.enqueued, + "skipped": stats.skipped, + }))) +} + +async fn trigger_process( + state: web::Data, +) -> Result { + let task_id = state.tasks.register("download"); + let state = state.clone(); + let tid = task_id.clone(); + + tokio::spawn(async move { + let cookies = state.config.download.cookies_path.clone(); + let format: shanty_dl::AudioFormat = state.config.download.format.parse().unwrap_or(shanty_dl::AudioFormat::Opus); + let source: shanty_dl::SearchSource = state.config.download.search_source.parse().unwrap_or(shanty_dl::SearchSource::YouTubeMusic); + let rate = if cookies.is_some() { 1800 } else { 450 }; + let backend = shanty_dl::YtDlpBackend::new(rate, source, cookies.clone()); + let backend_config = shanty_dl::BackendConfig { + output_dir: state.config.download_path.clone(), + format, + cookies_path: cookies, + }; + match shanty_dl::run_queue(state.db.conn(), &backend, &backend_config, false).await { + Ok(stats) => state.tasks.complete(&tid, format!("{stats}")), + Err(e) => state.tasks.fail(&tid, e.to_string()), + } + }); + + Ok(HttpResponse::Accepted().json(serde_json::json!({ "task_id": task_id }))) +} + +async fn retry_download( + state: web::Data, + path: web::Path, +) -> Result { + let id = path.into_inner(); + queries::downloads::retry_failed(state.db.conn(), id).await?; + Ok(HttpResponse::Ok().json(serde_json::json!({ "status": "requeued" }))) +} + +async fn cancel_download( + state: web::Data, + path: web::Path, +) -> Result { + let id = path.into_inner(); + queries::downloads::update_status(state.db.conn(), id, DownloadStatus::Cancelled, None).await?; + Ok(HttpResponse::Ok().json(serde_json::json!({ "status": "cancelled" }))) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs new file mode 100644 index 0000000..6cf29b5 --- /dev/null +++ b/src/routes/mod.rs @@ -0,0 +1,20 @@ +pub mod albums; +pub mod artists; +pub mod downloads; +pub mod search; +pub mod system; +pub mod tracks; + +use actix_web::web; + +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/api") + .configure(artists::configure) + .configure(albums::configure) + .configure(tracks::configure) + .configure(search::configure) + .configure(downloads::configure) + .configure(system::configure), + ); +} diff --git a/src/routes/search.rs b/src/routes/search.rs new file mode 100644 index 0000000..5aac97c --- /dev/null +++ b/src/routes/search.rs @@ -0,0 +1,70 @@ +use actix_web::{web, HttpResponse}; +use serde::Deserialize; + +use shanty_search::SearchProvider; + +use crate::error::ApiError; +use crate::state::AppState; + +#[derive(Deserialize)] +pub struct ArtistSearchParams { + q: String, + #[serde(default = "default_limit")] + limit: u32, +} + +#[derive(Deserialize)] +pub struct AlbumTrackSearchParams { + q: String, + artist: Option, + #[serde(default = "default_limit")] + limit: u32, +} + +fn default_limit() -> u32 { 10 } + +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service(web::resource("/search/artist").route(web::get().to(search_artist))) + .service(web::resource("/search/album").route(web::get().to(search_album))) + .service(web::resource("/search/track").route(web::get().to(search_track))) + .service(web::resource("/search/discography/{id}").route(web::get().to(get_discography))); +} + +async fn search_artist( + state: web::Data, + query: web::Query, +) -> Result { + let results = state.search.search_artist(&query.q, query.limit).await?; + Ok(HttpResponse::Ok().json(results)) +} + +async fn search_album( + state: web::Data, + query: web::Query, +) -> Result { + let results = state + .search + .search_album(&query.q, query.artist.as_deref(), query.limit) + .await?; + Ok(HttpResponse::Ok().json(results)) +} + +async fn search_track( + state: web::Data, + query: web::Query, +) -> Result { + let results = state + .search + .search_track(&query.q, query.artist.as_deref(), query.limit) + .await?; + Ok(HttpResponse::Ok().json(results)) +} + +async fn get_discography( + state: web::Data, + path: web::Path, +) -> Result { + let artist_id = path.into_inner(); + let disco = state.search.get_discography(&artist_id).await?; + Ok(HttpResponse::Ok().json(disco)) +} diff --git a/src/routes/system.rs b/src/routes/system.rs new file mode 100644 index 0000000..672a811 --- /dev/null +++ b/src/routes/system.rs @@ -0,0 +1,147 @@ +use actix_web::{web, HttpResponse}; + +use shanty_db::entities::download_queue::DownloadStatus; +use shanty_db::queries; + +use crate::error::ApiError; +use crate::state::AppState; + +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service(web::resource("/status").route(web::get().to(get_status))) + .service(web::resource("/index").route(web::post().to(trigger_index))) + .service(web::resource("/tag").route(web::post().to(trigger_tag))) + .service(web::resource("/organize").route(web::post().to(trigger_organize))) + .service(web::resource("/tasks/{id}").route(web::get().to(get_task))) + .service(web::resource("/watchlist").route(web::get().to(list_watchlist))) + .service(web::resource("/watchlist/{id}").route(web::delete().to(remove_watchlist))) + .service(web::resource("/config").route(web::get().to(get_config))); +} + +async fn get_status( + state: web::Data, +) -> Result { + let summary = shanty_watch::library_summary(state.db.conn()).await?; + let pending = queries::downloads::list(state.db.conn(), Some(DownloadStatus::Pending)) + .await? + .len(); + let downloading = queries::downloads::list(state.db.conn(), Some(DownloadStatus::Downloading)) + .await? + .len(); + let tasks = state.tasks.list(); + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "library": summary, + "queue": { + "pending": pending, + "downloading": downloading, + }, + "tasks": tasks, + }))) +} + +async fn trigger_index( + state: web::Data, +) -> Result { + let task_id = state.tasks.register("index"); + let state = state.clone(); + let tid = task_id.clone(); + + tokio::spawn(async move { + let scan_config = shanty_index::ScanConfig { + root: state.config.library_path.clone(), + dry_run: false, + concurrency: 4, + }; + match shanty_index::run_scan(state.db.conn(), &scan_config).await { + Ok(stats) => state.tasks.complete(&tid, format!("{stats}")), + Err(e) => state.tasks.fail(&tid, e.to_string()), + } + }); + + Ok(HttpResponse::Accepted().json(serde_json::json!({ "task_id": task_id }))) +} + +async fn trigger_tag( + state: web::Data, +) -> Result { + let task_id = state.tasks.register("tag"); + let state = state.clone(); + let tid = task_id.clone(); + + tokio::spawn(async move { + let mb = match shanty_tag::MusicBrainzClient::new() { + Ok(c) => c, + Err(e) => { + state.tasks.fail(&tid, e.to_string()); + return; + } + }; + let tag_config = shanty_tag::TagConfig { + dry_run: false, + write_tags: state.config.tagging.write_tags, + confidence: state.config.tagging.confidence, + }; + match shanty_tag::run_tagging(state.db.conn(), &mb, &tag_config, None).await { + Ok(stats) => state.tasks.complete(&tid, format!("{stats}")), + Err(e) => state.tasks.fail(&tid, e.to_string()), + } + }); + + Ok(HttpResponse::Accepted().json(serde_json::json!({ "task_id": task_id }))) +} + +async fn trigger_organize( + state: web::Data, +) -> Result { + let task_id = state.tasks.register("organize"); + let state = state.clone(); + let tid = task_id.clone(); + + tokio::spawn(async move { + let org_config = shanty_org::OrgConfig { + target_dir: state.config.library_path.clone(), + format: state.config.organization_format.clone(), + dry_run: false, + copy: false, + }; + match shanty_org::organize_from_db(state.db.conn(), &org_config).await { + Ok(stats) => state.tasks.complete(&tid, format!("{stats}")), + Err(e) => state.tasks.fail(&tid, e.to_string()), + } + }); + + Ok(HttpResponse::Accepted().json(serde_json::json!({ "task_id": task_id }))) +} + +async fn get_task( + state: web::Data, + path: web::Path, +) -> Result { + let id = path.into_inner(); + match state.tasks.get(&id) { + Some(task) => Ok(HttpResponse::Ok().json(task)), + None => Err(ApiError::NotFound(format!("task {id}"))), + } +} + +async fn list_watchlist( + state: web::Data, +) -> Result { + let items = shanty_watch::list_items(state.db.conn(), None, None).await?; + Ok(HttpResponse::Ok().json(items)) +} + +async fn remove_watchlist( + state: web::Data, + path: web::Path, +) -> Result { + let id = path.into_inner(); + shanty_watch::remove_item(state.db.conn(), id).await?; + Ok(HttpResponse::NoContent().finish()) +} + +async fn get_config( + state: web::Data, +) -> Result { + Ok(HttpResponse::Ok().json(&state.config)) +} diff --git a/src/routes/tracks.rs b/src/routes/tracks.rs new file mode 100644 index 0000000..9995f61 --- /dev/null +++ b/src/routes/tracks.rs @@ -0,0 +1,50 @@ +use actix_web::{web, HttpResponse}; +use serde::Deserialize; + +use shanty_db::queries; + +use crate::error::ApiError; +use crate::state::AppState; + +fn default_limit() -> u64 { 50 } + +#[derive(Deserialize)] +pub struct SearchParams { + q: Option, + #[serde(default = "default_limit")] + limit: u64, + #[serde(default)] + offset: u64, +} + +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service( + web::resource("/tracks") + .route(web::get().to(list_tracks)), + ) + .service( + web::resource("/tracks/{id}") + .route(web::get().to(get_track)), + ); +} + +async fn list_tracks( + state: web::Data, + query: web::Query, +) -> Result { + let tracks = if let Some(ref q) = query.q { + queries::tracks::search(state.db.conn(), q).await? + } else { + queries::tracks::list(state.db.conn(), query.limit, query.offset).await? + }; + Ok(HttpResponse::Ok().json(tracks)) +} + +async fn get_track( + state: web::Data, + path: web::Path, +) -> Result { + let id = path.into_inner(); + let track = queries::tracks::get_by_id(state.db.conn(), id).await?; + Ok(HttpResponse::Ok().json(track)) +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..023d2c0 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,14 @@ +use shanty_db::Database; +use shanty_search::MusicBrainzSearch; +use shanty_tag::MusicBrainzClient; + +use crate::config::AppConfig; +use crate::tasks::TaskManager; + +pub struct AppState { + pub db: Database, + pub mb_client: MusicBrainzClient, + pub search: MusicBrainzSearch, + pub config: AppConfig, + pub tasks: TaskManager, +} diff --git a/src/tasks.rs b/src/tasks.rs new file mode 100644 index 0000000..bc8a692 --- /dev/null +++ b/src/tasks.rs @@ -0,0 +1,77 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use chrono::{NaiveDateTime, Utc}; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct TaskInfo { + pub id: String, + pub task_type: String, + pub status: TaskStatus, + pub started_at: NaiveDateTime, + pub completed_at: Option, + pub result: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum TaskStatus { + Running, + Completed, + Failed, +} + +pub struct TaskManager { + tasks: Mutex>, +} + +impl TaskManager { + pub fn new() -> Self { + Self { + tasks: Mutex::new(HashMap::new()), + } + } + + /// Register a new task as Running. Returns the task ID. + pub fn register(&self, task_type: &str) -> String { + let id = uuid::Uuid::new_v4().to_string(); + let info = TaskInfo { + id: id.clone(), + task_type: task_type.to_string(), + status: TaskStatus::Running, + started_at: Utc::now().naive_utc(), + completed_at: None, + result: None, + }; + self.tasks.lock().unwrap().insert(id.clone(), info); + id + } + + /// Mark a task as completed with a result string. + pub fn complete(&self, id: &str, result: String) { + if let Some(task) = self.tasks.lock().unwrap().get_mut(id) { + task.status = TaskStatus::Completed; + task.completed_at = Some(Utc::now().naive_utc()); + task.result = Some(result); + } + } + + /// Mark a task as failed with an error message. + pub fn fail(&self, id: &str, error: String) { + if let Some(task) = self.tasks.lock().unwrap().get_mut(id) { + task.status = TaskStatus::Failed; + task.completed_at = Some(Utc::now().naive_utc()); + task.result = Some(error); + } + } + + /// Get a task by ID. + pub fn get(&self, id: &str) -> Option { + self.tasks.lock().unwrap().get(id).cloned() + } + + /// List all tasks. + pub fn list(&self) -> Vec { + self.tasks.lock().unwrap().values().cloned().collect() + } +}