terminal based website playing music now

This commit is contained in:
Priec
2026-05-19 17:13:23 +02:00
parent cbd642c62c
commit e9439382cc
8 changed files with 580 additions and 360 deletions

View File

@@ -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(&params.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, &params.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)))