icyws

a tiny ices2 input module+websocket
Download | Log | Files | Refs | README | LICENSE

main.go (8888B)


      1 // icyws - an ices2 input module which broadcasts metadata over websockets
      2 // Copyright (c) 2025 Andrew Kloet
      3 //
      4 // This program is free software: you can redistribute it and/or modify
      5 // it under the terms of the GNU Affero General Public License as published by
      6 // the Free Software Foundation, either version 3 of the License, or
      7 // (at your option) any later version.
      8 //
      9 // This program is distributed in the hope that it will be useful,
     10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
     11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     12 // GNU Affero General Public License for more details.
     13 //
     14 // You should have received a copy of the GNU Affero General Public License
     15 // along with this program.  If not, see <https://www.gnu.org/licenses/>.
     16 
     17 package main
     18 
     19 import (
     20 	"bufio"
     21 	"bytes"
     22 	"context"
     23 	"encoding/json"
     24 	"errors"
     25 	"flag"
     26 	"fmt"
     27 	"io"
     28 	"log"
     29 	"math/rand"
     30 	"net/http"
     31 	"os"
     32 	"os/signal"
     33 	"path/filepath"
     34 	"strings"
     35 	"sync"
     36 	"syscall"
     37 	"time"
     38 
     39 	"github.com/dhowden/tag"
     40 	"github.com/gorilla/websocket"
     41 )
     42 
     43 const (
     44 	defaultBindAddress = ":8765"
     45 	announceEndpoint   = "http://127.0.0.1:8765/announce"
     46 )
     47 
     48 var rng = rand.New(rand.NewSource(time.Now().UnixNano()))
     49 
     50 type trackInfo struct {
     51 	Path   string
     52 	Artist string
     53 	Title  string
     54 }
     55 
     56 func main() {
     57 	log.SetFlags(0)
     58 
     59 	if len(os.Args) > 1 && os.Args[1] == "serve" {
     60 		serveCmd(os.Args[2:])
     61 		return
     62 	}
     63 
     64 	pickCmd(os.Args[1:])
     65 }
     66 
     67 func pickCmd(args []string) {
     68 	fs := flag.NewFlagSet("pick", flag.ExitOnError)
     69 	tracksPath := fs.String("tracks", os.Getenv("ICYWS_TRACKLIST"), "path to track list (newline separated)")
     70 	fs.Parse(args)
     71 
     72 	if *tracksPath == "" {
     73 		log.Fatal("missing track list: set ICYWS_TRACKLIST or pass --tracks")
     74 	}
     75 
     76 	info, err := pickRandomTrack(*tracksPath)
     77 	if err != nil {
     78 		log.Fatalf("selecting track: %v", err)
     79 	}
     80 
     81 	fmt.Println(info.Path)
     82 
     83 	nowPlaying := formatNowPlaying(info.Artist, info.Title)
     84 	if err := announceNowPlaying(nowPlaying); err != nil {
     85 		fmt.Fprintf(os.Stderr, "warn: %v\n", err)
     86 	}
     87 }
     88 
     89 func pickRandomTrack(listPath string) (trackInfo, error) {
     90 	file, err := os.Open(listPath)
     91 	if err != nil {
     92 		return trackInfo{}, fmt.Errorf("open track list: %w", err)
     93 	}
     94 	defer file.Close()
     95 
     96 	scanner := bufio.NewScanner(file)
     97 	const maxLine = 1024 * 1024
     98 	scanner.Buffer(make([]byte, 0, 64*1024), maxLine)
     99 
    100 	var tracks []string
    101 	for scanner.Scan() {
    102 		line := strings.TrimSpace(scanner.Text())
    103 		if line == "" || strings.HasPrefix(line, "#") {
    104 			continue
    105 		}
    106 		tracks = append(tracks, line)
    107 	}
    108 	if err := scanner.Err(); err != nil {
    109 		return trackInfo{}, fmt.Errorf("read track list: %w", err)
    110 	}
    111 	if len(tracks) == 0 {
    112 		return trackInfo{}, errors.New("track list is empty")
    113 	}
    114 
    115 	path := tracks[rng.Intn(len(tracks))]
    116 	artist, title := extractMetadata(path)
    117 
    118 	return trackInfo{
    119 		Path:   path,
    120 		Artist: artist,
    121 		Title:  title,
    122 	}, nil
    123 }
    124 
    125 func extractMetadata(path string) (string, string) {
    126 	file, err := os.Open(path)
    127 	if err != nil {
    128 		return "Unknown Artist", fallbackTitle(path)
    129 	}
    130 	defer file.Close()
    131 
    132 	meta, err := tag.ReadFrom(file)
    133 	if err != nil {
    134 		return "Unknown Artist", fallbackTitle(path)
    135 	}
    136 
    137 	artist := meta.Artist()
    138 	if artist == "" {
    139 		artist = meta.AlbumArtist()
    140 	}
    141 	if artist == "" {
    142 		artist = "Unknown Artist"
    143 	}
    144 
    145 	title := meta.Title()
    146 	if title == "" {
    147 		title = fallbackTitle(path)
    148 	}
    149 
    150 	return artist, title
    151 }
    152 
    153 func fallbackTitle(path string) string {
    154 	base := filepath.Base(path)
    155 	ext := filepath.Ext(base)
    156 	return strings.TrimSuffix(base, ext)
    157 }
    158 
    159 func formatNowPlaying(artist, title string) string {
    160 	return fmt.Sprintf("%s - %s", artist, title)
    161 }
    162 
    163 func announceNowPlaying(nowPlaying string) error {
    164 	ctx, cancel := context.WithTimeout(context.Background(), 750*time.Millisecond)
    165 	defer cancel()
    166 
    167 	payload := map[string]string{"now_playing": nowPlaying}
    168 	body, _ := json.Marshal(payload)
    169 
    170 	req, err := http.NewRequestWithContext(ctx, http.MethodPost, announceEndpoint, bytes.NewReader(body))
    171 	if err != nil {
    172 		return fmt.Errorf("prepare announce request: %w", err)
    173 	}
    174 	req.Header.Set("Content-Type", "application/json")
    175 
    176 	resp, err := http.DefaultClient.Do(req)
    177 	if err != nil {
    178 		return fmt.Errorf("broadcast failed (is icyws serve running?): %w", err)
    179 	}
    180 	defer resp.Body.Close()
    181 
    182 	if resp.StatusCode >= 400 {
    183 		b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
    184 		return fmt.Errorf("broadcast rejected: %s %s", resp.Status, strings.TrimSpace(string(b)))
    185 	}
    186 
    187 	return nil
    188 }
    189 
    190 func serveCmd(args []string) {
    191 	fs := flag.NewFlagSet("serve", flag.ExitOnError)
    192 	bindAddr := fs.String("bind", defaultBindAddress, "address:port to listen on")
    193 	allowOrigin := fs.String("allow-origin", "", "restrict websocket Origin (optional)")
    194 	fs.Parse(args)
    195 
    196 	server := newNowPlayingServer(*allowOrigin)
    197 
    198 	mux := http.NewServeMux()
    199 	mux.HandleFunc("/radio/ws", server.handleWS)
    200 	mux.HandleFunc("/announce", server.handleAnnounce)
    201 	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
    202 		w.WriteHeader(http.StatusOK)
    203 		w.Write([]byte("ok"))
    204 	})
    205 
    206 	httpServer := &http.Server{
    207 		Addr:              *bindAddr,
    208 		Handler:           logRequests(mux),
    209 		ReadHeaderTimeout: 5 * time.Second,
    210 	}
    211 
    212 	shutdownCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    213 	defer stop()
    214 
    215 	go func() {
    216 		<-shutdownCtx.Done()
    217 		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    218 		defer cancel()
    219 		httpServer.Shutdown(ctx)
    220 	}()
    221 
    222 	log.Printf("icyws serve listening on %s", *bindAddr)
    223 	if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
    224 		log.Fatalf("server error: %v", err)
    225 	}
    226 	log.Println("icyws serve stopped")
    227 }
    228 
    229 type nowPlayingServer struct {
    230 	mu          sync.RWMutex
    231 	clients     map[*websocket.Conn]struct{}
    232 	lastMessage string
    233 	upgrader    websocket.Upgrader
    234 }
    235 
    236 func newNowPlayingServer(allowedOrigin string) *nowPlayingServer {
    237 	upgrader := websocket.Upgrader{
    238 		CheckOrigin: func(r *http.Request) bool {
    239 			if allowedOrigin == "" {
    240 				return true
    241 			}
    242 			return r.Header.Get("Origin") == allowedOrigin
    243 		},
    244 	}
    245 	return &nowPlayingServer{
    246 		clients:  make(map[*websocket.Conn]struct{}),
    247 		upgrader: upgrader,
    248 	}
    249 }
    250 
    251 func (s *nowPlayingServer) handleWS(w http.ResponseWriter, r *http.Request) {
    252 	conn, err := s.upgrader.Upgrade(w, r, nil)
    253 	if err != nil {
    254 		log.Printf("websocket upgrade: %v", err)
    255 		return
    256 	}
    257 
    258 	s.addClient(conn)
    259 
    260 	if last := s.getLast(); last != "" {
    261 		_ = conn.WriteMessage(websocket.TextMessage, []byte(last))
    262 	}
    263 
    264 	go s.readUntilClose(conn)
    265 }
    266 
    267 func (s *nowPlayingServer) handleAnnounce(w http.ResponseWriter, r *http.Request) {
    268 	if r.Method != http.MethodPost {
    269 		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
    270 		return
    271 	}
    272 	defer r.Body.Close()
    273 
    274 	body, err := io.ReadAll(io.LimitReader(r.Body, 2048))
    275 	if err != nil {
    276 		http.Error(w, "read body", http.StatusBadRequest)
    277 		return
    278 	}
    279 
    280 	var payload struct {
    281 		NowPlaying string `json:"now_playing"`
    282 	}
    283 
    284 	if len(bytes.TrimSpace(body)) == 0 {
    285 		http.Error(w, "empty payload", http.StatusBadRequest)
    286 		return
    287 	}
    288 
    289 	if err := json.Unmarshal(body, &payload); err != nil {
    290 		payload.NowPlaying = strings.TrimSpace(string(body))
    291 	}
    292 
    293 	payload.NowPlaying = strings.TrimSpace(payload.NowPlaying)
    294 	if payload.NowPlaying == "" {
    295 		http.Error(w, "missing now_playing", http.StatusBadRequest)
    296 		return
    297 	}
    298 
    299 	s.setLast(payload.NowPlaying)
    300 	s.broadcast(payload.NowPlaying)
    301 
    302 	w.WriteHeader(http.StatusAccepted)
    303 	w.Write([]byte("ok"))
    304 }
    305 
    306 func (s *nowPlayingServer) addClient(conn *websocket.Conn) {
    307 	s.mu.Lock()
    308 	s.clients[conn] = struct{}{}
    309 	s.mu.Unlock()
    310 }
    311 
    312 func (s *nowPlayingServer) removeClient(conn *websocket.Conn) {
    313 	s.mu.Lock()
    314 	delete(s.clients, conn)
    315 	s.mu.Unlock()
    316 	conn.Close()
    317 }
    318 
    319 func (s *nowPlayingServer) broadcast(message string) {
    320 	s.mu.RLock()
    321 	conns := make([]*websocket.Conn, 0, len(s.clients))
    322 	for c := range s.clients {
    323 		conns = append(conns, c)
    324 	}
    325 	s.mu.RUnlock()
    326 
    327 	for _, conn := range conns {
    328 		if err := conn.WriteMessage(websocket.TextMessage, []byte(message)); err != nil {
    329 			s.removeClient(conn)
    330 		}
    331 	}
    332 }
    333 
    334 func (s *nowPlayingServer) getLast() string {
    335 	s.mu.RLock()
    336 	defer s.mu.RUnlock()
    337 	return s.lastMessage
    338 }
    339 
    340 func (s *nowPlayingServer) setLast(message string) {
    341 	s.mu.Lock()
    342 	s.lastMessage = message
    343 	s.mu.Unlock()
    344 }
    345 
    346 func (s *nowPlayingServer) readUntilClose(conn *websocket.Conn) {
    347 	defer s.removeClient(conn)
    348 	conn.SetReadLimit(1024)
    349 	conn.SetReadDeadline(time.Now().Add(3600 * time.Second))
    350 	conn.SetPongHandler(func(string) error {
    351 		conn.SetReadDeadline(time.Now().Add(3600 * time.Second))
    352 		return nil
    353 	})
    354 
    355 	for {
    356 		if _, _, err := conn.ReadMessage(); err != nil {
    357 			break
    358 		}
    359 	}
    360 }
    361 
    362 func logRequests(next http.Handler) http.Handler {
    363 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    364 		start := time.Now()
    365 		next.ServeHTTP(w, r)
    366 		log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Truncate(time.Millisecond))
    367 	})
    368 }