0.1.52 Gateway

This commit is contained in:
ponzischeme89
2026-08-17 19:09:17 +12:00
parent d5a8d3ad88
commit 1da91e40a1
46 changed files with 1626 additions and 106 deletions
+73
View File
@@ -0,0 +1,73 @@
package store
import (
"context"
"fmt"
"time"
)
// DeviceActivityRetention is how long the per-day marks are kept. They are not history
// anybody reads — the notification they produced is the record, and that is itself pruned
// at thirty days — so this only has to be long enough that a clock correction or a
// database restored from a backup cannot make yesterday look like a day that never
// happened.
const DeviceActivityRetention = 90 * 24 * time.Hour
// MarkDeviceDay records that a television was in use on a household-local day, and
// reports whether this is the first time it has been seen today.
//
// The answer comes from the insert rather than from a read followed by a write, because
// every set in the house can reach this at once and two of them racing on the same row
// must not both be told they were first. `ON CONFLICT DO NOTHING` makes the primary key
// the arbiter: exactly one insert affects a row.
//
// It is keyed on the viewer as well as the television because a household set that two
// people sign into is two people opening Memby, and an operator reading the feed wants to
// know which of them it was.
func (s *Store) MarkDeviceDay(
ctx context.Context, deviceID, userID string, day time.Time,
) (bool, error) {
if deviceID == "" {
return false, nil
}
tag, err := s.pool.Exec(ctx, `
INSERT INTO device_activity_days (device_id, emby_user_id, day)
VALUES ($1, $2, $3)
ON CONFLICT (device_id, emby_user_id, day) DO NOTHING`,
deviceID, userID, day.Format("2006-01-02"))
if err != nil {
return false, fmt.Errorf("store: mark device day: %w", err)
}
return tag.RowsAffected() > 0, nil
}
// PruneDeviceActivityDays drops marks older than the retention window.
func (s *Store) PruneDeviceActivityDays(
ctx context.Context, retention time.Duration,
) (int64, error) {
if retention <= 0 {
retention = DeviceActivityRetention
}
tag, err := s.pool.Exec(ctx,
`DELETE FROM device_activity_days WHERE first_seen_at < now() - $1::interval`,
fmt.Sprintf("%d seconds", int64(retention.Seconds())))
if err != nil {
return 0, fmt.Errorf("store: prune device activity days: %w", err)
}
return tag.RowsAffected(), nil
}
// DeleteDeviceActivityDays retires a television's marks along with the television, the
// way its build history is retired: a set that is gone must not be able to announce a
// first use it can no longer have.
func (s *Store) DeleteDeviceActivityDays(ctx context.Context, deviceIDs ...string) error {
if len(deviceIDs) == 0 {
return nil
}
_, err := s.pool.Exec(ctx,
`DELETE FROM device_activity_days WHERE device_id = ANY($1::text[])`, deviceIDs)
if err != nil {
return fmt.Errorf("store: delete device activity days: %w", err)
}
return nil
}
+148
View File
@@ -0,0 +1,148 @@
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// GatewaySettingsKey is the app_settings row backing the gateway's own settings.
const GatewaySettingsKey = "gateway_settings"
// GatewaySettings is the handful of server-level decisions an operator can change
// without a redeployment.
//
// Every field is an *override* of the value the container was started with, and the zero
// value means "whatever was deployed". That is the whole design: `.env` remains the
// configuration — it is what a fresh container comes up with, what deploy-server.ps1
// writes and what an operator locked out of the console still has — and this row is a
// runtime amendment to it, so the console can always show the deployed value beside the
// one in force and an operator can put a setting back by clearing it rather than by
// remembering what it used to be.
//
// A window that can legitimately be *off* therefore needs a value distinct from "not
// overridden", which is why the two alert windows and the health interval take -1 for off
// and 0 for deployed. A plain zero would make "turn this off" indistinguishable from
// "leave it alone", and the operator would find the setting quietly ignored.
type GatewaySettings struct {
// Timezone is an IANA name. It decides the household's local day, which is what the
// schedule rows, the hero rotation, the sign-in history and the first-use
// notifications are all grouped by.
Timezone string `json:"timezone"`
// LogLevel is one of trace, debug, info, warn, error. It takes effect immediately on
// the running process, which is the point of it being here: turning debug on to watch
// something happen is worth nothing if it costs a restart of the thing being watched.
LogLevel string `json:"logLevel"`
// SessionIdleDays is how long a television may go unused before it is signed out.
SessionIdleDays int `json:"sessionIdleDays"`
// SonarrAlertMinutes and RadarrAlertMinutes are how long a "just aired" or "new movie
// added" banner stays on offer to a television that was switched off at the time.
SonarrAlertMinutes int `json:"sonarrAlertMinutes"`
RadarrAlertMinutes int `json:"radarrAlertMinutes"`
// EmbyHealthSeconds is how often the reachability probe asks Emby whether it is there.
EmbyHealthSeconds int `json:"embyHealthSeconds"`
UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy,omitempty"`
}
// GatewaySettingsOff is the value a duration-like override takes to mean "switched off",
// as distinct from the zero that means "use the deployed value".
const GatewaySettingsOff = -1
// GatewayLogLevels is the vocabulary the console offers, in order of severity. It is here
// rather than in the API because normalisation has to refuse a level nothing can parse,
// and refusing is worth nothing if the list it refuses against lives somewhere else.
var GatewayLogLevels = []string{"trace", "debug", "info", "warn", "error"}
func normalizeGatewaySettings(settings GatewaySettings) GatewaySettings {
settings.Timezone = strings.TrimSpace(settings.Timezone)
if settings.Timezone != "" {
// A timezone that will not load is dropped rather than stored: the alternative is
// a row that every later read has to fail on, and the failure would surface as a
// household whose idea of "today" quietly reverted with nothing saying why.
if _, err := time.LoadLocation(settings.Timezone); err != nil {
settings.Timezone = ""
}
}
settings.LogLevel = strings.ToLower(strings.TrimSpace(settings.LogLevel))
if settings.LogLevel != "" && !containsString(GatewayLogLevels, settings.LogLevel) {
settings.LogLevel = ""
}
settings.SessionIdleDays = clampOverride(settings.SessionIdleDays, 1, 3650, false)
settings.SonarrAlertMinutes = clampOverride(settings.SonarrAlertMinutes, 1, 24*60, true)
settings.RadarrAlertMinutes = clampOverride(settings.RadarrAlertMinutes, 1, 7*24*60, true)
settings.EmbyHealthSeconds = clampOverride(settings.EmbyHealthSeconds, 10, 3600, true)
return settings
}
// clampOverride keeps 0 meaning "deployed" and, where the setting can be switched off,
// keeps every negative number meaning off rather than only -1 — an operator typing -5 has
// said the same thing, and storing it verbatim would produce a second value with the same
// meaning that every reader would have to know about.
func clampOverride(value, low, high int, offAllowed bool) int {
if value == 0 {
return 0
}
if value < 0 {
if offAllowed {
return GatewaySettingsOff
}
return 0
}
return max(low, min(value, high))
}
func containsString(values []string, value string) bool {
for _, candidate := range values {
if candidate == value {
return true
}
}
return false
}
func (s *Store) GatewaySettings(ctx context.Context) (GatewaySettings, error) {
var raw []byte
err := s.pool.QueryRow(ctx,
`SELECT value FROM app_settings WHERE key = $1`, GatewaySettingsKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return GatewaySettings{}, nil
}
if err != nil {
return GatewaySettings{}, fmt.Errorf("store: read gateway settings: %w", err)
}
var settings GatewaySettings
if err := json.Unmarshal(raw, &settings); err != nil {
return GatewaySettings{}, fmt.Errorf("store: decode gateway settings: %w", err)
}
return normalizeGatewaySettings(settings), nil
}
// SetGatewaySettings writes the overrides and returns what was actually stored, because
// normalisation can refuse a value and the console must show what is in force rather than
// what was typed.
func (s *Store) SetGatewaySettings(
ctx context.Context, settings GatewaySettings,
) (GatewaySettings, error) {
settings = normalizeGatewaySettings(settings)
settings.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(settings)
if err != nil {
return GatewaySettings{}, err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
GatewaySettingsKey, string(raw))
if err != nil {
return GatewaySettings{}, fmt.Errorf("store: write gateway settings: %w", err)
}
return settings, nil
}
@@ -0,0 +1,47 @@
package store
import "testing"
// Normalisation is what stands between a hand-edited row (or a console built against an
// older vocabulary) and a gateway that cannot decide what day it is.
func TestNormalizeGatewaySettingsRefusesWhatItCannotUse(t *testing.T) {
settings := normalizeGatewaySettings(GatewaySettings{
Timezone: " Not/AZone ", LogLevel: "LOUD", SessionIdleDays: -4,
})
if settings.Timezone != "" {
t.Fatalf("an unloadable timezone should be dropped, got %q", settings.Timezone)
}
if settings.LogLevel != "" {
t.Fatalf("an unknown level should be dropped, got %q", settings.LogLevel)
}
// Session expiry cannot be switched off — a household with no expiry at all is a
// database full of live Emby tokens — so a negative reads as "leave it alone".
if settings.SessionIdleDays != 0 {
t.Fatalf("session expiry should not be switchable off, got %d", settings.SessionIdleDays)
}
}
func TestNormalizeGatewaySettingsKeepsTheThreeMeanings(t *testing.T) {
settings := normalizeGatewaySettings(GatewaySettings{
Timezone: "Pacific/Auckland", LogLevel: " Debug ",
SonarrAlertMinutes: -9, RadarrAlertMinutes: 0, EmbyHealthSeconds: 5,
})
if settings.Timezone != "Pacific/Auckland" {
t.Fatalf("a real zone should survive, got %q", settings.Timezone)
}
if settings.LogLevel != "debug" {
t.Fatalf("a level should be trimmed and folded, got %q", settings.LogLevel)
}
if settings.SonarrAlertMinutes != GatewaySettingsOff {
t.Fatalf("every negative should collapse to the one off value, got %d",
settings.SonarrAlertMinutes)
}
if settings.RadarrAlertMinutes != 0 {
t.Fatalf("zero must keep meaning deployed, got %d", settings.RadarrAlertMinutes)
}
// Below the floor rather than refused: an operator asking for a five-second probe has
// said "as often as possible", and the answer to that is the fastest allowed.
if settings.EmbyHealthSeconds != 10 {
t.Fatalf("a value under the floor should clamp to it, got %d", settings.EmbyHealthSeconds)
}
}
+21
View File
@@ -57,6 +57,27 @@ CREATE TABLE IF NOT EXISTS device_versions (
CREATE INDEX IF NOT EXISTS device_versions_recent_idx
ON device_versions (device_id, last_seen_at DESC);
-- One row per television per day it was used.
--
-- It exists to answer "is this the first time this set has opened Memby today", which is
-- a question a counter or a timestamp cannot answer safely: every television in the house
-- asks at once, so the answer has to come from the insert itself. The primary key is what
-- makes it atomic — the row either went in, which means first, or it did not.
--
-- The day is the *household's* local day, computed against MEMBY_TIMEZONE and stored as a
-- plain date, because "today" is a thing the people watching have an opinion about and
-- UTC does not agree with it for most of a New Zealand evening.
CREATE TABLE IF NOT EXISTS device_activity_days (
device_id TEXT NOT NULL,
emby_user_id TEXT NOT NULL,
day DATE NOT NULL,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (device_id, emby_user_id, day)
);
CREATE INDEX IF NOT EXISTS device_activity_days_day_idx
ON device_activity_days (day);
-- The imported library.
--
-- payload is Emby's item JSON verbatim, so rows served from here are byte-identical to