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
|
||||
go run . -i
|
||||
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.
|
||||
|
||||
30
cache.go
30
cache.go
@ -44,7 +44,10 @@ func (c *Cache) SaveLocation(query string, loc Location) error {
|
||||
Location: loc,
|
||||
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) {
|
||||
@ -59,6 +62,21 @@ func (c *Cache) LoadLocation() (Location, bool, error) {
|
||||
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 {
|
||||
if err := c.ensureDir(); err != nil {
|
||||
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) {
|
||||
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) {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(filepath.Join(c.dir, "radar.gif")); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
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) {
|
||||
tmp := t.TempDir()
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
|
||||
33
location.go
33
location.go
@ -19,13 +19,14 @@ import (
|
||||
const revGeoURL = "http://ip-api.com/json/"
|
||||
|
||||
type Location struct {
|
||||
Lat float64
|
||||
Lon float64
|
||||
City string
|
||||
State string
|
||||
Country string
|
||||
Zipcode string
|
||||
DisplayName string
|
||||
Lat float64
|
||||
Lon float64
|
||||
City string
|
||||
State string
|
||||
Country string
|
||||
Zipcode string
|
||||
DisplayName string
|
||||
RadarStation string
|
||||
}
|
||||
|
||||
func NewLocation() *Location {
|
||||
@ -35,14 +36,15 @@ func NewLocation() *Location {
|
||||
func (l *Location) UnmarshalJSON(b []byte) error {
|
||||
|
||||
var aux struct {
|
||||
Lat json.RawMessage `json:"lat"`
|
||||
Lon json.RawMessage `json:"lon"`
|
||||
DisplayName string `json:"display_name"`
|
||||
City string `json:"city"`
|
||||
State string `json:"state"`
|
||||
Country string `json:"country"`
|
||||
Zipcode string `json:"zipcode"`
|
||||
Address struct {
|
||||
Lat json.RawMessage `json:"lat"`
|
||||
Lon json.RawMessage `json:"lon"`
|
||||
DisplayName string `json:"display_name"`
|
||||
City string `json:"city"`
|
||||
State string `json:"state"`
|
||||
Country string `json:"country"`
|
||||
Zipcode string `json:"zipcode"`
|
||||
RadarStation string `json:"RadarStation"`
|
||||
Address struct {
|
||||
City string `json:"city"`
|
||||
Town string `json:"town"`
|
||||
Village string `json:"village"`
|
||||
@ -82,6 +84,7 @@ func (l *Location) UnmarshalJSON(b []byte) error {
|
||||
l.Country = firstNonEmpty(aux.Address.Country, aux.Country)
|
||||
l.Zipcode = firstNonEmpty(aux.Address.Zipcode, aux.Zipcode)
|
||||
l.DisplayName = firstNonEmpty(aux.DisplayName, aux.City)
|
||||
l.RadarStation = aux.RadarStation
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
13
main.go
13
main.go
@ -29,6 +29,7 @@ func run() error {
|
||||
clearCache bool
|
||||
refresh bool
|
||||
iconTest bool
|
||||
radarMode bool
|
||||
)
|
||||
|
||||
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(&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(&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()
|
||||
|
||||
ua, err := buildUserAgent()
|
||||
@ -73,6 +76,12 @@ func run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if radarMode {
|
||||
if err := requireMPV(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if clearCache {
|
||||
if err := cache.Clear(); err != nil {
|
||||
return err
|
||||
@ -104,6 +113,10 @@ func run() error {
|
||||
}
|
||||
}
|
||||
|
||||
if radarMode {
|
||||
return runRadar(ctx, cache, loc, search, ua)
|
||||
}
|
||||
|
||||
client := NewNWSClient(ua)
|
||||
var period ForecastPeriod
|
||||
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