Add admin UI for managing radio fallback music
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m44s
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:
104
backend/handlers/handle_radio.go
Normal file
104
backend/handlers/handle_radio.go
Normal 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})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
85
vue/src/views/admin/ManageRadio.vue
Normal file
85
vue/src/views/admin/ManageRadio.vue
Normal 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>
|
||||
Reference in New Issue
Block a user