Files
web/src/routes/subsonic/media.rs
2026-03-20 20:04:35 -04:00

313 lines
10 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::helpers::{authenticate, get_query_param, 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 {
Ok(v) => v,
Err(resp) => return resp,
};
let id_str = match get_query_param(&req, "id") {
Some(id) => id,
None => {
return response::error(
&params.format,
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(&params.format, 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(&params.format, 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 = get_query_param(&req, "format");
let max_bit_rate = get_query_param(&req, "maxBitRate")
.and_then(|s| s.parse::<u32>().ok())
.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
_ => {
// 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(
&params.format,
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 // Default transcoding bitrate
};
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(
&params.format,
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(&params.format, 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(
&params.format,
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 {
Ok(v) => v,
Err(resp) => return resp,
};
let id_str = match get_query_param(&req, "id") {
Some(id) => id,
None => {
return response::error(
&params.format,
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(&params.format, 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(&params.format, 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(
&params.format,
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 {
Ok(v) => v,
Err(resp) => return resp,
};
let id_str = match get_query_param(&req, "id") {
Some(id) => id,
None => {
return response::error(
&params.format,
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) {
Some(v) => v,
None => {
return response::error(
&params.format,
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(
&params.format,
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) => {
tracing::warn!(path = %cover_art_path, error = %e, "cover art file not found");
}
}
}
// 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()
.append_header(("Location", url.as_str()))
.finish();
}
// No cover art available
HttpResponse::NotFound().finish()
}
_ => {
// For other types, no cover art
HttpResponse::NotFound().finish()
}
}
}