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(3600 * time.Second)) conn.SetPongHandler(func(string) error { conn.SetReadDeadline(time.Now().Add(3600 * 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)) }) }