Files
radio-go/cmd/shipper/main.go
T
2026-09-30 21:59:30 +02:00

120 lines
3.0 KiB
Go

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