1package api23import (4 "bytes"5 "context"6 "encoding/json"7 "fmt"8 "io"9 "net/http"10 "net/url"11 "strconv"12 "strings"13 "time"1415 "airwaves/internal/dvr"16 "airwaves/internal/podcast"17 "airwaves/internal/service"18 "airwaves/internal/stream"19 "airwaves/internal/weather"20)2122// Client is a Backend backed by a remote airwavesd.23type Client struct {24 Base string // e.g. http://nas:808925 Token string26 HTTP *http.Client27}2829// NewClient returns a client for base. A bare host ("nas") gets http://30// and the default port; full URLs are used as given.31func NewClient(base, token string) *Client {32 base = strings.TrimRight(strings.TrimSpace(base), "/")33 if !strings.Contains(base, "://") {34 base = "http://" + base35 if u, err := url.Parse(base); err == nil && u.Port() == "" {36 u.Host += ":" + strconv.Itoa(DefaultPort)37 base = u.String()38 }39 }40 // Generous timeout: a first snapshot on a fresh server samples terrain41 // and downloads listings.42 return &Client{Base: base, Token: token, HTTP: &http.Client{Timeout: 2 * time.Minute}}43}4445// DefaultPort is where airwavesd listens.46const DefaultPort = 80894748// Info implements Backend.49func (c *Client) Info(ctx context.Context) (service.Info, error) {50 var i service.Info51 return i, c.call(ctx, http.MethodGet, "/api/info", nil, &i)52}5354// Config implements Backend.55func (c *Client) Config(ctx context.Context) (service.Config, error) {56 var cfg service.Config57 return cfg, c.call(ctx, http.MethodGet, "/api/config", nil, &cfg)58}5960// SetConfig implements Backend.61func (c *Client) SetConfig(ctx context.Context, cfg service.Config) (service.Config, error) {62 var out service.Config63 return out, c.call(ctx, http.MethodPut, "/api/config", cfg, &out)64}6566// Snapshot implements Backend.67func (c *Client) Snapshot(ctx context.Context, refresh bool) (*service.Snapshot, error) {68 p := "/api/snapshot"69 if refresh {70 p += "?refresh=1"71 }72 var s service.Snapshot73 return &s, c.call(ctx, http.MethodGet, p, nil, &s)74}7576// Signal implements Backend.77func (c *Client) Signal(ctx context.Context) (*service.SignalReport, error) {78 var r service.SignalReport79 return &r, c.call(ctx, http.MethodGet, "/api/signal", nil, &r)80}8182// Measure implements Backend.83func (c *Client) Measure(ctx context.Context) (*service.SweepStatus, error) {84 var st service.SweepStatus85 return &st, c.call(ctx, http.MethodPost, "/api/signal/measure", nil, &st)86}8788// Weather implements Backend.89func (c *Client) Weather(ctx context.Context) (*weather.Report, error) {90 var r weather.Report91 return &r, c.call(ctx, http.MethodGet, "/api/weather", nil, &r)92}9394// Tune implements Backend.95func (c *Client) Tune(ctx context.Context, client, number string) (*stream.Playback, error) {96 var pb stream.Playback97 if err := c.call(ctx, http.MethodPost, "/api/tune", clientReq{Client: client, Number: number}, &pb); err != nil {98 return nil, err99 }100 pb.URL = c.Base + pb.Path101 return &pb, nil102}103104// Stop implements Backend.105func (c *Client) Stop(ctx context.Context, client string) error {106 return c.call(ctx, http.MethodPost, "/api/stop", clientReq{Client: client}, nil)107}108109// Heartbeat implements Backend.110func (c *Client) Heartbeat(ctx context.Context, client string) (bool, error) {111 var out heartbeatReply112 err := c.call(ctx, http.MethodPost, "/api/heartbeat", clientReq{Client: client}, &out)113 return out.Stream, err114}115116// DVR implements Backend.117func (c *Client) DVR(ctx context.Context) (*dvr.State, error) {118 var s dvr.State119 return &s, c.call(ctx, http.MethodGet, "/api/dvr", nil, &s)120}121122// Record implements Backend.123func (c *Client) Record(ctx context.Context, req dvr.Request) (*dvr.Rule, error) {124 var r dvr.Rule125 return &r, c.call(ctx, http.MethodPost, "/api/dvr/record", req, &r)126}127128// DeleteRule implements Backend.129func (c *Client) DeleteRule(ctx context.Context, id string) error {130 return c.call(ctx, http.MethodDelete, "/api/dvr/rules/"+url.PathEscape(id), nil, nil)131}132133// DeleteRecording implements Backend.134func (c *Client) DeleteRecording(ctx context.Context, id string) error {135 return c.call(ctx, http.MethodDelete, "/api/dvr/recordings/"+url.PathEscape(id), nil, nil)136}137138// StopRecording implements Backend.139func (c *Client) StopRecording(ctx context.Context, id string) error {140 return c.call(ctx, http.MethodPost, "/api/dvr/recordings/"+url.PathEscape(id)+"/stop", struct{}{}, nil)141}142143// ExtendRecording implements Backend.144func (c *Client) ExtendRecording(ctx context.Context, id string, minutes int) (time.Time, error) {145 var out extendReq146 err := c.call(ctx, http.MethodPost, "/api/dvr/recordings/"+url.PathEscape(id)+"/extend", extendReq{Minutes: minutes}, &out)147 return out.Until, err148}149150// ScanChannels implements Backend.151func (c *Client) ScanChannels(ctx context.Context) (service.TunerInfo, error) {152 var out service.TunerInfo153 return out, c.call(ctx, http.MethodPost, "/api/tuner/scan", struct{}{}, &out)154}155156// PlayRecording implements Backend.157func (c *Client) PlayRecording(ctx context.Context, client, id string, from float64) (*stream.Playback, error) {158 var pb stream.Playback159 if err := c.call(ctx, http.MethodPost, "/api/dvr/play", clientReq{Client: client, ID: id, From: from}, &pb); err != nil {160 return nil, err161 }162 pb.URL = c.Base + pb.Path163 return &pb, nil164}165166// SaveProgress implements Backend.167func (c *Client) SaveProgress(ctx context.Context, id string, position, duration float64) error {168 return c.call(ctx, http.MethodPost, "/api/dvr/progress", progressReq{ID: id, Position: position, Duration: duration}, nil)169}170171// MarkWatched implements Backend.172func (c *Client) MarkWatched(ctx context.Context, id string, watched bool) error {173 return c.call(ctx, http.MethodPost, "/api/dvr/progress", progressReq{ID: id, Watched: &watched}, nil)174}175176// UpdateRule implements Backend.177func (c *Client) UpdateRule(ctx context.Context, id string, u dvr.RuleUpdate) (*dvr.Rule, error) {178 var r dvr.Rule179 return &r, c.call(ctx, http.MethodPut, "/api/dvr/rules/"+url.PathEscape(id), u, &r)180}181182// SetDVRPrefs implements Backend.183func (c *Client) SetDVRPrefs(ctx context.Context, p dvr.Prefs) (dvr.Prefs, error) {184 var out dvr.Prefs185 return out, c.call(ctx, http.MethodPut, "/api/dvr/prefs", p, &out)186}187188func (c *Client) call(ctx context.Context, method, path string, in, out any) error {189 var body io.Reader190 if in != nil {191 b, err := json.Marshal(in)192 if err != nil {193 return err194 }195 body = bytes.NewReader(b)196 }197 req, err := http.NewRequestWithContext(ctx, method, c.Base+path, body)198 if err != nil {199 return err200 }201 if in != nil {202 req.Header.Set("Content-Type", "application/json")203 }204 if c.Token != "" {205 req.Header.Set("Authorization", "Bearer "+c.Token)206 }207 resp, err := c.HTTP.Do(req)208 if err != nil {209 return fmt.Errorf("airwaves server: %w", err)210 }211 defer resp.Body.Close()212 if resp.StatusCode != http.StatusOK {213 var e errorBody214 if json.NewDecoder(resp.Body).Decode(&e) == nil && e.Error != "" {215 return fmt.Errorf("%s", e.Error)216 }217 return fmt.Errorf("airwaves server: HTTP %d", resp.StatusCode)218 }219 if out == nil {220 return nil221 }222 return json.NewDecoder(resp.Body).Decode(out)223}224225// Podcasts implements Backend.226func (c *Client) Podcasts(ctx context.Context) (*podcast.State, error) {227 var s podcast.State228 return &s, c.call(ctx, http.MethodGet, "/api/podcasts", nil, &s)229}230231// RefreshPodcasts implements Backend.232func (c *Client) RefreshPodcasts(ctx context.Context) (*podcast.State, error) {233 var s podcast.State234 return &s, c.call(ctx, http.MethodPost, "/api/podcasts/refresh", nil, &s)235}236237// SearchPodcasts implements Backend.238func (c *Client) SearchPodcasts(ctx context.Context, query string) ([]podcast.Found, error) {239 var out []podcast.Found240 return out, c.call(ctx, http.MethodGet, "/api/podcasts/search?q="+url.QueryEscape(query), nil, &out)241}242243// AddPodcast implements Backend.244func (c *Client) AddPodcast(ctx context.Context, u string, minMinutes float64) (*podcast.Show, error) {245 var s podcast.Show246 if err := c.call(ctx, http.MethodPost, "/api/podcasts/shows", showReq{URL: u, MinMinutes: minMinutes}, &s); err != nil {247 return nil, err248 }249 return &s, nil250}251252// RemovePodcast implements Backend.253func (c *Client) RemovePodcast(ctx context.Context, id string) error {254 return c.call(ctx, http.MethodDelete, "/api/podcasts/shows/"+url.PathEscape(id), nil, nil)255}256257// PlayEpisode implements Backend.258func (c *Client) PlayEpisode(ctx context.Context, client, id string, from float64) (*stream.Playback, error) {259 var pb stream.Playback260 if err := c.call(ctx, http.MethodPost, "/api/podcasts/play", clientReq{Client: client, ID: id, From: from}, &pb); err != nil {261 return nil, err262 }263 pb.URL = c.Base + pb.Path264 return &pb, nil265}266267// EpisodeProgress implements Backend.268func (c *Client) EpisodeProgress(ctx context.Context, id string, position, duration float64, watched bool) error {269 return c.call(ctx, http.MethodPost, "/api/podcasts/progress",270 episodeProgressReq{ID: id, Position: position, Duration: duration, Watched: watched}, nil)271}