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, ) -> 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, ) -> 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, ) -> 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(), } }