From c1ecfa459d821119fe9f36e3ccd5190822f371bb Mon Sep 17 00:00:00 2001 From: Priec Date: Sun, 17 May 2026 18:25:23 +0200 Subject: [PATCH] song upload --- assets/views/admin/audio/albums.html | 5 +- assets/views/admin/audio/songs.html | 85 ++++++++ assets/views/admin/audio/tracks.html | 17 ++ assets/views/admin/audio/upload_track.html | 21 ++ assets/views/audio/tracks.html | 34 ++++ assets/views/base.html | 2 + migration/src/lib.rs | 2 + ...20260517_000012_standalone_audio_tracks.rs | 131 ++++++++++++ src/controllers/media.rs | 191 +++++++++++++++--- src/models/_entities/audio_tracks.rs | 6 +- 10 files changed, 459 insertions(+), 35 deletions(-) create mode 100644 assets/views/admin/audio/songs.html create mode 100644 assets/views/audio/tracks.html create mode 100644 migration/src/m20260517_000012_standalone_audio_tracks.rs diff --git a/assets/views/admin/audio/albums.html b/assets/views/admin/audio/albums.html index dabd4ac..be8fc85 100644 --- a/assets/views/admin/audio/albums.html +++ b/assets/views/admin/audio/albums.html @@ -9,7 +9,10 @@

Audio Albums

Create albums and upload audio tracks.

- New album +
+ Songs + New album +
diff --git a/assets/views/admin/audio/songs.html b/assets/views/admin/audio/songs.html new file mode 100644 index 0000000..458130d --- /dev/null +++ b/assets/views/admin/audio/songs.html @@ -0,0 +1,85 @@ +{% extends "admin/base.html" %} + +{% block title %}Songs{% endblock title %} + +{% block content %} +
+
+
+

Songs

+

Publish songs directly; albums only group them together.

+
+ +
+ +
+
+ {% if tracks | length > 0 %} +
+ + + + + + + + + + + + {% for track in tracks %} + + + + + + + + {% endfor %} + +
SongGroupFileStatusActions
{{ track.title }} + {% if track.album_id %} + Album + {% else %} + Ungrouped + {% endif %} + {{ track.audio_file_id }} + {% if track.published %} + Published + {% else %} + Draft + {% endif %} + +
+ Play + {% if track.published %} +
+ +
+ {% else %} +
+ +
+ {% endif %} +
+ +
+
+
+
+ {% else %} +
+

No songs yet.

+

Upload a song, then group it into an album when needed.

+ +
+ {% endif %} +
+
+
+{% endblock content %} diff --git a/assets/views/admin/audio/tracks.html b/assets/views/admin/audio/tracks.html index 3d750c6..3443767 100644 --- a/assets/views/admin/audio/tracks.html +++ b/assets/views/admin/audio/tracks.html @@ -24,6 +24,7 @@ Track File + Status Featured Actions @@ -35,6 +36,13 @@ {% if track.track_number %}{{ track.track_number }}. {% endif %}{{ track.title }} {{ track.audio_file_id }} + + {% if track.published %} + Published + {% else %} + Draft + {% endif %} + {% if track.featured %} Yes @@ -45,6 +53,15 @@
Play + {% if track.published %} +
+ +
+ {% else %} +
+ +
+ {% endif %}
diff --git a/assets/views/admin/audio/upload_track.html b/assets/views/admin/audio/upload_track.html index 7f35310..9264d69 100644 --- a/assets/views/admin/audio/upload_track.html +++ b/assets/views/admin/audio/upload_track.html @@ -7,14 +7,26 @@

Upload Track

+ {% if album %}

{{ album.title }}

+ {% else %} +

Ungrouped song

+ {% endif %}
+ {% if album %} Back to tracks + {% else %} + Back to songs + {% endif %}
+ {% if album %}
+ {% else %} + + {% endif %}
@@ -35,9 +47,18 @@ Featured + +
+ {% if album %} Cancel + {% else %} + Cancel + {% endif %}
diff --git a/assets/views/audio/tracks.html b/assets/views/audio/tracks.html new file mode 100644 index 0000000..ecdee98 --- /dev/null +++ b/assets/views/audio/tracks.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block title %}Songs{% endblock title %} + +{% block content %} +
+
+
+

Songs

+

Published songs from every album and ungrouped uploads.

+
+ Albums +
+ +
+
+ {% if tracks | length > 0 %} +
+ {% for track in tracks %} +
+

{{ track.title }}

+ +
+ {% endfor %} +
+ {% else %} +

No published songs yet.

