song upload

This commit is contained in:
Priec
2026-05-17 18:25:23 +02:00
parent d164edf87c
commit c1ecfa459d
10 changed files with 459 additions and 35 deletions

View File

@@ -215,11 +215,14 @@ async fn read_multipart_file(mut multipart: Multipart, max_bytes: usize) -> Resu
Err(Error::BadRequest("multipart field `file` is required".to_string()))
}
async fn read_track_upload(mut multipart: Multipart) -> Result<(Vec<u8>, Option<String>, Option<i32>, bool)> {
async fn read_track_upload(
mut multipart: Multipart,
) -> Result<(Vec<u8>, Option<String>, Option<i32>, 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()
@@ -251,6 +254,7 @@ async fn read_track_upload(mut multipart: Multipart) -> Result<(Vec<u8>, Option<
track_number = value.trim().parse::<i32>().ok().filter(|number| *number > 0)
}
"featured" => featured = value == "on" || value == "true" || value == "1",
"published" => published = value == "on" || value == "true" || value == "1",
_ => {}
}
}
@@ -261,7 +265,7 @@ async fn read_track_upload(mut multipart: Multipart) -> Result<(Vec<u8>, Option<
return Err(Error::BadRequest("empty file upload".to_string()));
}
Ok((data, title, track_number, featured))
Ok((data, title, track_number, featured, published))
}
async fn unique_album_slug(ctx: &AppContext, title: &str) -> Result<String> {
@@ -282,18 +286,22 @@ async fn unique_album_slug(ctx: &AppContext, title: &str) -> Result<String> {
Ok(slug)
}
async fn unique_track_slug(ctx: &AppContext, album_id: Uuid, title: &str) -> Result<String> {
async fn unique_track_slug(ctx: &AppContext, album_id: Option<Uuid>, title: &str) -> Result<String> {
let base = slugify(title);
let mut slug = base.clone();
let mut suffix = 2;
while audio_tracks::Entity::find()
.filter(audio_tracks::Column::AlbumId.eq(album_id))
.filter(audio_tracks::Column::Slug.eq(&slug))
.count(&ctx.db)
.await?
> 0
{
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;
}
@@ -452,6 +460,7 @@ async fn public_album(
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)
@@ -468,6 +477,26 @@ async fn public_album(
)
}
#[debug_handler]
async fn public_tracks(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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 }),
)
}
#[debug_handler]
async fn admin_albums(
auth: auth::JWT,
@@ -491,6 +520,21 @@ async fn admin_albums(
format::view(&v, "admin/audio/albums.html", json!({ "albums": rows }))
}
#[debug_handler]
async fn admin_tracks(
auth: auth::JWT,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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 }))
}
#[debug_handler]
async fn admin_album_new(auth: auth::JWT, ViewEngine(v): ViewEngine<TeraView>, State(ctx): State<AppContext>) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
@@ -566,6 +610,45 @@ async fn admin_track_upload_form(
)
}
#[debug_handler]
async fn admin_song_upload_form(
auth: auth::JWT,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
format::view(&v, "admin/audio/upload_track.html", json!({ "album": null }))
}
async fn create_uploaded_track(
ctx: &AppContext,
album_id: Option<Uuid>,
multipart: Multipart,
) -> Result<audio_tracks::Model> {
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,
@@ -576,29 +659,22 @@ async fn admin_track_upload(
admin::current_admin(auth, &ctx).await?;
album_by_id(&ctx, album_id).await?;
let (data, title, track_number, featured) = 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),
play_count: Set(0),
..Default::default()
}
.insert(&ctx.db)
.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<AppContext>,
multipart: Multipart,
) -> Result<Response> {
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,
@@ -613,7 +689,53 @@ async fn admin_track_delete(
.delete(StdPath::new(&format!("{AUDIO_STORAGE_DIR}/{}", track.audio_file_id)))
.await;
track.delete(&ctx.db).await?;
format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
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<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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<Response> {
@@ -704,8 +826,7 @@ async fn track_stream(
State(ctx): State<AppContext>,
) -> Result<Response> {
let track = track_by_id(&ctx, id).await?;
let album = album_by_id(&ctx, track.album_id).await?;
if !album.published {
if !track.published {
return Err(Error::NotFound);
}
@@ -724,14 +845,20 @@ pub fn routes() -> Routes {
.add("/audio/stream/{filename}", get(raw_audio_stream))
.add("/audio/albums", get(public_albums))
.add("/audio/albums/{slug}", get(public_album))
.add("/audio/tracks", get(public_tracks))
.add("/audio/tracks/{id}/stream", get(track_stream))
.add("/admin/images", get(admin_images))
.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/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/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}/delete", post(admin_track_delete))
}

View File

@@ -8,16 +8,18 @@ use serde::{Deserialize, Serialize};
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub album_id: Uuid,
pub album_id: Option<Uuid>,
pub title: String,
pub slug: String,
pub audio_file_id: String,
pub track_number: Option<i32>,
pub duration: Option<i32>,
pub featured: bool,
pub published: bool,
pub play_count: i32,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
pub published_at: Option<DateTimeWithTimeZone>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -27,7 +29,7 @@ pub enum Relation {
from = "Column::AlbumId",
to = "super::audio_albums::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
on_delete = "SetNull"
)]
AudioAlbums,
#[sea_orm(has_many = "super::audio_track_tags::Entity")]