terminal based website playing music now
This commit is contained in:
@@ -36,15 +36,16 @@ const IMAGE_MAX_BYTES: usize = 10 * 1024 * 1024;
|
||||
pub const AUDIO_STORAGE_DIR: &str = "audio";
|
||||
pub const IMAGE_STORAGE_DIR: &str = "images";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AlbumForm {
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
cover_image_id: Option<String>,
|
||||
/// 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<String>,
|
||||
artist: Option<String>,
|
||||
release_date: Option<String>,
|
||||
published: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
published: bool,
|
||||
cover: Option<Vec<u8>>,
|
||||
track_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
@@ -104,10 +105,6 @@ fn normalize_empty(value: Option<String>) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn is_checked(value: &Option<String>) -> bool {
|
||||
value.as_deref().is_some_and(|value| value == "on" || value == "true" || value == "1")
|
||||
}
|
||||
|
||||
fn safe_filename(filename: &str) -> Result<&str> {
|
||||
if filename.is_empty()
|
||||
|| filename.contains('/')
|
||||
@@ -275,6 +272,73 @@ async fn read_track_upload(
|
||||
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<ParsedAlbumForm> {
|
||||
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<String> {
|
||||
let base = slugify(title);
|
||||
let mut slug = base.clone();
|
||||
@@ -557,34 +621,50 @@ async fn admin_album_new(auth: auth::JWT, ViewEngine(v): ViewEngine<TeraView>, S
|
||||
async fn admin_album_create(
|
||||
auth: auth::JWT,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(params): Form<AlbumForm>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
let admin_user = admin::current_admin(auth, &ctx).await?;
|
||||
let published = is_checked(¶ms.published);
|
||||
let release_date = normalize_empty(params.release_date)
|
||||
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(params.title.clone()),
|
||||
slug: Set(unique_album_slug(&ctx, ¶ms.title).await?),
|
||||
description: Set(normalize_empty(params.description)),
|
||||
cover_image_id: Set(normalize_empty(params.cover_image_id)),
|
||||
artist: Set(normalize_empty(params.artist)),
|
||||
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(published),
|
||||
published: Set(form.published),
|
||||
uploader_id: Set(admin_user.id),
|
||||
view_count: Set(0),
|
||||
published_at: Set(published.then(|| Utc::now().into())),
|
||||
published_at: Set(form.published.then(|| Utc::now().into())),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
|
||||
for track_id in params.track_ids {
|
||||
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()));
|
||||
return Err(Error::BadRequest(
|
||||
"selected song already belongs to an album".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut active = track.into_active_model();
|
||||
@@ -922,7 +1002,7 @@ pub fn routes() -> Routes {
|
||||
.add("/admin/images/upload", post(admin_image_upload).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)))
|
||||
.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))
|
||||
.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)))
|
||||
|
||||
Reference in New Issue
Block a user