From 9453485b89666dc319179fafcca2cecbb64b97e3 Mon Sep 17 00:00:00 2001 From: amrfti Date: Tue, 9 Dec 2025 16:55:08 -0500 Subject: [PATCH] initial commit --- README.md | 36 ++++++ go.mod | 8 ++ go.sum | 4 + main.go | 352 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 400 insertions(+) create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f384a6 --- /dev/null +++ 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 new file mode 100644 index 0000000..98756db --- /dev/null +++ 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 new file mode 100644 index 0000000..7e040fa --- /dev/null +++ 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 new file mode 100644 index 0000000..cc000d9 --- /dev/null +++ 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)) + }) +}