Now better matching of the airsonic spec
This commit is contained in:
@@ -6,38 +6,43 @@ use shanty_db::queries;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::helpers::{authenticate, get_query_param, parse_subsonic_id};
|
||||
use super::SubsonicArgs;
|
||||
use super::helpers::{authenticate, parse_subsonic_id};
|
||||
use super::response;
|
||||
|
||||
/// GET /rest/stream[.view]
|
||||
pub async fn stream(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&req, &state).await {
|
||||
/// /rest/stream[.view]
|
||||
pub async fn stream(
|
||||
args: SubsonicArgs,
|
||||
req: HttpRequest,
|
||||
state: web::Data<AppState>,
|
||||
) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&args, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let id_str = match get_query_param(&req, "id") {
|
||||
let id_str = match args.get("id") {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
¶ms,
|
||||
response::ERROR_MISSING_PARAM,
|
||||
"missing required parameter: id",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let (_prefix, track_id) = match parse_subsonic_id(&id_str) {
|
||||
let (_prefix, track_id) = match parse_subsonic_id(id_str) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "invalid song id");
|
||||
return response::error(¶ms, response::ERROR_NOT_FOUND, "invalid song id");
|
||||
}
|
||||
};
|
||||
|
||||
let track = match queries::tracks::get_by_id(state.db.conn(), track_id).await {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "song not found");
|
||||
return response::error(¶ms, response::ERROR_NOT_FOUND, "song not found");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -47,29 +52,21 @@ pub async fn stream(req: HttpRequest, state: web::Data<AppState>) -> HttpRespons
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
let requested_format = get_query_param(&req, "format");
|
||||
let max_bit_rate = get_query_param(&req, "maxBitRate")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or(0);
|
||||
let requested_format = args.get("format").map(|s| s.to_string());
|
||||
let max_bit_rate: u32 = args.get_parsed("maxBitRate").unwrap_or(0);
|
||||
|
||||
// Determine if transcoding is needed:
|
||||
// - Client explicitly requests a different format
|
||||
// - File is opus/ogg (many mobile clients can't play these natively)
|
||||
// - Client requests a specific bitrate
|
||||
let needs_transcode = match requested_format.as_deref() {
|
||||
Some("raw") => false, // Explicitly asked for no transcoding
|
||||
Some(fmt) if fmt != file_ext => true, // Different format requested
|
||||
Some("raw") => false,
|
||||
Some(fmt) if fmt != file_ext => true,
|
||||
_ => {
|
||||
// Auto-transcode opus/ogg to mp3 since many clients don't support them
|
||||
matches!(file_ext.as_str(), "opus" | "ogg") || (max_bit_rate > 0 && max_bit_rate < 320)
|
||||
}
|
||||
};
|
||||
|
||||
// Check file exists before doing anything
|
||||
if !std::path::Path::new(&track.file_path).exists() {
|
||||
tracing::error!(path = %track.file_path, "track file not found on disk");
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
¶ms,
|
||||
response::ERROR_NOT_FOUND,
|
||||
&format!("file not found: {}", track.file_path),
|
||||
);
|
||||
@@ -80,11 +77,7 @@ pub async fn stream(req: HttpRequest, state: web::Data<AppState>) -> HttpRespons
|
||||
.as_deref()
|
||||
.filter(|f| *f != "raw")
|
||||
.unwrap_or("mp3");
|
||||
let bitrate = if max_bit_rate > 0 {
|
||||
max_bit_rate
|
||||
} else {
|
||||
192 // Default transcoding bitrate
|
||||
};
|
||||
let bitrate = if max_bit_rate > 0 { max_bit_rate } else { 192 };
|
||||
|
||||
let content_type = match target_format {
|
||||
"mp3" => "audio/mpeg",
|
||||
@@ -143,7 +136,7 @@ pub async fn stream(req: HttpRequest, state: web::Data<AppState>) -> HttpRespons
|
||||
match NamedFile::open_async(&track.file_path).await {
|
||||
Ok(file) => file.into_response(&req),
|
||||
Err(_) => response::error(
|
||||
¶ms.format,
|
||||
¶ms,
|
||||
response::ERROR_NOT_FOUND,
|
||||
"transcoding failed",
|
||||
),
|
||||
@@ -154,57 +147,54 @@ pub async fn stream(req: HttpRequest, state: web::Data<AppState>) -> HttpRespons
|
||||
tracing::error!(error = %e, "failed to start ffmpeg");
|
||||
match NamedFile::open_async(&track.file_path).await {
|
||||
Ok(file) => file.into_response(&req),
|
||||
Err(_) => {
|
||||
response::error(¶ms.format, response::ERROR_NOT_FOUND, "file not found")
|
||||
}
|
||||
Err(_) => response::error(¶ms, response::ERROR_NOT_FOUND, "file not found"),
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Serve the file directly with Range request support
|
||||
match NamedFile::open_async(&track.file_path).await {
|
||||
Ok(file) => file.into_response(&req),
|
||||
Err(e) => {
|
||||
tracing::error!(path = %track.file_path, error = %e, "failed to open track file for streaming");
|
||||
response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_NOT_FOUND,
|
||||
"file not found on disk",
|
||||
)
|
||||
response::error(¶ms, response::ERROR_NOT_FOUND, "file not found on disk")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /rest/download[.view]
|
||||
pub async fn download(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&req, &state).await {
|
||||
/// /rest/download[.view]
|
||||
pub async fn download(
|
||||
args: SubsonicArgs,
|
||||
req: HttpRequest,
|
||||
state: web::Data<AppState>,
|
||||
) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&args, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let id_str = match get_query_param(&req, "id") {
|
||||
let id_str = match args.get("id") {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
¶ms,
|
||||
response::ERROR_MISSING_PARAM,
|
||||
"missing required parameter: id",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let (_prefix, track_id) = match parse_subsonic_id(&id_str) {
|
||||
let (_prefix, track_id) = match parse_subsonic_id(id_str) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "invalid song id");
|
||||
return response::error(¶ms, response::ERROR_NOT_FOUND, "invalid song id");
|
||||
}
|
||||
};
|
||||
|
||||
let track = match queries::tracks::get_by_id(state.db.conn(), track_id).await {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return response::error(¶ms.format, response::ERROR_NOT_FOUND, "song not found");
|
||||
return response::error(¶ms, response::ERROR_NOT_FOUND, "song not found");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -224,42 +214,37 @@ pub async fn download(req: HttpRequest, state: web::Data<AppState>) -> HttpRespo
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(path = %track.file_path, error = %e, "failed to open track file for download");
|
||||
response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_NOT_FOUND,
|
||||
"file not found on disk",
|
||||
)
|
||||
response::error(¶ms, response::ERROR_NOT_FOUND, "file not found on disk")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /rest/getCoverArt[.view]
|
||||
pub async fn get_cover_art(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&req, &state).await {
|
||||
/// /rest/getCoverArt[.view]
|
||||
pub async fn get_cover_art(
|
||||
args: SubsonicArgs,
|
||||
req: HttpRequest,
|
||||
state: web::Data<AppState>,
|
||||
) -> HttpResponse {
|
||||
let (params, _user) = match authenticate(&args, &state).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let id_str = match get_query_param(&req, "id") {
|
||||
let id_str = match args.get("id") {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
¶ms,
|
||||
response::ERROR_MISSING_PARAM,
|
||||
"missing required parameter: id",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Cover art IDs can be album IDs (al-N) or artist IDs (ar-N)
|
||||
let (prefix, entity_id) = match parse_subsonic_id(&id_str) {
|
||||
let (prefix, entity_id) = match parse_subsonic_id(id_str) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_NOT_FOUND,
|
||||
"invalid cover art id",
|
||||
);
|
||||
return response::error(¶ms, response::ERROR_NOT_FOUND, "invalid cover art id");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -268,23 +253,17 @@ pub async fn get_cover_art(req: HttpRequest, state: web::Data<AppState>) -> Http
|
||||
let album = match queries::albums::get_by_id(state.db.conn(), entity_id).await {
|
||||
Ok(a) => a,
|
||||
Err(_) => {
|
||||
return response::error(
|
||||
¶ms.format,
|
||||
response::ERROR_NOT_FOUND,
|
||||
"album not found",
|
||||
);
|
||||
return response::error(¶ms, response::ERROR_NOT_FOUND, "album not found");
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref cover_art_path) = album.cover_art_path {
|
||||
// If it's a URL, redirect to it
|
||||
if cover_art_path.starts_with("http://") || cover_art_path.starts_with("https://") {
|
||||
return HttpResponse::TemporaryRedirect()
|
||||
.append_header(("Location", cover_art_path.as_str()))
|
||||
.finish();
|
||||
}
|
||||
|
||||
// Otherwise try to serve as a local file
|
||||
match NamedFile::open_async(cover_art_path).await {
|
||||
Ok(file) => return file.into_response(&req),
|
||||
Err(e) => {
|
||||
@@ -293,7 +272,6 @@ pub async fn get_cover_art(req: HttpRequest, state: web::Data<AppState>) -> Http
|
||||
}
|
||||
}
|
||||
|
||||
// If album has a MusicBrainz ID, redirect to Cover Art Archive
|
||||
if let Some(ref mbid) = album.musicbrainz_id {
|
||||
let url = format!("https://coverartarchive.org/release/{mbid}/front-250");
|
||||
return HttpResponse::TemporaryRedirect()
|
||||
@@ -301,12 +279,45 @@ pub async fn get_cover_art(req: HttpRequest, state: web::Data<AppState>) -> Http
|
||||
.finish();
|
||||
}
|
||||
|
||||
// No cover art available
|
||||
HttpResponse::NotFound().finish()
|
||||
}
|
||||
_ => {
|
||||
// For other types, no cover art
|
||||
HttpResponse::NotFound().finish()
|
||||
"ar" => {
|
||||
let artist = match queries::artists::get_by_id(state.db.conn(), entity_id).await {
|
||||
Ok(a) => a,
|
||||
Err(_) => return HttpResponse::NotFound().finish(),
|
||||
};
|
||||
|
||||
// Look up the cached artist image — same lookup pattern that
|
||||
// browsing::get_artist_info2 uses.
|
||||
let mbid = artist.musicbrainz_id.as_deref().unwrap_or("");
|
||||
if mbid.is_empty() {
|
||||
return HttpResponse::NotFound().finish();
|
||||
}
|
||||
let config = state.config.read().await;
|
||||
let image_source = config.metadata.artist_image_source.clone();
|
||||
drop(config);
|
||||
let key = format!("artist_image:{image_source}:{mbid}");
|
||||
let cached = queries::cache::get(state.db.conn(), &key)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
if let Some(url) = cached {
|
||||
if url.starts_with("http://") || url.starts_with("https://") {
|
||||
HttpResponse::TemporaryRedirect()
|
||||
.append_header(("Location", url))
|
||||
.finish()
|
||||
} else {
|
||||
match NamedFile::open_async(&url).await {
|
||||
Ok(file) => file.into_response(&req),
|
||||
Err(_) => HttpResponse::NotFound().finish(),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HttpResponse::NotFound().finish()
|
||||
}
|
||||
}
|
||||
_ => HttpResponse::NotFound().finish(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user