Skip to content

Commit 201efcd

Browse files
author
Datanoise
committed
feat: internal MPD-compatible player streamer (AutoDJ) alpha release
1 parent 022b646 commit 201efcd

5 files changed

Lines changed: 529 additions & 1 deletion

File tree

config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ type Config struct {
9090
DirectoryListing bool `json:"directory_listing"`
9191
DirectoryServer string `json:"directory_server"`
9292

93+
// Internal Streamer (AutoDJ)
94+
MPDEnabled bool `json:"mpd_enabled"`
95+
MPDPort string `json:"mpd_port"`
96+
MusicDir string `json:"music_dir"`
97+
9398
// Multi-tenant
9499
Users map[string]*User `json:"users"`
95100
}

relay/mpd.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package relay
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"net"
7+
"path/filepath"
8+
"strings"
9+
10+
"github.com/sirupsen/logrus"
11+
)
12+
13+
type MPDServer struct {
14+
addr string
15+
streamer *Streamer
16+
listener net.Listener
17+
}
18+
19+
func NewMPDServer(addr string, s *Streamer) *MPDServer {
20+
return &MPDServer{
21+
addr: addr,
22+
streamer: s,
23+
}
24+
}
25+
26+
func (m *MPDServer) Start() error {
27+
l, err := net.Listen("tcp", m.addr)
28+
if err != nil {
29+
return err
30+
}
31+
m.listener = l
32+
logrus.Infof("MPD Server listening on %s", m.addr)
33+
34+
go func() {
35+
for {
36+
conn, err := l.Accept()
37+
if err != nil {
38+
return
39+
}
40+
go m.handleConnection(conn)
41+
}
42+
}()
43+
44+
return nil
45+
}
46+
47+
func (m *MPDServer) Stop() {
48+
if m.listener != nil {
49+
m.listener.Close()
50+
}
51+
}
52+
53+
func (m *MPDServer) handleConnection(conn net.Conn) {
54+
defer conn.Close()
55+
writer := bufio.NewWriter(conn)
56+
reader := bufio.NewReader(conn)
57+
58+
// Greeting
59+
writer.WriteString("OK MPD 0.23.5\n")
60+
writer.Flush()
61+
62+
for {
63+
line, err := reader.ReadString('\n')
64+
if err != nil {
65+
return
66+
}
67+
line = strings.TrimSpace(line)
68+
if line == "" {
69+
continue
70+
}
71+
72+
cmd := strings.Split(line, " ")[0]
73+
// args := strings.TrimPrefix(line, cmd+" ")
74+
75+
switch cmd { case "status":
76+
m.handleStatus(writer)
77+
case "currentsong":
78+
m.handleCurrentSong(writer)
79+
case "play":
80+
m.streamer.Play()
81+
writer.WriteString("OK\n")
82+
case "stop":
83+
m.streamer.Stop()
84+
writer.WriteString("OK\n")
85+
case "ping":
86+
writer.WriteString("OK\n")
87+
case "close":
88+
return
89+
case "listall", "listallinfo":
90+
m.handleListAll(writer)
91+
case "listplaylists":
92+
writer.WriteString("OK\n")
93+
case "lsinfo":
94+
m.handleListAll(writer)
95+
case "outputs":
96+
fmt.Fprintf(writer, "outputid: 0\noutputname: %s\noutputenabled: 1\n", m.streamer.OutputMount)
97+
writer.WriteString("OK\n")
98+
case "config":
99+
writer.WriteString("OK\n")
100+
case "stats":
101+
fmt.Fprintf(writer, "uptime: 0\nplaytime: 0\nartists: 0\nalbums: 0\nsongs: %d\n", len(m.streamer.Playlist))
102+
writer.WriteString("OK\n")
103+
default:
104+
logrus.Debugf("MPD: Unknown command: %s", cmd)
105+
writer.WriteString("OK\n") // Be lenient for now
106+
}
107+
writer.Flush()
108+
}}
109+
110+
func (m *MPDServer) handleStatus(w *bufio.Writer) {
111+
state := "stop"
112+
if m.streamer.State == StatePlaying {
113+
state = "play"
114+
} else if m.streamer.State == StatePaused {
115+
state = "pause"
116+
}
117+
118+
fmt.Fprintf(w, "state: %s\n", state)
119+
fmt.Fprintf(w, "volume: 100\n")
120+
fmt.Fprintf(w, "repeat: 0\n")
121+
fmt.Fprintf(w, "random: 0\n")
122+
fmt.Fprintf(w, "single: 0\n")
123+
fmt.Fprintf(w, "consume: 0\n")
124+
fmt.Fprintf(w, "playlist: 1\n")
125+
fmt.Fprintf(w, "playlistlength: %d\n", len(m.streamer.Playlist))
126+
w.WriteString("OK\n")
127+
}
128+
129+
func (m *MPDServer) handleCurrentSong(w *bufio.Writer) {
130+
m.streamer.mu.RLock()
131+
defer m.streamer.mu.RUnlock()
132+
fmt.Fprintf(w, "file: %s\n", m.streamer.CurrentFile)
133+
fmt.Fprintf(w, "Title: %s\n", m.streamer.CurrentFile)
134+
fmt.Fprintf(w, "Pos: %d\n", m.streamer.CurrentPos)
135+
fmt.Fprintf(w, "Id: %d\n", m.streamer.CurrentPos)
136+
w.WriteString("OK\n")
137+
}
138+
139+
func (m *MPDServer) handleListAll(w *bufio.Writer) {
140+
// Dummy for now, just list what's in the music dir or playlist
141+
for _, f := range m.streamer.Playlist {
142+
fmt.Fprintf(w, "file: %s\n", filepath.Base(f))
143+
}
144+
w.WriteString("OK\n")
145+
}

