simple epg-cacher
This commit is contained in:
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal 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
7
.idea/discord.xml
generated
Normal 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
9
.idea/epg-cacher.iml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
228
app.go
Normal 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
BIN
epg-cacher.exe
Normal file
Binary file not shown.
Reference in New Issue
Block a user