+
+
+
+
+ {% if album %}~/audio/{{ album.slug }}/upload{% else %}~/audio/songs/upload{% endif %}
+
+
{% endblock content %}
diff --git a/assets/views/admin/index.html b/assets/views/admin/index.html
index 67ab785..fa3a20e 100644
--- a/assets/views/admin/index.html
+++ b/assets/views/admin/index.html
@@ -68,7 +68,7 @@
audio
-
create albums and upload tracks.
+
upload songs, then group them into albums.
diff --git a/src/controllers/media.rs b/src/controllers/media.rs
index dd9afba..5029af8 100644
--- a/src/controllers/media.rs
+++ b/src/controllers/media.rs
@@ -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
,
- cover_image_id: Option,
+/// 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,
- published: Option,
- #[serde(default)]
+ description: Option,
+ published: bool,
+ cover: Option>,
track_ids: Vec,
}
@@ -104,10 +105,6 @@ fn normalize_empty(value: Option) -> Option {
})
}
-fn is_checked(value: &Option) -> 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 {
+ 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();
@@ -557,34 +621,50 @@ async fn admin_album_new(auth: auth::JWT, ViewEngine(v): ViewEngine, S
async fn admin_album_create(
auth: auth::JWT,
State(ctx): State,
- Form(params): Form,
+ multipart: Multipart,
) -> Result {
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)))