120 lines
3.0 KiB
Go
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
|
|
}
|