// Command shipper runs on the generator machine. // // Each cycle: // 1. Read the ai-radio library (index.json of FLAC tracks). // 2. Build the rolling, newest-biased playlist (domain policy). // 3. Transcode the playlist's FLAC -> MP3 (title tagged for stereo displays). // 4. Ship the MP3s + playlist.m3u8 to the streaming VPS (playlist last). // // Config: environment variables (see .env.example). package main import ( "encoding/json" "flag" "log" "os" "path/filepath" "time" "radio-go/internal/app" "radio-go/internal/domain" "radio-go/internal/infra" ) func env(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def } func main() { once := flag.Bool("once", false, "run one cycle and exit") flag.Parse() libraryDir := env("LIBRARY_DIR", "library") outDir := env("SHIP_STAGING_DIR", "ship_out") target := env("SHIP_REMOTE_TARGET", "") keyPath := env("SHIP_SSH_KEY", "") station := env("STATION_NAME", "AI Radio") size := parseInt(env("PLAYLIST_SIZE", "40")) bias := parseInt(env("NEWNESS_BIAS", "3")) bitrate := env("MP3_BITRATE", "128k") poll := time.Duration(parseInt(env("SHIP_POLL_INTERVAL_S", "60"))) * time.Second os.MkdirAll(outDir, 0o755) for { if err := cycle(libraryDir, outDir, target, keyPath, station, size, bias, bitrate); err != nil { log.Printf("cycle failed: %v", err) } if *once { return } time.Sleep(poll) } } func cycle(libraryDir, outDir, target, keyPath, station string, size, bias int, bitrate string) error { tracks, err := app.LoadLibrary(libraryDir) if err != nil { return err } if len(tracks) == 0 { log.Printf("library empty — nothing to ship") return nil } pl := domain.BuildPlaylist(tracks, size, bias, time.Now().UnixNano()) var files []string for _, t := range pl { dst := filepath.Join(outDir, t.ID+".mp3") if st, err := os.Stat(dst); err != nil || st.Size() == 0 { src := filepath.Join(libraryDir, t.Filename) if err := app.Transcode(src, dst, bitrate, t.DisplayTitle(), station); err != nil { return err } log.Printf("transcoded %s -> %s", t.Filename, filepath.Base(dst)) } files = append(files, dst) } m3u := infra.WriteM3U(station, pl) listPath := filepath.Join(outDir, "playlist.m3u8") if err := os.WriteFile(listPath, []byte(m3u), 0o644); err != nil { return err } files = append(files, listPath) // playlist last // sidecar: id -> display title, so the streamer can publish real // now-playing metadata without parsing ID3 tags titles := map[string]string{} for _, t := range pl { titles[t.ID] = t.DisplayTitle() } tb, _ := json.Marshal(titles) titlePath := filepath.Join(outDir, "titles.json") if err := os.WriteFile(titlePath, tb, 0o644); err != nil { return err } files = append(files, titlePath) if err := app.Ship(files, target, keyPath); err != nil { return err } log.Printf("shipped %d tracks (library: %d) to %s", len(pl), len(tracks), target) return nil } func parseInt(s string) int { var n int for _, c := range s { if c < '0' || c > '9' { return 0 } n = n*10 + int(c-'0') } return n }