+ {% endif %} +
+
+
+{% endblock content %} diff --git a/assets/views/base.html b/assets/views/base.html index 1b51d55..90ad1da 100644 --- a/assets/views/base.html +++ b/assets/views/base.html @@ -67,6 +67,7 @@
  • About
  • Blog
  • Audio
  • +
  • Songs
  • {% if logged_in_admin %}
  • Dashboard
  • @@ -92,6 +93,7 @@
  • About
  • Blog
  • Audio
  • +
  • Songs
  • {% if logged_in_admin %}
  • Dashboard
  • diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 54daf58..7c94285 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -13,6 +13,7 @@ mod m20260517_000008_audio_track_tags; mod m20260517_000009_simple_constraints; mod m20260517_000010_drop_user_roles; mod m20260517_000011_site_pages; +mod m20260517_000012_standalone_audio_tracks; pub struct Migrator; @@ -32,6 +33,7 @@ impl MigratorTrait for Migrator { Box::new(m20260517_000009_simple_constraints::Migration), Box::new(m20260517_000010_drop_user_roles::Migration), Box::new(m20260517_000011_site_pages::Migration), + Box::new(m20260517_000012_standalone_audio_tracks::Migration), // inject-above (do not remove this comment) ] } diff --git a/migration/src/m20260517_000012_standalone_audio_tracks.rs b/migration/src/m20260517_000012_standalone_audio_tracks.rs new file mode 100644 index 0000000..c556bf1 --- /dev/null +++ b/migration/src/m20260517_000012_standalone_audio_tracks.rs @@ -0,0 +1,131 @@ +use sea_orm_migration::{prelude::*, sea_query::Expr}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[derive(DeriveIden)] +enum AudioTracks { + Table, + AlbumId, + Published, + PublishedAt, +} + +#[derive(DeriveIden)] +enum AudioAlbums { + Table, + Id, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { + m.drop_foreign_key( + ForeignKey::drop() + .name("fk-audio_tracks-album_id-to-audio_albums") + .table(AudioTracks::Table) + .to_owned(), + ) + .await?; + + m.alter_table( + Table::alter() + .table(AudioTracks::Table) + .modify_column(ColumnDef::new(AudioTracks::AlbumId).uuid().null()) + .add_column( + ColumnDef::new(AudioTracks::Published) + .boolean() + .not_null() + .default(false), + ) + .add_column( + ColumnDef::new(AudioTracks::PublishedAt) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + + m.create_foreign_key( + ForeignKey::create() + .name("fk-audio_tracks-album_id-to-audio_albums") + .from(AudioTracks::Table, AudioTracks::AlbumId) + .to(AudioAlbums::Table, AudioAlbums::Id) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::Cascade) + .to_owned(), + ) + .await?; + + m.get_connection() + .execute_unprepared( + r#" + UPDATE audio_tracks t + SET published = TRUE, + published_at = COALESCE(a.published_at, CURRENT_TIMESTAMP) + FROM audio_albums a + WHERE t.album_id = a.id + AND a.published = TRUE + "#, + ) + .await?; + + m.create_index( + Index::create() + .name("idx-audio_tracks-published") + .table(AudioTracks::Table) + .col(AudioTracks::Published) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + m.drop_index( + Index::drop() + .name("idx-audio_tracks-published") + .table(AudioTracks::Table) + .to_owned(), + ) + .await?; + + m.drop_foreign_key( + ForeignKey::drop() + .name("fk-audio_tracks-album_id-to-audio_albums") + .table(AudioTracks::Table) + .to_owned(), + ) + .await?; + + m.alter_table( + Table::alter() + .table(AudioTracks::Table) + .drop_column(AudioTracks::PublishedAt) + .drop_column(AudioTracks::Published) + .modify_column( + ColumnDef::new(AudioTracks::AlbumId) + .uuid() + .not_null() + .default(Expr::cust("'00000000-0000-0000-0000-000000000000'::uuid")), + ) + .to_owned(), + ) + .await?; + + m.create_foreign_key( + ForeignKey::create() + .name("fk-audio_tracks-album_id-to-audio_albums") + .from(AudioTracks::Table, AudioTracks::AlbumId) + .to(AudioAlbums::Table, AudioAlbums::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade) + .to_owned(), + ) + .await?; + + Ok(()) + } +} diff --git a/src/controllers/media.rs b/src/controllers/media.rs index 14cdbfe..4270fbe 100644 --- a/src/controllers/media.rs +++ b/src/controllers/media.rs @@ -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, Option, Option, bool)> { +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() @@ -251,6 +254,7 @@ async fn read_track_upload(mut multipart: Multipart) -> Result<(Vec, Option< 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", _ => {} } } @@ -261,7 +265,7 @@ async fn read_track_upload(mut multipart: Multipart) -> Result<(Vec, 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 { @@ -282,18 +286,22 @@ async fn unique_album_slug(ctx: &AppContext, title: &str) -> Result { Ok(slug) } -async fn unique_track_slug(ctx: &AppContext, album_id: Uuid, title: &str) -> Result { +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; - 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, + 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 }), + ) +} + #[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, + 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 })) +} + #[debug_handler] async fn admin_album_new(auth: auth::JWT, ViewEngine(v): ViewEngine, State(ctx): State) -> Result { 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, + State(ctx): State, +) -> Result { + 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, + 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, @@ -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, + 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, @@ -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, + 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 { @@ -704,8 +826,7 @@ async fn track_stream( State(ctx): State, ) -> Result { 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)) } diff --git a/src/models/_entities/audio_tracks.rs b/src/models/_entities/audio_tracks.rs index 44a79fd..4edc6c9 100644 --- a/src/models/_entities/audio_tracks.rs +++ b/src/models/_entities/audio_tracks.rs @@ -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, pub title: String, pub slug: String, pub audio_file_id: String, pub track_number: Option, pub duration: Option, pub featured: bool, + pub published: bool, pub play_count: i32, pub created_at: DateTimeWithTimeZone, pub updated_at: DateTimeWithTimeZone, + pub published_at: Option, } #[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")]