Add admin UI for managing radio fallback music
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m44s

Upload, list, and delete fallback music files from the admin page.
Backend handlers validate file type/size and prevent path traversal.
Nginx max body size increased to 50M to support large audio files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-30 16:19:10 +01:00
parent 179f52d1d7
commit d215333128
7 changed files with 199 additions and 2 deletions

View File

@@ -0,0 +1,104 @@
package handlers
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
const fallbackMusicDir = "/backend/fallback_music"
var allowedAudioExtensions = map[string]bool{
".mp3": true, ".ogg": true, ".flac": true, ".wav": true, ".m4a": true, ".opus": true,
}
func (store *Store) UploadRadioSong(ctx *gin.Context) {
file, err := ctx.FormFile("file")
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
return
}
const maxSize = 50 << 20 // 50MB
if file.Size > maxSize {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file too large (max 50MB)"})
return
}
ext := strings.ToLower(filepath.Ext(file.Filename))
if !allowedAudioExtensions[ext] {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file type not allowed (accepted: .mp3, .ogg, .flac, .wav, .m4a, .opus)"})
return
}
filename := filepath.Base(file.Filename)
dest := filepath.Join(fallbackMusicDir, filename)
if _, err := os.Stat(dest); err == nil {
ctx.JSON(http.StatusConflict, gin.H{"error": "file already exists"})
return
}
if err := ctx.SaveUploadedFile(file, dest); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
return
}
ctx.JSON(http.StatusOK, gin.H{"name": filename})
}
func (store *Store) ListRadioSongs(ctx *gin.Context) {
entries, err := os.ReadDir(fallbackMusicDir)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read music directory"})
return
}
type songInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
Modified int64 `json:"modified"`
}
songs := []songInfo{}
for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
songs = append(songs, songInfo{
Name: entry.Name(),
Size: info.Size(),
Modified: info.ModTime().Unix(),
})
}
ctx.JSON(http.StatusOK, gin.H{"songs": songs})
}
func (store *Store) DeleteRadioSong(ctx *gin.Context) {
filename := filepath.Base(ctx.Param("filename"))
if filename == "." || filename == "/" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
return
}
path := filepath.Join(fallbackMusicDir, filename)
if _, err := os.Stat(path); os.IsNotExist(err) {
ctx.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
if err := os.Remove(path); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete file"})
return
}
ctx.JSON(http.StatusOK, gin.H{"deleted": filename})
}

View File

@@ -125,6 +125,11 @@ func main() {
r.GET("/spotify/recent", store.RecentlyPlayed)
// r.POST("/spotify", store.SendSong)
// RADIO
admin.POST("/radio/upload", store.UploadRadioSong)
admin.GET("/radio/songs", store.ListRadioSongs)
admin.DELETE("/radio/songs/:filename", store.DeleteRadioSong)
// MESSAGES
r.GET("/ws", store.ConnectWebSocket)
protected.POST("/messages/upload", store.UploadMessageFile)

View File

@@ -72,6 +72,7 @@ services:
- ${OBSIDIAN_DIR}:/backend/notes
- ./logs:/backend/logs
- uploads:/backend/uploads
- ./icecast2/fallback_music:/backend/fallback_music
db:
image: postgres:16

View File

@@ -9,7 +9,7 @@ http {
server_tokens off;
charset utf-8;
client_max_body_size 10M;
client_max_body_size 50M;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;

View File

@@ -9,7 +9,7 @@ http {
server_tokens off;
charset utf-8;
client_max_body_size 10M;
client_max_body_size 50M;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;

View File

@@ -9,6 +9,7 @@ import CreateFavorite from "./CreateFavorite.vue";
import CreateActivity from "./CreateActivity.vue";
import CreateRowing from "./CreateRowing.vue";
import ManageUsers from "./ManageUsers.vue";
import ManageRadio from "./ManageRadio.vue";
const auth = useAuthStore();
</script>
@@ -23,6 +24,7 @@ const auth = useAuthStore();
<CreateActivity class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
<CreateRowing class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
<ManageUsers class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
<ManageRadio class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
</div>
</main>
</template>

View File

@@ -0,0 +1,85 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref, onMounted } from "vue";
import axios from "axios";
const songs = ref([]);
const files = ref([]);
const results = ref([]);
const loading = ref(false);
async function fetchSongs() {
try {
const res = await axios.get("/api/radio/songs");
songs.value = res.data.songs;
} catch (err) {
console.error(err);
}
}
function onFileChange(e) {
files.value = Array.from(e.target.files);
results.value = [];
}
async function upload() {
if (!files.value.length) return;
loading.value = true;
results.value = files.value.map((f) => ({ name: f.name, status: "Uploading..." }));
await Promise.all(
files.value.map(async (file, i) => {
const formData = new FormData();
formData.append("file", file);
try {
await axios.post("/api/radio/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
results.value[i].status = "Uploaded";
results.value[i].ok = true;
} catch (err) {
results.value[i].status = err.response?.data?.error || "Upload failed";
results.value[i].ok = false;
}
})
);
files.value = [];
loading.value = false;
await fetchSongs();
}
async function deleteSong(name) {
try {
await axios.delete(`/api/radio/songs/${encodeURIComponent(name)}`);
await fetchSongs();
} catch (err) {
console.error(err);
}
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
}
onMounted(fetchSongs);
</script>
<template>
<div class="flex flex-col gap-2">
<h1>Manage Radio</h1>
<input type="file" accept=".mp3,.ogg,.flac,.wav,.m4a,.opus" multiple @change="onFileChange" />
<Button @click="upload" :disabled="loading">Upload</Button>
<div v-for="r in results" :key="r.name">
<span class="text-primary">{{ r.name }}: </span>
<span :class="r.ok ? 'text-secondary' : 'text-red-500'">{{ r.status }}</span>
</div>
<div v-for="song in songs" :key="song.name" class="flex flex-row items-center gap-2">
<span>{{ song.name }}</span>
<span class="text-secondary text-sm">{{ formatSize(song.size) }}</span>
<Button @click="deleteSong(song.name)">Delete</Button>
</div>
</div>
</template>