Skip to content

Commit 1ff48d3

Browse files
author
syso
committed
feat: external song command for AutoDJ (#16)
2 parents aace44f + 48b6efa commit 1ff48d3

32 files changed

Lines changed: 245 additions & 102 deletions

config/config.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,9 @@ type AutoDJConfig struct {
118118
MPDEnabled bool `json:"mpd_enabled"`
119119
MPDPort string `json:"mpd_port"`
120120
MPDPassword string `json:"mpd_password"`
121-
Visible bool `json:"visible"`
121+
Visible bool `json:"visible"`
122+
SongCommand string `json:"song_command"`
123+
SongCommandTimeout int `json:"song_command_timeout"`
122124
}
123125

124126
type IngestConfig struct {

relay/streamer.go

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"math/rand"
88
"os"
9+
"os/exec"
910
"path/filepath"
1011
"strings"
1112
"sync"
@@ -41,7 +42,9 @@ type Streamer struct {
4142
InjectMetadata bool
4243
Visible bool
4344
MPDPassword string
44-
LastPlaylist string
45+
LastPlaylist string
46+
SongCommand string
47+
SongCommandTimeout int
4548

4649
relay *Relay
4750
cancel context.CancelFunc
@@ -486,6 +489,51 @@ func (s *Streamer) signalStateChange() {
486489
}
487490
}
488491

492+
func (s *Streamer) execSongCommand() (string, error) {
493+
if s.SongCommand == "" {
494+
return "", fmt.Errorf("no song command configured")
495+
}
496+
497+
timeout := s.SongCommandTimeout
498+
if timeout <= 0 {
499+
timeout = 5
500+
}
501+
502+
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
503+
defer cancel()
504+
505+
cmd := exec.CommandContext(ctx, "sh", "-c", s.SongCommand)
506+
cmd.Dir = s.MusicDir
507+
508+
output, err := cmd.Output()
509+
if err != nil {
510+
return "", fmt.Errorf("song command failed: %w", err)
511+
}
512+
513+
filePath := strings.TrimSpace(string(output))
514+
if filePath == "" {
515+
return "", fmt.Errorf("song command returned empty output")
516+
}
517+
518+
// Take only the first line
519+
if idx := strings.IndexByte(filePath, '\n'); idx >= 0 {
520+
filePath = filePath[:idx]
521+
}
522+
filePath = strings.TrimSpace(filePath)
523+
524+
// Resolve relative paths against music dir
525+
if !filepath.IsAbs(filePath) {
526+
filePath = filepath.Join(s.MusicDir, filePath)
527+
}
528+
529+
// Validate the file exists and is a supported audio format
530+
if err := validateAudioFile(filePath); err != nil {
531+
return "", fmt.Errorf("song command returned invalid file %q: %w", filePath, err)
532+
}
533+
534+
return filePath, nil
535+
}
536+
489537
type StreamerStats struct {
490538
Name string
491539
Mount string
@@ -536,7 +584,7 @@ func (s *Streamer) GetStats() StreamerStats {
536584
}
537585
}
538586

539-
func (sm *StreamerManager) StartStreamer(name, mount, musicDir string, loop bool, format string, bitrate int, injectMetadata bool, initialPlaylistPaths []string, mpdEnabled bool, mpdPort, mpdPassword string, visible bool, lastPlaylist string) (*Streamer, error) {
587+
func (sm *StreamerManager) StartStreamer(name, mount, musicDir string, loop bool, format string, bitrate int, injectMetadata bool, initialPlaylistPaths []string, mpdEnabled bool, mpdPort, mpdPassword string, visible bool, lastPlaylist string, songCommand string, songCommandTimeout int) (*Streamer, error) {
540588
sm.mu.Lock()
541589
defer sm.mu.Unlock()
542590

@@ -566,8 +614,10 @@ func (sm *StreamerManager) StartStreamer(name, mount, musicDir string, loop bool
566614
InjectMetadata: injectMetadata,
567615
Visible: visible,
568616
MPDPassword: mpdPassword,
569-
LastPlaylist: lastPlaylist,
570-
relay: sm.relay,
617+
LastPlaylist: lastPlaylist,
618+
SongCommand: songCommand,
619+
SongCommandTimeout: songCommandTimeout,
620+
relay: sm.relay,
571621
cancel: cancel,
572622
titleCache: make(map[string]string),
573623
NextID: nextID, // Start NextID after initial playlist
@@ -687,8 +737,41 @@ func (sm *StreamerManager) runStreamerLoop(ctx context.Context, s *Streamer) {
687737
s.Queue = s.Queue[1:]
688738
fileID = -1 // Queue items don't have an ID from the playlist
689739
filePos = -1
740+
} else if s.SongCommand != "" {
741+
// 2. External song command (unlock during exec to avoid blocking)
742+
s.mu.Unlock()
743+
if path, err := s.execSongCommand(); err == nil {
744+
filePath = path
745+
fileID = -1
746+
filePos = -1
747+
} else {
748+
logger.L.Warnf("Streamer %s: Song command error, falling back to playlist: %v", s.Name, err)
749+
}
750+
s.mu.Lock()
751+
// If command failed, try playlist as fallback
752+
if filePath == "" && len(s.Playlist) > 0 {
753+
if s.Shuffle {
754+
s.CurrentPos = rand.Intn(len(s.Playlist))
755+
} else {
756+
if s.CurrentPos >= len(s.Playlist) {
757+
if s.Loop {
758+
s.CurrentPos = 0
759+
} else {
760+
s.State = StateStopped
761+
s.mu.Unlock()
762+
continue
763+
}
764+
}
765+
}
766+
filePath = s.Playlist[s.CurrentPos].Path
767+
fileID = s.Playlist[s.CurrentPos].ID
768+
filePos = s.CurrentPos
769+
if !s.Shuffle {
770+
s.CurrentPos++
771+
}
772+
}
690773
} else if len(s.Playlist) > 0 {
691-
// 2. Handle Shuffle or Sequential Playlist
774+
// 3. Normal playlist selection
692775
if s.Shuffle {
693776
s.CurrentPos = rand.Intn(len(s.Playlist))
694777
} else {

server/frontend/dist/assets/EqBars-B3Ed9YAS.js renamed to server/frontend/dist/assets/EqBars-BHQ_0ymB.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/frontend/dist/assets/Nav-BOInGAHA.js renamed to server/frontend/dist/assets/Nav-DF7gXnxE.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/frontend/dist/assets/StreamCard-quJVSamI.js renamed to server/frontend/dist/assets/StreamCard-C43R1wRH.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/frontend/dist/assets/VolumeKnob-nNJMXnrn.js renamed to server/frontend/dist/assets/VolumeKnob-Cq3ToTCb.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)