clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / cmd / airwavesd / main.go
main.go 14.7K
1// Command airwavesd runs the Airwaves engine on a home server: the lineup,2// reception and guide, live TV and recordings from an HDHomeRun, served to3// the TV app over HTTP. With AIRWAVES_ANTENNA=off it serves the custom4// channels only (Jellyfin, YouTube, folders of videos and the weather), with5// no antenna or tuner; see docs/self-hosting.md.6package main78import (9 "cmp"10 "context"11 "errors"12 "flag"13 "fmt"14 "log"15 "net"16 "net/http"17 "os"18 "os/exec"19 "os/signal"20 "path/filepath"21 "strconv"22 "strings"23 "syscall"24 "time"2526 "airwaves/frontend"27 "airwaves/internal/admin"28 "airwaves/internal/api"29 "airwaves/internal/hdhr"30 "airwaves/internal/service"31 "airwaves/internal/web"32 "airwaves/internal/youtube"33)3435func main() {36 if err := run(); err != nil {37 log.Fatal(err)38 }39}4041func env(key, def string) string { return cmp.Or(os.Getenv(key), def) }4243func envFloat(key string) float64 {44 v, _ := strconv.ParseFloat(os.Getenv(key), 64)45 return v46}4748func run() error {49 host, _ := os.Hostname()50 listen := flag.String("listen", env("AIRWAVES_LISTEN", ":"+strconv.Itoa(api.DefaultPort)), "HTTP listen address")51 data := flag.String("data", env("AIRWAVES_DATA", "/data"), "directory for config, caches and DVR rules")52 antenna := flag.String("antenna", os.Getenv("AIRWAVES_ANTENNA"), "on (the default): the over-the-air lineup, guide, HDHomeRun and recording; off: custom channels only")53 tunerAddr := flag.String("hdhomerun", os.Getenv("AIRWAVES_HDHOMERUN"), "the HDHomeRun's address or device ID; empty finds it on the network")54 recordings := flag.String("recordings", os.Getenv("AIRWAVES_RECORDINGS"), "folder for recordings (default <data>/recordings)")55 // Location is deployment configuration; clients never see or change it.56 zip := flag.String("zip", os.Getenv("AIRWAVES_ZIP"), "US ZIP code of the antenna, for its lineup and listings (required with an antenna); without an antenna, of the weather (default none)")57 lat := flag.Float64("lat", envFloat("AIRWAVES_LAT"), "antenna latitude; with -lon, overrides the ZIP centroid")58 lon := flag.Float64("lon", envFloat("AIRWAVES_LON"), "antenna longitude")59 name := flag.String("name", env("AIRWAVES_NAME", host), "server name shown in the app")60 token := flag.String("token", env("AIRWAVES_TOKEN", ""), "optional bearer token for the API")61 weatherStar := flag.Int("weatherstar-port", int(envFloat("AIRWAVES_WEATHERSTAR_PORT")), "host port of a WeatherStar 4000+ container; 0 disables the WX channel")62 weatherStarURL := flag.String("weatherstar-url", os.Getenv("AIRWAVES_WEATHERSTAR_URL"), "where airwavesd reaches the WeatherStar display, when not at 127.0.0.1 on -weatherstar-port (http://weatherstar:8080 for a container of its own)")63 chromium := flag.String("chromium", env("AIRWAVES_CHROMIUM", findChromium()), "Chromium for rendering the weather channel; empty disables it")64 music := flag.String("music", env("AIRWAVES_MUSIC", "/music"), "folder of MP3s for the weather channel")65 channels := flag.String("channels", env("AIRWAVES_CHANNELS", "/channels"), "folder of folder channels, a subfolder of videos each")66 hdhrSetting := flag.String("hdhr", os.Getenv("AIRWAVES_HDHR"), "emulated HDHomeRun for Jellyfin, Channels DVR and others: on or off (default on, off without an antenna)")67 hdhrListen := flag.String("hdhr-listen", env("AIRWAVES_HDHR_LISTEN", ":5004"), "address for the emulated HDHomeRun; empty disables it")68 discovery := flag.String("hdhr-discovery", os.Getenv("AIRWAVES_HDHR_DISCOVERY"), "answer HDHomeRun discovery on the LAN (UDP 65001): on or off (default on, off without an antenna)")69 ytdlp := flag.String("ytdlp", env("AIRWAVES_YTDLP", ""), "yt-dlp for YouTube channels; empty keeps an up-to-date copy in <data>/tools")70 loudnessSetting := flag.String("loudness", env("AIRWAVES_LOUDNESS", "off"), "even custom channels' sound out to this level in LUFS as it streams (-24 is broadcast TV's), or off")71 health := flag.Bool("healthcheck", false, "check that the airwavesd at -listen answers, then exit (for container health checks)")72 flag.Parse()7374 if *health {75 return healthcheck(*listen)76 }77 feat, err := resolveFeatures(*antenna, *hdhrSetting, *discovery)78 if err != nil {79 return err80 }81 // The admin password comes from the environment only, never a flag, so82 // it stays out of process listings, and it's dropped from the83 // environment so the tools airwavesd runs don't inherit it.84 adminPassword := os.Getenv("AIRWAVES_ADMIN_PASSWORD")85 _ = os.Unsetenv("AIRWAVES_ADMIN_PASSWORD")86 loudness, err := parseLoudness(*loudnessSetting)87 if err != nil {88 return err89 }90 if feat.antenna && *zip == "" {91 return errors.New("the antenna's lineup and listings need its ZIP code: set AIRWAVES_ZIP (or AIRWAVES_ANTENNA=off for custom channels only)")92 }93 if err := writable(*data); err != nil {94 return err95 }96 if *recordings == "" {97 *recordings = filepath.Join(*data, "recordings")98 }99 if feat.antenna {100 if err := writable(*recordings); err != nil {101 return err102 }103 }104 if *channels != "" {105 if err := writable(*channels); err != nil {106 log.Printf("channels: %v; the admin page can't save channels", err)107 }108 }109110 // YouTube changes faster than images are rebuilt, so by default111 // airwavesd keeps the latest yt-dlp release itself.112 yt := &youtube.YtDlp{Path: *ytdlp, HTTP: web.NewClient()}113 if yt.Path == "" {114 yt.Path, yt.Update = filepath.Join(*data, "tools", "yt-dlp"), true115 }116117 opt := service.Options{118 Name: *name,119 Mode: "server",120 CacheDir: filepath.Join(*data, "cache"),121 ConfigPath: filepath.Join(*data, "config.json"),122 Config: service.Config{ZIP: *zip, GuideHours: 48},123 DVRPath: filepath.Join(*data, "dvr.json"),124 RecordingsDir: *recordings,125 ListingsDir: filepath.Join(*data, "schedulesdirect"),126 SignalPath: filepath.Join(*data, "signal.json"),127 HDHomeRun: *tunerAddr,128 SkipDeviceID: hdhr.DeviceIDFor(*name),129 NoAntenna: !feat.antenna,130 WeatherStarPort: *weatherStar,131 WeatherStarURL: *weatherStarURL,132 Chromium: *chromium,133 MusicDir: *music,134 ChannelsDir: *channels,135 YtDlp: yt,136 PodcastsPath: filepath.Join(*data, "podcasts.json"),137 Loudness: loudness,138 Progress: func(s string) { log.Print(s) },139 }140 svc, err := service.New(opt)141 if err != nil {142 return err143 }144 defer svc.Close()145 // Apply the configured location over anything saved earlier; the saved146 // config keeps only what clients may change (hours of listings).147 cfg, _ := svc.Config(context.Background())148 cfg.ZIP, cfg.Lat, cfg.Lon = *zip, *lat, *lon149 if _, err := svc.SetConfig(context.Background(), cfg); err != nil {150 return err151 }152153 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)154 defer stop()155156 apiServer := &api.Server{157 Backend: svc, Streams: svc.StreamHandler(), Extra: svc.WeatherHandler(), Logos: svc.LogoHandler(),158 Token: *token, AdminPassword: adminPassword, TV: frontend.Dist(),159 // Where the folders are on the host, as the compose file mounts them.160 Admin: adminPage(ctx, svc, filepath.Join(*data, "backups"), env("AIRWAVES_HOST_CHANNELS", *channels), env("AIRWAVES_HOST_MUSIC", *music)),161 }162 srv := &http.Server{163 Addr: *listen,164 Handler: logRequests(apiServer.Handler()),165 ReadHeaderTimeout: 10 * time.Second,166 }167 go func() {168 <-ctx.Done()169 shut, cancel := context.WithTimeout(context.Background(), 5*time.Second)170 defer cancel()171 _ = srv.Shutdown(shut)172 }()173 log.Printf("airwavesd %s listening on %s", service.Version, *listen)174 if feat.antenna {175 where := "found on the network"176 if *tunerAddr != "" {177 where = "at " + *tunerAddr178 }179 log.Printf("antenna: on, the HDHomeRun %s; recordings in %s; signal measurements in %s", where, *recordings, opt.SignalPath)180 } else {181 log.Print("antenna: off, custom channels only")182 }183 switch {184 case *lat != 0 && *lon != 0:185 log.Printf("location: %.4f, %.4f", *lat, *lon)186 case *zip != "":187 log.Printf("location: ZIP %s", *zip)188 default:189 log.Print("location: none, so no weather (set AIRWAVES_ZIP, or AIRWAVES_LAT and AIRWAVES_LON)")190 }191 switch {192 case *weatherStar == 0:193 log.Print("weather channel: off, no WeatherStar display")194 case *weatherStarURL != "":195 log.Printf("weather channel: WeatherStar display at %s (port %d for the app), on while it answers", *weatherStarURL, *weatherStar)196 default:197 log.Printf("weather channel: WeatherStar display on port %d", *weatherStar)198 }199 if svc.Library() != nil {200 log.Printf("custom channels: %s", *channels)201 } else {202 log.Print("custom channels: off, they need a channels folder and ffmpeg")203 }204 switch {205 case apiServer.Admin == nil:206 log.Print("admin page: off")207 case adminPassword != "":208 log.Print("admin page: /admin/, with a password")209 default:210 log.Print("admin page: /admin/, with no password: keep this server on a trusted network or set AIRWAVES_ADMIN_PASSWORD")211 }212 log.Printf("app API token: %s", onOrOff(*token != ""))213 log.Print("TV app: /tv/, for browsers and the Android TV app")214 if info, _ := svc.Info(ctx); info.Podcasts {215 log.Printf("podcasts: on, shows in %s", opt.PodcastsPath)216 } else {217 log.Print("podcasts: off, they need ffmpeg")218 }219 if yt.Update {220 log.Printf("yt-dlp: %s, kept up to date", yt.Path)221 }222 if feat.hdhr && *hdhrListen != "" {223 startHDHR(ctx, svc, *hdhrListen, *name, feat.discovery)224 } else {225 log.Print("HDHomeRun: off")226 }227 go svc.Run(ctx)228 if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {229 return err230 }231 return nil232}233234type statusWriter struct {235 http.ResponseWriter236 code int237}238239func (w *statusWriter) WriteHeader(code int) {240 w.code = code241 w.ResponseWriter.WriteHeader(code)242}243244// logRequests logs API calls, skipping the steady stream of HLS fetches.245func logRequests(next http.Handler) http.Handler {246 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {247 start := time.Now()248 sw := &statusWriter{ResponseWriter: w, code: http.StatusOK}249 next.ServeHTTP(sw, r)250 if strings.HasPrefix(r.URL.Path, "/api/") {251 log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.code, time.Since(start).Round(time.Millisecond))252 }253 })254}255256// adminPage is the channel admin page, when there's a channels folder. It257// keeps backups of the channel setup in backups, making the daily ones258// until ctx ends. channels and music are the folders as the host sees259// them, for its hints.260func adminPage(ctx context.Context, svc *service.Service, backups, channels, music string) http.Handler {261 lib := svc.Library()262 if lib == nil {263 return nil264 }265 page := &admin.Server{Library: lib, Weather: svc.WeatherChannel(), Antenna: svc.AntennaChannels, HTTP: web.NewClient(),266 Backups: backups, Version: service.Version, ChannelsDir: channels, MusicDir: music}267 if l := svc.Listings(); l != nil {268 page.Listings = l269 }270 go page.Run(ctx)271 return page.Handler()272}273274// startHDHR serves the emulated HDHomeRun and, with discover, answers275// discovery on the LAN.276func startHDHR(ctx context.Context, svc *service.Service, addr, name string, discover bool) {277 _, portStr, err := net.SplitHostPort(addr)278 if err != nil {279 log.Printf("hdhr: bad listen address %q: %v", addr, err)280 return281 }282 port, _ := strconv.Atoi(portStr)283 dev := &hdhr.Server{284 Backend: svc.HDHR(),285 DeviceID: hdhr.DeviceIDFor(name),286 FriendlyName: "Airwaves",287 Tuners: svc.TunerCount,288 Logos: svc.LogoHandler(),289 FromHost: !discover,290 }291 srv := &http.Server{Addr: addr, Handler: dev.Handler(), ReadHeaderTimeout: 10 * time.Second}292 go func() {293 <-ctx.Done()294 _ = srv.Close()295 }()296 log.Printf("HDHomeRun: on %s (device %08X), LAN discovery %s", addr, dev.DeviceID, onOrOff(discover))297 go func() {298 if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {299 log.Printf("hdhr: %v", err)300 }301 }()302 if !discover {303 return304 }305 go func() {306 if err := dev.Discover(ctx, port); err != nil {307 log.Printf("hdhr discovery: %v", err)308 }309 }()310}311312// features are what this server does.313type features struct {314 antenna bool // the over-the-air lineup, guide, tuner and recording315 hdhr bool // the emulated HDHomeRun316 discovery bool // answering HDHomeRun discovery on the LAN317}318319// resolveFeatures reads the on and off settings. The HDHomeRun and its320// discovery default to on with an antenna, as on a home server on the LAN,321// and off without one, as in a container for someone's custom channels.322func resolveFeatures(antenna, hdhr, discovery string) (features, error) {323 var f features324 var err error325 if f.antenna, err = onOff("AIRWAVES_ANTENNA", antenna, true); err != nil {326 return f, err327 }328 if f.hdhr, err = onOff("AIRWAVES_HDHR", hdhr, f.antenna); err != nil {329 return f, err330 }331 if f.discovery, err = onOff("AIRWAVES_HDHR_DISCOVERY", discovery, f.antenna); err != nil {332 return f, err333 }334 f.discovery = f.discovery && f.hdhr335 return f, nil336}337338// onOff reads an on or off setting, def when it's empty.339func onOff(name, v string, def bool) (bool, error) {340 switch strings.ToLower(strings.TrimSpace(v)) {341 case "":342 return def, nil343 case "on", "true", "yes", "1":344 return true, nil345 case "off", "false", "no", "0":346 return false, nil347 }348 return false, fmt.Errorf("%s is on or off, not %q", name, v)349}350351func onOrOff(b bool) string {352 if b {353 return "on"354 }355 return "off"356}357358// writable makes sure dir exists and airwavesd can write in it. In a359// container, a folder mounted from the host often belongs to someone else.360func writable(dir string) error {361 err := os.MkdirAll(dir, 0o755)362 if err == nil {363 var f *os.File364 if f, err = os.CreateTemp(dir, ".airwaves-*"); err == nil {365 f.Close()366 _ = os.Remove(f.Name())367 return nil368 }369 }370 return fmt.Errorf("can't write to %s (%w): airwavesd runs as user %d and needs to (on the host: sudo chown -R %d:%d <that folder>)",371 dir, err, os.Getuid(), os.Getuid(), os.Getgid())372}373374// healthcheck asks the airwavesd listening on addr whether it's up.375func healthcheck(addr string) error {376 host, port, err := net.SplitHostPort(addr)377 if err != nil {378 return err379 }380 if host == "" || host == "0.0.0.0" || host == "::" {381 host = "127.0.0.1"382 }383 c := &http.Client{Timeout: 5 * time.Second}384 resp, err := c.Get("http://" + net.JoinHostPort(host, port) + "/healthz")385 if err != nil {386 return err387 }388 resp.Body.Close()389 if resp.StatusCode != http.StatusOK {390 return fmt.Errorf("airwavesd health: HTTP %d", resp.StatusCode)391 }392 return nil393}394395func findChromium() string {396 for _, name := range []string{"chromium", "chromium-browser", "google-chrome"} {397 if p, err := exec.LookPath(name); err == nil {398 return p399 }400 }401 return ""402}403404// parseLoudness reads AIRWAVES_LOUDNESS: a level in LUFS, from -40 to -5,405// or "off" (0).406func parseLoudness(s string) (float64, error) {407 if strings.EqualFold(strings.TrimSpace(s), "off") {408 return 0, nil409 }410 v, err := strconv.ParseFloat(strings.TrimSpace(s), 64)411 if err != nil || v < -40 || v > -5 {412 return 0, fmt.Errorf("loudness %q: give a level in LUFS from -40 to -5 (-24 is broadcast TV's), or off", s)413 }414 return v, nil415}