diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -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
diff --git a/.idea/discord.xml b/.idea/discord.xml
new file mode 100644
index 0000000..30bab2a
--- /dev/null
+++ b/.idea/discord.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/epg-cacher.iml b/.idea/epg-cacher.iml
new file mode 100644
index 0000000..5e764c4
--- /dev/null
+++ b/.idea/epg-cacher.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..c2e986d
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app.go b/app.go
new file mode 100644
index 0000000..59397cc
--- /dev/null
+++ b/app.go
@@ -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)
+ }
+}
diff --git a/epg-cacher.exe b/epg-cacher.exe
new file mode 100644
index 0000000..f298830
Binary files /dev/null and b/epg-cacher.exe differ
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..ec9f72c
--- /dev/null
+++ b/go.mod
@@ -0,0 +1 @@
+module epg-cacher