Memby v0.1.53: Android TV client plus gateway
Android TV client for Emby (Kotlin, Compose for TV) and the Memby gateway (Go, Postgres, Redis) that fronts it. Client: - Setup, profiles, home rows, Media3 playback, system screensaver (Dream) - Backend chosen at build time: gateway when memby.gatewayUrl is set, otherwise direct to Emby. Both paths stay working. - Server-composed home rows, rendered verbatim so new row types ship without an app release - Full-screen animated maintenance state, row engagement telemetry Gateway: - One request per TV screen; auth, caching, search and row shaping - Library import from Emby into Postgres (manual, then hourly incremental) - Recommendations from viewing history (recency-weighted genre affinity) - Admin page for imports, an offline switch, and per-row analytics - Video always direct-plays from Emby; only metadata passes through Identity is com.ponzischeme89.memby throughout, replacing com.mattcohen.embyclientsname. A changed applicationId installs as a new app: TVs need a fresh sign-in and the old package uninstalled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
// Package config loads the gateway's settings from the environment.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
|
||||
// EmbyURL is how the gateway itself reaches Emby (may be a private/docker address).
|
||||
EmbyURL string
|
||||
// EmbyPublicURL is the address handed to TV clients for direct video playback.
|
||||
// Defaults to EmbyURL; set it when the gateway talks to Emby over a network the
|
||||
// TVs cannot reach.
|
||||
EmbyPublicURL string
|
||||
|
||||
DatabaseURL string
|
||||
RedisURL string
|
||||
|
||||
// ClientName is reported to Emby in the X-Emby-Authorization header, so sessions
|
||||
// show up as this in Emby's dashboard.
|
||||
ClientName string
|
||||
|
||||
HomeTTL time.Duration
|
||||
ItemTTL time.Duration
|
||||
SearchTTL time.Duration
|
||||
ScreensaverTTL time.Duration
|
||||
SessionTTL time.Duration
|
||||
// SessionIdleExpiry retires gateway tokens that go unused for this long.
|
||||
SessionIdleExpiry time.Duration
|
||||
|
||||
// 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
|
||||
|
||||
UpstreamTimeout time.Duration
|
||||
|
||||
// AdminToken guards the operator interface. Empty disables /admin entirely, so an
|
||||
// unconfigured deployment cannot leave it exposed.
|
||||
AdminToken string
|
||||
|
||||
// SyncInterval is how often the library import runs. Zero disables the schedule.
|
||||
SyncInterval time.Duration
|
||||
// SyncTimeout bounds one import; a full pass over a large library is slow.
|
||||
SyncTimeout time.Duration
|
||||
// SyncOnStart triggers an incremental import at boot.
|
||||
SyncOnStart bool
|
||||
// SyncUserID / SyncAPIKey are an optional Emby service account for imports. Without
|
||||
// them the newest TV session is borrowed instead.
|
||||
SyncUserID string
|
||||
SyncAPIKey string
|
||||
|
||||
// AnalyticsRetention is how long raw row events are kept before being pruned.
|
||||
AnalyticsRetention time.Duration
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
c := Config{
|
||||
ListenAddr: env("MEMBY_LISTEN_ADDR", ":8080"),
|
||||
EmbyURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_URL"), "/"),
|
||||
EmbyPublicURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_PUBLIC_URL"), "/"),
|
||||
DatabaseURL: os.Getenv("MEMBY_DATABASE_URL"),
|
||||
RedisURL: env("MEMBY_REDIS_URL", "redis://localhost:6379/0"),
|
||||
ClientName: env("MEMBY_CLIENT_NAME", "Memby"),
|
||||
HomeTTL: duration("MEMBY_HOME_TTL", 60*time.Second),
|
||||
ItemTTL: duration("MEMBY_ITEM_TTL", 10*time.Minute),
|
||||
SearchTTL: duration("MEMBY_SEARCH_TTL", 5*time.Minute),
|
||||
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),
|
||||
RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 2*time.Hour),
|
||||
RecommendTimeout: duration("MEMBY_RECOMMEND_TIMEOUT", 60*time.Second),
|
||||
|
||||
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_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),
|
||||
}
|
||||
|
||||
if c.EmbyURL == "" {
|
||||
return c, fmt.Errorf("MEMBY_EMBY_URL is required")
|
||||
}
|
||||
if c.DatabaseURL == "" {
|
||||
return c, fmt.Errorf("MEMBY_DATABASE_URL is required")
|
||||
}
|
||||
if c.EmbyPublicURL == "" {
|
||||
c.EmbyPublicURL = c.EmbyURL
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func boolean(key string, fallback bool) bool {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
value, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func duration(key string, fallback time.Duration) time.Duration {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
if d, err := time.ParseDuration(raw); err == nil {
|
||||
return d
|
||||
}
|
||||
// Bare numbers are read as seconds, which is friendlier in a compose file.
|
||||
if secs, err := strconv.Atoi(raw); err == nil {
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
Reference in New Issue
Block a user