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.GET("/spotify/recent", store.RecentlyPlayed)
|
||||||
// r.POST("/spotify", store.SendSong)
|
// 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
|
// MESSAGES
|
||||||
r.GET("/ws", store.ConnectWebSocket)
|
r.GET("/ws", store.ConnectWebSocket)
|
||||||
protected.POST("/messages/upload", store.UploadMessageFile)
|
protected.POST("/messages/upload", store.UploadMessageFile)
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ services:
|
|||||||
- ${OBSIDIAN_DIR}:/backend/notes
|
- ${OBSIDIAN_DIR}:/backend/notes
|
||||||
- ./logs:/backend/logs
|
- ./logs:/backend/logs
|
||||||
- uploads:/backend/uploads
|
- uploads:/backend/uploads
|
||||||
|
- ./icecast2/fallback_music:/backend/fallback_music
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ http {
|
|||||||
server_tokens off;
|
server_tokens off;
|
||||||
charset utf-8;
|
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=login:10m rate=5r/m;
|
||||||
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
|
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ http {
|
|||||||
server_tokens off;
|
server_tokens off;
|
||||||
charset utf-8;
|
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=login:10m rate=5r/m;
|
||||||
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
|
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 CreateActivity from "./CreateActivity.vue";
|
||||||
import CreateRowing from "./CreateRowing.vue";
|
import CreateRowing from "./CreateRowing.vue";
|
||||||
import ManageUsers from "./ManageUsers.vue";
|
import ManageUsers from "./ManageUsers.vue";
|
||||||
|
import ManageRadio from "./ManageRadio.vue";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
</script>
|
</script>
|
||||||
@@ -23,6 +24,7 @@ const auth = useAuthStore();
|
|||||||
<CreateActivity class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
|
<CreateActivity class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
|
||||||
<CreateRowing 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" />
|
<ManageUsers class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
|
||||||
|
<ManageRadio class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</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