initial commit
This commit is contained in:
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# icyws
|
||||||
|
A tiny ices2 input module. It does two things:
|
||||||
|
* Runs a WebSocket server on port 8765 that broadcasts the current song as `Artist - Title`
|
||||||
|
* When run with no arguments, picks a random song from a newline‑separated file list, prints its full path, extracts metadata, and tells the WebSocket server
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Start the server
|
||||||
|
```
|
||||||
|
icyws serve --bind :8765 --allow-origin *
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pick a random track
|
||||||
|
```
|
||||||
|
ICYWS_TRACKLIST=tracks.txt icyws
|
||||||
|
```
|
||||||
|
|
||||||
|
This prints the selected file path to stdout and broadcasts the now‑playing info.
|
||||||
|
|
||||||
|
### Track list format
|
||||||
|
A plain text file with one absolute file path per line.
|
||||||
|
|
||||||
|
### WebSocket endpoint
|
||||||
|
Connect to: `/ws/radio`
|
||||||
|
Receives: `Artist - Title` whenever a new song is selected
|
||||||
|
|
||||||
|
### Announcement endpoint (internal)
|
||||||
|
If the websocket (serve mode) is live, picker mode POSTs to:
|
||||||
|
```
|
||||||
|
/announce
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build
|
||||||
|
```
|
||||||
|
go build -o icyws
|
||||||
|
```
|
||||||
8
go.mod
Normal file
8
go.mod
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
module git.kloet.net/amrfti/icyws
|
||||||
|
|
||||||
|
go 1.25.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
)
|
||||||
4
go.sum
Normal file
4
go.sum
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
|
||||||
|
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
352
main.go
Normal file
352
main.go
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dhowden/tag"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultBindAddress = ":8765"
|
||||||
|
announceEndpoint = "http://127.0.0.1:8765/announce"
|
||||||
|
)
|
||||||
|
|
||||||
|
var rng = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
|
||||||
|
type trackInfo struct {
|
||||||
|
Path string
|
||||||
|
Artist string
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(0)
|
||||||
|
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "serve" {
|
||||||
|
serveCmd(os.Args[2:])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pickCmd(os.Args[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickCmd(args []string) {
|
||||||
|
fs := flag.NewFlagSet("pick", flag.ExitOnError)
|
||||||
|
tracksPath := fs.String("tracks", os.Getenv("ICYWS_TRACKLIST"), "path to track list (newline separated)")
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
if *tracksPath == "" {
|
||||||
|
log.Fatal("missing track list: set ICYWS_TRACKLIST or pass --tracks")
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := pickRandomTrack(*tracksPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("selecting track: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(info.Path)
|
||||||
|
|
||||||
|
nowPlaying := formatNowPlaying(info.Artist, info.Title)
|
||||||
|
if err := announceNowPlaying(nowPlaying); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warn: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickRandomTrack(listPath string) (trackInfo, error) {
|
||||||
|
file, err := os.Open(listPath)
|
||||||
|
if err != nil {
|
||||||
|
return trackInfo{}, fmt.Errorf("open track list: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
const maxLine = 1024 * 1024
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), maxLine)
|
||||||
|
|
||||||
|
var tracks []string
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tracks = append(tracks, line)
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return trackInfo{}, fmt.Errorf("read track list: %w", err)
|
||||||
|
}
|
||||||
|
if len(tracks) == 0 {
|
||||||
|
return trackInfo{}, errors.New("track list is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
path := tracks[rng.Intn(len(tracks))]
|
||||||
|
artist, title := extractMetadata(path)
|
||||||
|
|
||||||
|
return trackInfo{
|
||||||
|
Path: path,
|
||||||
|
Artist: artist,
|
||||||
|
Title: title,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractMetadata(path string) (string, string) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "Unknown Artist", fallbackTitle(path)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
meta, err := tag.ReadFrom(file)
|
||||||
|
if err != nil {
|
||||||
|
return "Unknown Artist", fallbackTitle(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
artist := meta.Artist()
|
||||||
|
if artist == "" {
|
||||||
|
artist = meta.AlbumArtist()
|
||||||
|
}
|
||||||
|
if artist == "" {
|
||||||
|
artist = "Unknown Artist"
|
||||||
|
}
|
||||||
|
|
||||||
|
title := meta.Title()
|
||||||
|
if title == "" {
|
||||||
|
title = fallbackTitle(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return artist, title
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackTitle(path string) string {
|
||||||
|
base := filepath.Base(path)
|
||||||
|
ext := filepath.Ext(base)
|
||||||
|
return strings.TrimSuffix(base, ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatNowPlaying(artist, title string) string {
|
||||||
|
return fmt.Sprintf("%s - %s", artist, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func announceNowPlaying(nowPlaying string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 750*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
payload := map[string]string{"now_playing": nowPlaying}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, announceEndpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("prepare announce request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("broadcast failed (is icyws serve running?): %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return fmt.Errorf("broadcast rejected: %s %s", resp.Status, strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveCmd(args []string) {
|
||||||
|
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||||
|
bindAddr := fs.String("bind", defaultBindAddress, "address:port to listen on")
|
||||||
|
allowOrigin := fs.String("allow-origin", "", "restrict websocket Origin (optional)")
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
server := newNowPlayingServer(*allowOrigin)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/radio/ws", server.handleWS)
|
||||||
|
mux.HandleFunc("/announce", server.handleAnnounce)
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Addr: *bindAddr,
|
||||||
|
Handler: logRequests(mux),
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdownCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-shutdownCtx.Done()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
httpServer.Shutdown(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("icyws serve listening on %s", *bindAddr)
|
||||||
|
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Fatalf("server error: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("icyws serve stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
type nowPlayingServer struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
clients map[*websocket.Conn]struct{}
|
||||||
|
lastMessage string
|
||||||
|
upgrader websocket.Upgrader
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNowPlayingServer(allowedOrigin string) *nowPlayingServer {
|
||||||
|
upgrader := websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
if allowedOrigin == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return r.Header.Get("Origin") == allowedOrigin
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return &nowPlayingServer{
|
||||||
|
clients: make(map[*websocket.Conn]struct{}),
|
||||||
|
upgrader: upgrader,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *nowPlayingServer) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("websocket upgrade: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.addClient(conn)
|
||||||
|
|
||||||
|
if last := s.getLast(); last != "" {
|
||||||
|
_ = conn.WriteMessage(websocket.TextMessage, []byte(last))
|
||||||
|
}
|
||||||
|
|
||||||
|
go s.readUntilClose(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *nowPlayingServer) handleAnnounce(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 2048))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "read body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
NowPlaying string `json:"now_playing"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(bytes.TrimSpace(body)) == 0 {
|
||||||
|
http.Error(w, "empty payload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
payload.NowPlaying = strings.TrimSpace(string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.NowPlaying = strings.TrimSpace(payload.NowPlaying)
|
||||||
|
if payload.NowPlaying == "" {
|
||||||
|
http.Error(w, "missing now_playing", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.setLast(payload.NowPlaying)
|
||||||
|
s.broadcast(payload.NowPlaying)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *nowPlayingServer) addClient(conn *websocket.Conn) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.clients[conn] = struct{}{}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *nowPlayingServer) removeClient(conn *websocket.Conn) {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.clients, conn)
|
||||||
|
s.mu.Unlock()
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *nowPlayingServer) broadcast(message string) {
|
||||||
|
s.mu.RLock()
|
||||||
|
conns := make([]*websocket.Conn, 0, len(s.clients))
|
||||||
|
for c := range s.clients {
|
||||||
|
conns = append(conns, c)
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, conn := range conns {
|
||||||
|
if err := conn.WriteMessage(websocket.TextMessage, []byte(message)); err != nil {
|
||||||
|
s.removeClient(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *nowPlayingServer) getLast() string {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.lastMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *nowPlayingServer) setLast(message string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.lastMessage = message
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *nowPlayingServer) readUntilClose(conn *websocket.Conn) {
|
||||||
|
defer s.removeClient(conn)
|
||||||
|
conn.SetReadLimit(1024)
|
||||||
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
conn.SetPongHandler(func(string) error {
|
||||||
|
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
for {
|
||||||
|
if _, _, err := conn.ReadMessage(); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logRequests(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Truncate(time.Millisecond))
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user