Publish current app and server

This commit is contained in:
ponzischeme89
2026-08-02 22:10:19 +12:00
parent a265636139
commit 1ed180c739
203 changed files with 23933 additions and 2788 deletions
+50 -14
View File
@@ -2,6 +2,7 @@
package config
import (
"encoding/json"
"fmt"
"os"
"strconv"
@@ -34,18 +35,21 @@ type Config struct {
SessionTTL time.Duration
// SessionIdleExpiry retires gateway tokens that go unused for this long.
SessionIdleExpiry time.Duration
// MaxClientsPerUser is enforced transactionally when a new TV signs in.
MaxClientsPerUser int
// RecommendTTL is how long computed recommendation rows stay warm. Long, because
// taste moves slowly and each rebuild costs several Emby queries.
RecommendTTL time.Duration
// RecommendTimeout bounds a background rebuild, which fans out further than a
// normal request and so needs more headroom than UpstreamTimeout.
RecommendTimeout time.Duration
// RecommendationWeights is an optional JSON overlay on the weighted defaults.
RecommendationWeights string
UpstreamTimeout time.Duration
// EmbyHealthInterval paces the reachability probe behind the "server not responding"
// and "back online" banners. One probe per gateway, not per TV. Zero disables it.
EmbyHealthInterval time.Duration
// AdminToken guards the operator interface. Empty disables /admin entirely, so an
// unconfigured deployment cannot leave it exposed.
AdminToken string
@@ -73,7 +77,7 @@ type Config struct {
AnalyticsRetention time.Duration
// Sonarr is optional. When configured, its local calendar supplies the informational
// "Shows airing today" home row. The API key never leaves this server.
// Five-day "Shows airing" home row. The API key never leaves this server.
SonarrURL string
SonarrAPIKey string
SonarrTTL time.Duration
@@ -81,9 +85,24 @@ type Config struct {
// SonarrAlertWindow is how long after an episode airs the "aired, coming soon"
// banner keeps being offered to clients. Zero turns the banners off without
// touching the airing-today row.
// touching the five-day schedule row.
SonarrAlertWindow time.Duration
// Radarr is optional. Its calendar supplies the five-day digital movie release row.
RadarrURL string
RadarrAPIKey string
RadarrTTL time.Duration
RadarrLocation *time.Location
// RadarrWebhookToken guards the Radarr "on import" webhook. Empty means the hook
// 404s, so an unconfigured deployment cannot be fed alerts by anyone who finds it.
RadarrWebhookToken string
// RadarrAlertWindow is how long an imported movie keeps being announced to clients.
// It is a window rather than a one-shot push because the gateway has no connection
// to a TV that is switched off: a set turned on inside the window still gets the
// news, and each TV dedupes by alert id. Zero turns the banners off.
RadarrAlertWindow time.Duration
// Tracearr is an optional, read-only source of completion, session-length and
// direct-play signals for the per-user For You area. The public API key stays in
// the gateway and is never returned to a TV.
@@ -94,10 +113,11 @@ type Config struct {
// late/out-of-order updates and deletions without needing a source cursor.
TracearrSyncInterval time.Duration
TracearrFullInterval time.Duration
// ForYouMinRebuildAge coalesces bursts of playback/library changes. RefreshInterval
// is the acceptable age of a prepared pool before it is refreshed.
// Prepared pools rebuild daily at RebuildHour in the configured timezone. The age
// settings are safety bounds for manual/background fallback paths.
ForYouMinRebuildAge time.Duration
ForYouRefreshInterval time.Duration
ForYouRebuildHour int
}
func Load() (Config, error) {
@@ -114,9 +134,11 @@ func Load() (Config, error) {
ScreensaverTTL: duration("MEMBY_SCREENSAVER_TTL", 10*time.Minute),
SessionTTL: duration("MEMBY_SESSION_CACHE_TTL", 5*time.Minute),
SessionIdleExpiry: duration("MEMBY_SESSION_IDLE_EXPIRY", 90*24*time.Hour),
MaxClientsPerUser: integer("MEMBY_MAX_CLIENTS_PER_USER", 1),
RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 2*time.Hour),
RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 24*time.Hour),
RecommendTimeout: duration("MEMBY_RECOMMEND_TIMEOUT", 60*time.Second),
RecommendationWeights: strings.TrimSpace(
os.Getenv("MEMBY_RECOMMENDATION_WEIGHTS"),
),
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")),
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"),
@@ -131,17 +153,24 @@ func Load() (Config, error) {
SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")),
AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour),
UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second),
EmbyHealthInterval: duration("MEMBY_EMBY_HEALTH_INTERVAL", 60*time.Second),
SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"),
SonarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SONARR_API_KEY")),
SonarrTTL: duration("MEMBY_SONARR_TTL", 5*time.Minute),
SonarrAlertWindow: duration("MEMBY_SONARR_ALERT_WINDOW", 3*time.Hour),
RadarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_RADARR_URL")), "/"),
RadarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_RADARR_API_KEY")),
RadarrTTL: duration("MEMBY_RADARR_TTL", 5*time.Minute),
RadarrWebhookToken: strings.TrimSpace(os.Getenv("MEMBY_RADARR_WEBHOOK_TOKEN")),
RadarrAlertWindow: duration("MEMBY_RADARR_ALERT_WINDOW", 3*time.Hour),
TracearrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_URL")), "/"),
TracearrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_API_KEY")),
TracearrServerID: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_SERVER_ID")),
TracearrSyncInterval: duration("MEMBY_TRACEARR_SYNC_INTERVAL", 5*time.Minute),
TracearrFullInterval: duration("MEMBY_TRACEARR_FULL_INTERVAL", 24*time.Hour),
ForYouMinRebuildAge: duration("MEMBY_FOR_YOU_MIN_REBUILD_AGE", 10*time.Minute),
ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 30*time.Minute),
ForYouMinRebuildAge: duration("MEMBY_FOR_YOU_MIN_REBUILD_AGE", 24*time.Hour),
ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 24*time.Hour),
ForYouRebuildHour: integer("MEMBY_FOR_YOU_REBUILD_HOUR", 4),
}
if c.EmbyURL == "" {
@@ -150,9 +179,6 @@ func Load() (Config, error) {
if c.DatabaseURL == "" {
return c, fmt.Errorf("MEMBY_DATABASE_URL is required")
}
if c.MaxClientsPerUser < 1 {
return c, fmt.Errorf("MEMBY_MAX_CLIENTS_PER_USER must be at least 1")
}
if c.EmbyPublicURL == "" {
c.EmbyPublicURL = c.EmbyURL
}
@@ -162,14 +188,24 @@ func Load() (Config, error) {
if (c.SonarrURL == "") != (c.SonarrAPIKey == "") {
return c, fmt.Errorf("MEMBY_SONARR_URL and MEMBY_SONARR_API_KEY must be set together")
}
if (c.RadarrURL == "") != (c.RadarrAPIKey == "") {
return c, fmt.Errorf("MEMBY_RADARR_URL and MEMBY_RADARR_API_KEY must be set together")
}
if (c.TracearrURL == "") != (c.TracearrAPIKey == "") {
return c, fmt.Errorf("MEMBY_TRACEARR_URL and MEMBY_TRACEARR_API_KEY must be set together")
}
if c.ForYouRebuildHour < 0 || c.ForYouRebuildHour > 23 {
return c, fmt.Errorf("MEMBY_FOR_YOU_REBUILD_HOUR must be between 0 and 23")
}
if c.RecommendationWeights != "" && !json.Valid([]byte(c.RecommendationWeights)) {
return c, fmt.Errorf("MEMBY_RECOMMENDATION_WEIGHTS must be valid JSON")
}
location, err := time.LoadLocation(env("MEMBY_TIMEZONE", "Pacific/Auckland"))
if err != nil {
return c, fmt.Errorf("MEMBY_TIMEZONE: %w", err)
}
c.SonarrLocation = location
c.RadarrLocation = location
return c, nil
}
+52 -15
View File
@@ -1,20 +1,9 @@
package config
import "testing"
func TestDefaultClientAllowanceIsOne(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_MAX_CLIENTS_PER_USER", "")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.MaxClientsPerUser != 1 {
t.Fatalf("MaxClientsPerUser = %d, want 1", cfg.MaxClientsPerUser)
}
}
import (
"testing"
"time"
)
func TestTracearrURLAndKeyMustBeConfiguredTogether(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
@@ -26,3 +15,51 @@ func TestTracearrURLAndKeyMustBeConfiguredTogether(t *testing.T) {
t.Fatal("expected incomplete Tracearr configuration to fail")
}
}
func TestRadarrURLAndKeyMustBeConfiguredTogether(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_RADARR_URL", "http://radarr")
t.Setenv("MEMBY_RADARR_API_KEY", "")
if _, err := Load(); err == nil {
t.Fatal("expected incomplete Radarr configuration to fail")
}
}
func TestForYouDefaultsToDailyOffPeakRebuild(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_FOR_YOU_MIN_REBUILD_AGE", "")
t.Setenv("MEMBY_FOR_YOU_REFRESH_INTERVAL", "")
t.Setenv("MEMBY_FOR_YOU_REBUILD_HOUR", "")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.ForYouMinRebuildAge != 24*time.Hour ||
cfg.ForYouRefreshInterval != 24*time.Hour ||
cfg.ForYouRebuildHour != 4 {
t.Fatalf("For You schedule = %v/%v hour %d",
cfg.ForYouMinRebuildAge, cfg.ForYouRefreshInterval, cfg.ForYouRebuildHour)
}
}
func TestForYouRebuildHourIsValidated(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_FOR_YOU_REBUILD_HOUR", "24")
if _, err := Load(); err == nil {
t.Fatal("expected invalid rebuild hour to fail")
}
}
func TestRecommendationWeightsMustBeJSON(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_RECOMMENDATION_WEIGHTS", "{broken")
if _, err := Load(); err == nil {
t.Fatal("expected invalid recommendation weights to fail")
}
}