Initial commit
This commit is contained in:
@@ -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,
|
||||
})))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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" })))
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user