Files
icyws/main.go
2025-12-09 16:55:08 -05:00

353 lines
7.9 KiB
Go

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))
})
}