relay/streamer.go

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
package relay
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"os"
8+
"path/filepath"
9+
"sync"
10+
"sync/atomic"
11+
"time"
12+
13+
"github.com/DatanoiseTV/tinyice/config"
14+
"github.com/sirupsen/logrus"
15+
)
16+
17+
type StreamerState int
18+
19+
const (
20+
StateStopped StreamerState = iota
21+
StatePlaying
22+
StatePaused
23+
)
24+
25+
type Streamer struct {
26+
Name string
27+
OutputMount string
28+
MusicDir string
29+
Playlist []string
30+
CurrentPos int
31+
State StreamerState
32+
33+
relay *Relay
34+
cancel context.CancelFunc
35+
mu sync.RWMutex
36+
37+
// Stats
38+
BytesStreamed int64
39+
CurrentFile string
40+
}
41+
42+
type StreamerManager struct {
43+
instances map[string]*Streamer // key is OutputMount
44+
mu sync.RWMutex
45+
relay *Relay
46+
config *config.Config
47+
}
48+
49+
func NewStreamerManager(r *Relay, cfg *config.Config) *StreamerManager {
50+
return &StreamerManager{
51+
instances: make(map[string]*Streamer),
52+
relay: r,
53+
config: cfg,
54+
}
55+
}
56+
57+
func (s *Streamer) Play() {
58+
s.mu.Lock()
59+
defer s.mu.Unlock()
60+
s.State = StatePlaying
61+
}
62+
63+
func (s *Streamer) TogglePlay() {
64+
s.mu.Lock()
65+
defer s.mu.Unlock()
66+
if s.State == StatePlaying {
67+
s.State = StateStopped
68+
} else {
69+
s.State = StatePlaying
70+
}
71+
}
72+
73+
func (s *Streamer) Stop() {
74+
s.mu.Lock()
75+
defer s.mu.Unlock()
76+
s.State = StateStopped
77+
if s.cancel != nil {
78+
s.cancel()
79+
}
80+
}
81+
82+
func (s *Streamer) ScanMusicDir() error {
83+
s.mu.Lock()
84+
defer s.mu.Unlock()
85+
86+
if s.MusicDir == "" {
87+
return fmt.Errorf("music directory not configured")
88+
}
89+
90+
files, err := os.ReadDir(s.MusicDir)
91+
if err != nil {
92+
return err
93+
}
94+
95+
s.Playlist = []string{}
96+
for _, f := range files {
97+
if !f.IsDir() && filepath.Ext(f.Name()) == ".mp3" {
98+
s.Playlist = append(s.Playlist, filepath.Join(s.MusicDir, f.Name()))
99+
}
100+
}
101+
return nil
102+
}
103+
104+
func (sm *StreamerManager) StartStreamer(name, mount, musicDir string) (*Streamer, error) {
105+
sm.mu.Lock()
106+
defer sm.mu.Unlock()
107+
108+
if _, ok := sm.instances[mount]; ok {
109+
return nil, fmt.Errorf("streamer for mount %s already exists", mount)
110+
}
111+
112+
ctx, cancel := context.WithCancel(context.Background())
113+
s := &Streamer{
114+
Name: name,
115+
OutputMount: mount,
116+
MusicDir: musicDir,
117+
State: StateStopped,
118+
relay: sm.relay,
119+
cancel: cancel,
120+
}
121+
sm.instances[mount] = s
122+
123+
go sm.runStreamerLoop(ctx, s)
124+
return s, nil
125+
}
126+
127+
func (sm *StreamerManager) GetStreamers() []*Streamer {
128+
sm.mu.RLock()
129+
defer sm.mu.RUnlock()
130+
res := make([]*Streamer, 0, len(sm.instances))
131+
for _, s := range sm.instances {
132+
res = append(res, s)
133+
}
134+
return res
135+
}
136+
137+
func (sm *StreamerManager) GetStreamer(mount string) *Streamer {
138+
sm.mu.RLock()
139+
defer sm.mu.RUnlock()
140+
return sm.instances[mount]
141+
}
142+
143+
func (sm *StreamerManager) runStreamerLoop(ctx context.Context, s *Streamer) {
144+
logrus.Infof("Streamer %s starting for mount %s", s.Name, s.OutputMount)
145+
146+
for {
147+
select {
148+
case <-ctx.Done():
149+
return
150+
default:
151+
if s.State != StatePlaying {
152+
time.Sleep(100 * time.Millisecond)
153+
continue
154+
}
155+
156+
s.mu.RLock()
157+
if len(s.Playlist) == 0 {
158+
s.mu.RUnlock()
159+
time.Sleep(1 * time.Second)
160+
continue
161+
}
162+
163+
if s.CurrentPos >= len(s.Playlist) {
164+
s.CurrentPos = 0
165+
}
166+
filePath := s.Playlist[s.CurrentPos]
167+
s.mu.RUnlock()
168+
169+
err := sm.streamFile(ctx, s, filePath)
170+
if err != nil {
171+
logrus.WithError(err).Errorf("Streamer %s: Failed to stream %s", s.Name, filePath)
172+
time.Sleep(1 * time.Second)
173+
}
174+
175+
s.mu.Lock()
176+
s.CurrentPos++
177+
s.mu.Unlock()
178+
}
179+
}
180+
}
181+
182+
func (sm *StreamerManager) streamFile(ctx context.Context, s *Streamer, path string) error {
183+
f, err := os.Open(path)
184+
if err != nil {
185+
return err
186+
}
187+
defer f.Close()
188+
189+
s.mu.Lock()
190+
s.CurrentFile = filepath.Base(path)
191+
s.mu.Unlock()
192+
193+
// Update stream metadata
194+
output := sm.relay.GetOrCreateStream(s.OutputMount)
195+
output.CurrentSong = s.CurrentFile
196+
output.Name = s.Name
197+
198+
// For now, we only support MP3 files and we assume they are compatible
199+
// with our output settings.
200+
// TODO: Add proper decoding/encoding if formats mismatch.
201+
202+
buf := make([]byte, 8192)
203+
for {
204+
select {
205+
case <-ctx.Done():
206+
return nil
207+
default:
208+
if s.State != StatePlaying {
209+
return nil
210+
}
211+
212+
n, err := f.Read(buf)
213+
if n > 0 {
214+
output.Broadcast(buf[:n], sm.relay)
215+
atomic.AddInt64(&s.BytesStreamed, int64(n))
216+
217+
// Basic pacing for MP3 (very rough)
218+
// We should ideally use a proper pacer or decode to PCM to pace.
219+
// For now, let's just assume 128kbps for pacing estimation.
220+
time.Sleep(time.Duration(n) * 8 * time.Second / 128000)
221+
}
222+
if err == io.EOF {
223+
return nil
224+
}
225+
if err != nil {
226+
return err
227+
}
228+
}
229+
}
230+
}

0 commit comments

Comments
 (0)