adding radar support and caching the gif as well as invalidating cache on location change
This commit is contained in:
parent
430a51b659
commit
524d30c797
@ -39,6 +39,12 @@ go run . --clear-cache
|
|||||||
# Show all icons used by the app
|
# Show all icons used by the app
|
||||||
go run . -i
|
go run . -i
|
||||||
go run . --icon-test
|
go run . --icon-test
|
||||||
|
|
||||||
|
# Display radar loop for the current location (requires mpv)
|
||||||
|
go run . -R
|
||||||
|
go run . --radar
|
||||||
|
|
||||||
|
Radar mode caches the GIF on disk (10 minute TTL) under the cache directory.
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: `go run main.go` only compiles that file. Use `go run .` to include the whole package.
|
Note: `go run main.go` only compiles that file. Use `go run .` to include the whole package.
|
||||||
|
|||||||
30
cache.go
30
cache.go
@ -44,7 +44,10 @@ func (c *Cache) SaveLocation(query string, loc Location) error {
|
|||||||
Location: loc,
|
Location: loc,
|
||||||
UpdatedAt: time.Now().UTC(),
|
UpdatedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
return writeJSON(filepath.Join(c.dir, locationCacheFile), payload)
|
if err := writeJSON(filepath.Join(c.dir, locationCacheFile), payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.ClearForecastAndRadar()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cache) LoadLocation() (Location, bool, error) {
|
func (c *Cache) LoadLocation() (Location, bool, error) {
|
||||||
@ -59,6 +62,21 @@ func (c *Cache) LoadLocation() (Location, bool, error) {
|
|||||||
return payload.Location, true, nil
|
return payload.Location, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Cache) UpdateLocation(loc Location) error {
|
||||||
|
path := filepath.Join(c.dir, locationCacheFile)
|
||||||
|
var payload LocationCache
|
||||||
|
if err := readJSON(path, &payload); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("no cached location found to update")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.Location = loc
|
||||||
|
payload.UpdatedAt = time.Now().UTC()
|
||||||
|
return writeJSON(path, payload)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Cache) SaveForecast(loc Location, unit string, period ForecastPeriod) error {
|
func (c *Cache) SaveForecast(loc Location, unit string, period ForecastPeriod) error {
|
||||||
if err := c.ensureDir(); err != nil {
|
if err := c.ensureDir(); err != nil {
|
||||||
return err
|
return err
|
||||||
@ -109,9 +127,19 @@ func (c *Cache) Clear() error {
|
|||||||
if err := os.Remove(filepath.Join(c.dir, locationCacheFile)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
if err := os.Remove(filepath.Join(c.dir, locationCacheFile)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return c.ClearForecastAndRadar()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) ClearForecastAndRadar() error {
|
||||||
|
if c.dir == "" {
|
||||||
|
return fmt.Errorf("cache directory is empty")
|
||||||
|
}
|
||||||
if err := os.Remove(filepath.Join(c.dir, forecastCacheFile)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
if err := os.Remove(filepath.Join(c.dir, forecastCacheFile)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := os.Remove(filepath.Join(c.dir, "radar.gif")); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,58 @@ func TestCacheLocationRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCacheUpdateLocation(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
cache := NewCache(tmp)
|
||||||
|
loc := Location{Lat: 1.23, Lon: 4.56, City: "Testville", State: "TS"}
|
||||||
|
if err := cache.SaveLocation("Testville", loc); err != nil {
|
||||||
|
t.Fatalf("save location failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loc.RadarStation = "KBOX"
|
||||||
|
if err := cache.UpdateLocation(loc); err != nil {
|
||||||
|
t.Fatalf("update location failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, ok, err := cache.LoadLocation()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load location failed: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected cached location")
|
||||||
|
}
|
||||||
|
if loaded.RadarStation != "KBOX" {
|
||||||
|
t.Fatalf("expected radar station to persist, got %q", loaded.RadarStation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveLocationClearsForecastAndRadar(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
cache := NewCache(tmp)
|
||||||
|
|
||||||
|
forecastPath := filepath.Join(tmp, forecastCacheFile)
|
||||||
|
radarPath := filepath.Join(tmp, "radar.gif")
|
||||||
|
|
||||||
|
if err := os.WriteFile(forecastPath, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write forecast: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(radarPath, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write radar: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loc := Location{Lat: 1.23, Lon: 4.56, City: "Testville", State: "TS"}
|
||||||
|
if err := cache.SaveLocation("Testville", loc); err != nil {
|
||||||
|
t.Fatalf("save location failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(forecastPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected forecast cache removed, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(radarPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected radar cache removed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCacheForecastExpiry(t *testing.T) {
|
func TestCacheForecastExpiry(t *testing.T) {
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
cache := NewCache(tmp)
|
cache := NewCache(tmp)
|
||||||
|
|||||||
17
forecast.go
17
forecast.go
@ -85,6 +85,23 @@ func (c *NWSClient) FetchForecast(ctx context.Context, loc Location, unit string
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *NWSClient) FetchRadarStation(ctx context.Context, loc Location) (string, error) {
|
||||||
|
pointsURL := fmt.Sprintf("https://api.weather.gov/points/%.4f,%.4f", loc.Lat, loc.Lon)
|
||||||
|
var pointsResp struct {
|
||||||
|
Properties struct {
|
||||||
|
RadarStation string `json:"radarStation"`
|
||||||
|
} `json:"properties"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, pointsURL, &pointsResp); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
station := strings.TrimSpace(pointsResp.Properties.RadarStation)
|
||||||
|
if station == "" {
|
||||||
|
return "", fmt.Errorf("missing radar station from NWS points")
|
||||||
|
}
|
||||||
|
return station, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *NWSClient) getJSON(ctx context.Context, url string, target any) error {
|
func (c *NWSClient) getJSON(ctx context.Context, url string, target any) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
33
location.go
33
location.go
@ -19,13 +19,14 @@ import (
|
|||||||
const revGeoURL = "http://ip-api.com/json/"
|
const revGeoURL = "http://ip-api.com/json/"
|
||||||
|
|
||||||
type Location struct {
|
type Location struct {
|
||||||
Lat float64
|
Lat float64
|
||||||
Lon float64
|
Lon float64
|
||||||
City string
|
City string
|
||||||
State string
|
State string
|
||||||
Country string
|
Country string
|
||||||
Zipcode string
|
Zipcode string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
|
RadarStation string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLocation() *Location {
|
func NewLocation() *Location {
|
||||||
@ -35,14 +36,15 @@ func NewLocation() *Location {
|
|||||||
func (l *Location) UnmarshalJSON(b []byte) error {
|
func (l *Location) UnmarshalJSON(b []byte) error {
|
||||||
|
|
||||||
var aux struct {
|
var aux struct {
|
||||||
Lat json.RawMessage `json:"lat"`
|
Lat json.RawMessage `json:"lat"`
|
||||||
Lon json.RawMessage `json:"lon"`
|
Lon json.RawMessage `json:"lon"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
Zipcode string `json:"zipcode"`
|
Zipcode string `json:"zipcode"`
|
||||||
Address struct {
|
RadarStation string `json:"RadarStation"`
|
||||||
|
Address struct {
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
Town string `json:"town"`
|
Town string `json:"town"`
|
||||||
Village string `json:"village"`
|
Village string `json:"village"`
|
||||||
@ -82,6 +84,7 @@ func (l *Location) UnmarshalJSON(b []byte) error {
|
|||||||
l.Country = firstNonEmpty(aux.Address.Country, aux.Country)
|
l.Country = firstNonEmpty(aux.Address.Country, aux.Country)
|
||||||
l.Zipcode = firstNonEmpty(aux.Address.Zipcode, aux.Zipcode)
|
l.Zipcode = firstNonEmpty(aux.Address.Zipcode, aux.Zipcode)
|
||||||
l.DisplayName = firstNonEmpty(aux.DisplayName, aux.City)
|
l.DisplayName = firstNonEmpty(aux.DisplayName, aux.City)
|
||||||
|
l.RadarStation = aux.RadarStation
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
13
main.go
13
main.go
@ -29,6 +29,7 @@ func run() error {
|
|||||||
clearCache bool
|
clearCache bool
|
||||||
refresh bool
|
refresh bool
|
||||||
iconTest bool
|
iconTest bool
|
||||||
|
radarMode bool
|
||||||
)
|
)
|
||||||
|
|
||||||
flag.StringVar(&search, "s", "", "Search location (e.g. \"Somerville, MA\")")
|
flag.StringVar(&search, "s", "", "Search location (e.g. \"Somerville, MA\")")
|
||||||
@ -45,6 +46,8 @@ func run() error {
|
|||||||
flag.BoolVar(&refresh, "r", false, "Force refresh forecast data")
|
flag.BoolVar(&refresh, "r", false, "Force refresh forecast data")
|
||||||
flag.BoolVar(&iconTest, "icon-test", false, "Print all icons used by the program")
|
flag.BoolVar(&iconTest, "icon-test", false, "Print all icons used by the program")
|
||||||
flag.BoolVar(&iconTest, "i", false, "Print all icons used by the program")
|
flag.BoolVar(&iconTest, "i", false, "Print all icons used by the program")
|
||||||
|
flag.BoolVar(&radarMode, "radar", false, "Display radar loop for current location via mpv")
|
||||||
|
flag.BoolVar(&radarMode, "R", false, "Display radar loop for current location via mpv")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
ua, err := buildUserAgent()
|
ua, err := buildUserAgent()
|
||||||
@ -73,6 +76,12 @@ func run() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if radarMode {
|
||||||
|
if err := requireMPV(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if clearCache {
|
if clearCache {
|
||||||
if err := cache.Clear(); err != nil {
|
if err := cache.Clear(); err != nil {
|
||||||
return err
|
return err
|
||||||
@ -104,6 +113,10 @@ func run() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if radarMode {
|
||||||
|
return runRadar(ctx, cache, loc, search, ua)
|
||||||
|
}
|
||||||
|
|
||||||
client := NewNWSClient(ua)
|
client := NewNWSClient(ua)
|
||||||
var period ForecastPeriod
|
var period ForecastPeriod
|
||||||
if refresh {
|
if refresh {
|
||||||
|
|||||||
126
radar.go
Normal file
126
radar.go
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const radarCacheTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
func requireMPV() error {
|
||||||
|
if _, err := exec.LookPath("mpv"); err != nil {
|
||||||
|
return fmt.Errorf("mpv is required for radar mode")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRadar(ctx context.Context, cache *Cache, loc Location, searchQuery string, userAgent string) error {
|
||||||
|
station := strings.TrimSpace(loc.RadarStation)
|
||||||
|
if station == "" {
|
||||||
|
client := NewNWSClient(userAgent)
|
||||||
|
var err error
|
||||||
|
station, err = client.FetchRadarStation(ctx, loc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
loc.RadarStation = station
|
||||||
|
if err := cache.UpdateLocation(loc); err != nil {
|
||||||
|
if searchQuery == "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cache.SaveLocation(searchQuery, loc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := radarURL(station)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
radarPath, err := cacheRadarGIF(ctx, cache, url, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "mpv", "--fs", "--loop-file", radarPath)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func radarURL(station string) (string, error) {
|
||||||
|
station = strings.TrimSpace(station)
|
||||||
|
if station == "" {
|
||||||
|
return "", fmt.Errorf("radar station not set")
|
||||||
|
}
|
||||||
|
station = strings.ToUpper(station)
|
||||||
|
return fmt.Sprintf("https://radar.weather.gov/ridge/standard/%s_loop.gif", station), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheRadarGIF(ctx context.Context, cache *Cache, url, userAgent string) (string, error) {
|
||||||
|
if err := cache.ensureDir(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := filepath.Join(cache.dir, "radar.gif")
|
||||||
|
if !shouldRefreshFile(path, radarCacheTTL) {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 20 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode > 299 {
|
||||||
|
return "", fmt.Errorf("radar request failed with status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := path + ".tmp"
|
||||||
|
tmp, err := os.Create(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, copyErr := io.Copy(tmp, resp.Body)
|
||||||
|
closeErr := tmp.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return "", copyErr
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return "", closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(tmpPath, path); err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldRefreshFile(path string, ttl time.Duration) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return time.Since(info.ModTime()) > ttl
|
||||||
|
}
|
||||||
32
radar_cache_test.go
Normal file
32
radar_cache_test.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShouldRefreshFile(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "radar.gif")
|
||||||
|
|
||||||
|
if !shouldRefreshFile(path, time.Minute) {
|
||||||
|
t.Fatalf("expected refresh when file missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write temp file: %v", err)
|
||||||
|
}
|
||||||
|
if shouldRefreshFile(path, time.Minute) {
|
||||||
|
t.Fatalf("expected no refresh for fresh file")
|
||||||
|
}
|
||||||
|
|
||||||
|
old := time.Now().Add(-2 * time.Hour)
|
||||||
|
if err := os.Chtimes(path, old, old); err != nil {
|
||||||
|
t.Fatalf("chtimes: %v", err)
|
||||||
|
}
|
||||||
|
if !shouldRefreshFile(path, time.Minute) {
|
||||||
|
t.Fatalf("expected refresh for stale file")
|
||||||
|
}
|
||||||
|
}
|
||||||
14
radar_test.go
Normal file
14
radar_test.go
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRadarURL(t *testing.T) {
|
||||||
|
url, err := radarURL("kbox")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("radar url error: %v", err)
|
||||||
|
}
|
||||||
|
expected := "https://radar.weather.gov/ridge/standard/KBOX_loop.gif"
|
||||||
|
if url != expected {
|
||||||
|
t.Fatalf("unexpected radar url: %s", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user