Skip to content

Commit 2bcff70

Browse files
author
Datanoise
committed
feat: library file browser, playlist persistence and drag-drop fixes
1 parent 459b5af commit 2bcff70

4 files changed

Lines changed: 226 additions & 29 deletions

File tree

config/config.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,12 @@ type AutoDJConfig struct {
5050
Name string `json:"name"`
5151
Mount string `json:"mount"`
5252
MusicDir string `json:"music_dir"`
53-
Format string `json:"format"` // "mp3" or "opus"
54-
Bitrate int `json:"bitrate"`
55-
Enabled bool `json:"enabled"`
56-
Loop bool `json:"loop"`
57-
InjectMetadata bool `json:"inject_metadata"`
53+
Format string `json:"format"` // "mp3" or "opus"
54+
Bitrate int `json:"bitrate"`
55+
Enabled bool `json:"enabled"`
56+
Loop bool `json:"loop"`
57+
InjectMetadata bool `json:"inject_metadata"`
58+
Playlist []string `json:"playlist"`
5859
}
5960

6061
type Config struct {

relay/streamer.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,41 @@ func (s *Streamer) ScanMusicDir() error {
151151
return nil
152152
}
153153

154-
func (sm *StreamerManager) StartStreamer(name, mount, musicDir string, loop bool, format string, bitrate int, injectMetadata bool) (*Streamer, error) {
154+
func (s *Streamer) GetMusicDir() string {
155+
s.mu.RLock()
156+
defer s.mu.RUnlock()
157+
return s.MusicDir
158+
}
159+
160+
func (s *Streamer) GetPlaylist() []string {
161+
s.mu.RLock()
162+
defer s.mu.RUnlock()
163+
res := make([]string, len(s.Playlist))
164+
copy(res, s.Playlist)
165+
return res
166+
}
167+
168+
func (s *Streamer) SetPlaylist(p []string) {
169+
s.mu.Lock()
170+
defer s.mu.Unlock()
171+
s.Playlist = p
172+
}
173+
174+
func (s *Streamer) AddToPlaylist(path string) {
175+
s.mu.Lock()
176+
defer s.mu.Unlock()
177+
s.Playlist = append(s.Playlist, path)
178+
}
179+
180+
func (s *Streamer) RemoveFromPlaylist(idx int) {
181+
s.mu.Lock()
182+
defer s.mu.Unlock()
183+
if idx >= 0 && idx < len(s.Playlist) {
184+
s.Playlist = append(s.Playlist[:idx], s.Playlist[idx+1:]...)
185+
}
186+
}
187+
188+
func (sm *StreamerManager) StartStreamer(name, mount, musicDir string, loop bool, format string, bitrate int, injectMetadata bool, initialPlaylist []string) (*Streamer, error) {
155189
sm.mu.Lock()
156190
defer sm.mu.Unlock()
157191

@@ -166,6 +200,7 @@ func (sm *StreamerManager) StartStreamer(name, mount, musicDir string, loop bool
166200
MusicDir: musicDir,
167201
Format: format,
168202
Bitrate: bitrate,
203+
Playlist: initialPlaylist,
169204
State: StateStopped,
170205
Loop: loop,
171206
InjectMetadata: injectMetadata,

server/server.go

Lines changed: 108 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"net/http"
1616
"net/url"
1717
"os"
18+
"path/filepath"
1819
"runtime"
1920
"sort"
2021
"strings"
@@ -266,6 +267,8 @@ func (s *Server) setupRoutes() *http.ServeMux {
266267
mux.HandleFunc("/admin/player/reorder", s.handlePlayerReorder)
267268
mux.HandleFunc("/admin/player/queue", s.handlePlayerQueue)
268269
mux.HandleFunc("/admin/player/shuffle", s.handlePlayerShuffle)
270+
mux.HandleFunc("/admin/player/files", s.handlePlayerFiles)
271+
mux.HandleFunc("/admin/player/playlist-action", s.handlePlayerPlaylistAction)
269272
mux.HandleFunc("/admin/autodj/add", s.handleAddAutoDJ)
270273
mux.HandleFunc("/admin/autodj/delete", s.handleDeleteAutoDJ)
271274
mux.HandleFunc("/admin/autodj/toggle", s.handleToggleAutoDJ)
@@ -392,20 +395,22 @@ func (s *Server) Start() error {
392395
// Start configured AutoDJs
393396
for _, adj := range s.Config.AutoDJs {
394397
if adj.Enabled {
395-
streamer, err := s.StreamerM.StartStreamer(adj.Name, adj.Mount, adj.MusicDir, adj.Loop, adj.Format, adj.Bitrate, adj.InjectMetadata)
398+
streamer, err := s.StreamerM.StartStreamer(adj.Name, adj.Mount, adj.MusicDir, adj.Loop, adj.Format, adj.Bitrate, adj.InjectMetadata, adj.Playlist)
396399
if err != nil {
397400
logrus.WithError(err).Errorf("Failed to start AutoDJ %s", adj.Name)
398401
} else {
399402
logrus.Infof("AutoDJ %s started on %s", adj.Name, adj.Mount)
400-
streamer.ScanMusicDir() // Initial scan
403+
if len(adj.Playlist) == 0 {
404+
streamer.ScanMusicDir() // Initial scan
405+
}
401406
}
402407
}
403408
}
404409

405410
// Legacy/Default AutoDJ (backward compatibility)
406411
if s.Config.MusicDir != "" && s.StreamerM.GetStreamer("/autodj") == nil {
407412
// Start a default streamer for /autodj
408-
streamer, err := s.StreamerM.StartStreamer("AutoDJ", "/autodj", s.Config.MusicDir, true, "mp3", 128, true)
413+
streamer, err := s.StreamerM.StartStreamer("AutoDJ", "/autodj", s.Config.MusicDir, true, "mp3", 128, true, nil)
409414
if err == nil {
410415
if s.Config.MPDEnabled {
411416
port := s.Config.MPDPort
@@ -2146,6 +2151,16 @@ func (s *Server) handlePlayerScan(w http.ResponseWriter, r *http.Request) {
21462151
logrus.WithError(err).Error("Failed to scan music directory")
21472152
}
21482153

2154+
// Persist
2155+
playlistCopy := streamer.GetPlaylist()
2156+
for _, adj := range s.Config.AutoDJs {
2157+
if adj.Mount == mount {
2158+
adj.Playlist = playlistCopy
2159+
s.Config.SaveConfig()
2160+
break
2161+
}
2162+
}
2163+
21492164
http.Redirect(w, r, "/admin#tab-streamer", http.StatusSeeOther)
21502165
}
21512166

@@ -2198,7 +2213,7 @@ func (s *Server) handleAddAutoDJ(w http.ResponseWriter, r *http.Request) {
21982213
s.Config.SaveConfig()
21992214

22002215
// Start it immediately
2201-
streamer, err := s.StreamerM.StartStreamer(adj.Name, adj.Mount, adj.MusicDir, adj.Loop, adj.Format, adj.Bitrate, adj.InjectMetadata)
2216+
streamer, err := s.StreamerM.StartStreamer(adj.Name, adj.Mount, adj.MusicDir, adj.Loop, adj.Format, adj.Bitrate, adj.InjectMetadata, nil)
22022217
if err == nil {
22032218
streamer.ScanMusicDir()
22042219
streamer.Play()
@@ -2246,9 +2261,11 @@ func (s *Server) handleToggleAutoDJ(w http.ResponseWriter, r *http.Request) {
22462261
if adj.Mount == mount {
22472262
adj.Enabled = !adj.Enabled
22482263
if adj.Enabled {
2249-
streamer, err := s.StreamerM.StartStreamer(adj.Name, adj.Mount, adj.MusicDir, adj.Loop, adj.Format, adj.Bitrate, adj.InjectMetadata)
2264+
streamer, err := s.StreamerM.StartStreamer(adj.Name, adj.Mount, adj.MusicDir, adj.Loop, adj.Format, adj.Bitrate, adj.InjectMetadata, adj.Playlist)
22502265
if err == nil {
2251-
streamer.ScanMusicDir()
2266+
if len(adj.Playlist) == 0 {
2267+
streamer.ScanMusicDir()
2268+
}
22522269
streamer.Play()
22532270
}
22542271
} else {
@@ -2267,7 +2284,7 @@ func (s *Server) handleToggleAutoDJ(w http.ResponseWriter, r *http.Request) {
22672284
} else {
22682285
// If it was the legacy one, we can try to restart it
22692286
if mount == "/autodj" && s.Config.MusicDir != "" {
2270-
streamer, err := s.StreamerM.StartStreamer("AutoDJ", "/autodj", s.Config.MusicDir, true, "mp3", 128, true)
2287+
streamer, err := s.StreamerM.StartStreamer("AutoDJ", "/autodj", s.Config.MusicDir, true, "mp3", 128, true, nil)
22712288
if err == nil {
22722289
streamer.ScanMusicDir()
22732290
streamer.Play()
@@ -2304,6 +2321,17 @@ func (s *Server) handlePlayerReorder(w http.ResponseWriter, r *http.Request) {
23042321
fmt.Sscanf(toStr, "%d", &to)
23052322

23062323
streamer.MovePlaylistItem(from, to)
2324+
2325+
// Persist
2326+
playlistCopy := streamer.GetPlaylist()
2327+
for _, adj := range s.Config.AutoDJs {
2328+
if adj.Mount == mount {
2329+
adj.Playlist = playlistCopy
2330+
s.Config.SaveConfig()
2331+
break
2332+
}
2333+
}
2334+
23072335
w.WriteHeader(http.StatusOK)
23082336
}
23092337

@@ -2357,6 +2385,79 @@ func (s *Server) handlePlayerShuffle(w http.ResponseWriter, r *http.Request) {
23572385
http.Redirect(w, r, "/admin#tab-streamer", http.StatusSeeOther)
23582386
}
23592387

2388+
func (s *Server) handlePlayerFiles(w http.ResponseWriter, r *http.Request) {
2389+
if _, ok := s.checkAuth(r); !ok {
2390+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
2391+
return
2392+
}
2393+
2394+
mount := r.URL.Query().Get("mount")
2395+
streamer := s.StreamerM.GetStreamer(mount)
2396+
if streamer == nil {
2397+
http.Error(w, "Streamer not found", http.StatusNotFound)
2398+
return
2399+
}
2400+
2401+
musicDir := streamer.GetMusicDir()
2402+
2403+
files, err := os.ReadDir(musicDir)
2404+
if err != nil {
2405+
http.Error(w, err.Error(), http.StatusInternalServerError)
2406+
return
2407+
}
2408+
2409+
var res []string
2410+
for _, f := range files {
2411+
if !f.IsDir() && strings.ToLower(filepath.Ext(f.Name())) == ".mp3" {
2412+
res = append(res, f.Name())
2413+
}
2414+
}
2415+
2416+
w.Header().Set("Content-Type", "application/json")
2417+
json.NewEncoder(w).Encode(res)
2418+
}
2419+
2420+
func (s *Server) handlePlayerPlaylistAction(w http.ResponseWriter, r *http.Request) {
2421+
if !s.isCSRFSafe(r) {
2422+
http.Error(w, "Forbidden", http.StatusForbidden)
2423+
return
2424+
}
2425+
if _, ok := s.checkAuth(r); !ok {
2426+
return
2427+
}
2428+
2429+
mount := r.FormValue("mount")
2430+
action := r.FormValue("action")
2431+
fileName := r.FormValue("file")
2432+
2433+
streamer := s.StreamerM.GetStreamer(mount)
2434+
if streamer == nil {
2435+
http.Error(w, "Streamer not found", http.StatusNotFound)
2436+
return
2437+
}
2438+
2439+
if action == "add" {
2440+
fullPath := filepath.Join(streamer.GetMusicDir(), fileName)
2441+
streamer.AddToPlaylist(fullPath)
2442+
} else if action == "remove" {
2443+
var idx int
2444+
fmt.Sscanf(r.FormValue("index"), "%d", &idx)
2445+
streamer.RemoveFromPlaylist(idx)
2446+
}
2447+
playlistCopy := streamer.GetPlaylist()
2448+
2449+
// Persist
2450+
for _, adj := range s.Config.AutoDJs {
2451+
if adj.Mount == mount {
2452+
adj.Playlist = playlistCopy
2453+
s.Config.SaveConfig()
2454+
break
2455+
}
2456+
}
2457+
2458+
http.Redirect(w, r, "/admin#tab-streamer", http.StatusSeeOther)
2459+
}
2460+
23602461
func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) {
23612462
if _, ok := s.checkAuth(r); !ok {
23622463
http.Error(w, "Unauthorized", http.StatusUnauthorized)

server/templates/admin.html

Lines changed: 76 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -475,22 +475,38 @@ <h3 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
475475
{{$file}}
476476
</span>
477477
</div>
478-
<div style="display: flex; gap: 0.5rem;">
479-
<form action="/admin/player/queue" method="POST">
480-
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
481-
<input type="hidden" name="mount" value="{{$st.OutputMount}}">
482-
<input type="hidden" name="action" value="add">
483-
<input type="hidden" name="path" value="{{index $st.Playlist $idx}}">
484-
<button type="submit" class="btn btn-outline btn-sm" style="padding: 0.2rem 0.4rem;" title="Add to Queue"><i data-lucide="list-plus" style="width: 14px; height: 14px;"></i></button>
485-
</form>
486-
</div>
487-
</div>
488-
{{end}}
489-
</div>
490-
</div>
491-
492-
<div style="margin-top: 1rem; display: flex; gap: 1rem;">
493-
<form action="/admin/player/scan" method="POST" style="flex: 1;">
478+
<div style="display: flex; gap: 0.5rem;">
479+
<form action="/admin/player/queue" method="POST">
480+
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
481+
<input type="hidden" name="mount" value="{{$st.OutputMount}}">
482+
<input type="hidden" name="action" value="add">
483+
<input type="hidden" name="path" value="{{index $st.Playlist $idx}}">
484+
<button type="submit" class="btn btn-outline btn-sm" style="padding: 0.2rem 0.4rem;" title="Add to Queue"><i data-lucide="list-plus" style="width: 14px; height: 14px;"></i></button>
485+
</form>
486+
<form action="/admin/player/playlist-action" method="POST">
487+
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
488+
<input type="hidden" name="mount" value="{{$st.OutputMount}}">
489+
<input type="hidden" name="action" value="remove">
490+
<input type="hidden" name="index" value="{{$idx}}">
491+
<button type="submit" class="btn btn-danger btn-sm" style="padding: 0.2rem 0.4rem;" title="Remove from Playlist"><i data-lucide="trash-2" style="width: 14px; height: 14px;"></i></button>
492+
</form>
493+
</div>
494+
</div>
495+
{{end}}
496+
</div>
497+
</div>
498+
499+
<div style="margin-top: 1.5rem; border-top: 1px solid var(--border); padding-top: 1rem;">
500+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
501+
<div class="label" style="font-size: 0.65rem; color: var(--text-dim); margin-bottom: 0; letter-spacing: 1px;">LIBRARY (MUSIC DIRECTORY)</div>
502+
<button class="btn btn-outline btn-sm" style="padding: 0.2rem 0.5rem; font-size: 0.7rem;" onclick="loadLibrary('{{.OutputMount}}', this.parentElement.nextElementSibling)">BROWSE FILES</button>
503+
</div>
504+
<div class="library-container" style="display: none; max-height: 250px; overflow-y: auto; background: rgba(0,0,0,0.1); border-radius: 4px; padding: 0.5rem;">
505+
<!-- Populated via JS -->
506+
</div>
507+
</div>
508+
509+
<div style="margin-top: 1rem; display: flex; gap: 1rem;"> <form action="/admin/player/scan" method="POST" style="flex: 1;">
494510
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
495511
<input type="hidden" name="mount" value="{{.OutputMount}}">
496512
<button type="submit" class="btn btn-outline btn-sm" style="width: 100%;"><i data-lucide="refresh-cw" style="width: 12px; height: 12px; margin-right: 4px;"></i> RESCAN DIRECTORY</button>
@@ -584,6 +600,50 @@ <h2>Top Connection Scanners (404s)</h2>
584600
}
585601
setInterval(updateAutoDJProgress, 1000);
586602

603+
function loadLibrary(mount, container) {
604+
if (container.style.display === 'block') {
605+
container.style.display = 'none';
606+
return;
607+
}
608+
container.innerHTML = '<div style="text-align:center; padding: 1rem; color: var(--text-dim); font-size: 0.8rem;">Scanning directory...</div>';
609+
container.style.display = 'block';
610+
611+
fetch('/admin/player/files?mount=' + encodeURIComponent(mount))
612+
.then(r => r.json())
613+
.then(files => {
614+
if (!files || files.length === 0) {
615+
container.innerHTML = '<div style="text-align:center; padding: 1rem; color: var(--text-dim); font-size: 0.8rem;">No .mp3 files found.</div>';
616+
return;
617+
}
618+
619+
let html = '<div style="display:flex; flex-direction:column; gap:4px;">';
620+
files.forEach(f => {
621+
html += `
622+
<div style="display:flex; justify-content:space-between; align-items:center; padding:0.4rem; background:rgba(255,255,255,0.02); border-radius:4px; font-size:0.8rem;">
623+
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1;">${f}</span>
624+
<div style="display:flex; gap:0.3rem;">
625+
<form action="/admin/player/queue" method="POST" style="margin:0;">
626+
<input type="hidden" name="csrf" value="${csrfToken}">
627+
<input type="hidden" name="mount" value="${mount}">
628+
<input type="hidden" name="action" value="add">
629+
<input type="hidden" name="path" value="${f}">
630+
<button type="submit" class="btn btn-outline btn-sm" style="padding:0.1rem 0.3rem; font-size:0.7rem;" title="Queue Next">QUEUE</button>
631+
</form>
632+
<form action="/admin/player/playlist-action" method="POST" style="margin:0;">
633+
<input type="hidden" name="csrf" value="${csrfToken}">
634+
<input type="hidden" name="mount" value="${mount}">
635+
<input type="hidden" name="action" value="add">
636+
<input type="hidden" name="file" value="${f}">
637+
<button type="submit" class="btn btn-primary btn-sm" style="padding:0.1rem 0.3rem; font-size:0.7rem;" title="Add to Playlist">ADD</button>
638+
</form>
639+
</div>
640+
</div>`;
641+
});
642+
html += '</div>';
643+
container.innerHTML = html;
644+
});
645+
}
646+
587647
function initSortable() {
588648
document.querySelectorAll('.playlist-sortable').forEach(function(el) {
589649
new Sortable(el, {

0 commit comments

Comments
 (0)