song upload
This commit is contained in:
@@ -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">
|
||||
|
||||
85
assets/views/admin/audio/songs.html
Normal file
85
assets/views/admin/audio/songs.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
34
assets/views/audio/tracks.html
Normal file
34
assets/views/audio/tracks.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
}
|
||||
|
||||
131
migration/src/m20260517_000012_standalone_audio_tracks.rs
Normal file
131
migration/src/m20260517_000012_standalone_audio_tracks.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user