Files
web/src/routes/albums.rs
2026-03-24 15:58:14 -04:00

229 lines
6.9 KiB
Rust

use actix_session::Session;
use actix_web::{HttpResponse, web};
use serde::{Deserialize, Serialize};
use shanty_data::MetadataFetcher;
use shanty_db::entities::wanted_item::WantedStatus;
use shanty_db::queries;
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}/watch").route(web::delete().to(unwatch_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, _release_mbid) = match state.mb_client.get_release_tracks(&mbid).await {
Ok(tracks) => (tracks, mbid.clone()),
Err(_) => {
// Probably a release-group MBID. Browse releases for this group.
let resolved = resolve_release_from_group(&state, &mbid).await?;
let tracks = state
.mb_client
.get_release_tracks(&resolved)
.await
.map_err(|e| ApiError::Internal(format!("MusicBrainz error: {e}")))?;
(tracks, resolved)
}
};
// Get the album artist from the release's recording credits
let album_artist = if let Some(first_track) = mb_tracks.first() {
state
.mb_client
.get_recording(&first_track.recording_mbid)
.await
.ok()
.map(|r| r.artist)
} else {
None
};
// 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,
"artist": album_artist,
"tracks": tracks,
})))
}
/// Given a release-group MBID, find the first release MBID via the shared MB client.
async fn resolve_release_from_group(
state: &web::Data<AppState>,
release_group_mbid: &str,
) -> Result<String, ApiError> {
state
.mb_client
.resolve_release_from_group(release_group_mbid)
.await
.map_err(|e| {
ApiError::NotFound(format!(
"no releases found for release group {release_group_mbid}: {e}"
))
})
}
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,
})))
}
async fn unwatch_album(
state: web::Data<AppState>,
session: Session,
path: web::Path<String>,
) -> Result<HttpResponse, ApiError> {
auth::require_auth(&session)?;
let mbid = path.into_inner();
let conn = state.db.conn();
// Get the album's tracks from MB to find their recording MBIDs
let tracks = match state.mb_client.get_release_tracks(&mbid).await {
Ok(t) => t,
Err(_) => {
// Try as release-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}")))?
}
};
let mut removed = 0u64;
for track in &tracks {
removed += queries::wanted::remove_by_mbid(conn, &track.recording_mbid).await?;
}
Ok(HttpResponse::Ok().json(serde_json::json!({"removed": removed})))
}