Completely AI generated nonsense, seems to work
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial commit

Owen Campbell 952d38b2

+762
+15
.env.example
··· 1 + # Mumble server address (host:port). 2 + MUMBLE_SERVER=mumble-server:64738 3 + 4 + # Bot display name in mumble. 5 + MUMBLE_USERNAME=MusicBot 6 + 7 + # Server password (leave empty if none). 8 + MUMBLE_PASSWORD= 9 + 10 + # Channel to auto-join (slash-separated path, e.g. "Music Room"). 11 + # Leave empty to stay in root. 12 + MUMBLE_CHANNEL= 13 + 14 + # Accept self-signed TLS certificates (true/false). 15 + MUMBLE_INSECURE=true
+1
.gitignore
··· 1 + .env
+27
Dockerfile
··· 1 + # ── Build stage ──────────────────────────────────────────────── 2 + FROM golang:1.22-alpine AS builder 3 + 4 + RUN apk add --no-cache gcc musl-dev opus-dev pkgconf 5 + 6 + WORKDIR /src 7 + COPY go.mod go.sum ./ 8 + RUN go mod download 9 + 10 + COPY . . 11 + RUN CGO_ENABLED=1 go build -o /bot ./cmd/bot 12 + 13 + # ── Runtime stage ───────────────────────────────────────────── 14 + FROM alpine:3.19 15 + 16 + RUN apk add --no-cache \ 17 + ffmpeg \ 18 + opus \ 19 + python3 \ 20 + py3-pip \ 21 + ca-certificates \ 22 + && pip3 install --no-cache-dir --break-system-packages yt-dlp \ 23 + && rm -rf /root/.cache 24 + 25 + COPY --from=builder /bot /usr/local/bin/bot 26 + 27 + ENTRYPOINT ["bot"]
+104
README.md
··· 1 + # Mumble Music Bot 2 + 3 + A Mumble bot written in Go that streams YouTube audio into a voice channel. Users paste YouTube URLs in chat and the bot plays them using `yt-dlp` + `ffmpeg`. 4 + 5 + ## Features 6 + 7 + - Paste a YouTube URL in chat to play audio 8 + - Song queue with auto-advance 9 + - Chat commands: `!skip`, `!stop`, `!queue`, `!np`, `!vol <0-100>`, `!help` 10 + - Fetches and displays video titles 11 + - Configurable via environment variables 12 + 13 + ## Building 14 + 15 + The bot is packaged as a Docker image. Build it from this directory: 16 + 17 + ```bash 18 + # Linux / macOS 19 + ./build.sh 20 + 21 + # Windows (PowerShell) 22 + .\build.ps1 23 + ``` 24 + 25 + This produces an image tagged `mumble-music-bot:latest`. 26 + 27 + ## Deployment 28 + 29 + The image is self-contained — it includes `ffmpeg`, `yt-dlp`, and the compiled bot binary. No files from this repository are needed at the deployment location, only the image and your configuration. 30 + 31 + ### Adding to an existing Docker Compose stack 32 + 33 + In your compose file (wherever your Mumble server is defined), add the bot as a service referencing the built image: 34 + 35 + ```yaml 36 + services: 37 + mumble-server: 38 + # ... your existing mumble server config ... 39 + 40 + mumble-bot: 41 + image: mumble-music-bot 42 + restart: unless-stopped 43 + environment: 44 + MUMBLE_SERVER: mumble-server:64738 45 + MUMBLE_USERNAME: MusicBot 46 + MUMBLE_PASSWORD: your-server-password 47 + MUMBLE_INSECURE: "true" 48 + depends_on: 49 + - mumble-server 50 + ``` 51 + 52 + Alternatively, use an env file: 53 + 54 + ```yaml 55 + mumble-bot: 56 + image: mumble-music-bot 57 + restart: unless-stopped 58 + env_file: ./mumble-bot.env 59 + depends_on: 60 + - mumble-server 61 + ``` 62 + 63 + ## Configuration 64 + 65 + All configuration is done through environment variables: 66 + 67 + | Variable | Default | Description | 68 + |---|---|---| 69 + | `MUMBLE_SERVER` | `localhost:64738` | Mumble server address (`host:port`) | 70 + | `MUMBLE_USERNAME` | `MusicBot` | Bot display name | 71 + | `MUMBLE_PASSWORD` | *(empty)* | Server password (leave empty if none) | 72 + | `MUMBLE_CHANNEL` | *(empty)* | Channel to auto-join (e.g. `Music Room`). Empty = stay in root | 73 + | `MUMBLE_INSECURE` | `false` | Accept self-signed TLS certificates | 74 + 75 + See [.env.example](.env.example) for a template. 76 + 77 + ## Chat Commands 78 + 79 + | Command | Description | 80 + |---|---| 81 + | *(YouTube URL)* | Queue and play a YouTube video | 82 + | `!skip` | Skip the current track | 83 + | `!stop` | Stop playback and clear the queue | 84 + | `!queue` / `!q` | Show the current queue | 85 + | `!np` / `!nowplaying` | Show what's currently playing | 86 + | `!vol <0-100>` | Set playback volume | 87 + | `!help` | Show available commands | 88 + 89 + ## Project Structure 90 + 91 + ``` 92 + ├── cmd/bot/main.go # Entrypoint 93 + ├── internal/ 94 + │ ├── bot/ 95 + │ │ ├── bot.go # Mumble connection and lifecycle 96 + │ │ └── handler.go # Chat command handling and URL parsing 97 + │ └── player/ 98 + │ ├── player.go # Audio streaming (yt-dlp → ffmpeg → Mumble) 99 + │ └── queue.go # Thread-safe song queue 100 + ├── Dockerfile # Multi-stage build (Go builder + Alpine runtime) 101 + ├── build.sh / build.ps1 # Image build scripts 102 + ├── .env.example # Configuration template 103 + └── README.md 104 + ```
+13
build.ps1
··· 1 + $IMAGE = "mumble-music-bot" 2 + $TAG = "latest" 3 + 4 + Write-Host "Building $IMAGE`:$TAG ..." -ForegroundColor Cyan 5 + docker build -t "${IMAGE}:${TAG}" . 6 + 7 + if ($LASTEXITCODE -eq 0) { 8 + Write-Host "Done: $IMAGE`:$TAG" -ForegroundColor Green 9 + } 10 + else { 11 + Write-Host "Build failed." -ForegroundColor Red 12 + exit 1 13 + }
+10
build.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + IMAGE="mumble-music-bot" 5 + TAG="latest" 6 + 7 + echo "Building ${IMAGE}:${TAG} ..." 8 + docker build -t "${IMAGE}:${TAG}" . 9 + 10 + echo "Done: ${IMAGE}:${TAG}"
+17
cmd/bot/main.go
··· 1 + package main 2 + 3 + import ( 4 + "log" 5 + 6 + "github.com/mumble-music/bot/internal/bot" 7 + _ "layeh.com/gumble/opus" 8 + ) 9 + 10 + func main() { 11 + log.SetFlags(log.LstdFlags | log.Lshortfile) 12 + 13 + cfg := bot.ConfigFromEnv() 14 + if err := bot.Run(cfg); err != nil { 15 + log.Fatalf("fatal: %v", err) 16 + } 17 + }
+10
go.mod
··· 1 + module github.com/mumble-music/bot 2 + 3 + go 1.21.5 4 + 5 + require layeh.com/gumble v0.0.0-20221205141517-d1df60a3cc14 6 + 7 + require ( 8 + github.com/golang/protobuf v1.3.1 // indirect 9 + layeh.com/gopus v0.0.0-20210501142526-1ee02d434e32 // indirect 10 + )
+8
go.sum
··· 1 + github.com/dchote/go-openal v0.0.0-20171116030048-f4a9a141d372/go.mod h1:74z+CYu2/mx4N+mcIS/rsvfAxBPBV9uv8zRAnwyFkdI= 2 + github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= 3 + github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 4 + layeh.com/gopus v0.0.0-20161224163843-0ebf989153aa/go.mod h1:AOef7vHz0+v4sWwJnr0jSyHiX/1NgsMoaxl+rEPz/I0= 5 + layeh.com/gopus v0.0.0-20210501142526-1ee02d434e32 h1:/S1gOotFo2sADAIdSGk1sDq1VxetoCWr6f5nxOG0dpY= 6 + layeh.com/gopus v0.0.0-20210501142526-1ee02d434e32/go.mod h1:yDtyzWZDFCVnva8NGtg38eH2Ns4J0D/6hD+MMeUGdF0= 7 + layeh.com/gumble v0.0.0-20221205141517-d1df60a3cc14 h1:wY8eeq7DpM5iAugNbFrvuhdtmN8XM1iU+Ki5YZWjukg= 8 + layeh.com/gumble v0.0.0-20221205141517-d1df60a3cc14/go.mod h1:tWPVA9ZAfImNwabjcd9uDE+Mtz0Hfs7a7G3vxrnrwyc=
+146
internal/bot/bot.go
··· 1 + package bot 2 + 3 + import ( 4 + "crypto/tls" 5 + "fmt" 6 + "log" 7 + "net" 8 + "os" 9 + "os/signal" 10 + "strconv" 11 + "strings" 12 + "syscall" 13 + 14 + "github.com/mumble-music/bot/internal/player" 15 + "layeh.com/gumble/gumble" 16 + "layeh.com/gumble/gumbleutil" 17 + ) 18 + 19 + // Config holds the bot configuration, typically populated from env vars. 20 + type Config struct { 21 + Server string // host:port 22 + Username string 23 + Password string 24 + Channel string // channel to auto-join (path like "Music Room") 25 + Insecure bool // skip TLS certificate verification 26 + } 27 + 28 + // ConfigFromEnv reads the bot configuration from environment variables. 29 + func ConfigFromEnv() Config { 30 + server := envOr("MUMBLE_SERVER", "localhost:64738") 31 + if !strings.Contains(server, ":") { 32 + server += ":64738" 33 + } 34 + 35 + insecure, _ := strconv.ParseBool(envOr("MUMBLE_INSECURE", "false")) 36 + 37 + return Config{ 38 + Server: server, 39 + Username: envOr("MUMBLE_USERNAME", "MusicBot"), 40 + Password: envOr("MUMBLE_PASSWORD", ""), 41 + Channel: envOr("MUMBLE_CHANNEL", ""), 42 + Insecure: insecure, 43 + } 44 + } 45 + 46 + func envOr(key, fallback string) string { 47 + if v := os.Getenv(key); v != "" { 48 + return v 49 + } 50 + return fallback 51 + } 52 + 53 + // Bot is the top-level mumble music bot. 54 + type Bot struct { 55 + cfg Config 56 + client *gumble.Client 57 + player *player.Player 58 + } 59 + 60 + // Run connects to the Mumble server, attaches event listeners, and blocks 61 + // until a termination signal is received. 62 + func Run(cfg Config) error { 63 + b := &Bot{cfg: cfg} 64 + 65 + gumbleConfig := gumble.NewConfig() 66 + gumbleConfig.Username = cfg.Username 67 + if cfg.Password != "" { 68 + gumbleConfig.Password = cfg.Password 69 + } 70 + 71 + // Attach helpers and event listener. 72 + gumbleConfig.Attach(gumbleutil.AutoBitrate) 73 + gumbleConfig.Attach(gumbleutil.Listener{ 74 + Connect: b.onConnect, 75 + TextMessage: b.onTextMessage, 76 + Disconnect: b.onDisconnect, 77 + }) 78 + 79 + // TLS configuration. 80 + tlsCfg := &tls.Config{} 81 + if cfg.Insecure { 82 + tlsCfg.InsecureSkipVerify = true 83 + } 84 + 85 + host, _, err := net.SplitHostPort(cfg.Server) 86 + if err != nil { 87 + host = cfg.Server 88 + } 89 + tlsCfg.ServerName = host 90 + 91 + log.Printf("connecting to %s as %s …", cfg.Server, cfg.Username) 92 + client, err := gumble.DialWithDialer(new(net.Dialer), cfg.Server, gumbleConfig, tlsCfg) 93 + if err != nil { 94 + return fmt.Errorf("dial: %w", err) 95 + } 96 + b.client = client 97 + b.player = player.New(client) 98 + 99 + // Wire player callbacks to send chat messages. 100 + b.player.OnTrackStart = func(s player.Song) { 101 + title := s.Title 102 + if title == "" { 103 + title = s.URL 104 + } 105 + b.sendMessage(fmt.Sprintf("▶ Now playing: <b>%s</b><br><i>requested by %s</i>", title, s.Requester)) 106 + } 107 + b.player.OnQueueEmpty = func() { 108 + b.sendMessage("Queue finished.") 109 + } 110 + 111 + // Wait for OS signal to shut down. 112 + sig := make(chan os.Signal, 1) 113 + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) 114 + <-sig 115 + 116 + log.Println("shutting down …") 117 + b.player.Stop() 118 + client.Disconnect() 119 + return nil 120 + } 121 + 122 + func (b *Bot) onConnect(e *gumble.ConnectEvent) { 123 + log.Printf("connected to %s", b.cfg.Server) 124 + 125 + if b.cfg.Channel != "" { 126 + ch := e.Client.Channels.Find(strings.Split(b.cfg.Channel, "/")...) 127 + if ch != nil { 128 + e.Client.Self.Move(ch) 129 + log.Printf("moved to channel: %s", b.cfg.Channel) 130 + } else { 131 + log.Printf("channel %q not found", b.cfg.Channel) 132 + } 133 + } 134 + } 135 + 136 + func (b *Bot) onDisconnect(e *gumble.DisconnectEvent) { 137 + log.Println("disconnected from server") 138 + } 139 + 140 + // sendMessage sends a text message to the bot's current channel. 141 + func (b *Bot) sendMessage(msg string) { 142 + if b.client == nil || b.client.Self == nil || b.client.Self.Channel == nil { 143 + return 144 + } 145 + b.client.Self.Channel.Send(msg, false) 146 + }
+172
internal/bot/handler.go
··· 1 + package bot 2 + 3 + import ( 4 + "fmt" 5 + "html" 6 + "log" 7 + "os/exec" 8 + "regexp" 9 + "strconv" 10 + "strings" 11 + 12 + "github.com/mumble-music/bot/internal/player" 13 + "layeh.com/gumble/gumble" 14 + ) 15 + 16 + // youtubeRe matches standard YouTube URLs as well as youtu.be short links. 17 + var youtubeRe = regexp.MustCompile( 18 + `https?://(?:www\.)?(?:youtube\.com/watch\?[^\s]*v=[\w-]+|youtu\.be/[\w-]+|youtube\.com/shorts/[\w-]+)[^\s]*`, 19 + ) 20 + 21 + // onTextMessage is the gumble event handler for incoming chat messages. 22 + func (b *Bot) onTextMessage(e *gumble.TextMessageEvent) { 23 + if e.Sender == nil { 24 + return 25 + } 26 + 27 + // Strip HTML tags mumble might wrap the message in. 28 + raw := stripHTML(e.Message) 29 + raw = strings.TrimSpace(raw) 30 + sender := e.Sender.Name 31 + 32 + // Handle commands. 33 + switch { 34 + case strings.EqualFold(raw, "!skip"): 35 + b.handleSkip(e) 36 + case strings.EqualFold(raw, "!stop"): 37 + b.handleStop(e) 38 + case strings.EqualFold(raw, "!queue") || strings.EqualFold(raw, "!q"): 39 + b.handleQueue(e) 40 + case strings.HasPrefix(strings.ToLower(raw), "!np") || strings.EqualFold(raw, "!nowplaying"): 41 + b.handleNowPlaying(e) 42 + case strings.HasPrefix(strings.ToLower(raw), "!vol"): 43 + b.handleVolume(e, raw) 44 + case strings.EqualFold(raw, "!help"): 45 + b.handleHelp(e) 46 + default: 47 + // Try to find a YouTube URL anywhere in the message. 48 + urls := youtubeRe.FindAllString(raw, -1) 49 + if len(urls) == 0 { 50 + // Also try the original HTML message (Mumble wraps URLs in <a> tags). 51 + urls = youtubeRe.FindAllString(e.Message, -1) 52 + } 53 + for _, u := range urls { 54 + title := fetchTitle(u) 55 + log.Printf("[%s] requested: %s (%s)", sender, title, u) 56 + song := player.Song{ 57 + URL: u, 58 + Title: title, 59 + Requester: sender, 60 + } 61 + display := title 62 + if display == "" { 63 + display = u 64 + } 65 + b.player.Enqueue(song) 66 + pos := b.player.Queue.Len() 67 + if b.player.IsPlaying() { 68 + reply(e, fmt.Sprintf("📋 Queued (#%d): <b>%s</b>", pos+1, display)) 69 + } else { 70 + reply(e, fmt.Sprintf("▶ Playing: <b>%s</b>", display)) 71 + } 72 + } 73 + } 74 + } 75 + 76 + func (b *Bot) handleSkip(e *gumble.TextMessageEvent) { 77 + if !b.player.IsPlaying() { 78 + reply(e, "Nothing is playing.") 79 + return 80 + } 81 + b.player.Skip() 82 + reply(e, "⏭ Skipped.") 83 + } 84 + 85 + func (b *Bot) handleStop(e *gumble.TextMessageEvent) { 86 + if !b.player.IsPlaying() { 87 + reply(e, "Nothing is playing.") 88 + return 89 + } 90 + b.player.Stop() 91 + reply(e, "⏹ Stopped and cleared queue.") 92 + } 93 + 94 + func (b *Bot) handleQueue(e *gumble.TextMessageEvent) { 95 + msg := "" 96 + if song, ok := b.player.NowPlaying(); ok { 97 + title := song.Title 98 + if title == "" { 99 + title = song.URL 100 + } 101 + msg = fmt.Sprintf("▶ Now playing: <b>%s</b><br>", title) 102 + } 103 + msg += b.player.FormatQueue() 104 + reply(e, msg) 105 + } 106 + 107 + func (b *Bot) handleNowPlaying(e *gumble.TextMessageEvent) { 108 + song, ok := b.player.NowPlaying() 109 + if !ok { 110 + reply(e, "Nothing is playing.") 111 + return 112 + } 113 + title := song.Title 114 + if title == "" { 115 + title = song.URL 116 + } 117 + reply(e, fmt.Sprintf("▶ Now playing: <b>%s</b><br><i>requested by %s</i>", title, song.Requester)) 118 + } 119 + 120 + func (b *Bot) handleVolume(e *gumble.TextMessageEvent, raw string) { 121 + parts := strings.Fields(raw) 122 + if len(parts) < 2 { 123 + reply(e, fmt.Sprintf("Volume: %.0f%%", b.player.Volume*100)) 124 + return 125 + } 126 + v, err := strconv.ParseFloat(parts[1], 32) 127 + if err != nil || v < 0 || v > 100 { 128 + reply(e, "Usage: !vol <0-100>") 129 + return 130 + } 131 + b.player.SetVolume(float32(v / 100)) 132 + reply(e, fmt.Sprintf("🔊 Volume set to %.0f%%", v)) 133 + } 134 + 135 + func (b *Bot) handleHelp(e *gumble.TextMessageEvent) { 136 + help := `<b>MusicBot Commands:</b><br> 137 + • Paste a YouTube URL to play/queue a track<br> 138 + • <b>!skip</b> — skip the current track<br> 139 + • <b>!stop</b> — stop playback and clear queue<br> 140 + • <b>!queue</b> — show the current queue<br> 141 + • <b>!np</b> — show what's currently playing<br> 142 + • <b>!vol &lt;0-100&gt;</b> — set volume<br> 143 + • <b>!help</b> — show this message` 144 + reply(e, help) 145 + } 146 + 147 + // reply sends a message back to the sender's channel. 148 + func reply(e *gumble.TextMessageEvent, msg string) { 149 + if e.Sender != nil && e.Sender.Channel != nil { 150 + e.Sender.Channel.Send(msg, false) 151 + } 152 + } 153 + 154 + // fetchTitle uses yt-dlp to retrieve the video title for a URL. 155 + func fetchTitle(url string) string { 156 + out, err := exec.Command("yt-dlp", "--get-title", "--no-warnings", "--quiet", url).Output() 157 + if err != nil { 158 + log.Printf("failed to fetch title for %s: %v", url, err) 159 + return "" 160 + } 161 + return strings.TrimSpace(string(out)) 162 + } 163 + 164 + // stripHTML is a simple HTML tag stripper and entity decoder. 165 + func stripHTML(s string) string { 166 + // Remove HTML tags. 167 + re := regexp.MustCompile(`<[^>]*>`) 168 + s = re.ReplaceAllString(s, "") 169 + // Decode common HTML entities. 170 + s = html.UnescapeString(s) 171 + return s 172 + }
+171
internal/player/player.go
··· 1 + package player 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "sync" 7 + 8 + "layeh.com/gumble/gumble" 9 + "layeh.com/gumble/gumbleffmpeg" 10 + ) 11 + 12 + // Player manages audio playback and the song queue for a Mumble client. 13 + type Player struct { 14 + mu sync.Mutex 15 + client *gumble.Client 16 + stream *gumbleffmpeg.Stream 17 + current Song 18 + Queue Queue 19 + Volume float32 20 + playing bool 21 + 22 + // OnTrackStart is called when a new track begins playing. 23 + OnTrackStart func(song Song) 24 + // OnTrackEnd is called when a track finishes (naturally or skipped). 25 + OnTrackEnd func() 26 + // OnQueueEmpty is called when the queue is exhausted. 27 + OnQueueEmpty func() 28 + } 29 + 30 + // New creates a new Player bound to the given Mumble client. 31 + func New(client *gumble.Client) *Player { 32 + return &Player{ 33 + client: client, 34 + Volume: 0.5, 35 + } 36 + } 37 + 38 + // Enqueue adds a song to the queue and starts playback if idle. 39 + func (p *Player) Enqueue(s Song) { 40 + p.Queue.Push(s) 41 + p.mu.Lock() 42 + idle := !p.playing 43 + p.mu.Unlock() 44 + if idle { 45 + go p.advance() 46 + } 47 + } 48 + 49 + // Skip stops the currently playing track and advances to the next one. 50 + func (p *Player) Skip() { 51 + p.mu.Lock() 52 + defer p.mu.Unlock() 53 + if p.stream != nil { 54 + p.stream.Stop() 55 + // The wait goroutine in advance() will call advance() again. 56 + } 57 + } 58 + 59 + // Stop clears the queue and stops the current track. 60 + func (p *Player) Stop() { 61 + p.Queue.Clear() 62 + p.mu.Lock() 63 + defer p.mu.Unlock() 64 + if p.stream != nil { 65 + p.stream.Stop() 66 + } 67 + } 68 + 69 + // NowPlaying returns the currently playing song, or false if idle. 70 + func (p *Player) NowPlaying() (Song, bool) { 71 + p.mu.Lock() 72 + defer p.mu.Unlock() 73 + if !p.playing { 74 + return Song{}, false 75 + } 76 + return p.current, true 77 + } 78 + 79 + // IsPlaying reports whether a track is currently playing. 80 + func (p *Player) IsPlaying() bool { 81 + p.mu.Lock() 82 + defer p.mu.Unlock() 83 + return p.playing 84 + } 85 + 86 + // advance pops the next song from the queue and plays it. 87 + func (p *Player) advance() { 88 + song, ok := p.Queue.Pop() 89 + if !ok { 90 + p.mu.Lock() 91 + p.playing = false 92 + p.mu.Unlock() 93 + if p.OnQueueEmpty != nil { 94 + p.OnQueueEmpty() 95 + } 96 + return 97 + } 98 + 99 + source := gumbleffmpeg.SourceExec( 100 + "yt-dlp", 101 + "--quiet", 102 + "--no-warnings", 103 + "--format", "bestaudio", 104 + "--output", "-", 105 + song.URL, 106 + ) 107 + 108 + stream := gumbleffmpeg.New(p.client, source) 109 + stream.Volume = p.Volume 110 + 111 + p.mu.Lock() 112 + p.stream = stream 113 + p.current = song 114 + p.playing = true 115 + p.mu.Unlock() 116 + 117 + if p.OnTrackStart != nil { 118 + p.OnTrackStart(song) 119 + } 120 + 121 + if err := stream.Play(); err != nil { 122 + log.Printf("playback error: %v", err) 123 + p.mu.Lock() 124 + p.playing = false 125 + p.stream = nil 126 + p.mu.Unlock() 127 + // Try next song. 128 + go p.advance() 129 + return 130 + } 131 + 132 + stream.Wait() 133 + 134 + p.mu.Lock() 135 + p.stream = nil 136 + p.mu.Unlock() 137 + 138 + if p.OnTrackEnd != nil { 139 + p.OnTrackEnd() 140 + } 141 + 142 + // Auto-advance to the next song. 143 + p.advance() 144 + } 145 + 146 + // SetVolume adjusts the playback volume (0.0 – 1.0). 147 + func (p *Player) SetVolume(v float32) { 148 + p.mu.Lock() 149 + defer p.mu.Unlock() 150 + p.Volume = v 151 + if p.stream != nil { 152 + p.stream.Volume = v 153 + } 154 + } 155 + 156 + // FormatQueue returns a human-readable list of queued songs. 157 + func (p *Player) FormatQueue() string { 158 + songs := p.Queue.List() 159 + if len(songs) == 0 { 160 + return "<i>Queue is empty.</i>" 161 + } 162 + msg := fmt.Sprintf("<b>Queue (%d):</b><br>", len(songs)) 163 + for i, s := range songs { 164 + title := s.Title 165 + if title == "" { 166 + title = s.URL 167 + } 168 + msg += fmt.Sprintf("%d. %s <i>— %s</i><br>", i+1, title, s.Requester) 169 + } 170 + return msg 171 + }
+68
internal/player/queue.go
··· 1 + package player 2 + 3 + import "sync" 4 + 5 + // Song represents a queued track. 6 + type Song struct { 7 + URL string 8 + Title string 9 + Requester string 10 + } 11 + 12 + // Queue is a thread-safe FIFO queue of songs. 13 + type Queue struct { 14 + mu sync.Mutex 15 + items []Song 16 + } 17 + 18 + // Push appends a song to the end of the queue. 19 + func (q *Queue) Push(s Song) { 20 + q.mu.Lock() 21 + defer q.mu.Unlock() 22 + q.items = append(q.items, s) 23 + } 24 + 25 + // Pop removes and returns the first song. Returns false if empty. 26 + func (q *Queue) Pop() (Song, bool) { 27 + q.mu.Lock() 28 + defer q.mu.Unlock() 29 + if len(q.items) == 0 { 30 + return Song{}, false 31 + } 32 + s := q.items[0] 33 + q.items = q.items[1:] 34 + return s, true 35 + } 36 + 37 + // Peek returns the first song without removing it. Returns false if empty. 38 + func (q *Queue) Peek() (Song, bool) { 39 + q.mu.Lock() 40 + defer q.mu.Unlock() 41 + if len(q.items) == 0 { 42 + return Song{}, false 43 + } 44 + return q.items[0], false 45 + } 46 + 47 + // List returns a copy of all songs currently in the queue. 48 + func (q *Queue) List() []Song { 49 + q.mu.Lock() 50 + defer q.mu.Unlock() 51 + out := make([]Song, len(q.items)) 52 + copy(out, q.items) 53 + return out 54 + } 55 + 56 + // Len returns the number of songs in the queue. 57 + func (q *Queue) Len() int { 58 + q.mu.Lock() 59 + defer q.mu.Unlock() 60 + return len(q.items) 61 + } 62 + 63 + // Clear removes all songs from the queue. 64 + func (q *Queue) Clear() { 65 + q.mu.Lock() 66 + defer q.mu.Unlock() 67 + q.items = nil 68 + }