Files
web/src/routes/albums.rs
Connor Johnstone 421ec3199b Added auth
2026-03-19 14:02:33 -04:00

216 lines
6.8 KiB
Rust

use actix_session::Session;
use actix_web::{HttpResponse, web};
use serde::{Deserialize, Serialize};
use shanty_db::entities::wanted_item::WantedStatus;
use shanty_db::queries;
use shanty_tag::provider::MetadataProvider;
use crate::auth;
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>,
}
#[derive(Serialize)]
struct AlbumTrackInfo {
recording_mbid: String,
title: String,
track_number: Option<i32>,
disc_number: Option<i32>,
duration_ms: Option<u64>,
status: 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/{mbid}").route(web::get().to(get_album)));
}
async fn list_albums(
state: web::Data<AppState>,
session: Session,
query: web::Query<PaginationParams>,
) -> Result<HttpResponse, ApiError> {
auth::require_auth(&session)?;
let albums = queries::albums::list(state.db.conn(), query.limit, query.offset).await?;
Ok(HttpResponse::Ok().json(albums))
}
/// Get album by MBID. Accepts either a release MBID or a release-group MBID.
/// Tries as a release first; if that fails (404), treats it as a release-group
/// and browses for its first release.
async fn get_album(
state: web::Data<AppState>,
session: Session,
path: web::Path<String>,
) -> Result<HttpResponse, ApiError> {
auth::require_auth(&session)?;
let mbid = path.into_inner();
// Try fetching as a release first
let mb_tracks = match state.mb_client.get_release_tracks(&mbid).await {
Ok(tracks) => tracks,
Err(_) => {
// Probably a release-group MBID. Browse releases for this group.
let release_mbid = resolve_release_from_group(&state, &mbid).await?;
state
.mb_client
.get_release_tracks(&release_mbid)
.await
.map_err(|e| ApiError::Internal(format!("MusicBrainz error: {e}")))?
}
};
// Get all wanted items to check local status
let all_wanted = queries::wanted::list(state.db.conn(), None, None).await?;
let tracks: Vec<AlbumTrackInfo> = mb_tracks
.into_iter()
.map(|t| {
let status = all_wanted
.iter()
.find(|w| w.musicbrainz_id.as_deref() == Some(&t.recording_mbid))
.map(|w| match w.status {
WantedStatus::Owned => "owned",
WantedStatus::Downloaded => "downloaded",
WantedStatus::Wanted => "wanted",
WantedStatus::Available => "available",
})
.map(String::from);
AlbumTrackInfo {
recording_mbid: t.recording_mbid,
title: t.title,
track_number: t.track_number,
disc_number: t.disc_number,
duration_ms: t.duration_ms,
status,
}
})
.collect();
Ok(HttpResponse::Ok().json(serde_json::json!({
"mbid": mbid,
"tracks": tracks,
})))
}
/// Given a release-group MBID, find the first release MBID via the MB API.
async fn resolve_release_from_group(
_state: &web::Data<AppState>,
release_group_mbid: &str,
) -> Result<String, ApiError> {
// Use the MB client's get_json (it's private, so we go through search)
// The approach: search for releases by this release group
// MB API: /ws/2/release?release-group={mbid}&fmt=json&limit=1
// Since we can't call get_json directly, use the artist_releases approach
// to find a release that matches this group.
//
// Actually, the simplest: the MetadataProvider trait has get_artist_releases
// which returns releases, but we need releases for a release GROUP.
// Let's add a direct HTTP call here via reqwest.
let url = format!(
"https://musicbrainz.org/ws/2/release?release-group={release_group_mbid}&fmt=json&limit=1"
);
// Respect rate limiting by going through a small delay
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
let client = reqwest::Client::builder()
.user_agent("Shanty/0.1.0 (shanty-music-app)")
.build()
.map_err(|e| ApiError::Internal(e.to_string()))?;
let resp: serde_json::Value = client
.get(&url)
.send()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.json()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
resp.get("releases")
.and_then(|r| r.as_array())
.and_then(|arr| arr.first())
.and_then(|r| r.get("id"))
.and_then(|id| id.as_str())
.map(String::from)
.ok_or_else(|| {
ApiError::NotFound(format!(
"no releases found for release group {release_group_mbid}"
))
})
}
async fn add_album(
state: web::Data<AppState>,
session: Session,
body: web::Json<AddAlbumRequest>,
) -> Result<HttpResponse, ApiError> {
let (user_id, _, _) = auth::require_auth(&session)?;
if body.artist.is_none() && body.album.is_none() && body.mbid.is_none() {
return Err(ApiError::BadRequest("provide artist+album or mbid".into()));
}
// Try adding with the given MBID first. If it fails (e.g., the MBID is a release-group,
// not a release), resolve it to an actual release MBID and retry.
let mut mbid = body.mbid.clone();
let result = shanty_watch::add_album(
state.db.conn(),
body.artist.as_deref(),
body.album.as_deref(),
mbid.as_deref(),
&state.mb_client,
Some(user_id),
)
.await;
let summary = match result {
Ok(s) => s,
Err(_) if mbid.is_some() => {
let rg_mbid = mbid.as_deref().unwrap();
let release_mbid = resolve_release_from_group(&state, rg_mbid).await?;
mbid = Some(release_mbid);
shanty_watch::add_album(
state.db.conn(),
body.artist.as_deref(),
body.album.as_deref(),
mbid.as_deref(),
&state.mb_client,
Some(user_id),
)
.await?
}
Err(e) => return Err(e.into()),
};
Ok(HttpResponse::Ok().json(serde_json::json!({
"tracks_added": summary.tracks_added,
"tracks_already_owned": summary.tracks_already_owned,
"errors": summary.errors,
})))
}