simple epg-cacher

This commit was merged in pull request #1.
This commit is contained in:
2025-11-24 18:23:05 +01:00
committed by Sepp J Morris
parent a45a1cb14b
commit ac5d515ac3
7 changed files with 261 additions and 0 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

7
.idea/discord.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="ASK" />
<option name="description" value="" />
</component>
</project>

9
.idea/epg-cacher.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/epg-cacher.iml" filepath="$PROJECT_DIR$/.idea/epg-cacher.iml" />
</modules>
</component>
</project>

228
app.go Normal file
View File

@@ -0,0 +1,228 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"time"
)
type Cache struct {
mu sync.RWMutex
filePath string
contentType string
lastSuccess time.Time
lastAttempt time.Time
lastError string
hasData bool
}
func (c *Cache) Update(contentType string, err error) {
c.mu.Lock()
defer c.mu.Unlock()
c.lastAttempt = time.Now()
if err != nil {
c.lastError = "fetch failed (check server logs for details)"
return
}
c.contentType = contentType
c.lastSuccess = time.Now()
c.lastError = ""
c.hasData = true
}
func (c *Cache) GetContent() (filePath string, contentType string, ok bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if !c.hasData {
return "", "", false
}
return c.filePath, c.contentType, true
}
func (c *Cache) Status() map[string]interface{} {
c.mu.RLock()
defer c.mu.RUnlock()
var lastSuccessStr, lastAttemptStr, lastErrorStr string
if !c.lastSuccess.IsZero() {
lastSuccessStr = c.lastSuccess.UTC().Format(time.RFC3339)
}
if !c.lastAttempt.IsZero() {
lastAttemptStr = c.lastAttempt.UTC().Format(time.RFC3339)
}
if c.lastError != "" {
lastErrorStr = c.lastError
}
return map[string]interface{}{
"has_data": c.hasData,
"last_success": lastSuccessStr,
"last_attempt": lastAttemptStr,
"last_error": lastErrorStr,
"content_present": c.hasData,
}
}
func fetchOnce(url, destPath string) (string, error) {
client := &http.Client{
Timeout: 30 * time.Second,
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("performing request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
tmpPath := destPath + ".tmp"
f, err := os.Create(tmpPath)
if err != nil {
return "", fmt.Errorf("creating temp file: %w", err)
}
_, copyErr := io.Copy(f, resp.Body)
closeErr := f.Close()
if copyErr != nil {
_ = os.Remove(tmpPath)
return "", fmt.Errorf("copying body: %w", copyErr)
}
if closeErr != nil {
_ = os.Remove(tmpPath)
return "", fmt.Errorf("closing temp file: %w", closeErr)
}
if err := os.Rename(tmpPath, destPath); err != nil {
_ = os.Remove(tmpPath)
return "", fmt.Errorf("renaming temp file: %w", err)
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "text/xml; charset=utf-8"
}
return contentType, nil
}
func startFetcher(ctx context.Context, cache *Cache, url string, okInterval, failInterval time.Duration) {
go func() {
for {
contentType, err := fetchOnce(url, cache.filePath)
if err != nil {
log.Printf("error fetching %s: %v", url, err)
}
cache.Update(contentType, err)
var nextInterval time.Duration
if err != nil || !cache.hasData {
nextInterval = failInterval
} else {
nextInterval = okInterval
}
select {
case <-ctx.Done():
return
case <-time.After(nextInterval):
}
}
}()
}
func parseEnvDurationHours(key string, defaultHours int) time.Duration {
val := os.Getenv(key)
if val == "" {
return time.Duration(defaultHours) * time.Hour
}
hours, err := strconv.Atoi(val)
if err != nil || hours <= 0 {
log.Printf("Invalid value for %s=%q, using default %d hours", key, val, defaultHours)
return time.Duration(defaultHours) * time.Hour
}
return time.Duration(hours) * time.Hour
}
func main() {
url := os.Getenv("CACHE_URL")
if url == "" {
log.Fatal("CACHE_URL env var is required")
}
okInterval := parseEnvDurationHours("CACHE_OK_INTERVAL_HOURS", 8)
failInterval := parseEnvDurationHours("CACHE_FAIL_INTERVAL_HOURS", 4)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
exePath, err := os.Executable()
if err != nil {
log.Fatalf("failed to get executable path: %v", err)
}
exeDir := filepath.Dir(exePath)
cacheFilePath := filepath.Join(exeDir, "cache.xml")
cache := &Cache{
filePath: cacheFilePath,
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
startFetcher(ctx, cache, url, okInterval, failInterval)
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
path, contentType, ok := cache.GetContent()
if !ok {
http.Error(w, "Cache not yet initialized; no successful fetch so far", http.StatusServiceUnavailable)
return
}
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
http.ServeFile(w, r, path)
})
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
status := cache.Status()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(status); err != nil {
http.Error(w, "failed to encode status", http.StatusInternalServerError)
return
}
})
addr := ":" + port
log.Printf("Starting server on %s, caching %s to %s (ok interval: %s, fail interval: %s)", addr, url, cacheFilePath, okInterval, failInterval)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("server error: %v", err)
}
}

BIN
epg-cacher.exe Normal file

Binary file not shown.

1
go.mod Normal file
View File

@@ -0,0 +1 @@
module epg-cacher