324 lines
11 KiB
Rust
324 lines
11 KiB
Rust
use actix_files::NamedFile;
|
|
use actix_web::{HttpRequest, HttpResponse, web};
|
|
use tokio::process::Command;
|
|
|
|
use shanty_db::queries;
|
|
|
|
use crate::state::AppState;
|
|
|
|
use super::SubsonicArgs;
|
|
use super::helpers::{authenticate, parse_subsonic_id};
|
|
use super::response;
|
|
|
|
/// /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 args.get("id") {
|
|
Some(id) => id,
|
|
None => {
|
|
return response::error(
|
|
¶ms,
|
|
response::ERROR_MISSING_PARAM,
|
|
"missing required parameter: id",
|
|
);
|
|
}
|
|
};
|
|
|
|
let (_prefix, track_id) = match parse_subsonic_id(id_str) {
|
|
Some(v) => v,
|
|
None => {
|
|
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, response::ERROR_NOT_FOUND, "song not found");
|
|
}
|
|
};
|
|
|
|
let file_ext = std::path::Path::new(&track.file_path)
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
|
|
let requested_format = args.get("format").map(|s| s.to_string());
|
|
let max_bit_rate: u32 = args.get_parsed("maxBitRate").unwrap_or(0);
|
|
|
|
let needs_transcode = match requested_format.as_deref() {
|
|
Some("raw") => false,
|
|
Some(fmt) if fmt != file_ext => true,
|
|
_ => {
|
|
matches!(file_ext.as_str(), "opus" | "ogg") || (max_bit_rate > 0 && max_bit_rate < 320)
|
|
}
|
|
};
|
|
|
|
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,
|
|
response::ERROR_NOT_FOUND,
|
|
&format!("file not found: {}", track.file_path),
|
|
);
|
|
}
|
|
|
|
if needs_transcode {
|
|
let target_format = requested_format
|
|
.as_deref()
|
|
.filter(|f| *f != "raw")
|
|
.unwrap_or("mp3");
|
|
let bitrate = if max_bit_rate > 0 { max_bit_rate } else { 192 };
|
|
|
|
let content_type = match target_format {
|
|
"mp3" => "audio/mpeg",
|
|
"opus" => "audio/ogg",
|
|
"ogg" => "audio/ogg",
|
|
"aac" | "m4a" => "audio/mp4",
|
|
"flac" => "audio/flac",
|
|
_ => "audio/mpeg",
|
|
};
|
|
|
|
tracing::debug!(
|
|
track_id = track_id,
|
|
from = %file_ext,
|
|
to = target_format,
|
|
bitrate = bitrate,
|
|
"transcoding stream"
|
|
);
|
|
|
|
match Command::new("ffmpeg")
|
|
.args([
|
|
"-i",
|
|
&track.file_path,
|
|
"-map",
|
|
"0:a",
|
|
"-b:a",
|
|
&format!("{bitrate}k"),
|
|
"-v",
|
|
"0",
|
|
"-f",
|
|
target_format,
|
|
"-",
|
|
])
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.output()
|
|
.await
|
|
{
|
|
Ok(output) => {
|
|
if output.status.success() && !output.stdout.is_empty() {
|
|
tracing::debug!(
|
|
track_id = track_id,
|
|
bytes = output.stdout.len(),
|
|
"transcoding complete"
|
|
);
|
|
HttpResponse::Ok()
|
|
.content_type(content_type)
|
|
.body(output.stdout)
|
|
} else {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
tracing::error!(
|
|
status = ?output.status,
|
|
stderr = %stderr,
|
|
path = %track.file_path,
|
|
"ffmpeg transcoding failed"
|
|
);
|
|
match NamedFile::open_async(&track.file_path).await {
|
|
Ok(file) => file.into_response(&req),
|
|
Err(_) => response::error(
|
|
¶ms,
|
|
response::ERROR_NOT_FOUND,
|
|
"transcoding failed",
|
|
),
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
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, response::ERROR_NOT_FOUND, "file not found"),
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
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, response::ERROR_NOT_FOUND, "file not found on disk")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// /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 args.get("id") {
|
|
Some(id) => id,
|
|
None => {
|
|
return response::error(
|
|
¶ms,
|
|
response::ERROR_MISSING_PARAM,
|
|
"missing required parameter: id",
|
|
);
|
|
}
|
|
};
|
|
|
|
let (_prefix, track_id) = match parse_subsonic_id(id_str) {
|
|
Some(v) => v,
|
|
None => {
|
|
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, response::ERROR_NOT_FOUND, "song not found");
|
|
}
|
|
};
|
|
|
|
match NamedFile::open_async(&track.file_path).await {
|
|
Ok(file) => {
|
|
let file = file.set_content_disposition(actix_web::http::header::ContentDisposition {
|
|
disposition: actix_web::http::header::DispositionType::Attachment,
|
|
parameters: vec![actix_web::http::header::DispositionParam::Filename(
|
|
std::path::Path::new(&track.file_path)
|
|
.file_name()
|
|
.and_then(|f| f.to_str())
|
|
.unwrap_or("track")
|
|
.to_string(),
|
|
)],
|
|
});
|
|
file.into_response(&req)
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(path = %track.file_path, error = %e, "failed to open track file for download");
|
|
response::error(¶ms, response::ERROR_NOT_FOUND, "file not found on disk")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// /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 args.get("id") {
|
|
Some(id) => id,
|
|
None => {
|
|
return response::error(
|
|
¶ms,
|
|
response::ERROR_MISSING_PARAM,
|
|
"missing required parameter: id",
|
|
);
|
|
}
|
|
};
|
|
|
|
let (prefix, entity_id) = match parse_subsonic_id(id_str) {
|
|
Some(v) => v,
|
|
None => {
|
|
return response::error(¶ms, response::ERROR_NOT_FOUND, "invalid cover art id");
|
|
}
|
|
};
|
|
|
|
match prefix {
|
|
"al" => {
|
|
let album = match queries::albums::get_by_id(state.db.conn(), entity_id).await {
|
|
Ok(a) => a,
|
|
Err(_) => {
|
|
return response::error(¶ms, response::ERROR_NOT_FOUND, "album not found");
|
|
}
|
|
};
|
|
|
|
if let Some(ref cover_art_path) = album.cover_art_path {
|
|
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();
|
|
}
|
|
|
|
match NamedFile::open_async(cover_art_path).await {
|
|
Ok(file) => return file.into_response(&req),
|
|
Err(e) => {
|
|
tracing::warn!(path = %cover_art_path, error = %e, "cover art file not found");
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(ref mbid) = album.musicbrainz_id {
|
|
let url = format!("https://coverartarchive.org/release/{mbid}/front-250");
|
|
return HttpResponse::TemporaryRedirect()
|
|
.append_header(("Location", url.as_str()))
|
|
.finish();
|
|
}
|
|
|
|
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(),
|
|
}
|
|
}
|