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