Initial commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
target/
|
||||
.env
|
||||
*.db
|
||||
*.db-journal
|
||||
static/
|
||||
37
Cargo.toml
Normal file
37
Cargo.toml
Normal file
@@ -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"] }
|
||||
30
readme.md
Normal file
30
readme.md
Normal file
@@ -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.
|
||||
204
src/config.rs
Normal file
204
src/config.rs
Normal file
@@ -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<PathBuf>,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
68
src/error.rs
Normal file
68
src/error.rs
Normal file
@@ -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<shanty_db::DbError> 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<shanty_watch::WatchError> for ApiError {
|
||||
fn from(e: shanty_watch::WatchError) -> Self {
|
||||
ApiError::Internal(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<shanty_search::SearchError> for ApiError {
|
||||
fn from(e: shanty_search::SearchError) -> Self {
|
||||
ApiError::Internal(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<shanty_dl::DlError> for ApiError {
|
||||
fn from(e: shanty_dl::DlError) -> Self {
|
||||
ApiError::Internal(e.to_string())
|
||||
}
|
||||
}
|
||||
11
src/lib.rs
Normal file
11
src/lib.rs
Normal file
@@ -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;
|
||||
88
src/main.rs
Normal file
88
src/main.rs
Normal file
@@ -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<String>,
|
||||
|
||||
/// Override the port.
|
||||
#[arg(long)]
|
||||
port: Option<u16>,
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
78
src/routes/albums.rs
Normal file
78
src/routes/albums.rs
Normal file
@@ -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<String>,
|
||||
album: Option<String>,
|
||||
mbid: Option<String>,
|
||||
}
|
||||
|
||||
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<AppState>,
|
||||
query: web::Query<PaginationParams>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
path: web::Path<i32>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
body: web::Json<AddAlbumRequest>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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,
|
||||
})))
|
||||
}
|
||||
86
src/routes/artists.rs
Normal file
86
src/routes/artists.rs
Normal file
@@ -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<String>,
|
||||
mbid: Option<String>,
|
||||
}
|
||||
|
||||
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<AppState>,
|
||||
query: web::Query<PaginationParams>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
path: web::Path<i32>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
body: web::Json<AddArtistRequest>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
path: web::Path<i32>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let id = path.into_inner();
|
||||
queries::artists::delete(state.db.conn(), id).await?;
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
125
src/routes/downloads.rs
Normal file
125
src/routes/downloads.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<AppState>,
|
||||
query: web::Query<QueueParams>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
body: web::Json<EnqueueRequest>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
path: web::Path<i32>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
path: web::Path<i32>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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" })))
|
||||
}
|
||||
20
src/routes/mod.rs
Normal file
20
src/routes/mod.rs
Normal file
@@ -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),
|
||||
);
|
||||
}
|
||||
70
src/routes/search.rs
Normal file
70
src/routes/search.rs
Normal file
@@ -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<String>,
|
||||
#[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<AppState>,
|
||||
query: web::Query<ArtistSearchParams>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let results = state.search.search_artist(&query.q, query.limit).await?;
|
||||
Ok(HttpResponse::Ok().json(results))
|
||||
}
|
||||
|
||||
async fn search_album(
|
||||
state: web::Data<AppState>,
|
||||
query: web::Query<AlbumTrackSearchParams>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
query: web::Query<AlbumTrackSearchParams>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let artist_id = path.into_inner();
|
||||
let disco = state.search.get_discography(&artist_id).await?;
|
||||
Ok(HttpResponse::Ok().json(disco))
|
||||
}
|
||||
147
src/routes/system.rs
Normal file
147
src/routes/system.rs
Normal file
@@ -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<AppState>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let items = shanty_watch::list_items(state.db.conn(), None, None).await?;
|
||||
Ok(HttpResponse::Ok().json(items))
|
||||
}
|
||||
|
||||
async fn remove_watchlist(
|
||||
state: web::Data<AppState>,
|
||||
path: web::Path<i32>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
Ok(HttpResponse::Ok().json(&state.config))
|
||||
}
|
||||
50
src/routes/tracks.rs
Normal file
50
src/routes/tracks.rs
Normal file
@@ -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<String>,
|
||||
#[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<AppState>,
|
||||
query: web::Query<SearchParams>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<AppState>,
|
||||
path: web::Path<i32>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let id = path.into_inner();
|
||||
let track = queries::tracks::get_by_id(state.db.conn(), id).await?;
|
||||
Ok(HttpResponse::Ok().json(track))
|
||||
}
|
||||
14
src/state.rs
Normal file
14
src/state.rs
Normal file
@@ -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,
|
||||
}
|
||||
77
src/tasks.rs
Normal file
77
src/tasks.rs
Normal file
@@ -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<NaiveDateTime>,
|
||||
pub result: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum TaskStatus {
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
pub struct TaskManager {
|
||||
tasks: Mutex<HashMap<String, TaskInfo>>,
|
||||
}
|
||||
|
||||
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<TaskInfo> {
|
||||
self.tasks.lock().unwrap().get(id).cloned()
|
||||
}
|
||||
|
||||
/// List all tasks.
|
||||
pub fn list(&self) -> Vec<TaskInfo> {
|
||||
self.tasks.lock().unwrap().values().cloned().collect()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user