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

@@ -9,7 +9,10 @@
<h1 class="text-2xl font-bold">Audio Albums</h1>
<p class="text-sm opacity-70">Create albums and upload audio tracks.</p>
</div>
<a href="/admin/audio/albums/create" class="btn btn-neutral btn-sm">New album</a>
<div class="flex flex-wrap gap-2">
<a href="/admin/audio/tracks" class="btn btn-ghost btn-sm">Songs</a>
<a href="/admin/audio/albums/create" class="btn btn-neutral btn-sm">New album</a>
</div>
</div>
<div class="card border border-base-300 bg-base-100 shadow-sm">

View File

@@ -0,0 +1,85 @@
{% extends "admin/base.html" %}
{% block title %}Songs{% endblock title %}
{% block content %}
<div class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">Songs</h1>
<p class="text-sm opacity-70">Publish songs directly; albums only group them together.</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/admin/audio/tracks/upload" class="btn btn-neutral btn-sm">Upload song</a>
<a href="/admin/audio/albums" class="btn btn-ghost btn-sm">Albums</a>
</div>
</div>
<div class="card border border-base-300 bg-base-100 shadow-sm">
<div class="card-body">
{% if tracks | length > 0 %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Song</th>
<th>Group</th>
<th>File</th>
<th>Status</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
{% for track in tracks %}
<tr>
<td class="font-medium">{{ track.title }}</td>
<td>
{% if track.album_id %}
<span class="badge">Album</span>
{% else %}
<span class="badge opacity-70">Ungrouped</span>
{% endif %}
</td>
<td class="text-sm">{{ track.audio_file_id }}</td>
<td>
{% if track.published %}
<span class="badge">Published</span>
{% else %}
<span class="badge opacity-70">Draft</span>
{% endif %}
</td>
<td>
<div class="flex gap-2">
<a href="/audio/tracks/{{ track.id }}/stream" class="btn btn-ghost btn-sm">Play</a>
{% if track.published %}
<form method="post" action="/admin/audio/tracks/{{ track.id }}/unpublish">
<button type="submit" class="btn btn-ghost btn-sm">Unpublish</button>
</form>
{% else %}
<form method="post" action="/admin/audio/tracks/{{ track.id }}/publish">
<button type="submit" class="btn btn-ghost btn-sm">Publish</button>
</form>
{% endif %}
<form method="post" action="/admin/audio/tracks/{{ track.id }}/delete">
<button type="submit" class="btn btn-ghost btn-sm">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center">
<p class="font-medium">No songs yet.</p>
<p class="text-sm opacity-70">Upload a song, then group it into an album when needed.</p>
<div class="pt-2">
<a href="/admin/audio/tracks/upload" class="btn btn-neutral btn-sm">Upload song</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock content %}

View File

@@ -24,6 +24,7 @@
<tr>
<th>Track</th>
<th>File</th>
<th>Status</th>
<th>Featured</th>
<th class="text-right">Actions</th>
</tr>
@@ -35,6 +36,13 @@
{% if track.track_number %}{{ track.track_number }}. {% endif %}{{ track.title }}
</td>
<td class="text-sm">{{ track.audio_file_id }}</td>
<td>
{% if track.published %}
<span class="badge">Published</span>
{% else %}
<span class="badge opacity-70">Draft</span>
{% endif %}
</td>
<td>
{% if track.featured %}
<span class="badge">Yes</span>
@@ -45,6 +53,15 @@
<td>
<div class="flex gap-2">
<a href="/audio/tracks/{{ track.id }}/stream" class="btn btn-ghost btn-sm">Play</a>
{% if track.published %}
<form method="post" action="/admin/audio/tracks/{{ track.id }}/unpublish">
<button type="submit" class="btn btn-ghost btn-sm">Unpublish</button>
</form>
{% else %}
<form method="post" action="/admin/audio/tracks/{{ track.id }}/publish">
<button type="submit" class="btn btn-ghost btn-sm">Publish</button>
</form>
{% endif %}
<form method="post" action="/admin/audio/tracks/{{ track.id }}/delete">
<button type="submit" class="btn btn-ghost btn-sm">Delete</button>
</form>

View File

@@ -7,14 +7,26 @@
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">Upload Track</h1>
{% if album %}
<p class="text-sm opacity-70">{{ album.title }}</p>
{% else %}
<p class="text-sm opacity-70">Ungrouped song</p>
{% endif %}
</div>
{% if album %}
<a href="/admin/audio/albums/{{ album.id }}/tracks" class="btn btn-ghost btn-sm">Back to tracks</a>
{% else %}
<a href="/admin/audio/tracks" class="btn btn-ghost btn-sm">Back to songs</a>
{% endif %}
</div>
<div class="card border border-base-300 bg-base-100 shadow-sm">
<div class="card-body">
{% if album %}
<form method="post" action="/admin/audio/albums/{{ album.id }}/tracks/upload-file" enctype="multipart/form-data" class="space-y-2">
{% else %}
<form method="post" action="/admin/audio/tracks/upload-file" enctype="multipart/form-data" class="space-y-2">
{% endif %}
<div class="form-control">
<label class="label"><span class="label-text">Audio file</span></label>
<input type="file" name="file" accept="audio/mpeg,audio/wav,audio/ogg,audio/flac,audio/aac,audio/mp4,audio/webm" required class="file-input file-input-bordered w-full">
@@ -35,9 +47,18 @@
<span class="label-text">Featured</span>
</label>
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" name="published" class="checkbox checkbox-sm">
<span class="label-text">Published</span>
</label>
<div class="flex flex-wrap gap-2 pt-2">
<button type="submit" class="btn btn-neutral btn-sm">Upload</button>
{% if album %}
<a href="/admin/audio/albums/{{ album.id }}/tracks" class="btn btn-ghost btn-sm">Cancel</a>
{% else %}
<a href="/admin/audio/tracks" class="btn btn-ghost btn-sm">Cancel</a>
{% endif %}
</div>
</form>
</div>

View File

@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Songs{% endblock title %}
{% block content %}
<div class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">Songs</h1>
<p class="text-sm opacity-70">Published songs from every album and ungrouped uploads.</p>
</div>
<a href="/audio/albums" class="btn btn-ghost btn-sm">Albums</a>
</div>
<div class="card border border-base-300 bg-base-100 shadow-sm">
<div class="card-body">
{% if tracks | length > 0 %}
<div class="space-y-2">
{% for track in tracks %}
<div class="border-t border-base-300 pt-2">
<p class="font-medium">{{ track.title }}</p>
<audio controls preload="metadata" class="mt-2 w-full">
<source src="/audio/tracks/{{ track.id }}/stream">
</audio>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-center font-medium">No published songs yet.</p>
{% endif %}
</div>
</div>
</div>
{% endblock content %}

View File

@@ -67,6 +67,7 @@
<li><a href="/about">About</a></li>
<li><a href="/blog">Blog</a></li>
<li><a href="/audio/albums">Audio</a></li>
<li><a href="/audio/tracks">Songs</a></li>
{% if logged_in_admin %}
<li><a href="/admin/dashboard">Dashboard</a></li>
<li>
@@ -92,6 +93,7 @@
<li><a href="/about">About</a></li>
<li><a href="/blog">Blog</a></li>
<li><a href="/audio/albums">Audio</a></li>
<li><a href="/audio/tracks">Songs</a></li>
{% if logged_in_admin %}
<li><a href="/admin/dashboard">Dashboard</a></li>
<li>

View File

@@ -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)
]
}

View File

@@ -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(())
}
}

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")]