1// Package api exposes the Airwaves engine over HTTP (airwavesd) and2// provides a client with the same shape, so the desktop app can use a local3// engine or a remote server interchangeably.4package api56import (7 "compress/gzip"8 "context"9 "crypto/sha256"10 "crypto/subtle"11 "encoding/json"12 "errors"13 "io"14 "io/fs"15 "log"16 "net/http"17 "strings"18 "time"1920 "airwaves/internal/dvr"21 "airwaves/internal/phase"22 "airwaves/internal/podcast"23 "airwaves/internal/service"24 "airwaves/internal/stream"25 "airwaves/internal/tuner"26 "airwaves/internal/weather"27)2829// Backend is what a UI needs from an engine.30type Backend interface {31 Info(ctx context.Context) (service.Info, error)32 Config(ctx context.Context) (service.Config, error)33 SetConfig(ctx context.Context, c service.Config) (service.Config, error)34 Snapshot(ctx context.Context, refresh bool) (*service.Snapshot, error)35 // Signal is what the tuners measured, by RF channel and by channel.36 Signal(ctx context.Context) (*service.SignalReport, error)37 // Measure starts measuring every RF channel on an idle tuner.38 Measure(ctx context.Context) (*service.SweepStatus, error)39 // ScanChannels has the tuner scan for channels again.40 ScanChannels(ctx context.Context) (service.TunerInfo, error)41 Weather(ctx context.Context) (*weather.Report, error)42 Tune(ctx context.Context, client, number string) (*stream.Playback, error)43 Stop(ctx context.Context, client string) error44 // Heartbeat keeps client's stream from being stopped as idle, and45 // reports whether it still has one.46 Heartbeat(ctx context.Context, client string) (bool, error)47 DVR(ctx context.Context) (*dvr.State, error)48 Record(ctx context.Context, req dvr.Request) (*dvr.Rule, error)49 DeleteRule(ctx context.Context, id string) error50 DeleteRecording(ctx context.Context, id string) error51 // StopRecording ends a recording in progress, keeping it;52 // ExtendRecording makes one run longer.53 StopRecording(ctx context.Context, id string) error54 ExtendRecording(ctx context.Context, id string, minutes int) (time.Time, error)55 PlayRecording(ctx context.Context, client, id string, from float64) (*stream.Playback, error)56 SaveProgress(ctx context.Context, id string, position, duration float64) error57 MarkWatched(ctx context.Context, id string, watched bool) error58 UpdateRule(ctx context.Context, id string, u dvr.RuleUpdate) (*dvr.Rule, error)59 SetDVRPrefs(ctx context.Context, p dvr.Prefs) (dvr.Prefs, error)60 // The Podcasts app: shows and their episodes, YouTube channels found61 // to add, and an episode played from a point, with how far it was62 // watched.63 Podcasts(ctx context.Context) (*podcast.State, error)64 RefreshPodcasts(ctx context.Context) (*podcast.State, error)65 SearchPodcasts(ctx context.Context, query string) ([]podcast.Found, error)66 AddPodcast(ctx context.Context, url string, minMinutes float64) (*podcast.Show, error)67 RemovePodcast(ctx context.Context, id string) error68 PlayEpisode(ctx context.Context, client, id string, from float64) (*stream.Playback, error)69 EpisodeProgress(ctx context.Context, id string, position, duration float64, watched bool) error70}7172var (73 _ Backend = (*service.Service)(nil)74 _ Backend = (*Client)(nil)75)7677// Server serves a Backend.78type Server struct {79 Backend Backend80 // Streams serves /live/ HLS files.81 Streams http.Handler82 // Extra serves other unauthenticated paths (weather images, the83 // WeatherStar redirect).84 Extra http.Handler85 // Logos serves custom channels' logos at /channel-logos/<number>, and86 // the guide's pictures at /guide-art/, unauthenticated like Extra.87 Logos http.Handler88 // Token, when set, must be sent as "Authorization: Bearer <token>" on89 // /api/ requests. Stream URLs are unguessable and stay open so native90 // players can fetch them.91 Token string92 // Admin serves the owner's channel admin page at /admin/. Without an93 // AdminPassword it is only as private as the address the server94 // listens on.95 Admin http.Handler96 // AdminPassword, when set, is asked for on /admin/ (the page, its API97 // and the MCP endpoint at /admin/mcp): as HTTP Basic auth with any user98 // name, or as "Authorization: Bearer <password>" from MCP clients.99 AdminPassword string100 // TV is the TV interface (frontend/dist), served at /tv/ for browsers101 // and the Android TV app. The page needs no token; the API it calls102 // does.103 TV fs.FS104}105106type clientReq struct {107 Client string `json:"client"`108 Number string `json:"number,omitempty"`109 ID string `json:"id,omitempty"`110 From float64 `json:"from,omitempty"`111}112113// metricsReq is a channel change as the app timed it.114type metricsReq struct {115 Number string `json:"number"`116 // Kind is the channel's: "antenna", "folder", "jellyfin", "youtube"117 // or "weather".118 Kind string `json:"kind"`119 FirstFrameMS float64 `json:"firstFrameMs"`120}121122type progressReq struct {123 ID string `json:"id"`124 Position float64 `json:"position"`125 Duration float64 `json:"duration"`126 Watched *bool `json:"watched,omitempty"`127}128129// showReq adds a show: a YouTube channel, its live streams' tab or a130// playlist, with the shortest video that's an episode (0 for the default).131type showReq struct {132 URL string `json:"url"`133 MinMinutes float64 `json:"minMinutes,omitempty"`134}135136// episodeProgressReq is how far an episode was watched.137type episodeProgressReq struct {138 ID string `json:"id"`139 Position float64 `json:"position"`140 Duration float64 `json:"duration"`141 Watched bool `json:"watched"`142}143144// heartbeatReply says whether the client still has a stream.145type heartbeatReply struct {146 Stream bool `json:"stream"`147}148149// extendReq extends a recording by Minutes; the reply says Until when.150type extendReq struct {151 Minutes int `json:"minutes,omitempty"`152 Until time.Time `json:"until,omitzero"`153}154155// Handler routes requests.156func (s *Server) Handler() http.Handler {157 b := s.Backend158 mux := http.NewServeMux()159 mux.HandleFunc("GET /api/info", func(w http.ResponseWriter, r *http.Request) {160 reply(w)(b.Info(r.Context()))161 })162 mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) {163 reply(w)(b.Config(r.Context()))164 })165 // Apps change only the hours of listings: the location is the166 // server's own, from its flags.167 mux.HandleFunc("PUT /api/config", func(w http.ResponseWriter, r *http.Request) {168 var c service.Config169 if !decode(w, r, &c) {170 return171 }172 cur, err := b.Config(r.Context())173 if err != nil {174 reply(w)(nil, err)175 return176 }177 cur.GuideHours = c.GuideHours178 reply(w)(b.SetConfig(r.Context(), cur))179 })180 mux.HandleFunc("GET /api/snapshot", func(w http.ResponseWriter, r *http.Request) {181 reply(w)(b.Snapshot(r.Context(), r.URL.Query().Get("refresh") == "1"))182 })183 mux.HandleFunc("GET /api/signal", func(w http.ResponseWriter, r *http.Request) {184 reply(w)(b.Signal(r.Context()))185 })186 mux.HandleFunc("POST /api/signal/measure", func(w http.ResponseWriter, r *http.Request) {187 reply(w)(b.Measure(r.Context()))188 })189 mux.HandleFunc("POST /api/tuner/scan", func(w http.ResponseWriter, r *http.Request) {190 reply(w)(b.ScanChannels(r.Context()))191 })192 mux.HandleFunc("GET /api/weather", func(w http.ResponseWriter, r *http.Request) {193 reply(w)(b.Weather(r.Context()))194 })195 mux.HandleFunc("POST /api/tune", func(w http.ResponseWriter, r *http.Request) {196 var req clientReq197 if !decode(w, r, &req) {198 return199 }200 reply(w)(b.Tune(r.Context(), req.Client, req.Number))201 })202 mux.HandleFunc("POST /api/stop", func(w http.ResponseWriter, r *http.Request) {203 var req clientReq204 if !decode(w, r, &req) {205 return206 }207 reply(w)(struct{}{}, b.Stop(r.Context(), req.Client))208 })209 // A player holding a stream without fetching from it (paused) says210 // so every half minute or so; a stream idle two minutes is stopped.211 mux.HandleFunc("POST /api/heartbeat", func(w http.ResponseWriter, r *http.Request) {212 var req clientReq213 if !decode(w, r, &req) {214 return215 }216 on, err := b.Heartbeat(r.Context(), req.Client)217 reply(w)(heartbeatReply{Stream: on}, err)218 })219 // The app reports how long its channel changes took, from the key220 // press to the first frame playing, for the admin page.221 mux.HandleFunc("POST /api/metrics", func(w http.ResponseWriter, r *http.Request) {222 var req metricsReq223 if !decode(w, r, &req) {224 return225 }226 phase.Tunes.Add(req.Kind, phase.FirstFrame, time.Duration(req.FirstFrameMS*float64(time.Millisecond)))227 reply(w)(struct{}{}, nil)228 })229 mux.HandleFunc("GET /api/metrics", func(w http.ResponseWriter, r *http.Request) {230 reply(w)(phase.Tunes.Summaries(), nil)231 })232 mux.HandleFunc("GET /api/dvr", func(w http.ResponseWriter, r *http.Request) {233 reply(w)(b.DVR(r.Context()))234 })235 mux.HandleFunc("POST /api/dvr/record", func(w http.ResponseWriter, r *http.Request) {236 var req dvr.Request237 if !decode(w, r, &req) {238 return239 }240 reply(w)(b.Record(r.Context(), req))241 })242 mux.HandleFunc("DELETE /api/dvr/rules/{id}", func(w http.ResponseWriter, r *http.Request) {243 reply(w)(struct{}{}, b.DeleteRule(r.Context(), r.PathValue("id")))244 })245 mux.HandleFunc("DELETE /api/dvr/recordings/{id}", func(w http.ResponseWriter, r *http.Request) {246 reply(w)(struct{}{}, b.DeleteRecording(r.Context(), r.PathValue("id")))247 })248 mux.HandleFunc("POST /api/dvr/recordings/{id}/stop", func(w http.ResponseWriter, r *http.Request) {249 reply(w)(struct{}{}, b.StopRecording(r.Context(), r.PathValue("id")))250 })251 mux.HandleFunc("POST /api/dvr/recordings/{id}/extend", func(w http.ResponseWriter, r *http.Request) {252 var req extendReq253 if !decode(w, r, &req) {254 return255 }256 until, err := b.ExtendRecording(r.Context(), r.PathValue("id"), req.Minutes)257 reply(w)(extendReq{Until: until}, err)258 })259 mux.HandleFunc("POST /api/dvr/play", func(w http.ResponseWriter, r *http.Request) {260 var req clientReq261 if !decode(w, r, &req) {262 return263 }264 reply(w)(b.PlayRecording(r.Context(), req.Client, req.ID, req.From))265 })266 mux.HandleFunc("POST /api/dvr/progress", func(w http.ResponseWriter, r *http.Request) {267 var req progressReq268 if !decode(w, r, &req) {269 return270 }271 if req.Watched != nil {272 reply(w)(struct{}{}, b.MarkWatched(r.Context(), req.ID, *req.Watched))273 return274 }275 reply(w)(struct{}{}, b.SaveProgress(r.Context(), req.ID, req.Position, req.Duration))276 })277 mux.HandleFunc("PUT /api/dvr/rules/{id}", func(w http.ResponseWriter, r *http.Request) {278 var u dvr.RuleUpdate279 if !decode(w, r, &u) {280 return281 }282 reply(w)(b.UpdateRule(r.Context(), r.PathValue("id"), u))283 })284 mux.HandleFunc("PUT /api/dvr/prefs", func(w http.ResponseWriter, r *http.Request) {285 var p dvr.Prefs286 if !decode(w, r, &p) {287 return288 }289 reply(w)(b.SetDVRPrefs(r.Context(), p))290 })291 mux.HandleFunc("GET /api/podcasts", func(w http.ResponseWriter, r *http.Request) {292 reply(w)(b.Podcasts(r.Context()))293 })294 mux.HandleFunc("POST /api/podcasts/refresh", func(w http.ResponseWriter, r *http.Request) {295 reply(w)(b.RefreshPodcasts(r.Context()))296 })297 mux.HandleFunc("GET /api/podcasts/search", func(w http.ResponseWriter, r *http.Request) {298 reply(w)(b.SearchPodcasts(r.Context(), r.URL.Query().Get("q")))299 })300 mux.HandleFunc("POST /api/podcasts/shows", func(w http.ResponseWriter, r *http.Request) {301 var req showReq302 if !decode(w, r, &req) {303 return304 }305 reply(w)(b.AddPodcast(r.Context(), req.URL, req.MinMinutes))306 })307 mux.HandleFunc("DELETE /api/podcasts/shows/{id}", func(w http.ResponseWriter, r *http.Request) {308 reply(w)(struct{}{}, b.RemovePodcast(r.Context(), r.PathValue("id")))309 })310 mux.HandleFunc("POST /api/podcasts/play", func(w http.ResponseWriter, r *http.Request) {311 var req clientReq312 if !decode(w, r, &req) {313 return314 }315 reply(w)(b.PlayEpisode(r.Context(), req.Client, req.ID, req.From))316 })317 mux.HandleFunc("POST /api/podcasts/progress", func(w http.ResponseWriter, r *http.Request) {318 var req episodeProgressReq319 if !decode(w, r, &req) {320 return321 }322 reply(w)(struct{}{}, b.EpisodeProgress(r.Context(), req.ID, req.Position, req.Duration, req.Watched))323 })324 if s.Streams != nil {325 mux.Handle("/live/", s.Streams)326 }327 if s.Extra != nil {328 mux.Handle("/wximg/", s.Extra)329 mux.Handle("/weatherstar", s.Extra)330 mux.Handle("/weatherstar/", s.Extra)331 mux.Handle("/wxmusic/", s.Extra)332 }333 if s.Logos != nil {334 mux.Handle("/channel-logos/", s.Logos)335 mux.Handle("/guide-art/", s.Logos)336 }337 if s.Admin != nil {338 admin := s.Admin339 if s.AdminPassword != "" {340 admin = adminAuth(s.AdminPassword, admin)341 }342 mux.Handle("/admin/", admin)343 mux.Handle("GET /admin", http.RedirectHandler("/admin/", http.StatusFound))344 }345 if s.TV != nil {346 mux.Handle("GET /tv/", http.StripPrefix("/tv", tvFiles(s.TV)))347 mux.Handle("GET /tv", http.RedirectHandler("/tv/", http.StatusFound))348 // The server's own address leads to the TV app, as a name of its349 // own (a Tailscale Service, say) would be expected to.350 mux.Handle("GET /{$}", http.RedirectHandler("/tv/", http.StatusFound))351 }352 // For container health checks: up, without the token.353 mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {354 _, _ = io.WriteString(w, "ok\n")355 })356 // A page on another site can't make a browser here change anything:357 // cross-site POSTs, PUTs and DELETEs are refused. The app's own page,358 // the desktop app and other non-browser clients aren't affected.359 return http.NewCrossOriginProtection().Handler(s.auth(gzipJSON(mux)))360}361362// adminAuth asks for the admin password. Both sides are hashed before the363// constant-time compare, so neither the password nor its length leaks364// through timing. Wrong passwords are logged with the client's address,365// never the password.366func adminAuth(password string, next http.Handler) http.Handler {367 want := sha256.Sum256([]byte(password))368 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {369 got, sent := "", true370 if _, p, ok := r.BasicAuth(); ok {371 got = p372 } else if b, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok {373 got = b374 } else {375 sent = false376 }377 h := sha256.Sum256([]byte(got))378 if !sent || subtle.ConstantTimeCompare(h[:], want[:]) != 1 {379 if sent {380 log.Printf("admin: wrong password from %s", r.RemoteAddr)381 }382 w.Header().Set("WWW-Authenticate", `Basic realm="Airwaves admin", charset="UTF-8"`)383 http.Error(w, "The admin page needs its password.", http.StatusUnauthorized)384 return385 }386 next.ServeHTTP(w, r)387 })388}389390func (s *Server) auth(next http.Handler) http.Handler {391 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {392 if s.Token != "" && strings.HasPrefix(r.URL.Path, "/api/") {393 got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")394 if subtle.ConstantTimeCompare([]byte(got), []byte(s.Token)) != 1 {395 writeErr(w, http.StatusUnauthorized, errors.New("missing or wrong token"))396 return397 }398 }399 next.ServeHTTP(w, r)400 })401}402403func decode(w http.ResponseWriter, r *http.Request, v any) bool {404 if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(v); err != nil {405 writeErr(w, http.StatusBadRequest, err)406 return false407 }408 return true409}410411// reply writes a value or an error. Used as reply(w)(f()).412func reply(w http.ResponseWriter) func(any, error) {413 return func(v any, err error) {414 if err != nil {415 writeErr(w, errStatus(err), err)416 return417 }418 w.Header().Set("Content-Type", "application/json")419 _ = json.NewEncoder(w).Encode(v)420 }421}422423// errStatus is the HTTP status for a backend error: 501 for what this424// server doesn't have (an antenna, a tuner, recording, a location for the425// weather, podcasts), 503 for a channel with no signal or no free tuner426// right now, 400 for what isn't a show, 404 for a show or an episode427// there isn't, else 500.428func errStatus(err error) int {429 var noSignal *service.NoSignalError430 var busy *tuner.BusyError431 switch {432 case errors.Is(err, service.ErrNoAntenna), errors.Is(err, service.ErrNoTuner), errors.Is(err, service.ErrNoDVR), errors.Is(err, service.ErrNoLocation),433 errors.Is(err, service.ErrNoPodcasts):434 return http.StatusNotImplemented435 case errors.Is(err, podcast.ErrBadShow):436 return http.StatusBadRequest437 case errors.Is(err, podcast.ErrNotFound):438 return http.StatusNotFound439 case errors.As(err, &noSignal), errors.As(err, &busy):440 return http.StatusServiceUnavailable441 }442 return http.StatusInternalServerError443}444445type errorBody struct {446 Error string `json:"error"`447}448449func writeErr(w http.ResponseWriter, code int, err error) {450 w.Header().Set("Content-Type", "application/json")451 w.WriteHeader(code)452 _ = json.NewEncoder(w).Encode(errorBody{Error: err.Error()})453}454455// gzipJSON compresses API responses; snapshots are a few megabytes of JSON.456func gzipJSON(next http.Handler) http.Handler {457 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {458 if !strings.HasPrefix(r.URL.Path, "/api/") || !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {459 next.ServeHTTP(w, r)460 return461 }462 w.Header().Set("Content-Encoding", "gzip")463 w.Header().Add("Vary", "Accept-Encoding")464 gz := gzip.NewWriter(w)465 defer gz.Close()466 next.ServeHTTP(gzipWriter{ResponseWriter: w, w: gz}, r)467 })468}469470type gzipWriter struct {471 http.ResponseWriter472 w io.Writer473}474475func (g gzipWriter) Write(b []byte) (int, error) { return g.w.Write(b) }