add cmd/shipper/main.go
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user