use crate::{ controllers::{admin, auth as auth_controller, i18n::current_lang}, models::{ _entities::{audio_albums, audio_tracks}, users, }, }; use axum::{ body::Body, extract::{DefaultBodyLimit, Multipart}, http::{ header::{self, HeaderMap}, StatusCode, }, }; use axum_extra::extract::cookie::CookieJar; use bytes::Bytes; use chrono::{NaiveDate, Utc}; use loco_rs::{config::Config, prelude::*}; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter, QueryOrder, Set, }; use serde::{Deserialize, Serialize}; use serde_json::json; use std::{ path::{Path as StdPath, PathBuf}, str::FromStr, }; use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom}; use uuid::Uuid; const AUDIO_MAX_BYTES: usize = 50 * 1024 * 1024; const IMAGE_MAX_BYTES: usize = 10 * 1024 * 1024; pub const AUDIO_STORAGE_DIR: &str = "audio"; pub const IMAGE_STORAGE_DIR: &str = "images"; /// Album-create form, parsed manually from `multipart/form-data` so the page /// can both upload a cover image and submit any number of `track_ids` /// checkboxes (a urlencoded `Form` can't deserialize repeated keys into a Vec). struct ParsedAlbumForm { title: Option, artist: Option, release_date: Option, description: Option, published: bool, cover: Option>, track_ids: Vec, } #[derive(Debug, Deserialize)] struct AlbumSongForm { track_id: Uuid, } #[derive(Debug, Serialize)] struct UploadResponse { filename: String, url: String, size: usize, } pub fn uploads_root(config: &Config) -> Result { config .settings .as_ref() .and_then(|settings| settings.get("uploads_root")) .and_then(|value| value.as_str()) .filter(|value| !value.trim().is_empty()) .map(PathBuf::from) .ok_or_else(|| Error::string("settings.uploads_root must be configured")) } fn slugify(title: &str) -> String { let mut slug = String::new(); let mut last_was_dash = false; for ch in title.chars().flat_map(char::to_lowercase) { if ch.is_ascii_alphanumeric() { slug.push(ch); last_was_dash = false; } else if !last_was_dash && !slug.is_empty() { slug.push('-'); last_was_dash = true; } } let slug = slug.trim_matches('-').to_string(); if slug.is_empty() { Uuid::new_v4().to_string() } else { slug } } fn normalize_empty(value: Option) -> Option { value.and_then(|value| { let value = value.trim().to_string(); if value.is_empty() { None } else { Some(value) } }) } fn safe_filename(filename: &str) -> Result<&str> { if filename.is_empty() || filename.contains('/') || filename.contains('\\') || filename.contains("..") { return Err(Error::BadRequest("invalid filename".to_string())); } Ok(filename) } fn audio_content_type(extension: &str) -> &'static str { match extension { "aac" => "audio/aac", "flac" => "audio/flac", "m4a" => "audio/mp4", "ogg" => "audio/ogg", "wav" => "audio/wav", "webm" => "audio/webm", _ => "audio/mpeg", } } fn image_content_type(extension: &str) -> &'static str { match extension { "gif" => "image/gif", "jpg" | "jpeg" => "image/jpeg", "png" => "image/png", "webp" => "image/webp", _ => "application/octet-stream", } } fn detect_audio_extension(data: &[u8]) -> Result<&'static str> { if data.len() < 12 { return Err(Error::BadRequest("audio file is too small".to_string())); } if data.starts_with(b"ID3") || (data[0] == 0xff && (data[1] & 0xe0) == 0xe0) { return Ok("mp3"); } if data.starts_with(b"RIFF") && &data[8..12] == b"WAVE" { return Ok("wav"); } if data.starts_with(b"OggS") { return Ok("ogg"); } if data.starts_with(b"fLaC") { return Ok("flac"); } if data.len() >= 12 && &data[4..8] == b"ftyp" { return Ok("m4a"); } if data.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) { return Ok("webm"); } if data.starts_with(&[0xff, 0xf1]) || data.starts_with(&[0xff, 0xf9]) { return Ok("aac"); } Err(Error::BadRequest("unsupported audio format".to_string())) } fn detect_image_extension(data: &[u8]) -> Result<&'static str> { if data.len() < 12 { return Err(Error::BadRequest("image file is too small".to_string())); } if data.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) { return Ok("png"); } if data.starts_with(&[0xff, 0xd8, 0xff]) { return Ok("jpg"); } if data.starts_with(b"RIFF") && &data[8..12] == b"WEBP" { return Ok("webp"); } if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") { return Ok("gif"); } Err(Error::BadRequest("unsupported image format".to_string())) } async fn read_multipart_file(mut multipart: Multipart, max_bytes: usize) -> Result> { while let Some(mut field) = multipart .next_field() .await .map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))? { if field.name() != Some("file") { continue; } let mut data = Vec::new(); while let Some(chunk) = field .chunk() .await .map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))? { data.extend_from_slice(&chunk); if data.len() > max_bytes { return Err(Error::BadRequest(format!( "file is larger than {} MB", max_bytes / 1024 / 1024 ))); } } if data.is_empty() { return Err(Error::BadRequest("empty file upload".to_string())); } return Ok(data); } Err(Error::BadRequest( "multipart field `file` is required".to_string(), )) } async fn read_track_upload( mut multipart: Multipart, ) -> Result<(Vec, Option, Option, bool, bool)> { let mut data = None; let mut title = None; let mut track_number = None; let mut featured = false; let mut published = false; while let Some(mut field) = multipart .next_field() .await .map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))? { let name = field.name().unwrap_or("").to_string(); if name == "file" { let mut file = Vec::new(); while let Some(chunk) = field .chunk() .await .map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))? { file.extend_from_slice(&chunk); if file.len() > AUDIO_MAX_BYTES { return Err(Error::BadRequest("file is larger than 50 MB".to_string())); } } data = Some(file); } else { let value = field .text() .await .map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?; match name.as_str() { "title" => title = normalize_empty(Some(value)), "track_number" => { track_number = value .trim() .parse::() .ok() .filter(|number| *number > 0) } "featured" => featured = value == "on" || value == "true" || value == "1", "published" => published = value == "on" || value == "true" || value == "1", _ => {} } } } let data = data.ok_or_else(|| Error::BadRequest("multipart field `file` is required".to_string()))?; if data.is_empty() { return Err(Error::BadRequest("empty file upload".to_string())); } Ok((data, title, track_number, featured, published)) } /// Parse the new-album `multipart/form-data` body: text fields, an optional /// cover image file, and zero or more `track_ids` checkbox values. async fn read_album_form(mut multipart: Multipart) -> Result { let mut form = ParsedAlbumForm { title: None, artist: None, release_date: None, description: None, published: false, cover: None, track_ids: Vec::new(), }; while let Some(mut field) = multipart .next_field() .await .map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))? { let name = field.name().unwrap_or("").to_string(); if name == "cover" { let mut data = Vec::new(); while let Some(chunk) = field .chunk() .await .map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))? { data.extend_from_slice(&chunk); if data.len() > IMAGE_MAX_BYTES { return Err(Error::BadRequest(format!( "cover image is larger than {} MB", IMAGE_MAX_BYTES / 1024 / 1024 ))); } } // An unselected file input still sends an empty `cover` part. if !data.is_empty() { form.cover = Some(data); } } else { let value = field .text() .await .map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?; match name.as_str() { "title" => form.title = normalize_empty(Some(value)), "artist" => form.artist = normalize_empty(Some(value)), "release_date" => form.release_date = normalize_empty(Some(value)), "description" => form.description = normalize_empty(Some(value)), "published" => { form.published = value == "on" || value == "true" || value == "1"; } "track_ids" => { let trimmed = value.trim(); if !trimmed.is_empty() { let id = Uuid::parse_str(trimmed) .map_err(|_| Error::BadRequest("invalid song selection".to_string()))?; form.track_ids.push(id); } } _ => {} } } } Ok(form) } async fn unique_album_slug(ctx: &AppContext, title: &str) -> Result { let base = slugify(title); let mut slug = base.clone(); let mut suffix = 2; while audio_albums::Entity::find() .filter(audio_albums::Column::Slug.eq(&slug)) .count(&ctx.db) .await? > 0 { slug = format!("{base}-{suffix}"); suffix += 1; } Ok(slug) } async fn unique_track_slug( ctx: &AppContext, album_id: Option, title: &str, ) -> Result { let base = slugify(title); let mut slug = base.clone(); let mut suffix = 2; loop { let mut query = audio_tracks::Entity::find().filter(audio_tracks::Column::Slug.eq(&slug)); query = if let Some(album_id) = album_id { query.filter(audio_tracks::Column::AlbumId.eq(album_id)) } else { query.filter(audio_tracks::Column::AlbumId.is_null()) }; if query.count(&ctx.db).await? == 0 { break; } slug = format!("{base}-{suffix}"); suffix += 1; } Ok(slug) } async fn logged_in_admin(ctx: &AppContext, jar: &CookieJar) -> bool { let Some(cookie) = jar.get(auth_controller::AUTH_COOKIE) else { return false; }; let Ok(jwt_config) = ctx.config.get_jwt_config() else { return false; }; let Ok(claims) = loco_rs::auth::jwt::JWT::new(&jwt_config.secret).validate(cookie.value()) else { return false; }; let Ok(user) = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await else { return false; }; admin::is_admin(ctx, &user) } async fn album_by_id(ctx: &AppContext, id: Uuid) -> Result { audio_albums::Entity::find_by_id(id) .one(&ctx.db) .await? .ok_or_else(|| Error::NotFound) } async fn track_by_id(ctx: &AppContext, id: Uuid) -> Result { audio_tracks::Entity::find_by_id(id) .one(&ctx.db) .await? .ok_or_else(|| Error::NotFound) } async fn store_upload( ctx: &AppContext, folder: &str, extension: &str, data: Vec, ) -> Result { let filename = format!("{}.{}", Uuid::new_v4(), extension); let key = format!("{folder}/{filename}"); ctx.storage .upload(StdPath::new(&key), &Bytes::from(data)) .await?; Ok(filename) } #[debug_handler] async fn image_upload( auth: auth::JWT, State(ctx): State, multipart: Multipart, ) -> Result { admin::current_admin(auth, &ctx).await?; let data = read_multipart_file(multipart, IMAGE_MAX_BYTES).await?; let extension = detect_image_extension(&data)?; let size = data.len(); let filename = store_upload(&ctx, IMAGE_STORAGE_DIR, extension, data).await?; format::json(UploadResponse { url: format!("/images/{filename}"), filename, size, }) } #[debug_handler] async fn image_serve( Path(filename): Path, State(ctx): State, ) -> Result { let filename = safe_filename(&filename)?; let extension = filename.rsplit('.').next().unwrap_or(""); let key = format!("{IMAGE_STORAGE_DIR}/{filename}"); let body: Vec = ctx.storage.download(StdPath::new(&key)).await?; Response::builder() .header(header::CONTENT_TYPE, image_content_type(extension)) .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") .body(Body::from(body)) .map_err(Error::from) } #[debug_handler] async fn audio_upload( auth: auth::JWT, State(ctx): State, multipart: Multipart, ) -> Result { admin::current_admin(auth, &ctx).await?; let data = read_multipart_file(multipart, AUDIO_MAX_BYTES).await?; let extension = detect_audio_extension(&data)?; let size = data.len(); let filename = store_upload(&ctx, AUDIO_STORAGE_DIR, extension, data).await?; format::json(UploadResponse { url: format!("/audio/stream/{filename}"), filename, size, }) } #[debug_handler] async fn public_albums( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { let albums = audio_albums::Entity::find() .filter(audio_albums::Column::Published.eq(true)) .order_by_desc(audio_albums::Column::PublishedAt) .all(&ctx.db) .await?; format::view( &v, "audio/albums.html", json!({ "albums": albums, "logged_in_admin": logged_in_admin(&ctx, &jar).await, "lang": current_lang(&jar), }), ) } #[debug_handler] async fn public_album( jar: CookieJar, ViewEngine(v): ViewEngine, Path(slug): Path, State(ctx): State, ) -> Result { let album = audio_albums::Entity::find() .filter(audio_albums::Column::Slug.eq(slug)) .filter(audio_albums::Column::Published.eq(true)) .one(&ctx.db) .await? .ok_or_else(|| Error::NotFound)?; let mut active = album.clone().into_active_model(); active.view_count = Set(album.view_count + 1); let album = active.update(&ctx.db).await?; let tracks = audio_tracks::Entity::find() .filter(audio_tracks::Column::AlbumId.eq(album.id)) .filter(audio_tracks::Column::Published.eq(true)) .order_by_asc(audio_tracks::Column::TrackNumber) .order_by_asc(audio_tracks::Column::Title) .all(&ctx.db) .await?; format::view( &v, "audio/album.html", json!({ "album": album, "tracks": tracks, "logged_in_admin": logged_in_admin(&ctx, &jar).await, "lang": current_lang(&jar), }), ) } /// Published tracks of an album as JSON, so the audio listing page can /// queue a whole album without navigating to its detail page. #[debug_handler] async fn public_album_tracks( Path(slug): Path, State(ctx): State, ) -> Result { let album = audio_albums::Entity::find() .filter(audio_albums::Column::Slug.eq(slug)) .filter(audio_albums::Column::Published.eq(true)) .one(&ctx.db) .await? .ok_or_else(|| Error::NotFound)?; let tracks = audio_tracks::Entity::find() .filter(audio_tracks::Column::AlbumId.eq(album.id)) .filter(audio_tracks::Column::Published.eq(true)) .order_by_asc(audio_tracks::Column::TrackNumber) .order_by_asc(audio_tracks::Column::Title) .all(&ctx.db) .await?; let items: Vec = tracks .into_iter() .map(|t| { json!({ "src": format!("/audio/tracks/{}/stream", t.id), "title": t.title, }) }) .collect(); format::json(json!({ "tracks": items })) } #[debug_handler] async fn public_tracks( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { let tracks = audio_tracks::Entity::find() .filter(audio_tracks::Column::Published.eq(true)) .order_by_desc(audio_tracks::Column::PublishedAt) .order_by_desc(audio_tracks::Column::CreatedAt) .all(&ctx.db) .await?; format::view( &v, "audio/tracks.html", json!({ "tracks": tracks, "logged_in_admin": logged_in_admin(&ctx, &jar).await, "lang": current_lang(&jar), }), ) } #[debug_handler] async fn admin_albums( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; let albums = audio_albums::Entity::find() .order_by_desc(audio_albums::Column::CreatedAt) .all(&ctx.db) .await?; let mut rows = Vec::new(); for album in albums { let track_count = audio_tracks::Entity::find() .filter(audio_tracks::Column::AlbumId.eq(album.id)) .count(&ctx.db) .await?; rows.push(json!({ "album": album, "track_count": track_count })); } format::view( &v, "admin/audio/albums.html", json!({ "albums": rows, "lang": current_lang(&jar) }), ) } #[debug_handler] async fn admin_tracks( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; let tracks = audio_tracks::Entity::find() .order_by_desc(audio_tracks::Column::CreatedAt) .all(&ctx.db) .await?; format::view( &v, "admin/audio/songs.html", json!({ "tracks": tracks, "lang": current_lang(&jar) }), ) } #[debug_handler] async fn admin_album_new( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; let available_tracks = audio_tracks::Entity::find() .filter(audio_tracks::Column::AlbumId.is_null()) .order_by_asc(audio_tracks::Column::Title) .all(&ctx.db) .await?; format::view( &v, "admin/audio/new_album.html", json!({ "available_tracks": available_tracks, "lang": current_lang(&jar), }), ) } #[debug_handler] async fn admin_album_create( auth: auth::JWT, State(ctx): State, multipart: Multipart, ) -> Result { let admin_user = admin::current_admin(auth, &ctx).await?; let form = read_album_form(multipart).await?; let title = form .title .ok_or_else(|| Error::BadRequest("album title is required".to_string()))?; let release_date = form .release_date .and_then(|date| NaiveDate::parse_from_str(&date, "%Y-%m-%d").ok()); // Store the uploaded cover (if any) and keep its filename as cover_image_id. let cover_image_id = match form.cover { Some(data) => { let extension = detect_image_extension(&data)?; Some(store_upload(&ctx, IMAGE_STORAGE_DIR, extension, data).await?) } None => None, }; let album = audio_albums::ActiveModel { id: Set(Uuid::new_v4()), title: Set(title.clone()), slug: Set(unique_album_slug(&ctx, &title).await?), description: Set(form.description), cover_image_id: Set(cover_image_id), artist: Set(form.artist), release_date: Set(release_date), published: Set(form.published), uploader_id: Set(admin_user.id), view_count: Set(0), published_at: Set(form.published.then(|| Utc::now().into())), ..Default::default() } .insert(&ctx.db) .await?; for track_id in form.track_ids { let track = track_by_id(&ctx, track_id).await?; if track.album_id.is_some() { return Err(Error::BadRequest( "selected song already belongs to an album".to_string(), )); } let mut active = track.into_active_model(); active.album_id = Set(Some(album.id)); active.update(&ctx.db).await?; } format::redirect("/admin/audio/albums") } #[debug_handler] async fn admin_album_tracks( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, Path(album_id): Path, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; let album = album_by_id(&ctx, album_id).await?; let tracks = audio_tracks::Entity::find() .filter(audio_tracks::Column::AlbumId.eq(album_id)) .order_by_asc(audio_tracks::Column::TrackNumber) .order_by_asc(audio_tracks::Column::Title) .all(&ctx.db) .await?; let available_tracks = audio_tracks::Entity::find() .filter(audio_tracks::Column::AlbumId.is_null()) .order_by_asc(audio_tracks::Column::Title) .all(&ctx.db) .await?; format::view( &v, "admin/audio/tracks.html", json!({ "album": album, "tracks": tracks, "available_tracks": available_tracks, "lang": current_lang(&jar), }), ) } #[debug_handler] async fn admin_album_add_track( auth: auth::JWT, Path(album_id): Path, State(ctx): State, Form(params): Form, ) -> Result { admin::current_admin(auth, &ctx).await?; album_by_id(&ctx, album_id).await?; let track = track_by_id(&ctx, params.track_id).await?; if track.album_id.is_some() { return Err(Error::BadRequest( "song already belongs to an album".to_string(), )); } let mut active = track.into_active_model(); active.album_id = Set(Some(album_id)); active.update(&ctx.db).await?; format::redirect(&format!("/admin/audio/albums/{album_id}/tracks")) } #[debug_handler] async fn admin_track_remove_from_album( auth: auth::JWT, Path(id): Path, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; let track = track_by_id(&ctx, id).await?; let album_id = track.album_id; let mut active = track.into_active_model(); active.album_id = Set(None); active.track_number = Set(None); active.update(&ctx.db).await?; if let Some(album_id) = album_id { format::redirect(&format!("/admin/audio/albums/{album_id}/tracks")) } else { format::redirect("/admin/audio/tracks") } } #[debug_handler] async fn admin_track_upload_form( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, Path(album_id): Path, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; format::view( &v, "admin/audio/upload_track.html", json!({ "album": album_by_id(&ctx, album_id).await?, "lang": current_lang(&jar) }), ) } #[debug_handler] async fn admin_song_upload_form( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; format::view( &v, "admin/audio/upload_track.html", json!({ "album": null, "lang": current_lang(&jar) }), ) } async fn create_uploaded_track( ctx: &AppContext, album_id: Option, multipart: Multipart, ) -> Result { let (data, title, track_number, featured, published) = read_track_upload(multipart).await?; let extension = detect_audio_extension(&data)?; let filename = store_upload(ctx, AUDIO_STORAGE_DIR, extension, data).await?; let title = title.unwrap_or_else(|| { filename .trim_end_matches(&format!(".{extension}")) .to_string() }); audio_tracks::ActiveModel { id: Set(Uuid::new_v4()), album_id: Set(album_id), title: Set(title.clone()), slug: Set(unique_track_slug(ctx, album_id, &title).await?), audio_file_id: Set(filename), track_number: Set(track_number), duration: Set(None), featured: Set(featured), published: Set(published), play_count: Set(0), published_at: Set(published.then(|| Utc::now().into())), ..Default::default() } .insert(&ctx.db) .await .map_err(Error::from) } #[debug_handler] async fn admin_track_upload( auth: auth::JWT, Path(album_id): Path, State(ctx): State, multipart: Multipart, ) -> Result { admin::current_admin(auth, &ctx).await?; album_by_id(&ctx, album_id).await?; create_uploaded_track(&ctx, Some(album_id), multipart).await?; format::redirect(&format!("/admin/audio/albums/{album_id}/tracks")) } #[debug_handler] async fn admin_song_upload( auth: auth::JWT, State(ctx): State, multipart: Multipart, ) -> Result { admin::current_admin(auth, &ctx).await?; create_uploaded_track(&ctx, None, multipart).await?; format::redirect("/admin/audio/tracks") } #[debug_handler] async fn admin_track_delete( auth: auth::JWT, Path(id): Path, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; let track = track_by_id(&ctx, id).await?; let album_id = track.album_id; let _ = ctx .storage .delete(StdPath::new(&format!( "{AUDIO_STORAGE_DIR}/{}", track.audio_file_id ))) .await; track.delete(&ctx.db).await?; if let Some(album_id) = album_id { format::redirect(&format!("/admin/audio/albums/{album_id}/tracks")) } else { format::redirect("/admin/audio/tracks") } } #[debug_handler] async fn admin_track_publish( auth: auth::JWT, Path(id): Path, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; let track = track_by_id(&ctx, id).await?; let album_id = track.album_id; let mut active = track.into_active_model(); active.published = Set(true); active.published_at = Set(Some(Utc::now().into())); active.update(&ctx.db).await?; if let Some(album_id) = album_id { format::redirect(&format!("/admin/audio/albums/{album_id}/tracks")) } else { format::redirect("/admin/audio/tracks") } } #[debug_handler] async fn admin_track_unpublish( auth: auth::JWT, Path(id): Path, State(ctx): State, ) -> Result { admin::current_admin(auth, &ctx).await?; let track = track_by_id(&ctx, id).await?; let album_id = track.album_id; let mut active = track.into_active_model(); active.published = Set(false); active.published_at = Set(None); active.update(&ctx.db).await?; if let Some(album_id) = album_id { format::redirect(&format!("/admin/audio/albums/{album_id}/tracks")) } else { format::redirect("/admin/audio/tracks") } } async fn stream_audio_file( config: &Config, filename: &str, headers: &HeaderMap, ) -> Result { let filename = safe_filename(filename)?; let path = uploads_root(config)?.join(AUDIO_STORAGE_DIR).join(filename); let mut file = tokio::fs::File::open(&path) .await .map_err(|_| Error::NotFound)?; let total_len = file.metadata().await?.len(); let extension = filename.rsplit('.').next().unwrap_or("mp3"); let content_type = audio_content_type(extension); let (status, start, end) = parse_range(headers, total_len)?; let len = end.saturating_sub(start) + 1; file.seek(SeekFrom::Start(start)).await?; let mut body = vec![0; len as usize]; file.read_exact(&mut body).await?; let mut builder = Response::builder() .status(status) .header(header::CONTENT_TYPE, content_type) .header(header::ACCEPT_RANGES, "bytes") .header(header::CONTENT_LENGTH, len.to_string()); if status == StatusCode::PARTIAL_CONTENT { builder = builder.header( header::CONTENT_RANGE, format!("bytes {start}-{end}/{total_len}"), ); } builder.body(Body::from(body)).map_err(Error::from) } fn parse_range(headers: &HeaderMap, total_len: u64) -> Result<(StatusCode, u64, u64)> { if total_len == 0 { return Ok((StatusCode::OK, 0, 0)); } let Some(range_header) = headers.get(header::RANGE) else { return Ok((StatusCode::OK, 0, total_len - 1)); }; let range = range_header .to_str() .map_err(|_| Error::BadRequest("invalid range header".to_string()))?; let Some(range) = range.strip_prefix("bytes=") else { return Err(Error::BadRequest("invalid range header".to_string())); }; let Some((start, end)) = range.split_once('-') else { return Err(Error::BadRequest("invalid range header".to_string())); }; let suffix_range = start.is_empty(); let start = if suffix_range { let suffix = u64::from_str(end) .map_err(|_| Error::BadRequest("invalid range header".to_string()))?; total_len.saturating_sub(suffix) } else { u64::from_str(start).map_err(|_| Error::BadRequest("invalid range header".to_string()))? }; let end = if suffix_range || end.is_empty() { total_len - 1 } else { u64::from_str(end).map_err(|_| Error::BadRequest("invalid range header".to_string()))? }; if start >= total_len || end >= total_len || start > end { return Err(Error::CustomError( StatusCode::RANGE_NOT_SATISFIABLE, loco_rs::controller::ErrorDetail::new("range-not-satisfiable", "range not satisfiable"), )); } Ok((StatusCode::PARTIAL_CONTENT, start, end)) } #[debug_handler] async fn raw_audio_stream( Path(filename): Path, headers: HeaderMap, State(ctx): State, ) -> Result { stream_audio_file(&ctx.config, &filename, &headers).await } #[debug_handler] async fn track_stream( Path(id): Path, headers: HeaderMap, State(ctx): State, ) -> Result { let track = track_by_id(&ctx, id).await?; if !track.published { return Err(Error::NotFound); } let mut active = track.clone().into_active_model(); active.play_count = Set(track.play_count + 1); let track = active.update(&ctx.db).await?; stream_audio_file(&ctx.config, &track.audio_file_id, &headers).await } pub fn routes() -> Routes { Routes::new() .add( "/images/upload", post(image_upload).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)), ) .add("/images/{filename}", get(image_serve)) .add( "/audio/upload", post(audio_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)), ) .add("/audio/stream/{filename}", get(raw_audio_stream)) .add("/audio/albums", get(public_albums)) .add("/audio/albums/{slug}", get(public_album)) .add("/audio/albums/{slug}/tracks", get(public_album_tracks)) .add("/audio/tracks", get(public_tracks)) .add("/audio/tracks/{id}/stream", get(track_stream)) .add("/admin/audio/albums", get(admin_albums)) .add("/admin/audio/albums/create", get(admin_album_new)) .add( "/admin/audio/albums/create", post(admin_album_create).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)), ) .add("/admin/audio/tracks", get(admin_tracks)) .add("/admin/audio/tracks/upload", get(admin_song_upload_form)) .add( "/admin/audio/tracks/upload-file", post(admin_song_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)), ) .add( "/admin/audio/albums/{album_id}/tracks", get(admin_album_tracks), ) .add( "/admin/audio/albums/{album_id}/tracks/add", post(admin_album_add_track), ) .add( "/admin/audio/albums/{album_id}/tracks/upload", get(admin_track_upload_form), ) .add( "/admin/audio/albums/{album_id}/tracks/upload-file", post(admin_track_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)), ) .add( "/admin/audio/tracks/{id}/publish", post(admin_track_publish), ) .add( "/admin/audio/tracks/{id}/unpublish", post(admin_track_unpublish), ) .add( "/admin/audio/tracks/{id}/remove-from-album", post(admin_track_remove_from_album), ) .add("/admin/audio/tracks/{id}/delete", post(admin_track_delete)) }