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 / internal / vchan / weather.go
weather.go 11.2K
1package vchan23import (4 "context"5 "fmt"6 "io"7 "net/http"8 "net/url"9 "os"10 "os/exec"11 "path/filepath"12 "regexp"13 "strconv"14 "strings"15 "sync"16 "syscall"17 "time"1819 "github.com/chromedp/cdproto/emulation"20 "github.com/chromedp/cdproto/page"21 "github.com/chromedp/chromedp"2223 "airwaves/internal/weather"24)2526// Weather renders the Airwaves Weather display (WeatherStar 4000+) to video27// with background music, using headless Chromium and ffmpeg.28type Weather struct {29 // Record is the channel's record, WeatherFile in the channels folder:30 // its number and name as well as its details. Empty keeps the31 // defaults (1.1 Airwaves Weather, WX).32 Record string33 // PageURL returns the display's kiosk URL for the server's location.34 PageURL func(ctx context.Context) (string, error)35 // MusicDir holds the display's bundled tracks (an HTTP directory36 // listing), used when LocalMusic has no MP3s.37 MusicDir string38 LocalMusic string39 FFmpeg string40 Chromium string41 HTTP *http.Client42 // Forecast supplies the weather report the guide is written from.43 Forecast func(ctx context.Context) (*weather.Report, error)44 // Loudness is the level the music is evened out to, in LUFS; 0 leaves45 // it as it is.46 Loudness float644748 record recordFile49 music memoryLevels // the music's loudness as it played, by tracks50}5152const (53 weatherWidth = 128054 weatherHeight = 72055 captureFPS = 1556)5758// Number implements Channel.59func (wx *Weather) Number() string { return wx.Details().Number }6061// Name implements Channel.62func (wx *Weather) Name() string { return wx.Details().Name }6364// Details implements Channel, from the record. A number that isn't one,65// or a blank name, leaves the default.66func (wx *Weather) Details() Details {67 d := Details{Number: WeatherNumber, Name: WeatherName, CallSign: WeatherCallSign, Category: "Weather",68 Description: weatherBlurb, Enabled: true}69 if wx.Record == "" {70 return d71 }72 rec := wx.record.read(wx.Record)73 if n, ok := ParseNumber(rec.Number); ok {74 d.Number = n75 }76 if name := strings.TrimSpace(rec.Name); name != "" {77 d.Name = name78 }79 return rec.details(filepath.Dir(wx.Record), WeatherLogoName, d)80}8182// Programs implements Channel. Like a cable weather channel, the guide has83// an hour-long "Local Forecast" block each hour, and each block carries that84// hour's forecast, so browsing ahead in the guide reads as the forecast.85func (wx *Weather) Programs(from, to time.Time) []Program {86 var rep *weather.Report87 if wx.Forecast != nil {88 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)89 rep, _ = wx.Forecast(ctx)90 cancel()91 }92 var out []Program93 for t := from.Truncate(time.Hour); t.Before(to); t = t.Add(time.Hour) {94 out = append(out, ForecastProgram(rep, t))95 }96 return out97}9899// ForecastProgram is the guide entry for the hour starting at t.100func ForecastProgram(rep *weather.Report, t time.Time) Program {101 p := Program{102 Start: t, End: t.Add(time.Hour), Title: "Local Forecast", Category: "Weather",103 Description: "Current conditions, the local and extended forecast, and radar.",104 }105 if rep == nil {106 return p107 }108 for _, h := range rep.Hourly {109 if !h.Start.After(t) && t.Before(h.Start.Add(time.Hour)) {110 p.Subtitle = fmt.Sprintf("%d° and %s", h.TempF, h.Short)111 break112 }113 }114 for i, per := range rep.Periods {115 end := per.Start.Add(12 * time.Hour)116 if i+1 < len(rep.Periods) {117 end = rep.Periods[i+1].Start118 }119 if !per.Start.After(t) && t.Before(end) {120 p.Description = per.Name + ": " + per.Detailed121 break122 }123 }124 for _, a := range rep.Alerts {125 if (a.Onset.IsZero() || !a.Onset.After(t.Add(time.Hour))) && (a.Ends.IsZero() || a.Ends.After(t)) {126 p.Subtitle = a.Event + ". " + p.Subtitle127 break128 }129 }130 return p131}132133// Stream implements Channel.134func (wx *Weather) Stream(ctx context.Context, w io.Writer) error {135 pageURL, err := wx.PageURL(ctx)136 if err != nil {137 return err138 }139 ctx, cancel := context.WithCancel(ctx)140 defer cancel()141142 tracks := wx.Tracks(ctx)143 audio, cleanup, err := wx.audioInput(tracks)144 if err != nil {145 return err146 }147 defer cleanup()148 var music *os.File // the leveled music, for ffmpeg's fd 3149 if wx.Loudness != 0 && len(tracks) > 0 {150 if music, err = wx.levelMusic(ctx, audio, strings.Join(tracks, "\x00")); err != nil {151 return err152 }153 defer music.Close()154 audio = []string{"-f", "s16le", "-ar", strconv.Itoa(sampleRate), "-ac", "2", "-i", "pipe:3"}155 }156 args := []string{157 "-hide_banner", "-loglevel", "error", "-nostdin",158 "-f", "image2pipe", "-framerate", fmt.Sprint(captureFPS), "-c:v", "mjpeg", "-i", "pipe:0",159 }160 args = append(args, audio...)161 args = append(args,162 "-map", "0:v", "-map", "1:a",163 "-vf", fmt.Sprintf("scale=%d:%d,fps=30,format=yuv420p", weatherWidth, weatherHeight),164 "-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency", "-g", "60",165 "-b:v", "3M", "-maxrate", "4M", "-bufsize", "6M",166 "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2",167 "-f", "mpegts", "pipe:1",168 )169 ff := exec.CommandContext(ctx, wx.FFmpeg, args...)170 if music != nil {171 ff.ExtraFiles = []*os.File{music}172 }173 ff.Stdout = w174 var stderr strings.Builder175 ff.Stderr = &stderr176 frames, err := ff.StdinPipe()177 if err != nil {178 return err179 }180 if err := ff.Start(); err != nil {181 return fmt.Errorf("start ffmpeg: %w", err)182 }183 defer func() { _ = ff.Wait() }()184 defer frames.Close()185186 // Chromium runs in its own process group so the whole browser (renderer187 // and helper processes included) can be killed when the viewer leaves;188 // cancelling chromedp's context alone leaves it running.189 var procMu sync.Mutex190 var procs []*exec.Cmd191 alloc, cancelAlloc := chromedp.NewExecAllocator(ctx,192 chromedp.ModifyCmdFunc(func(cmd *exec.Cmd) {193 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}194 procMu.Lock()195 procs = append(procs, cmd)196 procMu.Unlock()197 }),198 chromedp.ExecPath(wx.Chromium),199 chromedp.Headless,200 chromedp.NoSandbox,201 chromedp.DisableGPU,202 chromedp.NoFirstRun,203 chromedp.NoDefaultBrowserCheck,204 chromedp.WindowSize(weatherWidth, weatherHeight),205 chromedp.Flag("disable-dev-shm-usage", true),206 chromedp.Flag("hide-scrollbars", true),207 chromedp.Flag("mute-audio", true),208 )209 defer cancelAlloc()210 browser, cancelBrowser := chromedp.NewContext(alloc)211 defer cancelBrowser()212 defer func() {213 closing, cancel := context.WithTimeout(context.Background(), 2*time.Second)214 defer cancel()215 _ = chromedp.Cancel(browser)216 <-closing.Done()217 procMu.Lock()218 defer procMu.Unlock()219 for _, p := range procs {220 if p.Process != nil {221 _ = syscall.Kill(-p.Process.Pid, syscall.SIGKILL)222 }223 }224 }()225226 if _, err := chromedp.Call(browser, emulation.SetDeviceMetricsOverride, emulation.SetDeviceMetricsOverrideParams{227 Width: weatherWidth, Height: weatherHeight, DeviceScaleFactor: 1,228 }); err != nil {229 return fmt.Errorf("start chromium: %w", err)230 }231 if err := chromedp.Do(browser, chromedp.Navigate(pageURL)); err != nil {232 return fmt.Errorf("open weather display: %w", err)233 }234235 // Chromium pushes a frame whenever the page changes; keep the newest236 // and feed ffmpeg at a steady rate so time keeps moving on still pages.237 var mu sync.Mutex238 var latest []byte239 frameEvents := chromedp.Events(browser, page.ScreencastFrame)240 if _, err := chromedp.Call(browser, page.StartScreencast, page.StartScreencastParams{241 Format: "jpeg", Quality: 85, MaxWidth: weatherWidth, MaxHeight: weatherHeight,242 }); err != nil {243 return fmt.Errorf("start capture: %w", err)244 }245 go func() {246 for f, err := range frameEvents {247 if err != nil {248 return249 }250 mu.Lock()251 latest = f.Data252 mu.Unlock()253 _, _ = chromedp.Call(browser, page.ScreencastFrameAck, page.ScreencastFrameAckParams{SessionID: f.SessionID})254 }255 }()256257 tick := time.NewTicker(time.Second / captureFPS)258 defer tick.Stop()259 for {260 select {261 case <-ctx.Done():262 return nil263 case <-tick.C:264 mu.Lock()265 frame := latest266 mu.Unlock()267 if frame == nil {268 continue269 }270 if _, err := frames.Write(frame); err != nil {271 if ctx.Err() != nil {272 return nil273 }274 return fmt.Errorf("ffmpeg: %v: %s", err, strings.TrimSpace(stderr.String()))275 }276 }277 }278}279280// levelMusic decodes the music (input) to raw sound and levels it as it281// plays, metering it the way videos are, and returns the leveled sound282// for the encoder to read. key names the tracks, whose loudness is283// remembered for the next viewing.284func (wx *Weather) levelMusic(ctx context.Context, input []string, key string) (*os.File, error) {285 dec := exec.CommandContext(ctx, wx.FFmpeg, append(append([]string{"-hide_banner", "-loglevel", "error", "-nostdin"}, input...),286 "-vn", "-f", "s16le", "-ar", strconv.Itoa(sampleRate), "-ac", "2", "pipe:1")...)287 src, err := dec.StdoutPipe()288 if err != nil {289 return nil, err290 }291 r, w, err := os.Pipe()292 if err != nil {293 return nil, err294 }295 if err := dec.Start(); err != nil {296 r.Close()297 w.Close()298 return nil, fmt.Errorf("start ffmpeg: %w", err)299 }300 lev := newLeveler(&itemLevel{Target: wx.Loudness, Key: key, Memory: &wx.music})301 go func() {302 defer func() { _ = dec.Wait() }()303 defer lev.finish()304 defer w.Close()305 buf := make([]byte, relayChunk)306 for {307 n, err := io.ReadFull(src, buf)308 whole := buf[:n-n%4]309 lev.process(whole)310 if _, werr := w.Write(whole); werr != nil || err != nil {311 return312 }313 }314 }()315 return r, nil316}317318// Tracks lists the music: the MP3 files in LocalMusic, else the display's319// bundled tracks as URLs.320func (wx *Weather) Tracks(ctx context.Context) []string {321 if wx.LocalMusic != "" {322 if files, _ := filepath.Glob(filepath.Join(wx.LocalMusic, "*.mp3")); len(files) > 0 {323 return files324 }325 }326 if wx.MusicDir != "" {327 return wx.remoteTracks(ctx)328 }329 return nil330}331332var mp3Link = regexp.MustCompile(`href="([^"]+\.mp3)"`)333334// musicRepeats is how many times the music playlist plays.335const musicRepeats = 500336337// audioInput returns ffmpeg input options for a looping music playlist of338// tracks (see Tracks), or silence without any.339func (wx *Weather) audioInput(tracks []string) ([]string, func(), error) {340 silence := []string{"-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"}341 if len(tracks) == 0 {342 return silence, func() {}, nil343 }344 list, err := os.CreateTemp("", "airwaves-music-*.txt")345 if err != nil {346 return nil, nil, err347 }348 // The playlist over and over: -stream_loop doesn't loop the concat349 // demuxer (it fails at the end of the list), so the list repeats,350 // longer than anyone watches.351 for range musicRepeats {352 for _, t := range tracks {353 fmt.Fprintf(list, "file '%s'\n", strings.ReplaceAll(t, "'", `'\''`))354 }355 }356 list.Close()357 cleanup := func() { os.Remove(list.Name()) }358 return []string{359 "-re", "-protocol_whitelist", "file,http,https,tcp,tls,crypto",360 "-f", "concat", "-safe", "0", "-i", list.Name(),361 }, cleanup, nil362}363364func (wx *Weather) remoteTracks(ctx context.Context) []string {365 ctx, cancel := context.WithTimeout(ctx, 5*time.Second)366 defer cancel()367 req, err := http.NewRequestWithContext(ctx, http.MethodGet, wx.MusicDir, nil)368 if err != nil {369 return nil370 }371 resp, err := wx.HTTP.Do(req)372 if err != nil {373 return nil374 }375 defer resp.Body.Close()376 body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))377 base, err := url.Parse(wx.MusicDir)378 if err != nil {379 return nil380 }381 var out []string382 for _, m := range mp3Link.FindAllStringSubmatch(string(body), -1) {383 if ref, err := url.Parse(m[1]); err == nil {384 out = append(out, base.ResolveReference(ref).String())385 }386 }387 return out388}