1// Package service is the Airwaves engine: the tuner's lineup and what was2// measured of its signal, the guide, live streams and recordings. airwavesd3// runs it on a home server and exposes it over HTTP (see package api).4//5// What it serves the app is either measured (the tuner's channels and6// signal) or on record (the FCC's transmitters, the listings, the ATSC 3.07// list). Reception estimates are for planning, in otascan, not here.8package service910import (11 "cmp"12 "context"13 "encoding/json"14 "errors"15 "fmt"16 "io"17 "io/fs"18 "log"19 "net"20 "net/http"21 "net/url"22 "path/filepath"23 "slices"24 "strconv"25 "strings"26 "sync"27 "time"2829 "airwaves/internal/cc"30 "airwaves/internal/dvr"31 "airwaves/internal/geo"32 "airwaves/internal/guide"33 "airwaves/internal/hdhr"34 "airwaves/internal/lineup"35 "airwaves/internal/phase"36 "airwaves/internal/podcast"37 "airwaves/internal/sd"38 "airwaves/internal/signal"39 "airwaves/internal/store"40 "airwaves/internal/stream"41 "airwaves/internal/tuner"42 "airwaves/internal/vchan"43 "airwaves/internal/weather"44 "airwaves/internal/web"45 "airwaves/internal/youtube"46)4748// Version is reported to clients.49const Version = "0.2.0"5051// ErrNoDVR is returned by recording calls on a server with nowhere to52// keep recordings.53var ErrNoDVR = errors.New("recording needs a recordings folder on the server")5455// ErrNotRecordable is returned when asked to record a custom channel.56var ErrNotRecordable = errors.New("custom channels play from the server and can't be recorded")5758// ErrNoAntenna is returned for what needs an antenna (antenna channels,59// signal, recording) by a server that has only custom channels.60var ErrNoAntenna = errors.New("this Airwaves server has no antenna, only custom channels")6162// ErrNoLocation is returned for the weather by a server without an63// antenna that wasn't told where it is.64var ErrNoLocation = errors.New("the weather needs a location: set AIRWAVES_ZIP, or AIRWAVES_LAT and AIRWAVES_LON, on the server")6566// reportTTL is how long a built report is reused before rebuilding from67// (mostly cached) sources.68const reportTTL = 30 * time.Minute6970// Config is the location the lineup is built for.71type Config struct {72 ZIP string `json:"zip"`73 // Lat and Lon override the ZIP centroid when both are non-zero.74 Lat float64 `json:"lat"`75 Lon float64 `json:"lon"`76 RadiusKm float64 `json:"radiusKm"`77 GuideHours int `json:"guideHours"`78}7980func (c Config) normalized() Config {81 if c.RadiusKm <= 0 {82 c.RadiusKm = 16083 }84 if c.GuideHours <= 0 {85 c.GuideHours = 2486 }87 c.GuideHours = min(c.GuideHours, 72)88 return c89}9091// Info describes the engine a client is talking to.92type Info struct {93 Name string `json:"name"`94 Mode string `json:"mode"` // "local" or "server"95 Version string `json:"version"`96 Tuner tuner.Device `json:"tuner"`97 DVR bool `json:"dvr"`98 Playback string `json:"playback,omitempty"` // why streaming is unavailable99 // WeatherStar is set when a WeatherStar 4000+ display is available at100 // /weatherstar on the server.101 WeatherStar bool `json:"weatherStar,omitempty"`102 // Antenna is false for a server with custom channels only: no antenna103 // channels, reception reports or recording.104 Antenna bool `json:"antenna"`105 // Podcasts is set when the server has the Podcasts app.106 Podcasts bool `json:"podcasts"`107}108109// Snapshot is the lineup with its listings.110type Snapshot struct {111 // Report is what is on record at the location: the FCC's transmitters112 // (with no estimates), the ATSC 3.0 hosts and the data sources. Its113 // Channels are empty: the channels are the tuner's, in Antenna.114 Report *lineup.Report `json:"report"`115 // Guide is the listings of the tuner's channels.116 Guide *guide.Guide `json:"guide"`117 // Custom lists the custom channels that are on, by number, with their118 // details and schedules: the weather channel and the folder channels.119 // It's always a list in a Snapshot, if an empty one, so the app can tell120 // a server that lists the weather channel here from one before that.121 Custom []CustomChannel `json:"custom"`122 // Antenna is the tuner, its channels and what was measured of them.123 Antenna *Antenna `json:"antenna"`124}125126// CustomChannel is a custom channel and its schedule, for the app's guide.127type CustomChannel struct {128 Number string `json:"number"`129 Name string `json:"name"`130 // Kind is "weather", "folder", "jellyfin" or "youtube". The app shows131 // the weather channel itself, from /weatherstar, and writes its guide132 // from the weather report, so its Programs are empty.133 Kind string `json:"kind"`134 CallSign string `json:"callSign,omitempty"` // a short label, "WX"135 Category string `json:"category"` // one of vchan.Categories136 Description string `json:"description,omitempty"`137 // Logo is a path on the server, "/channel-logos/1.4?v=…", when the138 // channel has one.139 Logo string `json:"logo,omitempty"`140 Programs []guide.Program `json:"programs"`141}142143// Options configure a Service.144type Options struct {145 Name string146 Mode string // "local" or "server"147 CacheDir string // defaults to the user cache dir148 // ConfigPath persists Config; empty keeps it in memory.149 ConfigPath string150 Config Config151 // HDHomeRun is the tuner's address or device ID; empty finds it by152 // discovery. SkipDeviceID is a device never to use: this server's own153 // emulated one.154 HDHomeRun string155 SkipDeviceID uint32156 // DVRPath keeps the recording rules and recordings, and RecordingsDir157 // the recorded files; recording needs both.158 DVRPath string159 RecordingsDir string160 // ListingsDir keeps a Schedules Direct account and its listings,161 // which replace Gracenote's public grid once there's an account.162 ListingsDir string163 // NoAntenna serves custom channels only: no tuner, lineup or listings164 // from the ZIP code, reception reports or recording.165 // The location (Config) is then only for the weather, and there's none166 // unless it's given.167 NoAntenna bool168 // WeatherStarPort is where a WeatherStar 4000+ container listens on the169 // server's host; zero disables the WX channel.170 WeatherStarPort int171 // WeatherStarURL is where the server itself reaches the display, when172 // that isn't 127.0.0.1 at WeatherStarPort: "http://weatherstar:8080"173 // for a container of its own. The weather channel is then on only174 // while the display answers there, since it may not have been started.175 WeatherStarURL string176 // Chromium, when set, renders Airwaves Weather to video as a custom177 // channel for HDHomeRun clients.178 Chromium string179 // MusicDir holds MP3s for the weather channel; empty or without MP3s180 // uses the display's bundled tracks.181 MusicDir string182 // ChannelsDir holds folder channels, a subfolder of videos each.183 ChannelsDir string184 // YtDlp runs yt-dlp for YouTube channels and podcasts; yt-dlp from185 // PATH when nil.186 YtDlp *youtube.YtDlp187 // PodcastsPath keeps the Podcasts app's shows and how far their188 // episodes were watched; empty leaves the app off.189 PodcastsPath string190 // Loudness is the level custom channels' sound is evened out to as191 // it streams, in LUFS (vchan.DefaultLoudness is antenna TV's); 0192 // leaves it as it is.193 Loudness float64194 // SignalPath keeps the signal measurements; empty keeps them in195 // memory.196 SignalPath string197 Progress func(string)198}199200// Service is the engine.201type Service struct {202 opt Options203 http *http.Client204 cache *store.Cache205 signals *signal.Store206 timing measureTiming207 // ctx lasts until Close, for work that outlives a request (sweeps).208 ctx context.Context209 cancel context.CancelFunc210 streams *stream.Server211 streamErr error212 tuners *tuner.Manager213 dvr *dvr.Manager214 listings *sd.Source215 weather *weather.Source216 // wx is the weather channel, when there's a WeatherStar display;217 // wxStreams when it can be rendered to video for HDHomeRun clients.218 // wxLocal is where the server reaches the display.219 wx *vchan.Weather220 wxStreams bool221 wxLocal string222 library *vchan.Library223 podcasts *podcast.Library224225 buildMu sync.Mutex // serializes report builds226227 mu sync.Mutex228 cfg Config229 report *lineup.Report230 guide *guide.Guide231 built time.Time232 wxUp bool // the display at WeatherStarURL answered last time233 // dev is the HDHomeRun, nil until found; devErr why the last search234 // didn't find it.235 dev *hdhr.Device236 devErr string237 // lineup is the tuner's channels as of lineupAt; scan its channel238 // scan as of scanAt.239 lineup []hdhr.Channel240 lineupAt time.Time241 scan hdhr.ScanStatus242 scanAt time.Time243 // lastSample is the tuners at the last sample, and when.244 lastSample map[int]tuner.State245 lastSampleAt time.Time246 sweep SweepStatus247 sweepFreq int64 // the RF channel the sweep is on248}249250// New builds a Service. Streaming failures (no ffmpeg) are reported by251// Info rather than failing construction, so the guide still works.252func New(opt Options) (*Service, error) {253 s := &Service{opt: opt, http: web.NewClient(), timing: defaultTiming}254 s.ctx, s.cancel = context.WithCancel(context.Background())255 s.cfg = s.withDefaults(opt.Config)256 // A saved config that can't be read, nor any backup of it, leaves the257 // defaults rather than a server that won't start.258 if opt.ConfigPath != "" {259 err := store.ReadFile(opt.ConfigPath, func(raw []byte) error {260 var c Config261 if err := json.Unmarshal(raw, &c); err != nil {262 return err263 }264 s.cfg = s.withDefaults(c)265 return nil266 })267 switch {268 case errors.Is(err, store.ErrCorrupt):269 log.Printf("config: %v; using the defaults", err)270 case err != nil && !errors.Is(err, fs.ErrNotExist):271 return nil, err272 }273 }274 var err error275 if opt.CacheDir != "" {276 s.cache, err = store.OpenCache(opt.CacheDir)277 } else {278 s.cache, err = store.NewCache("airwaves")279 }280 if err != nil {281 log.Printf("cache: %v; data will be refetched", err)282 }283 if s.signals, err = signal.Open(opt.SignalPath); err != nil {284 if s.signals == nil {285 return nil, err286 }287 log.Print(err)288 }289 s.weather = weather.NewSource(s.http)290 s.streams, s.streamErr = stream.NewServer(false)291292 if !opt.NoAntenna && opt.ListingsDir != "" {293 s.listings = &sd.Source{294 Dir: opt.ListingsDir, ZIP: s.cfg.ZIP, HTTP: s.http, UserAgent: "airwaves/" + Version,295 // New listings make the next snapshot rebuild with them.296 OnUpdate: func() {297 s.mu.Lock()298 s.built = time.Time{}299 s.mu.Unlock()300 },301 }302 }303 if !opt.NoAntenna {304 s.tuners = tuner.NewManager()305 if opt.DVRPath != "" && opt.RecordingsDir != "" {306 if s.dvr, err = dvr.Open(opt.DVRPath, opt.RecordingsDir, s.tuners, s.tuning); err != nil {307 return nil, err308 }309 }310 }311 if opt.WeatherStarPort > 0 {312 s.wxLocal = strings.TrimRight(opt.WeatherStarURL, "/")313 if s.wxLocal == "" {314 s.wxLocal = fmt.Sprintf("http://127.0.0.1:%d", opt.WeatherStarPort)315 }316 s.wx = &vchan.Weather{317 PageURL: func(ctx context.Context) (string, error) { return s.weatherStarURL(ctx, s.wxLocal, false) },318 Forecast: s.Weather,319 MusicDir: s.wxLocal + "/music/default/",320 LocalMusic: opt.MusicDir,321 Chromium: opt.Chromium,322 HTTP: s.http,323 Loudness: opt.Loudness,324 }325 if opt.ChannelsDir != "" {326 s.wx.Record = filepath.Join(opt.ChannelsDir, vchan.WeatherFile)327 }328 if s.streamErr == nil {329 s.wx.FFmpeg = s.streams.FFmpeg()330 s.wxStreams = opt.Chromium != ""331 }332 }333 if opt.ChannelsDir != "" && s.streamErr == nil {334 s.library = &vchan.Library{335 Root: opt.ChannelsDir, FFmpeg: s.streams.FFmpeg(), FFprobe: s.streams.FFprobe(),336 Reserved: s.reserved, YtDlp: opt.YtDlp, Loudness: opt.Loudness,337 }338 }339 if opt.PodcastsPath != "" && s.streamErr == nil {340 s.podcasts = &podcast.Library{Path: opt.PodcastsPath, YtDlp: opt.YtDlp, FFmpeg: s.streams.FFmpeg()}341 }342 return s, nil343}344345// Library is the folder channels, or nil when there's no channels folder.346func (s *Service) Library() *vchan.Library { return s.library }347348// Listings is the Schedules Direct account and its listings, or nil on a349// server without an antenna.350func (s *Service) Listings() *sd.Source { return s.listings }351352// WeatherChannel is the weather channel, or nil when there's no WeatherStar353// display.354func (s *Service) WeatherChannel() *vchan.Weather { return s.wx }355356// reserved is the number folder channels can't have: the weather357// channel's.358func (s *Service) reserved() []string {359 if s.wx == nil {360 return nil361 }362 return []string{s.wx.Number()}363}364365// withDefaults fills in a Config's defaults. There's no default ZIP code:366// someone else's would be wrong.367func (s *Service) withDefaults(c Config) Config {368 return c.normalized()369}370371// wxOn reports whether the weather channel can be shown: there's a372// WeatherStar display, answering when it runs apart from the server, and a373// location for it to show.374func (s *Service) wxOn() bool {375 if s.wx == nil {376 return false377 }378 s.mu.Lock()379 defer s.mu.Unlock()380 located := s.cfg.ZIP != "" || s.cfg.Lat != 0 && s.cfg.Lon != 0381 return located && (s.opt.WeatherStarURL == "" || s.wxUp)382}383384// checkWeatherStar notes whether the display at WeatherStarURL answers.385func (s *Service) checkWeatherStar(ctx context.Context) {386 if s.wx == nil || s.opt.WeatherStarURL == "" {387 return388 }389 ctx, cancel := context.WithTimeout(ctx, 5*time.Second)390 defer cancel()391 up := false392 if req, err := http.NewRequestWithContext(ctx, http.MethodHead, s.wxLocal+"/", nil); err == nil {393 if resp, err := s.http.Do(req); err == nil {394 resp.Body.Close()395 up = resp.StatusCode < http.StatusInternalServerError396 }397 }398 s.mu.Lock()399 changed := up != s.wxUp400 s.wxUp = up401 s.mu.Unlock()402 switch {403 case changed && up:404 log.Printf("weather channel: on, WeatherStar display at %s", s.wxLocal)405 case changed:406 log.Printf("weather channel: off, no WeatherStar display at %s", s.wxLocal)407 }408}409410// customChannels lists the custom channels in the HDHomeRun lineup: the411// weather channel when it can be rendered, and the folder channels that412// have something to play, leaving out those turned off.413func (s *Service) customChannels() []vchan.Channel {414 var all []vchan.Channel415 if s.wxStreams && s.wxOn() {416 all = append(all, s.wx)417 }418 if s.library != nil {419 all = append(all, s.library.Channels()...)420 }421 var out []vchan.Channel422 for _, v := range all {423 if v.Details().Enabled {424 out = append(out, v)425 }426 }427 return out428}429430func (s *Service) customChannel(number string) vchan.Channel {431 for _, v := range s.customChannels() {432 if vchan.SameNumber(v.Number(), number) {433 return v434 }435 }436 return nil437}438439// Close stops all streams, recordings and measuring, and saves the440// measurements. Recordings in progress carry on at the next start.441func (s *Service) Close() {442 s.cancel()443 if s.dvr != nil {444 s.dvr.Close()445 }446 if s.streams != nil {447 s.streams.Close()448 }449 if err := s.signals.Save(); err != nil {450 log.Printf("signal measurements: %v", err)451 }452}453454// StreamHandler serves /live/ HLS files.455func (s *Service) StreamHandler() http.Handler {456 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {457 if s.streams == nil {458 http.NotFound(w, r)459 return460 }461 s.streams.ServeHTTP(w, r)462 })463}464465// Info implements the Backend contract.466func (s *Service) Info(context.Context) (Info, error) {467 i := Info{Name: s.opt.Name, Mode: s.opt.Mode, Version: Version, Tuner: s.tunerDevice(), DVR: s.dvr != nil, WeatherStar: s.wxOn(),468 Antenna: !s.opt.NoAntenna, Podcasts: s.podcasts != nil}469 if s.streamErr != nil {470 i.Playback = s.streamErr.Error()471 }472 return i, nil473}474475// Config returns the location settings.476func (s *Service) Config(context.Context) (Config, error) {477 s.mu.Lock()478 defer s.mu.Unlock()479 return s.cfg, nil480}481482// SetConfig changes the location or listings window; the next Snapshot483// rebuilds. Clients only change the listings window; airwavesd sets the484// location from its configuration at startup.485func (s *Service) SetConfig(_ context.Context, c Config) (Config, error) {486 c = s.withDefaults(c)487 s.mu.Lock()488 changed := c != s.cfg489 s.cfg = c490 if changed {491 s.built = time.Time{}492 }493 s.mu.Unlock()494 if s.opt.ConfigPath != "" {495 raw, _ := json.MarshalIndent(c, "", " ")496 if err := store.WriteFile(s.opt.ConfigPath, raw, 0o644); err != nil {497 return c, err498 }499 }500 return c, nil501}502503// Snapshot returns the tuner's lineup described from the records (the504// report, rebuilt when stale) with its listings, what was measured, and505// the custom channels' details and schedules. With refresh it also506// bypasses cached source data.507func (s *Service) Snapshot(ctx context.Context, refresh bool) (*Snapshot, error) {508 snap, err := s.snapshot(ctx, refresh)509 if err != nil {510 return nil, err511 }512 out := &Snapshot{Report: snap.Report, Guide: snap.Guide, Custom: s.custom()}513 if s.opt.NoAntenna {514 out.Antenna = s.antenna(ctx, nil, nil)515 return out, nil516 }517 a := s.antenna(ctx, snap.Report, snap.Guide)518 chans := make([]lineup.TunerChannel, len(a.Channels))519 for i, c := range a.Channels {520 chans[i] = c.TunerChannel521 }522 out.Report = lineup.ForTuner(snap.Report, chans)523 if len(a.Channels) == 0 && a.Tuner.Reason != "" {524 out.Report.Warnings = append(slices.Clone(out.Report.Warnings), "No antenna channels: "+a.Tuner.Reason)525 }526 s.mu.Lock()527 hours := s.cfg.GuideHours528 s.mu.Unlock()529 out.Guide = listedOnly(snap.Guide, a.Channels, time.Now().Add(-time.Hour), time.Now().Add(time.Duration(hours)*time.Hour))530 out.Antenna = a531 return out, nil532}533534// custom lists the custom channels for the app: the weather channel (which535// the app plays itself) whenever there's a WeatherStar display, and the536// folder channels with something to play; none that are turned off.537func (s *Service) custom() []CustomChannel {538 s.mu.Lock()539 hours := s.cfg.GuideHours540 s.mu.Unlock()541 from := time.Now().Add(-time.Hour)542 to := time.Now().Add(time.Duration(hours+1) * time.Hour)543 out := []CustomChannel{}544 if s.wxOn() {545 if d := s.wx.Details(); d.Enabled {546 out = append(out, customChannel(d, "weather"))547 }548 }549 if s.library != nil {550 for _, f := range s.library.Channels() {551 d := f.Details()552 if !d.Enabled {553 continue554 }555 c := customChannel(d, vchan.KindOf(f))556 for _, p := range f.Programs(from, to) {557 c.Programs = append(c.Programs, customProgram(p))558 }559 out = append(out, c)560 }561 }562 slices.SortStableFunc(out, func(a, b CustomChannel) int { return guide.CompareNumbers(a.Number, b.Number) })563 return out564}565566func customChannel(d vchan.Details, kind string) CustomChannel {567 return CustomChannel{568 Number: d.Number, Name: d.Name, Kind: kind, CallSign: d.CallSign, Category: d.Category,569 Description: d.Description, Logo: logoPath(d), Programs: []guide.Program{},570 }571}572573// logoPath is where a channel's logo is served, with its version so a new574// logo is fetched again; "" when it has none.575func logoPath(d vchan.Details) string {576 if d.Logo == "" {577 return ""578 }579 tag := vchan.LogoTag(d.Logo)580 if tag == "" {581 return ""582 }583 return "/channel-logos/" + url.PathEscape(d.Number) + "?v=" + tag584}585586// LogoHandler serves /channel-logos/<number>: custom channels' logos, off587// or on, for the app and HDHomeRun clients; and /guide-art/<name>, the588// programs' pictures from Schedules Direct.589func (s *Service) LogoHandler() http.Handler {590 mux := http.NewServeMux()591 if s.listings != nil {592 mux.Handle("GET "+sd.ArtPath+"{name}", s.listings.ArtHandler())593 }594 mux.HandleFunc("GET /channel-logos/{number}", func(w http.ResponseWriter, r *http.Request) {595 number := r.PathValue("number")596 var chans []vchan.Channel597 if s.wx != nil {598 chans = append(chans, s.wx)599 }600 if s.library != nil {601 for _, e := range s.library.Entries() {602 chans = append(chans, e.Channel)603 }604 }605 for _, v := range chans {606 if d := v.Details(); vchan.SameNumber(d.Number, number) && d.Logo != "" {607 vchan.ServeLogo(w, r, d.Logo)608 return609 }610 }611 http.NotFound(w, r)612 })613 return mux614}615616// AntennaChannels lists the tuner's channels by number, with their call617// signs; none without a tuner. Custom channels take the place of antenna618// channels with the same number in the HDHomeRun lineup.619func (s *Service) AntennaChannels(ctx context.Context) map[string]string {620 out := map[string]string{}621 s.mu.Lock()622 rep, g := s.report, s.guide623 s.mu.Unlock()624 chans, _, err := s.matched(ctx, rep, g)625 if err != nil {626 log.Printf("antenna channels: %v", err)627 }628 for _, c := range chans {629 out[c.Number] = cmp.Or(c.CallSign, c.Name)630 }631 return out632}633634// snapshot returns the current report and guide.635func (s *Service) snapshot(ctx context.Context, refresh bool) (*Snapshot, error) {636 if snap := s.current(refresh); snap != nil {637 return snap, nil638 }639 s.buildMu.Lock()640 defer s.buildMu.Unlock()641 if snap := s.current(refresh); snap != nil {642 return snap, nil // another caller just built it643 }644 s.mu.Lock()645 cfg := s.cfg646 s.mu.Unlock()647648 if s.opt.NoAntenna {649 rep, ok := s.locate(context.WithoutCancel(ctx), cfg)650 s.mu.Lock()651 s.report, s.guide, s.built = rep, nil, time.Now()652 if !ok {653 // Look the ZIP code up again in a minute, not half an hour.654 s.built = s.built.Add(time.Minute - reportTTL)655 }656 s.mu.Unlock()657 return &Snapshot{Report: rep}, nil658 }659660 // The records only: no terrain, so no estimates.661 b := &lineup.Builder{HTTP: s.http, Cache: s.cache, Progress: s.opt.Progress}662 if s.listings != nil {663 b.Listings = s.listings.Guide664 }665 opt := lineup.Options{ZIP: cfg.ZIP, RadiusKm: cfg.RadiusKm, GuideHours: cfg.GuideHours, Refresh: refresh}666 if cfg.Lat != 0 && cfg.Lon != 0 {667 opt.Point = &geo.Point{Lat: cfg.Lat, Lon: cfg.Lon}668 }669 // One client hanging up must not abort a build others are waiting on.670 rep, g, err := b.Build(context.WithoutCancel(ctx), opt)671 if err != nil {672 return nil, err673 }674 s.mu.Lock()675 s.report, s.guide, s.built = rep, g, time.Now()676 s.mu.Unlock()677 return &Snapshot{Report: rep, Guide: g}, nil678}679680// locate is the report of a server without an antenna: no transmitters,681// channels or listings, only where the weather is for, when that's set. It682// reports false when the ZIP code couldn't be looked up.683func (s *Service) locate(ctx context.Context, cfg Config) (*lineup.Report, bool) {684 rep := &lineup.Report{685 Generated: time.Now(), Stations: []lineup.Station{},686 Channels: []lineup.Channel{}, Sources: []lineup.Source{}, Warnings: []string{},687 }688 ok := true689 if cfg.ZIP != "" {690 place, err := store.Load(ctx, s.cache, "zip-"+cfg.ZIP, 365*24*time.Hour, false, func(ctx context.Context) (geo.Place, error) {691 return geo.LookupZIP(ctx, s.http, cfg.ZIP)692 })693 if err != nil {694 rep.Warnings = append(rep.Warnings, err.Error())695 ok = false696 } else {697 rep.Place, rep.Point = place, place.Point698 rep.Sources = append(rep.Sources, lineup.Source{Name: "Zippopotam.us", URL: "https://zippopotam.us/", Use: "Location lookup"})699 }700 }701 if cfg.Lat != 0 && cfg.Lon != 0 {702 rep.Point = geo.Point{Lat: cfg.Lat, Lon: cfg.Lon}703 }704 if rep.Point == (geo.Point{}) && ok && s.wx != nil {705 rep.Warnings = append(rep.Warnings, ErrNoLocation.Error())706 }707 return rep, ok708}709710func (s *Service) current(refresh bool) *Snapshot {711 s.mu.Lock()712 defer s.mu.Unlock()713 if refresh || s.report == nil || time.Since(s.built) > reportTTL {714 return nil715 }716 return &Snapshot{Report: s.report, Guide: s.guide}717}718719// Weather returns the local weather report.720func (s *Service) Weather(ctx context.Context) (*weather.Report, error) {721 snap, err := s.snapshot(ctx, false)722 if err != nil {723 return nil, err724 }725 if snap.Report.Point == (geo.Point{}) {726 return nil, ErrNoLocation727 }728 return s.weather.Report(ctx, snap.Report.Point)729}730731// WeatherHandler serves /wximg/<name> (radar loop and satellite images) and732// /weatherstar, which redirects to the WeatherStar 4000+ display set to the733// server's location so clients never need coordinates.734func (s *Service) WeatherHandler() http.Handler {735 mux := http.NewServeMux()736 mux.HandleFunc("GET /wximg/{name}", func(w http.ResponseWriter, r *http.Request) {737 snap, err := s.snapshot(r.Context(), false)738 if err == nil && snap.Report.Point == (geo.Point{}) {739 err = ErrNoLocation740 }741 if err != nil {742 http.Error(w, err.Error(), http.StatusServiceUnavailable)743 return744 }745 data, kind, err := s.weather.Image(r.Context(), snap.Report.Point, r.PathValue("name"))746 if err != nil {747 http.Error(w, err.Error(), http.StatusBadGateway)748 return749 }750 w.Header().Set("Content-Type", kind)751 w.Header().Set("Cache-Control", "max-age=120")752 _, _ = w.Write(data)753 })754 mux.HandleFunc("GET /weatherstar", func(w http.ResponseWriter, r *http.Request) {755 if !s.wxOn() {756 http.NotFound(w, r)757 return758 }759 target, err := s.weatherStarURL(r.Context(), s.weatherStarBase(r), r.URL.Query().Get("music") == "1")760 if err != nil {761 http.Error(w, err.Error(), http.StatusServiceUnavailable)762 return763 }764 w.Header().Set("Cache-Control", "no-store")765 http.Redirect(w, r, target, http.StatusFound)766 })767 // The app plays the weather channel's music itself, so muting it is768 // instant rather than a reload of the display.769 mux.HandleFunc("GET /weatherstar/music", func(w http.ResponseWriter, r *http.Request) {770 tracks := []string{}771 if wx := s.weatherMusic(); wx != nil {772 for _, t := range wx.Tracks(r.Context()) {773 if strings.HasPrefix(t, "http") {774 // The display's own tracks, where the client reaches it.775 if rest, ok := strings.CutPrefix(t, s.wxLocal); ok {776 t = s.weatherStarBase(r) + rest777 }778 tracks = append(tracks, t)779 } else {780 tracks = append(tracks, "/wxmusic/"+url.PathEscape(filepath.Base(t)))781 }782 }783 }784 w.Header().Set("Content-Type", "application/json")785 w.Header().Set("Access-Control-Allow-Origin", "*")786 _ = json.NewEncoder(w).Encode(map[string]any{"tracks": tracks})787 })788 mux.HandleFunc("GET /wxmusic/{name}", func(w http.ResponseWriter, r *http.Request) {789 name := r.PathValue("name")790 if s.opt.MusicDir == "" || name != filepath.Base(name) || !strings.HasSuffix(strings.ToLower(name), ".mp3") {791 http.NotFound(w, r)792 return793 }794 http.ServeFile(w, r, filepath.Join(s.opt.MusicDir, name))795 })796 return mux797}798799// weatherMusic is where the weather channel's music comes from.800func (s *Service) weatherMusic() *vchan.Weather { return s.wx }801802// weatherStarBase is the display's address for the client making r: the803// host it reached the server at, on the display's port.804func (s *Service) weatherStarBase(r *http.Request) string {805 host, _, err := net.SplitHostPort(r.Host)806 if err != nil {807 host = r.Host808 }809 return "http://" + net.JoinHostPort(host, strconv.Itoa(s.opt.WeatherStarPort))810}811812// Tune starts live playback of a channel for client.813func (s *Service) Tune(ctx context.Context, client, number string) (*stream.Playback, error) {814 if s.streamErr != nil {815 return nil, s.streamErr816 }817 if v := s.customChannel(number); v != nil {818 ctx = phase.NewContext(ctx, phase.New("tune "+number+" ("+vchan.KindOf(v)+")", vchan.KindOf(v)))819 // The app gets subtitles as text, a WebVTT track it shows itself,820 // turning it on for programs not in English, so none are burned in.821 var script *cc.Script822 if vchan.HasCaptions(v) {823 script = &cc.Script{}824 }825 in := tuner.Input{826 Args: []string{"-f", "mpegts", "-i", "pipe:0"}, Video: "0:v:0",827 Audio: []tuner.AudioTrack{{Map: "0:a:0"}}, Captions: script != nil, Subtitles: script,828 Source: func(ctx context.Context, w io.Writer) error { return v.Stream(vchan.ForApp(ctx, script), w) },829 }830 return s.streams.Start(ctx, client, in)831 }832 if s.opt.NoAntenna {833 return nil, fmt.Errorf("no channel %s: %w", number, ErrNoAntenna)834 }835 ctx = phase.NewContext(ctx, phase.New("tune "+number+" (antenna)", "antenna"))836 var pb *stream.Playback837 err := s.tuneAntenna(ctx, client, number, func(ctx context.Context, in tuner.Input) error {838 var err error839 pb, err = s.streams.Start(ctx, client, in)840 return err841 })842 return pb, err843}844845// Stop ends client's playback.846func (s *Service) Stop(_ context.Context, client string) error {847 if s.streams != nil {848 s.streams.Stop(client)849 }850 return nil851}852853// Heartbeat keeps client's stream (live TV or a recording) from being854// stopped as idle while its player holds it without fetching (paused),855// and reports whether client still has one.856func (s *Service) Heartbeat(_ context.Context, client string) (bool, error) {857 if s.streams == nil {858 return false, nil859 }860 return s.streams.Touch(client), nil861}862863// DVR returns rules, schedule and recordings.864func (s *Service) DVR(ctx context.Context) (*dvr.State, error) {865 if s.dvr == nil {866 return &dvr.State{Reason: s.noDVR().Error()}, nil867 }868 g, chans, _ := s.recordingChannels(ctx)869 return s.dvr.State(ctx, g, chans)870}871872// noDVR is why there's no recording.873func (s *Service) noDVR() error {874 if s.opt.NoAntenna {875 return ErrNoAntenna876 }877 return ErrNoDVR878}879880// Record adds a rule from a guide selection.881func (s *Service) Record(ctx context.Context, req dvr.Request) (*dvr.Rule, error) {882 // Custom channels replay what is already on the server (or render the883 // forecast live); there is nothing to record.884 if req.Channel == "WX" || s.customChannel(req.Channel) != nil ||885 s.wx != nil && vchan.SameNumber(req.Channel, s.wx.Number()) && s.wx.Details().Enabled {886 return nil, ErrNotRecordable887 }888 if s.dvr == nil {889 return nil, s.noDVR()890 }891 if _, err := s.snapshot(ctx, false); err != nil {892 return nil, err893 }894 g, chans, err := s.recordingChannels(ctx)895 if err != nil {896 return nil, err897 }898 return s.dvr.Add(ctx, req, g, chans)899}900901// DeleteRule removes a rule and its unstarted recordings.902func (s *Service) DeleteRule(ctx context.Context, id string) error {903 if s.dvr == nil {904 return ErrNoDVR905 }906 return s.dvr.DeleteRule(ctx, id)907}908909// DeleteRecording skips, cancels or deletes one recording.910func (s *Service) DeleteRecording(ctx context.Context, id string) error {911 if s.dvr == nil {912 return ErrNoDVR913 }914 return s.dvr.DeleteEntry(ctx, id)915}916917// PlayRecording streams a recording to client, from seconds in (zero918// plays from the beginning). One still recording plays as far as it has919// got, and on as it grows.920func (s *Service) PlayRecording(ctx context.Context, client, id string, from float64) (*stream.Playback, error) {921 if s.dvr == nil {922 return nil, s.noDVR()923 }924 if s.streamErr != nil {925 return nil, s.streamErr926 }927 r, path, ok := s.dvr.Get(id)928 if !ok {929 return nil, fmt.Errorf("no recording %s", id)930 }931 if r.Status == dvr.Failed {932 return nil, errors.New("that recording failed and can't be played")933 }934 audio, err := s.streams.ProbeAudio(ctx, path)935 if err != nil || len(audio) == 0 {936 audio = []tuner.AudioTrack{{Map: "0:a:0"}}937 }938 var args []string939 if from <= 5 || from >= r.Duration(time.Now()).Seconds() {940 from = 0941 } else {942 args = append(args, "-ss", strconv.FormatFloat(from, 'f', 1, 64))943 }944 if r.Status == dvr.Recording {945 args = append(args, "-follow", "1")946 }947 args = append(args, "-i", path)948 in := tuner.Input{Args: args, Video: "0:v:0", Audio: audio, Broadcast: true, VOD: true, Offset: from}949 return s.streams.Start(ctx, client, in)950}951952// StopRecording ends a recording in progress early, keeping what it953// recorded.954func (s *Service) StopRecording(_ context.Context, id string) error {955 if s.dvr == nil {956 return s.noDVR()957 }958 return s.dvr.Stop(id)959}960961// ExtendRecording makes a recording in progress run minutes longer.962func (s *Service) ExtendRecording(_ context.Context, id string, minutes int) (time.Time, error) {963 if s.dvr == nil {964 return time.Time{}, s.noDVR()965 }966 return s.dvr.Extend(id, time.Duration(minutes)*time.Minute)967}968969// SaveProgress records how far a recording has been watched.970func (s *Service) SaveProgress(_ context.Context, id string, position, duration float64) error {971 if s.dvr == nil {972 return ErrNoDVR973 }974 return s.dvr.SaveProgress(id, position, duration)975}976977// MarkWatched sets a recording's watched state.978func (s *Service) MarkWatched(_ context.Context, id string, watched bool) error {979 if s.dvr == nil {980 return ErrNoDVR981 }982 return s.dvr.MarkWatched(id, watched)983}984985// UpdateRule changes a series rule's keep count and new-only setting.986func (s *Service) UpdateRule(_ context.Context, id string, u dvr.RuleUpdate) (*dvr.Rule, error) {987 if s.dvr == nil {988 return nil, ErrNoDVR989 }990 return s.dvr.UpdateRule(id, u)991}992993// SetDVRPrefs changes library-wide recording settings.994func (s *Service) SetDVRPrefs(_ context.Context, p dvr.Prefs) (dvr.Prefs, error) {995 if s.dvr == nil {996 return p, ErrNoDVR997 }998 return s.dvr.SetPrefs(p)999}10001001// recordingChannels is the listings and the tuner's channels, for1002// recording.1003func (s *Service) recordingChannels(ctx context.Context) (*guide.Guide, []lineup.Channel, error) {1004 s.mu.Lock()1005 rep, g := s.report, s.guide1006 s.mu.Unlock()1007 chans, _, err := s.matched(ctx, rep, g)1008 return g, dvrChannels(chans), err1009}10101011// Run keeps a server healthy until ctx ends: refreshes listings, finds the1012// tuner and keeps up with it, measures the signal while tuners are in use,1013// makes recordings, and keeps the podcasts up to date.1014func (s *Service) Run(ctx context.Context) {1015 if s.listings != nil {1016 go s.listings.Run(ctx)1017 }1018 if s.podcasts != nil {1019 go s.podcasts.Run(ctx)1020 }1021 if s.tuners != nil {1022 s.findTuner(ctx)1023 go s.sample(ctx)1024 if s.dvr != nil {1025 go s.dvr.Run(ctx, s.recordingChannels)1026 }1027 }1028 tick := time.NewTicker(time.Minute)1029 defer tick.Stop()1030 for n := 0; ; n++ {1031 s.maintain(ctx, n%10 == 0)1032 select {1033 case <-ctx.Done():1034 return1035 case <-tick.C:1036 }1037 }1038}10391040// maintain runs once a minute.1041func (s *Service) maintain(ctx context.Context, prune bool) {1042 s.checkWeatherStar(ctx)1043 if _, err := s.snapshot(ctx, false); err != nil {1044 log.Printf("snapshot: %v", err)1045 }1046 if s.tuners == nil {1047 return1048 }1049 s.findTuner(ctx)1050 if prune && s.dvr != nil {1051 if err := s.dvr.Retain(ctx); err != nil {1052 log.Printf("retain recordings: %v", err)1053 }1054 if err := s.dvr.Prune(ctx); err != nil {1055 log.Printf("prune recordings: %v", err)1056 }1057 }1058}