song upload
This commit is contained in:
@@ -9,7 +9,10 @@
|
|||||||
<h1 class="text-2xl font-bold">Audio Albums</h1>
|
<h1 class="text-2xl font-bold">Audio Albums</h1>
|
||||||
<p class="text-sm opacity-70">Create albums and upload audio tracks.</p>
|
<p class="text-sm opacity-70">Create albums and upload audio tracks.</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
<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>
|
<tr>
|
||||||
<th>Track</th>
|
<th>Track</th>
|
||||||
<th>File</th>
|
<th>File</th>
|
||||||
|
<th>Status</th>
|
||||||
<th>Featured</th>
|
<th>Featured</th>
|
||||||
<th class="text-right">Actions</th>
|
<th class="text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -35,6 +36,13 @@
|
|||||||
{% if track.track_number %}{{ track.track_number }}. {% endif %}{{ track.title }}
|
{% if track.track_number %}{{ track.track_number }}. {% endif %}{{ track.title }}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-sm">{{ track.audio_file_id }}</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>
|
<td>
|
||||||
{% if track.featured %}
|
{% if track.featured %}
|
||||||
<span class="badge">Yes</span>
|
<span class="badge">Yes</span>
|
||||||
@@ -45,6 +53,15 @@
|
|||||||
<td>
|
<td>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="/audio/tracks/{{ track.id }}/stream" class="btn btn-ghost btn-sm">Play</a>
|
<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">
|
<form method="post" action="/admin/audio/tracks/{{ track.id }}/delete">
|
||||||
<button type="submit" class="btn btn-ghost btn-sm">Delete</button>
|
<button type="submit" class="btn btn-ghost btn-sm">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -7,14 +7,26 @@
|
|||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold">Upload Track</h1>
|
<h1 class="text-2xl font-bold">Upload Track</h1>
|
||||||
|
{% if album %}
|
||||||
<p class="text-sm opacity-70">{{ album.title }}</p>
|
<p class="text-sm opacity-70">{{ album.title }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm opacity-70">Ungrouped song</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if album %}
|
||||||
<a href="/admin/audio/albums/{{ album.id }}/tracks" class="btn btn-ghost btn-sm">Back to tracks</a>
|
<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>
|
||||||
|
|
||||||
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||||
<div class="card-body">
|
<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">
|
<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">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Audio file</span></label>
|
<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">
|
<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>
|
<span class="label-text">Featured</span>
|
||||||
</label>
|
</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">
|
<div class="flex flex-wrap gap-2 pt-2">
|
||||||
<button type="submit" class="btn btn-neutral btn-sm">Upload</button>
|
<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>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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="/about">About</a></li>
|
||||||
<li><a href="/blog">Blog</a></li>
|
<li><a href="/blog">Blog</a></li>
|
||||||
<li><a href="/audio/albums">Audio</a></li>
|
<li><a href="/audio/albums">Audio</a></li>
|
||||||
|
<li><a href="/audio/tracks">Songs</a></li>
|
||||||
{% if logged_in_admin %}
|
{% if logged_in_admin %}
|
||||||
<li><a href="/admin/dashboard">Dashboard</a></li>
|
<li><a href="/admin/dashboard">Dashboard</a></li>
|
||||||
<li>
|
<li>
|
||||||
@@ -92,6 +93,7 @@
|
|||||||
<li><a href="/about">About</a></li>
|
<li><a href="/about">About</a></li>
|
||||||
<li><a href="/blog">Blog</a></li>
|
<li><a href="/blog">Blog</a></li>
|
||||||
<li><a href="/audio/albums">Audio</a></li>
|
<li><a href="/audio/albums">Audio</a></li>
|
||||||
|
<li><a href="/audio/tracks">Songs</a></li>
|
||||||
{% if logged_in_admin %}
|
{% if logged_in_admin %}
|
||||||
<li><a href="/admin/dashboard">Dashboard</a></li>
|
<li><a href="/admin/dashboard">Dashboard</a></li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ mod m20260517_000008_audio_track_tags;
|
|||||||
mod m20260517_000009_simple_constraints;
|
mod m20260517_000009_simple_constraints;
|
||||||
mod m20260517_000010_drop_user_roles;
|
mod m20260517_000010_drop_user_roles;
|
||||||
mod m20260517_000011_site_pages;
|
mod m20260517_000011_site_pages;
|
||||||
|
mod m20260517_000012_standalone_audio_tracks;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260517_000009_simple_constraints::Migration),
|
Box::new(m20260517_000009_simple_constraints::Migration),
|
||||||
Box::new(m20260517_000010_drop_user_roles::Migration),
|
Box::new(m20260517_000010_drop_user_roles::Migration),
|
||||||
Box::new(m20260517_000011_site_pages::Migration),
|
Box::new(m20260517_000011_site_pages::Migration),
|
||||||
|
Box::new(m20260517_000012_standalone_audio_tracks::Migration),
|
||||||
// inject-above (do not remove this comment)
|
// 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()))
|
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 data = None;
|
||||||
let mut title = None;
|
let mut title = None;
|
||||||
let mut track_number = None;
|
let mut track_number = None;
|
||||||
let mut featured = false;
|
let mut featured = false;
|
||||||
|
let mut published = false;
|
||||||
|
|
||||||
while let Some(mut field) = multipart
|
while let Some(mut field) = multipart
|
||||||
.next_field()
|
.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)
|
track_number = value.trim().parse::<i32>().ok().filter(|number| *number > 0)
|
||||||
}
|
}
|
||||||
"featured" => featured = value == "on" || value == "true" || value == "1",
|
"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()));
|
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> {
|
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)
|
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 base = slugify(title);
|
||||||
let mut slug = base.clone();
|
let mut slug = base.clone();
|
||||||
let mut suffix = 2;
|
let mut suffix = 2;
|
||||||
|
|
||||||
while audio_tracks::Entity::find()
|
loop {
|
||||||
.filter(audio_tracks::Column::AlbumId.eq(album_id))
|
let mut query = audio_tracks::Entity::find().filter(audio_tracks::Column::Slug.eq(&slug));
|
||||||
.filter(audio_tracks::Column::Slug.eq(&slug))
|
query = if let Some(album_id) = album_id {
|
||||||
.count(&ctx.db)
|
query.filter(audio_tracks::Column::AlbumId.eq(album_id))
|
||||||
.await?
|
} else {
|
||||||
> 0
|
query.filter(audio_tracks::Column::AlbumId.is_null())
|
||||||
{
|
};
|
||||||
|
|
||||||
|
if query.count(&ctx.db).await? == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
slug = format!("{base}-{suffix}");
|
slug = format!("{base}-{suffix}");
|
||||||
suffix += 1;
|
suffix += 1;
|
||||||
}
|
}
|
||||||
@@ -452,6 +460,7 @@ async fn public_album(
|
|||||||
|
|
||||||
let tracks = audio_tracks::Entity::find()
|
let tracks = audio_tracks::Entity::find()
|
||||||
.filter(audio_tracks::Column::AlbumId.eq(album.id))
|
.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::TrackNumber)
|
||||||
.order_by_asc(audio_tracks::Column::Title)
|
.order_by_asc(audio_tracks::Column::Title)
|
||||||
.all(&ctx.db)
|
.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]
|
#[debug_handler]
|
||||||
async fn admin_albums(
|
async fn admin_albums(
|
||||||
auth: auth::JWT,
|
auth: auth::JWT,
|
||||||
@@ -491,6 +520,21 @@ async fn admin_albums(
|
|||||||
format::view(&v, "admin/audio/albums.html", json!({ "albums": rows }))
|
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]
|
#[debug_handler]
|
||||||
async fn admin_album_new(auth: auth::JWT, ViewEngine(v): ViewEngine<TeraView>, State(ctx): State<AppContext>) -> Result<Response> {
|
async fn admin_album_new(auth: auth::JWT, ViewEngine(v): ViewEngine<TeraView>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
admin::current_admin(auth, &ctx).await?;
|
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]
|
#[debug_handler]
|
||||||
async fn admin_track_upload(
|
async fn admin_track_upload(
|
||||||
auth: auth::JWT,
|
auth: auth::JWT,
|
||||||
@@ -576,29 +659,22 @@ async fn admin_track_upload(
|
|||||||
admin::current_admin(auth, &ctx).await?;
|
admin::current_admin(auth, &ctx).await?;
|
||||||
album_by_id(&ctx, album_id).await?;
|
album_by_id(&ctx, album_id).await?;
|
||||||
|
|
||||||
let (data, title, track_number, featured) = read_track_upload(multipart).await?;
|
create_uploaded_track(&ctx, Some(album_id), 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?;
|
|
||||||
|
|
||||||
format::redirect(&format!("/admin/audio/albums/{album_id}/tracks"))
|
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]
|
#[debug_handler]
|
||||||
async fn admin_track_delete(
|
async fn admin_track_delete(
|
||||||
auth: auth::JWT,
|
auth: auth::JWT,
|
||||||
@@ -613,7 +689,53 @@ async fn admin_track_delete(
|
|||||||
.delete(StdPath::new(&format!("{AUDIO_STORAGE_DIR}/{}", track.audio_file_id)))
|
.delete(StdPath::new(&format!("{AUDIO_STORAGE_DIR}/{}", track.audio_file_id)))
|
||||||
.await;
|
.await;
|
||||||
track.delete(&ctx.db).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> {
|
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>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let track = track_by_id(&ctx, id).await?;
|
let track = track_by_id(&ctx, id).await?;
|
||||||
let album = album_by_id(&ctx, track.album_id).await?;
|
if !track.published {
|
||||||
if !album.published {
|
|
||||||
return Err(Error::NotFound);
|
return Err(Error::NotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -724,14 +845,20 @@ pub fn routes() -> Routes {
|
|||||||
.add("/audio/stream/{filename}", get(raw_audio_stream))
|
.add("/audio/stream/{filename}", get(raw_audio_stream))
|
||||||
.add("/audio/albums", get(public_albums))
|
.add("/audio/albums", get(public_albums))
|
||||||
.add("/audio/albums/{slug}", get(public_album))
|
.add("/audio/albums/{slug}", get(public_album))
|
||||||
|
.add("/audio/tracks", get(public_tracks))
|
||||||
.add("/audio/tracks/{id}/stream", get(track_stream))
|
.add("/audio/tracks/{id}/stream", get(track_stream))
|
||||||
.add("/admin/images", get(admin_images))
|
.add("/admin/images", get(admin_images))
|
||||||
.add("/admin/images/upload", post(admin_image_upload).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)))
|
.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", get(admin_albums))
|
||||||
.add("/admin/audio/albums/create", get(admin_album_new))
|
.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))
|
||||||
|
.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", 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", 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/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))
|
.add("/admin/audio/tracks/{id}/delete", post(admin_track_delete))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,16 +8,18 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub struct Model {
|
pub struct Model {
|
||||||
#[sea_orm(primary_key, auto_increment = false)]
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub album_id: Uuid,
|
pub album_id: Option<Uuid>,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub audio_file_id: String,
|
pub audio_file_id: String,
|
||||||
pub track_number: Option<i32>,
|
pub track_number: Option<i32>,
|
||||||
pub duration: Option<i32>,
|
pub duration: Option<i32>,
|
||||||
pub featured: bool,
|
pub featured: bool,
|
||||||
|
pub published: bool,
|
||||||
pub play_count: i32,
|
pub play_count: i32,
|
||||||
pub created_at: DateTimeWithTimeZone,
|
pub created_at: DateTimeWithTimeZone,
|
||||||
pub updated_at: DateTimeWithTimeZone,
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
pub published_at: Option<DateTimeWithTimeZone>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
@@ -27,7 +29,7 @@ pub enum Relation {
|
|||||||
from = "Column::AlbumId",
|
from = "Column::AlbumId",
|
||||||
to = "super::audio_albums::Column::Id",
|
to = "super::audio_albums::Column::Id",
|
||||||
on_update = "Cascade",
|
on_update = "Cascade",
|
||||||
on_delete = "Cascade"
|
on_delete = "SetNull"
|
||||||
)]
|
)]
|
||||||
AudioAlbums,
|
AudioAlbums,
|
||||||
#[sea_orm(has_many = "super::audio_track_tags::Entity")]
|
#[sea_orm(has_many = "super::audio_track_tags::Entity")]
|
||||||
|
|||||||
Reference in New Issue
Block a user