0.2.64 update

This commit is contained in:
ponzischeme89
2026-08-15 09:23:26 +12:00
parent a2ca7e8061
commit d5d47473a2
90 changed files with 9188 additions and 451 deletions
+87 -36
View File
@@ -81,6 +81,9 @@ type Config struct {
// ReleasePublishToken authorizes the CI-only release upload endpoint. It is separate
// from AdminToken so a compromised build runner cannot change maintenance settings.
ReleasePublishToken string
// ReleaseBuilderURL is the private Compose address of the Android release controller.
// It is never given to the browser; the authenticated admin API relays requests to it.
ReleaseBuilderURL string
// SyncInterval is how often the library import runs. Zero disables the schedule.
SyncInterval time.Duration
@@ -144,6 +147,26 @@ type Config struct {
TracearrURL string
TracearrAPIKey string
TracearrServerID string
// Credits detection discovers where an episode's closing credits begin, for the small
// number of episodes the household is about to watch. It is demand-driven — Tracearr
// says what is worth scanning — so these settings shape how far ahead of a viewer it
// prepares, never how much of the library it reads.
//
// CreditsEnabled is off by default: it is the only thing in the gateway that reads media
// bytes, and switching that on is an operator's decision rather than a default.
CreditsEnabled bool
// CreditsFFmpeg is the decoder. Absent, the subsystem still runs and still writes
// markers, on behavioural evidence alone — which on a well-watched show is the better
// signal anyway.
CreditsFFmpeg string
// CreditsPrefetchEpisodes is the look-ahead for an ordinary viewer; velocity moves the
// actual depth either side of it, and CreditsMaxPrefetch is the ceiling nothing exceeds.
CreditsPrefetchEpisodes int
CreditsMaxPrefetch int
// CreditsQueueLimit bounds pending candidates. Past it, low-priority speculation is
// discarded rather than queued.
CreditsQueueLimit int
// TracearrSyncInterval imports recent changed sessions. FullInterval reconciles
// late/out-of-order updates and deletions without needing a source cursor.
TracearrSyncInterval time.Duration
@@ -160,6 +183,10 @@ func Load() (Config, error) {
if err != nil {
return Config{}, err
}
releasePublishToken, err := secret("MEMBY_RELEASE_PUBLISH_TOKEN")
if err != nil {
return Config{}, err
}
c := Config{
ListenAddr: env("MEMBY_LISTEN_ADDR", ":8080"),
EmbyURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_URL"), "/"),
@@ -181,42 +208,46 @@ func Load() (Config, error) {
),
RemoteConfig: remoteConfig,
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")),
AdminUIURL: env("MEMBY_ADMIN_UI_URL", "http://memby-admin:80"),
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"),
ReleaseDir: env("MEMBY_RELEASE_DIR", "/data/releases"),
ReleasePublishToken: strings.TrimSpace(
os.Getenv("MEMBY_RELEASE_PUBLISH_TOKEN"),
),
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
SyncUserID: strings.TrimSpace(os.Getenv("MEMBY_SYNC_USER_ID")),
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),
BazarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_BAZARR_URL")), "/"),
BazarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_BAZARR_API_KEY")),
BazarrTTL: duration("MEMBY_BAZARR_TTL", 5*time.Minute),
BazarrTimeout: duration("MEMBY_BAZARR_TIMEOUT", 45*time.Second),
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", 24*time.Hour),
ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 24*time.Hour),
ForYouRebuildHour: integer("MEMBY_FOR_YOU_REBUILD_HOUR", 4),
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")),
AdminUIURL: env("MEMBY_ADMIN_UI_URL", "http://memby-admin:80"),
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"),
ReleaseDir: env("MEMBY_RELEASE_DIR", "/data/releases"),
ReleasePublishToken: releasePublishToken,
ReleaseBuilderURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_RELEASE_BUILDER_URL")), "/"),
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
SyncUserID: strings.TrimSpace(os.Getenv("MEMBY_SYNC_USER_ID")),
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),
BazarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_BAZARR_URL")), "/"),
BazarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_BAZARR_API_KEY")),
BazarrTTL: duration("MEMBY_BAZARR_TTL", 5*time.Minute),
BazarrTimeout: duration("MEMBY_BAZARR_TIMEOUT", 45*time.Second),
CreditsEnabled: boolean("MEMBY_CREDITS_ENABLED", false),
CreditsFFmpeg: strings.TrimSpace(os.Getenv("MEMBY_CREDITS_FFMPEG")),
CreditsPrefetchEpisodes: integer("MEMBY_CREDITS_PREFETCH_EPISODES", 3),
CreditsMaxPrefetch: integer("MEMBY_CREDITS_MAX_PREFETCH", 5),
CreditsQueueLimit: integer("MEMBY_CREDITS_QUEUE_LIMIT", 20),
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", 24*time.Hour),
ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 24*time.Hour),
ForYouRebuildHour: integer("MEMBY_FOR_YOU_REBUILD_HOUR", 4),
}
if c.AnalyticsRetention < 30*24*time.Hour {
c.AnalyticsRetention = 30 * 24 * time.Hour
@@ -268,6 +299,26 @@ func env(key, fallback string) string {
return fallback
}
// secret reads a Docker/Kubernetes-style file-backed secret when KEY_FILE is set,
// falling back to KEY for existing non-Compose deployments. The file's contents are
// never included in an error, and Compose uses only the file form so `docker inspect`
// cannot reveal the release-publish credential.
func secret(key string) (string, error) {
path := strings.TrimSpace(os.Getenv(key + "_FILE"))
if path == "" {
return strings.TrimSpace(os.Getenv(key)), nil
}
value, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("%s_FILE: %w", key, err)
}
trimmed := strings.TrimSpace(string(value))
if trimmed == "" {
return "", fmt.Errorf("%s_FILE is empty", key)
}
return trimmed, nil
}
func boolean(key string, fallback bool) bool {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
+32
View File
@@ -1,10 +1,42 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestReleasePublishTokenCanComeFromSecretFile(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_PUBLIC_URL", "https://memby.example")
t.Setenv("MEMBY_RELEASE_PUBLISH_TOKEN", "legacy-environment-value")
path := filepath.Join(t.TempDir(), "release-token")
if err := os.WriteFile(path, []byte("file-backed-token\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("MEMBY_RELEASE_PUBLISH_TOKEN_FILE", path)
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.ReleasePublishToken != "file-backed-token" {
t.Fatalf("release token = %q, want the file-backed value", cfg.ReleasePublishToken)
}
}
func TestConfiguredReleaseSecretFileMustBeReadable(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_RELEASE_PUBLISH_TOKEN_FILE", filepath.Join(t.TempDir(), "missing"))
if _, err := Load(); err == nil {
t.Fatal("expected a missing release token secret to fail")
}
}
func TestTracearrURLAndKeyMustBeConfiguredTogether(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")