Initial commit

This commit is contained in:
Connor Johnstone
2026-03-17 21:56:12 -04:00
commit 50a0ddcdbc
16 changed files with 1110 additions and 0 deletions
+78
View 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
View 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
View 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
View 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
View 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
View 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
View 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))
}