diff --git a/cmd/shipper/main.go b/cmd/shipper/main.go new file mode 100644 index 0000000..263cd3b --- /dev/null +++ b/cmd/shipper/main.go @@ -0,0 +1,119 @@ +// 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 +}