commit 9453485b89666dc319179fafcca2cecbb64b97e3
Author: amrfti <andrew@kloet.net>
Date: Tue, 9 Dec 2025 16:55:08 -0500
initial commit
Diffstat:
| A | README.md | | | 36 | ++++++++++++++++++++++++++++++++++++ |
| A | go.mod | | | 8 | ++++++++ |
| A | go.sum | | | 4 | ++++ |
| A | main.go | | | 352 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
4 files changed, 400 insertions(+), 0 deletions(-)
diff --git a/README.md b/README.md
@@ -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
+```
diff --git a/go.mod b/go.mod
@@ -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
+)
diff --git a/go.sum b/go.sum
@@ -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=
diff --git a/main.go b/main.go
@@ -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))
+ })
+}