0.1.38 gateway

This commit is contained in:
ponzischeme89
2026-08-14 09:40:03 +12:00
parent abc392d30b
commit 5e2ed3d12e
2847 changed files with 1072928 additions and 3783 deletions
+247
View File
@@ -0,0 +1,247 @@
package store
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
// AdminEventRetention is how long an administrative event is kept. The feed is a rolling
// operational record, not an archive: anything worth keeping longer is already a log line
// or a row in the table the event was about.
const AdminEventRetention = 30 * 24 * time.Hour
// Severities. Three, not six — this decides whether a row is worth an operator's
// attention, and a scale finer than that is one nobody applies consistently.
const (
SeverityInfo = "info"
SeverityWarning = "warning"
SeverityError = "error"
)
// AdminEvent is one thing that happened, in the shape the console and every integration
// read it in.
//
// The vocabulary is deliberately about *shape* rather than about any particular subject:
// Type is a routing key, Title is the headline, Summary is the sentence, Actor is who or
// what caused it and Target is what it happened to. A publisher that cannot fill one
// leaves it blank. Metadata is for the fields only that publisher's readers understand,
// and nothing in the console depends on any key in it, so adding one is never a
// migration.
//
// Link is a console path (not a URL), so an event can carry the operator to the page that
// explains it. It is a path because the console does not reliably know its own external
// address, exactly as the gateway does not for a subtitle it serves.
type AdminEvent struct {
ID int64 `json:"id"`
OccurredAt time.Time `json:"occurredAt"`
Type string `json:"type"`
Severity string `json:"severity"`
Title string `json:"title"`
Summary string `json:"summary"`
Actor string `json:"actor,omitempty"`
Target string `json:"target,omitempty"`
Link string `json:"link,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
ReadAt *time.Time `json:"readAt,omitempty"`
}
// Read reports whether the operator has already seen this one.
func (e AdminEvent) Read() bool { return e.ReadAt != nil }
// AdminEventFilter narrows the feed. Types is a set rather than a single value because
// the natural question is "show me the authentication ones", which is several types.
type AdminEventFilter struct {
Types []string
Severities []string
UnreadOnly bool
Since time.Time
Limit int
Offset int
}
// AdminEventPage carries the unread count alongside the rows, because the bell needs it
// and asking for it separately would mean the badge and the list could disagree.
type AdminEventPage struct {
Events []AdminEvent `json:"events"`
Total int `json:"total"`
Unread int `json:"unread"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// RecordAdminEvent persists one event and fills in its assigned id and timestamp, which
// the caller needs in order to broadcast the same row it stored — a subscriber shown an
// event with no id could neither mark it read nor recognise it on a later poll.
func (s *Store) RecordAdminEvent(ctx context.Context, event AdminEvent) (AdminEvent, error) {
if event.Severity == "" {
event.Severity = SeverityInfo
}
if len(event.Metadata) == 0 {
event.Metadata = json.RawMessage(`{}`)
}
err := s.pool.QueryRow(ctx, `
INSERT INTO admin_events (type, severity, title, summary, actor, target, link, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, occurred_at`,
event.Type, event.Severity, event.Title, event.Summary,
event.Actor, event.Target, event.Link, []byte(event.Metadata),
).Scan(&event.ID, &event.OccurredAt)
if err != nil {
return event, fmt.Errorf("store: record admin event: %w", err)
}
return event, nil
}
func adminEventWhere(filter AdminEventFilter) (string, []any) {
clauses := []string{"TRUE"}
args := []any{}
add := func(clause string, value any) {
args = append(args, value)
clauses = append(clauses, fmt.Sprintf(clause, len(args)))
}
if len(filter.Types) > 0 {
add("type = ANY($%d::text[])", filter.Types)
}
if len(filter.Severities) > 0 {
add("severity = ANY($%d::text[])", filter.Severities)
}
if filter.UnreadOnly {
clauses = append(clauses, "read_at IS NULL")
}
if !filter.Since.IsZero() {
add("occurred_at >= $%d", filter.Since)
}
return strings.Join(clauses, " AND "), args
}
// AdminEvents answers the filtered feed, newest first.
func (s *Store) AdminEvents(ctx context.Context, filter AdminEventFilter) (AdminEventPage, error) {
limit := filter.Limit
if limit <= 0 || limit > 200 {
limit = 50
}
offset := filter.Offset
if offset < 0 {
offset = 0
}
where, args := adminEventWhere(filter)
page := AdminEventPage{Events: []AdminEvent{}, Limit: limit, Offset: offset}
// The unread count is over the *whole* feed rather than the filter: the badge says how
// much news there is, and a filtered view that also shrank the badge would report the
// household as caught up because the operator had narrowed the list.
if err := s.pool.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE `+where+`), count(*) FILTER (WHERE read_at IS NULL)
FROM admin_events`, args...,
).Scan(&page.Total, &page.Unread); err != nil {
return page, fmt.Errorf("store: count admin events: %w", err)
}
rows, err := s.pool.Query(ctx, `
SELECT id, occurred_at, type, severity, title, summary, actor, target, link,
metadata, read_at
FROM admin_events
WHERE `+where+`
ORDER BY occurred_at DESC, id DESC
LIMIT $`+fmt.Sprint(len(args)+1)+` OFFSET $`+fmt.Sprint(len(args)+2),
append(args, limit, offset)...)
if err != nil {
return page, fmt.Errorf("store: list admin events: %w", err)
}
defer rows.Close()
for rows.Next() {
var event AdminEvent
var metadata []byte
if err := rows.Scan(&event.ID, &event.OccurredAt, &event.Type, &event.Severity,
&event.Title, &event.Summary, &event.Actor, &event.Target, &event.Link,
&metadata, &event.ReadAt); err != nil {
return page, fmt.Errorf("store: scan admin event: %w", err)
}
event.Metadata = json.RawMessage(metadata)
page.Events = append(page.Events, event)
}
return page, rows.Err()
}
// MarkAdminEventsRead marks the given ids, or every unread event when none are given.
func (s *Store) MarkAdminEventsRead(ctx context.Context, ids []int64) (int64, error) {
if len(ids) == 0 {
tag, err := s.pool.Exec(ctx,
`UPDATE admin_events SET read_at = now() WHERE read_at IS NULL`)
if err != nil {
return 0, fmt.Errorf("store: mark admin events read: %w", err)
}
return tag.RowsAffected(), nil
}
tag, err := s.pool.Exec(ctx,
`UPDATE admin_events SET read_at = now() WHERE read_at IS NULL AND id = ANY($1::bigint[])`,
ids)
if err != nil {
return 0, fmt.Errorf("store: mark admin events read: %w", err)
}
return tag.RowsAffected(), nil
}
// UnreadAdminEvents is the badge on its own, for callers that want the count without the
// rows behind it.
func (s *Store) UnreadAdminEvents(ctx context.Context) (int, error) {
var unread int
err := s.pool.QueryRow(ctx,
`SELECT count(*) FROM admin_events WHERE read_at IS NULL`).Scan(&unread)
if err != nil {
return 0, fmt.Errorf("store: count unread admin events: %w", err)
}
return unread, nil
}
// AdminEventTypeCount is one type's share of the feed, for the filter control — a list of
// types built from what has actually been published cannot offer a filter that matches
// nothing, and cannot miss a type added after this was written.
type AdminEventTypeCount struct {
Type string `json:"type"`
Count int `json:"count"`
}
func (s *Store) AdminEventTypes(ctx context.Context, since time.Time) ([]AdminEventTypeCount, error) {
rows, err := s.pool.Query(ctx, `
SELECT type, count(*) FROM admin_events
WHERE ($1::timestamptz IS NULL OR occurred_at >= $1)
GROUP BY type ORDER BY count(*) DESC, type`, nullableTime(since))
if err != nil {
return nil, fmt.Errorf("store: admin event types: %w", err)
}
defer rows.Close()
counts := []AdminEventTypeCount{}
for rows.Next() {
var count AdminEventTypeCount
if err := rows.Scan(&count.Type, &count.Count); err != nil {
return nil, fmt.Errorf("store: scan admin event type: %w", err)
}
counts = append(counts, count)
}
return counts, rows.Err()
}
// PruneAdminEvents drops events past the retention period.
func (s *Store) PruneAdminEvents(ctx context.Context, retention time.Duration) (int64, error) {
if retention <= 0 {
retention = AdminEventRetention
}
tag, err := s.pool.Exec(ctx,
`DELETE FROM admin_events WHERE occurred_at < now() - $1::interval`,
fmt.Sprintf("%d seconds", int64(retention.Seconds())))
if err != nil {
return 0, fmt.Errorf("store: prune admin events: %w", err)
}
return tag.RowsAffected(), nil
}
func nullableTime(value time.Time) any {
if value.IsZero() {
return nil
}
return value
}
+95
View File
@@ -119,6 +119,101 @@ type JourneyActionStat struct {
Journeys int64 `json:"journeys"`
}
// ViewStats is app use measured at the launcher, rather than media playback. A visit is a
// foreground journey that reached home; a viewer is one signed-in profile in that window.
type ViewStats struct {
Visits int64 `json:"visits"`
Viewers int64 `json:"viewers"`
}
type ViewBucket struct {
Label string `json:"label"`
Visits int64 `json:"visits"`
Viewers int64 `json:"viewers"`
}
type ViewsReport struct {
Today ViewStats `json:"today"`
LastWeek ViewStats `json:"lastWeek"`
Daily []ViewBucket `json:"daily"`
Hourly []ViewBucket `json:"hourly"`
BusiestHour string `json:"busiestHour"`
}
// ViewsReport aggregates only app launcher openings. `journey_start` remains in the
// predicate so deployments gain a useful history before clients begin sending home_open.
func (s *Store) ViewsReport(ctx context.Context, now time.Time) (ViewsReport, error) {
var out ViewsReport
location, err := time.LoadLocation("Pacific/Auckland")
if err != nil {
location = time.UTC
}
localNow := now.In(location)
todayStart := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, location).UTC()
// The dashboard comparison ends at the same elapsed point in last week's day, not at
// midnight, so a morning view is compared with another morning rather than a full day.
lastWeekStart := todayStart.AddDate(0, 0, -7)
lastWeekEnd := lastWeekStart.Add(now.Sub(todayStart))
count := func(start, end time.Time) (ViewStats, error) {
var value ViewStats
err := s.pool.QueryRow(ctx, `SELECT count(DISTINCT journey_id), count(DISTINCT emby_user_id)
FROM journey_events WHERE occurred_at >= $1 AND occurred_at < $2
AND action IN ('home_open', 'journey_start')`, start, end).Scan(&value.Visits, &value.Viewers)
return value, err
}
if out.Today, err = count(todayStart, now); err != nil {
return out, fmt.Errorf("store: today views: %w", err)
}
if out.LastWeek, err = count(lastWeekStart, lastWeekEnd); err != nil {
return out, fmt.Errorf("store: last-week views: %w", err)
}
rows, err := s.pool.Query(ctx, `SELECT to_char(timezone('Pacific/Auckland', occurred_at), 'YYYY-MM-DD'),
count(DISTINCT journey_id), count(DISTINCT emby_user_id)
FROM journey_events WHERE occurred_at >= $1 AND action IN ('home_open', 'journey_start')
GROUP BY 1 ORDER BY 1`, todayStart.AddDate(0, 0, -29))
if err != nil {
return out, fmt.Errorf("store: daily views: %w", err)
}
for rows.Next() {
var bucket ViewBucket
if err := rows.Scan(&bucket.Label, &bucket.Visits, &bucket.Viewers); err != nil {
rows.Close()
return out, err
}
out.Daily = append(out.Daily, bucket)
}
if err := rows.Err(); err != nil {
rows.Close()
return out, err
}
rows.Close()
rows, err = s.pool.Query(ctx, `SELECT to_char(timezone('Pacific/Auckland', occurred_at), 'HH24:00'),
count(DISTINCT journey_id), count(DISTINCT emby_user_id)
FROM journey_events WHERE occurred_at >= $1 AND occurred_at < $2 AND action IN ('home_open', 'journey_start')
GROUP BY 1 ORDER BY 1`, todayStart, now)
if err != nil {
return out, fmt.Errorf("store: hourly views: %w", err)
}
var busiestVisits int64
for rows.Next() {
var bucket ViewBucket
if err := rows.Scan(&bucket.Label, &bucket.Visits, &bucket.Viewers); err != nil {
rows.Close()
return out, err
}
out.Hourly = append(out.Hourly, bucket)
if bucket.Visits > busiestVisits {
busiestVisits = bucket.Visits
out.BusiestHour = bucket.Label
}
}
if err := rows.Err(); err != nil {
rows.Close()
return out, err
}
return out, nil
}
// Event kinds. Impressions say a row was drawn; focus says the remote actually landed
// on it and for how long; select says something was opened from it.
const (
+177
View File
@@ -0,0 +1,177 @@
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// IntegrationsKey is the app_settings row holding every configured external destination.
//
// One document rather than a table, for the reason the feature policy is one document:
// what an integration *is* belongs to the code, and only the operator's choices belong in
// the database. A schema per integration would make adding a second one a migration.
const IntegrationsKey = "integrations"
// MaxIntegrations bounds the list. This is a household's operations console, not a
// notification fan-out service, and an unbounded list is an unbounded number of outbound
// requests per event.
const MaxIntegrations = 20
// IntegrationDiscord is the only kind today. The kind is stored rather than inferred from
// the URL, because a second webhook-shaped destination — Slack, ntfy — would be
// indistinguishable otherwise and would silently receive Discord's payload.
const IntegrationDiscord = "discord"
// Integration is one configured destination.
//
// Events is an explicit selection rather than a "send everything" flag with exceptions:
// an integration that quietly gained a new event type because one was added to the
// gateway is one that starts posting things nobody asked for. An empty selection
// therefore sends nothing, and the console says so.
type Integration struct {
ID string `json:"id"`
Kind string `json:"kind"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
URL string `json:"url"`
Events []string `json:"events"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Redacted is the integration as the console may read it.
//
// The webhook URL *is* the credential — anybody holding it can post into the channel — so
// it is never returned, exactly as the MDBList key and the OpenSubtitles login are never
// returned. What the console needs is whether one is set and enough of it to recognise
// which channel this row is, which is what Hint carries.
type RedactedIntegration struct {
Integration
URL string `json:"url,omitempty"` // always empty; the field stays for shape
HasURL bool `json:"hasUrl"`
Hint string `json:"hint,omitempty"`
}
type IntegrationSettings struct {
Integrations []Integration `json:"integrations"`
UpdatedAt time.Time `json:"updatedAt"`
}
// webhookHint is the recognisable, non-secret part of a Discord webhook URL: the channel
// id in the path, and never the token after it. A hint that included any of the token
// would be a leak wearing a shorter name.
func webhookHint(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parts := strings.Split(strings.TrimSuffix(raw, "/"), "/")
// .../api/webhooks/<channel id>/<token>
if len(parts) >= 2 {
if id := parts[len(parts)-2]; id != "" && len(id) <= 32 {
return "…/" + id
}
}
return "configured"
}
// Redact prepares an integration for the console.
func (i Integration) Redact() RedactedIntegration {
return RedactedIntegration{
Integration: Integration{
ID: i.ID, Kind: i.Kind, Name: i.Name, Enabled: i.Enabled,
Events: i.Events, CreatedAt: i.CreatedAt, UpdatedAt: i.UpdatedAt,
},
HasURL: strings.TrimSpace(i.URL) != "",
Hint: webhookHint(i.URL),
}
}
func normalizeIntegrations(settings IntegrationSettings) IntegrationSettings {
seen := map[string]bool{}
cleaned := make([]Integration, 0, len(settings.Integrations))
for _, integration := range settings.Integrations {
integration.ID = strings.TrimSpace(integration.ID)
if integration.ID == "" || seen[integration.ID] || len(cleaned) >= MaxIntegrations {
continue
}
seen[integration.ID] = true
if integration.Kind == "" {
integration.Kind = IntegrationDiscord
}
integration.Name = strings.TrimSpace(integration.Name)
if integration.Name == "" {
integration.Name = "Discord"
}
if runes := []rune(integration.Name); len(runes) > 60 {
integration.Name = string(runes[:60])
}
integration.URL = strings.TrimSpace(integration.URL)
// An integration with no destination cannot be on. Storing it as enabled would
// give the console a switch that reports "on" over something that can never
// deliver, which is the state the subtitle settings deliberately refuse too.
if integration.URL == "" {
integration.Enabled = false
}
events := make([]string, 0, len(integration.Events))
eventSeen := map[string]bool{}
for _, event := range integration.Events {
event = strings.TrimSpace(event)
if event == "" || eventSeen[event] || len(events) >= 40 {
continue
}
eventSeen[event] = true
events = append(events, event)
}
integration.Events = events
cleaned = append(cleaned, integration)
}
settings.Integrations = cleaned
return settings
}
func (s *Store) Integrations(ctx context.Context) (IntegrationSettings, error) {
empty := IntegrationSettings{Integrations: []Integration{}}
var raw []byte
err := s.pool.QueryRow(ctx,
`SELECT value FROM app_settings WHERE key = $1`, IntegrationsKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return empty, nil
}
if err != nil {
return empty, fmt.Errorf("store: read integrations: %w", err)
}
var settings IntegrationSettings
if err := json.Unmarshal(raw, &settings); err != nil {
return empty, fmt.Errorf("store: decode integrations: %w", err)
}
settings = normalizeIntegrations(settings)
if settings.Integrations == nil {
settings.Integrations = []Integration{}
}
return settings, nil
}
func (s *Store) SetIntegrations(ctx context.Context, settings IntegrationSettings) error {
settings = normalizeIntegrations(settings)
settings.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(settings)
if err != nil {
return 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()`,
IntegrationsKey, string(raw))
if err != nil {
return fmt.Errorf("store: write integrations: %w", err)
}
return nil
}
@@ -0,0 +1,95 @@
package store
import (
"strings"
"testing"
)
const sampleWebhook = "https://discord.com/api/webhooks/1234567890/abcdefSECRETtoken"
// The redaction rule is the one thing here that must not be got wrong. The webhook URL is
// the credential: anybody holding it can post into the household's channel, and this
// console is read over the open internet.
func TestRedactNeverReturnsTheWebhookAddress(t *testing.T) {
redacted := Integration{ID: "a", Name: "Family", URL: sampleWebhook}.Redact()
if redacted.URL != "" {
t.Fatalf("the webhook address must never leave the server, got %q", redacted.URL)
}
if strings.Contains(redacted.Hint, "SECRET") {
t.Fatalf("the hint leaked part of the token: %q", redacted.Hint)
}
if !redacted.HasURL {
t.Fatal("the console still has to know one is configured")
}
}
func TestRedactHintNamesTheChannelSoRowsAreTellableApart(t *testing.T) {
redacted := Integration{ID: "a", URL: sampleWebhook}.Redact()
if !strings.Contains(redacted.Hint, "1234567890") {
t.Fatalf("the hint should carry the channel id, got %q", redacted.Hint)
}
}
func TestRedactSaysNothingWhenNothingIsConfigured(t *testing.T) {
redacted := Integration{ID: "a"}.Redact()
if redacted.HasURL || redacted.Hint != "" {
t.Fatalf("an unconfigured integration should claim nothing, got %+v", redacted)
}
}
func TestNormalizeRefusesToEnableAnIntegrationWithNoDestination(t *testing.T) {
// A switch reading "on" over something that can never deliver is the state the
// subtitle settings deliberately refuse too.
settings := normalizeIntegrations(IntegrationSettings{Integrations: []Integration{
{ID: "a", Enabled: true, URL: " "},
}})
if settings.Integrations[0].Enabled {
t.Fatal("an integration with no address must not be stored as enabled")
}
}
func TestNormalizeDropsDuplicateAndAnonymousRows(t *testing.T) {
settings := normalizeIntegrations(IntegrationSettings{Integrations: []Integration{
{ID: "a", URL: sampleWebhook},
{ID: "a", URL: sampleWebhook},
{ID: " ", URL: sampleWebhook},
}})
if len(settings.Integrations) != 1 {
t.Fatalf("expected one surviving row, got %d", len(settings.Integrations))
}
}
func TestNormalizeDeduplicatesTheEventSelection(t *testing.T) {
// A repeated event would deliver the same message twice to one channel.
settings := normalizeIntegrations(IntegrationSettings{Integrations: []Integration{
{ID: "a", URL: sampleWebhook, Events: []string{"auth.login", "auth.login", " ", "device.registered"}},
}})
events := settings.Integrations[0].Events
if len(events) != 2 || events[0] != "auth.login" || events[1] != "device.registered" {
t.Fatalf("unexpected selection %v", events)
}
}
func TestNormalizeBoundsTheList(t *testing.T) {
// Every event costs one outbound request per enabled destination.
many := make([]Integration, MaxIntegrations+5)
for i := range many {
many[i] = Integration{ID: string(rune('a'+i%26)) + itoa(i), URL: sampleWebhook}
}
settings := normalizeIntegrations(IntegrationSettings{Integrations: many})
if len(settings.Integrations) != MaxIntegrations {
t.Fatalf("expected the list capped at %d, got %d", MaxIntegrations, len(settings.Integrations))
}
}
func TestNormalizeDefaultsTheKindSoAnOlderDocumentStillDelivers(t *testing.T) {
// The kind was introduced with the second transport in mind; a row written before it
// has none, and reading that as "no transport" would silently stop delivering.
settings := normalizeIntegrations(IntegrationSettings{Integrations: []Integration{
{ID: "a", URL: sampleWebhook},
}})
if settings.Integrations[0].Kind != IntegrationDiscord {
t.Fatalf("expected a default kind, got %q", settings.Integrations[0].Kind)
}
}
+447
View File
@@ -0,0 +1,447 @@
package store
import (
"context"
"fmt"
"strings"
"time"
)
// LoginRetention is how long a sign-in attempt is kept. Long enough to answer "has this
// television been connecting all month", short enough that the table stays a working
// record rather than an archive nobody prunes. The scheduled housekeeping task reads this
// rather than carrying a number of its own, so the retention lives in one place.
const LoginRetention = 90 * 24 * time.Hour
// Authentication methods. A sign-in is recorded with the route that made it, because
// "this television signed in" and "somebody opened the admin console" are different
// events wearing the same shape, and an operator reading the list must be able to tell
// them apart without inferring it from the device name.
const (
LoginMethodPassword = "password" // a television exchanging Emby credentials
LoginMethodAdmin = "admin" // an operator signing into the admin console
LoginMethodInstaller = "installer" // the web installer's own password check
)
// LoginEvent is one attempt, successful or not.
//
// It is deliberately a *history* rather than a counter on the session row: a session
// carries only the state of the television right now, is overwritten by the next sign-in
// and is deleted when the device is removed — so on its own it can never answer "how many
// times did this set connect today, at what times, and from which addresses". Every field
// here is what was true at the moment of the attempt, including the ones that later move.
type LoginEvent struct {
ID int64 `json:"id"`
OccurredAt time.Time `json:"occurredAt"`
EmbyUserID string `json:"embyUserId"`
Username string `json:"username"`
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
ClientVersion string `json:"clientVersion"`
ClientProtocol string `json:"clientProtocol"`
IPAddress string `json:"ipAddress"`
Success bool `json:"success"`
Method string `json:"method"`
FailureReason string `json:"failureReason,omitempty"`
NewDevice bool `json:"newDevice"`
}
// LoginFilter narrows the history. Every field is optional and an unset field is "any",
// so the console's filter bar maps onto it one control per field with no special cases.
type LoginFilter struct {
EmbyUserID string
DeviceID string
IPAddress string
Query string // free text over username, device name and address
From time.Time
To time.Time
Outcome string // "success" | "failure" | "" for both
Method string
Limit int
Offset int
}
// LoginPage is a window onto the history plus the size of the whole match, so the console
// can page without asking twice and can say "showing 50 of 1,284".
type LoginPage struct {
Events []LoginEvent `json:"events"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// LoginDeviceSummary is one television's whole relationship with the gateway: how often
// it has signed in, when it last managed to, and how many distinct addresses it has come
// from. This is the row the devices table is built out of.
type LoginDeviceSummary struct {
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
EmbyUserID string `json:"embyUserId"`
Username string `json:"username"`
ClientVersion string `json:"clientVersion"`
Logins int `json:"logins"`
Failures int `json:"failures"`
FirstLogin time.Time `json:"firstLogin"`
LastLogin time.Time `json:"lastLogin"`
LastIP string `json:"lastIp"`
DistinctIPs int `json:"distinctIps"`
LoginsToday int `json:"loginsToday"`
}
// LoginDay is one local day's tally. The day is a string rather than a time because it is
// a *label* — the calendar day in the household's zone — and turning it back into an
// instant on the way through the console would invite it being re-formatted in the
// browser's zone instead, which is how a set that connected at 11pm moves to tomorrow.
type LoginDay struct {
Day string `json:"day"`
Logins int `json:"logins"`
Failures int `json:"failures"`
Devices int `json:"devices"`
}
// LoginAddress is one source address a device or a household has been seen from.
type LoginAddress struct {
IPAddress string `json:"ipAddress"`
Logins int `json:"logins"`
Failures int `json:"failures"`
FirstSeen time.Time `json:"firstSeen"`
LastSeen time.Time `json:"lastSeen"`
}
// RecordLogin writes one attempt.
//
// Callers treat a failure here as unimportant: a sign-in that succeeded must not be
// undone because the audit row could not be written, and a sign-in that failed is already
// being refused. It is the caller that decides that, not this function, which is why the
// error is still returned.
func (s *Store) RecordLogin(ctx context.Context, event LoginEvent) error {
if event.Method == "" {
event.Method = LoginMethodPassword
}
_, err := s.pool.Exec(ctx, `
INSERT INTO login_events (
emby_user_id, username, device_id, device_name, client_version,
client_protocol, ip_address, success, method, failure_reason, new_device
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
event.EmbyUserID, event.Username, event.DeviceID, event.DeviceName,
event.ClientVersion, event.ClientProtocol, event.IPAddress, event.Success,
event.Method, event.FailureReason, event.NewDevice)
if err != nil {
return fmt.Errorf("store: record login: %w", err)
}
return nil
}
// loginWhere builds the shared predicate. Both the page query and its count run against
// exactly the same clause — derived once rather than written twice, because a filter that
// applied to the rows and not to the total is a table that says "50 of 1,284" over
// something else's total.
func loginWhere(filter LoginFilter) (string, []any) {
clauses := []string{"TRUE"}
args := []any{}
add := func(clause string, value any) {
args = append(args, value)
clauses = append(clauses, fmt.Sprintf(clause, len(args)))
}
if id := strings.TrimSpace(filter.EmbyUserID); id != "" {
add("emby_user_id = $%d", id)
}
if id := strings.TrimSpace(filter.DeviceID); id != "" {
add("device_id = $%d", id)
}
if ip := strings.TrimSpace(filter.IPAddress); ip != "" {
add("ip_address = $%d", ip)
}
if query := strings.TrimSpace(filter.Query); query != "" {
add("(username ILIKE '%%' || $%[1]d || '%%'"+
" OR device_name ILIKE '%%' || $%[1]d || '%%'"+
" OR ip_address ILIKE '%%' || $%[1]d || '%%')", query)
}
if !filter.From.IsZero() {
add("occurred_at >= $%d", filter.From)
}
if !filter.To.IsZero() {
add("occurred_at < $%d", filter.To)
}
switch filter.Outcome {
case "success":
clauses = append(clauses, "success")
case "failure":
clauses = append(clauses, "NOT success")
}
if method := strings.TrimSpace(filter.Method); method != "" {
add("method = $%d", method)
}
return strings.Join(clauses, " AND "), args
}
// LoginEvents answers the filtered history, newest first.
func (s *Store) LoginEvents(ctx context.Context, filter LoginFilter) (LoginPage, error) {
limit := filter.Limit
if limit <= 0 || limit > 500 {
limit = 100
}
offset := filter.Offset
if offset < 0 {
offset = 0
}
where, args := loginWhere(filter)
page := LoginPage{Events: []LoginEvent{}, Limit: limit, Offset: offset}
if err := s.pool.QueryRow(ctx,
`SELECT count(*) FROM login_events WHERE `+where, args...,
).Scan(&page.Total); err != nil {
return page, fmt.Errorf("store: count logins: %w", err)
}
rows, err := s.pool.Query(ctx, `
SELECT id, occurred_at, emby_user_id, username, device_id, device_name,
client_version, client_protocol, ip_address, success, method,
failure_reason, new_device
FROM login_events
WHERE `+where+`
ORDER BY occurred_at DESC, id DESC
LIMIT $`+fmt.Sprint(len(args)+1)+` OFFSET $`+fmt.Sprint(len(args)+2),
append(args, limit, offset)...)
if err != nil {
return page, fmt.Errorf("store: list logins: %w", err)
}
defer rows.Close()
for rows.Next() {
var event LoginEvent
if err := rows.Scan(
&event.ID, &event.OccurredAt, &event.EmbyUserID, &event.Username,
&event.DeviceID, &event.DeviceName, &event.ClientVersion,
&event.ClientProtocol, &event.IPAddress, &event.Success, &event.Method,
&event.FailureReason, &event.NewDevice,
); err != nil {
return page, fmt.Errorf("store: scan login: %w", err)
}
page.Events = append(page.Events, event)
}
return page, rows.Err()
}
// LoginDeviceSummaries is the devices table: one row per television the history knows
// about, ordered by how recently it managed to sign in.
//
// It is grouped from the history rather than joined onto sessions on purpose. A set that
// has been removed, or whose session has expired, still connected — and the question this
// page exists to answer is about what happened, not about what is currently valid. The
// session row is what supplies the *current* name and build where one still exists, which
// is why the two are combined here rather than one being preferred outright.
func (s *Store) LoginDeviceSummaries(ctx context.Context, filter LoginFilter, zone string) ([]LoginDeviceSummary, error) {
where, args := loginWhere(filter)
args = append(args, zone)
zoneArg := fmt.Sprintf("$%d", len(args))
rows, err := s.pool.Query(ctx, `
WITH history AS (
SELECT * FROM login_events WHERE `+where+`
),
summary AS (
SELECT device_id,
count(*) FILTER (WHERE success) AS logins,
count(*) FILTER (WHERE NOT success) AS failures,
min(occurred_at) FILTER (WHERE success) AS first_login,
max(occurred_at) FILTER (WHERE success) AS last_login,
count(DISTINCT ip_address) FILTER (WHERE ip_address <> '') AS distinct_ips,
count(*) FILTER (
WHERE success
AND (occurred_at AT TIME ZONE `+zoneArg+`)::date
= (now() AT TIME ZONE `+zoneArg+`)::date
) AS logins_today
FROM history GROUP BY device_id
),
latest AS (
SELECT DISTINCT ON (device_id)
device_id, emby_user_id, username, device_name, client_version, ip_address
FROM history
ORDER BY device_id, occurred_at DESC, id DESC
)
SELECT l.device_id,
COALESCE(s.device_name, l.device_name),
l.emby_user_id, l.username,
COALESCE(NULLIF(s.client_version, ''), l.client_version),
summary.logins, summary.failures,
summary.first_login, summary.last_login,
l.ip_address, summary.distinct_ips, summary.logins_today
FROM latest l
JOIN summary ON summary.device_id = l.device_id
LEFT JOIN sessions s
ON s.device_id = l.device_id AND s.emby_user_id = l.emby_user_id
ORDER BY summary.last_login DESC NULLS LAST, l.device_id`, args...)
if err != nil {
return nil, fmt.Errorf("store: summarise login devices: %w", err)
}
defer rows.Close()
summaries := []LoginDeviceSummary{}
for rows.Next() {
var row LoginDeviceSummary
var first, last *time.Time
if err := rows.Scan(
&row.DeviceID, &row.DeviceName, &row.EmbyUserID, &row.Username,
&row.ClientVersion, &row.Logins, &row.Failures, &first, &last,
&row.LastIP, &row.DistinctIPs, &row.LoginsToday,
); err != nil {
return nil, fmt.Errorf("store: scan login device: %w", err)
}
// A device with failures and no successes has no first or last login, which is a
// real answer and not a zero one — the console prints an em dash for it.
if first != nil {
row.FirstLogin = *first
}
if last != nil {
row.LastLogin = *last
}
summaries = append(summaries, row)
}
return summaries, rows.Err()
}
// LoginDays groups the filtered history by local calendar day, oldest first so the
// console can draw it left to right without reversing it.
//
// The zone is the household's, passed in rather than read here: grouping in UTC would
// move every evening sign-in in New Zealand onto the following day, which is exactly the
// kind of quiet wrongness an operator would never think to question.
func (s *Store) LoginDays(ctx context.Context, filter LoginFilter, zone string) ([]LoginDay, error) {
where, args := loginWhere(filter)
args = append(args, zone)
zoneArg := fmt.Sprintf("$%d", len(args))
rows, err := s.pool.Query(ctx, `
SELECT to_char((occurred_at AT TIME ZONE `+zoneArg+`)::date, 'YYYY-MM-DD') AS day,
count(*) FILTER (WHERE success) AS logins,
count(*) FILTER (WHERE NOT success) AS failures,
count(DISTINCT device_id) FILTER (WHERE success) AS devices
FROM login_events
WHERE `+where+`
GROUP BY day
ORDER BY day`, args...)
if err != nil {
return nil, fmt.Errorf("store: group logins by day: %w", err)
}
defer rows.Close()
days := []LoginDay{}
for rows.Next() {
var day LoginDay
if err := rows.Scan(&day.Day, &day.Logins, &day.Failures, &day.Devices); err != nil {
return nil, fmt.Errorf("store: scan login day: %w", err)
}
days = append(days, day)
}
return days, rows.Err()
}
// LoginAddresses is where the filtered attempts came from, busiest first.
func (s *Store) LoginAddresses(ctx context.Context, filter LoginFilter, limit int) ([]LoginAddress, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
where, args := loginWhere(filter)
args = append(args, limit)
rows, err := s.pool.Query(ctx, `
SELECT ip_address,
count(*) FILTER (WHERE success) AS logins,
count(*) FILTER (WHERE NOT success) AS failures,
min(occurred_at), max(occurred_at)
FROM login_events
WHERE `+where+` AND ip_address <> ''
GROUP BY ip_address
ORDER BY count(*) DESC, max(occurred_at) DESC
LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return nil, fmt.Errorf("store: group logins by address: %w", err)
}
defer rows.Close()
addresses := []LoginAddress{}
for rows.Next() {
var address LoginAddress
if err := rows.Scan(&address.IPAddress, &address.Logins, &address.Failures,
&address.FirstSeen, &address.LastSeen); err != nil {
return nil, fmt.Errorf("store: scan login address: %w", err)
}
addresses = append(addresses, address)
}
return addresses, rows.Err()
}
// LoginTotals is the headline: how the whole filtered window breaks down. It is its own
// query rather than a sum of the page above it, for the reason SearchTotals is — the page
// is a window, and adding it up would report one screenful's total as the household's.
type LoginTotals struct {
Logins int `json:"logins"`
Failures int `json:"failures"`
Devices int `json:"devices"`
Users int `json:"users"`
Addresses int `json:"addresses"`
First time.Time `json:"first"`
Last time.Time `json:"last"`
}
func (s *Store) LoginTotals(ctx context.Context, filter LoginFilter) (LoginTotals, error) {
where, args := loginWhere(filter)
var totals LoginTotals
var first, last *time.Time
err := s.pool.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE success),
count(*) FILTER (WHERE NOT success),
count(DISTINCT device_id) FILTER (WHERE device_id <> ''),
count(DISTINCT emby_user_id) FILTER (WHERE emby_user_id <> ''),
count(DISTINCT ip_address) FILTER (WHERE ip_address <> ''),
min(occurred_at), max(occurred_at)
FROM login_events WHERE `+where, args...).
Scan(&totals.Logins, &totals.Failures, &totals.Devices, &totals.Users,
&totals.Addresses, &first, &last)
if err != nil {
return totals, fmt.Errorf("store: login totals: %w", err)
}
if first != nil {
totals.First = *first
}
if last != nil {
totals.Last = *last
}
return totals, nil
}
// DeviceHasLoggedIn reports whether this television has ever successfully signed in
// before. It is what makes "new device registered" a distinguishable event rather than
// one indistinguishable from every subsequent sign-in by the same set — asked before the
// attempt is recorded, so the current sign-in cannot answer for itself.
func (s *Store) DeviceHasLoggedIn(ctx context.Context, embyUserID, deviceID string) (bool, error) {
if deviceID == "" {
return true, nil
}
var exists bool
err := s.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM login_events
WHERE device_id = $1 AND emby_user_id = $2 AND success
)`, deviceID, embyUserID).Scan(&exists)
if err != nil {
return false, fmt.Errorf("store: device login lookup: %w", err)
}
return exists, nil
}
// PruneLoginEvents drops attempts older than the retention period. Called by the
// scheduled housekeeping task rather than on the write path: pruning inside a sign-in
// would put a delete over the whole table in front of somebody waiting to watch
// something.
func (s *Store) PruneLoginEvents(ctx context.Context, retention time.Duration) (int64, error) {
if retention <= 0 {
retention = LoginRetention
}
tag, err := s.pool.Exec(ctx,
`DELETE FROM login_events WHERE occurred_at < now() - $1::interval`,
fmt.Sprintf("%d seconds", int64(retention.Seconds())))
if err != nil {
return 0, fmt.Errorf("store: prune login events: %w", err)
}
return tag.RowsAffected(), nil
}
+102
View File
@@ -0,0 +1,102 @@
package store
import (
"strings"
"testing"
"time"
)
// The filter predicate is the one piece of this feature worth pinning without a database:
// it is built by string concatenation with hand-numbered placeholders, and the failure it
// can produce is not a compile error or a crash but a *wrong answer* — a page of somebody
// else's sign-ins, or a total that does not match the rows above it.
func TestLoginWhereIsEmptyForAnEmptyFilter(t *testing.T) {
where, args := loginWhere(LoginFilter{})
if where != "TRUE" {
t.Fatalf("an unfiltered history should match everything, got %q", where)
}
if len(args) != 0 {
t.Fatalf("no filter means no arguments, got %v", args)
}
}
func TestLoginWhereNumbersPlaceholdersInOrder(t *testing.T) {
// Every argument must be referenced exactly once, by its own position. A duplicated or
// skipped number is how one filter silently applies another's value.
where, args := loginWhere(LoginFilter{
EmbyUserID: "user-1",
DeviceID: "device-1",
IPAddress: "10.0.0.9",
From: time.Now().Add(-time.Hour),
To: time.Now(),
Method: LoginMethodPassword,
})
if len(args) != 6 {
t.Fatalf("expected six bound values, got %d (%v)", len(args), args)
}
for position := 1; position <= len(args); position++ {
placeholder := "$" + itoa(position)
if strings.Count(where, placeholder) != 1 {
t.Fatalf("placeholder %s appears %d times in %q",
placeholder, strings.Count(where, placeholder), where)
}
}
}
func TestLoginWhereQueryReusesOneArgumentAcrossThreeColumns(t *testing.T) {
// The free-text control searches three columns from a single bound value. If it ever
// bound three, every later placeholder would be numbered wrongly.
where, args := loginWhere(LoginFilter{Query: "lounge"})
if len(args) != 1 {
t.Fatalf("free text should bind one value, got %d", len(args))
}
if strings.Count(where, "$1") != 3 {
t.Fatalf("free text should search three columns, got %q", where)
}
}
func TestLoginWhereOutcomeBindsNothing(t *testing.T) {
// Outcome is a literal predicate rather than a bound value; if it ever consumed an
// argument slot without appending to args, the numbering after it would be wrong.
for _, outcome := range []string{"success", "failure"} {
where, args := loginWhere(LoginFilter{Outcome: outcome, DeviceID: "device-1"})
if len(args) != 1 {
t.Fatalf("%s: expected one bound value, got %d", outcome, len(args))
}
if !strings.Contains(where, "$1") {
t.Fatalf("%s: the device filter lost its placeholder: %q", outcome, where)
}
}
}
func TestLoginWhereIgnoresBlankAndWhitespaceFilters(t *testing.T) {
// The console sends empty controls as empty strings. A blank that reached the query
// would match no rows at all rather than every row, which reads as "this television
// has never connected".
where, args := loginWhere(LoginFilter{EmbyUserID: " ", DeviceID: "", IPAddress: "\t"})
if where != "TRUE" || len(args) != 0 {
t.Fatalf("blank filters should not narrow anything, got %q %v", where, args)
}
}
func TestLoginWhereUnknownOutcomeMatchesBoth(t *testing.T) {
// An outcome the server does not recognise must mean "any", not "none": the filter is
// a convenience, and a typo in a bookmarked URL should not empty the page.
where, _ := loginWhere(LoginFilter{Outcome: "maybe"})
if strings.Contains(where, "success") {
t.Fatalf("an unrecognised outcome should not filter, got %q", where)
}
}
func itoa(value int) string {
if value == 0 {
return "0"
}
digits := ""
for value > 0 {
digits = string(rune('0'+value%10)) + digits
value /= 10
}
return digits
}
+27
View File
@@ -18,6 +18,33 @@ type MediaRequest struct {
RequestedAt time.Time `json:"requestedAt"`
}
// RequestUsage is the operator-facing use of the request feature. A recorded request is
// one that Memby has already handed to Radarr or Sonarr; their later download state is
// deliberately not duplicated here.
type RequestUsage struct {
UserID string `json:"userId"`
Requests int64 `json:"requests"`
LastRequest time.Time `json:"lastRequest,omitempty"`
}
func (s *Store) RequestUsage(ctx context.Context) ([]RequestUsage, error) {
rows, err := s.pool.Query(ctx, `SELECT emby_user_id, count(*), max(requested_at)
FROM media_requests GROUP BY emby_user_id`)
if err != nil {
return nil, fmt.Errorf("store: request usage: %w", err)
}
defer rows.Close()
out := []RequestUsage{}
for rows.Next() {
var value RequestUsage
if err := rows.Scan(&value.UserID, &value.Requests, &value.LastRequest); err != nil {
return nil, err
}
out = append(out, value)
}
return out, rows.Err()
}
// MediaRequestLimit caps what one viewer's page will read back. A household that has been
// asking for things for two years should not turn the page into an unbounded query, and
// nobody scrolls past the most recent hundred by remote.
+340
View File
@@ -0,0 +1,340 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// TaskRunRetention is how much run history a task keeps. Enough to see a pattern in an
// overnight job, not so much that a task running every five minutes fills the table.
const TaskRunRetention = 30 * 24 * time.Hour
// Run statuses.
const (
TaskRunning = "running"
TaskSuccess = "success"
TaskFailed = "failed"
TaskSkipped = "skipped"
)
// Triggers. A run says how it came to happen, because "it has not run since Tuesday" and
// "it has only ever run when somebody pressed the button" are different problems.
const (
TriggerSchedule = "schedule"
TriggerManual = "manual"
TriggerStartup = "startup"
)
// TaskRun is one execution.
type TaskRun struct {
ID int64 `json:"id"`
TaskID string `json:"taskId"`
Trigger string `json:"trigger"`
Status string `json:"status"`
StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
DurationMS int64 `json:"durationMs"`
Detail string `json:"detail,omitempty"`
Error string `json:"error,omitempty"`
}
// TaskSettings is an operator's override for one task. IntervalSeconds of 0 means "the
// interval the task declares", so a task whose schedule is changed in code takes effect
// for every operator who never overrode it.
type TaskSettings struct {
TaskID string `json:"taskId"`
Enabled bool `json:"enabled"`
IntervalSeconds int `json:"intervalSeconds"`
UpdatedAt time.Time `json:"updatedAt"`
}
// BeginTaskRun opens a run and returns its id. The row exists before the work starts so a
// task killed by a restart leaves evidence it began — which is the only way to tell a job
// that hangs from one that was never scheduled.
func (s *Store) BeginTaskRun(ctx context.Context, taskID, trigger string) (int64, error) {
var id int64
err := s.pool.QueryRow(ctx, `
INSERT INTO scheduled_task_runs (task_id, trigger, status)
VALUES ($1, $2, $3) RETURNING id`, taskID, trigger, TaskRunning).Scan(&id)
if err != nil {
return 0, fmt.Errorf("store: begin task run: %w", err)
}
return id, nil
}
// FinishTaskRun closes a run with its outcome.
func (s *Store) FinishTaskRun(ctx context.Context, id int64, status, detail, failure string) error {
_, err := s.pool.Exec(ctx, `
UPDATE scheduled_task_runs
SET status = $2, finished_at = now(), detail = $3, error = $4,
duration_ms = GREATEST(0, EXTRACT(EPOCH FROM (now() - started_at)) * 1000)::bigint
WHERE id = $1`, id, status, detail, failure)
if err != nil {
return fmt.Errorf("store: finish task run: %w", err)
}
return nil
}
// AbandonRunningTasks closes runs left open by a process that went away.
//
// Called once at start-up: a run in "running" with nothing running it is a lie the
// console would otherwise print for ever, and it is indistinguishable from a genuinely
// long job unless it is resolved at the one moment the answer is known — the moment the
// process that could have owned it has just started.
func (s *Store) AbandonRunningTasks(ctx context.Context) (int64, error) {
tag, err := s.pool.Exec(ctx, `
UPDATE scheduled_task_runs
SET status = $1, finished_at = now(),
error = 'interrupted by a server restart',
duration_ms = GREATEST(0, EXTRACT(EPOCH FROM (now() - started_at)) * 1000)::bigint
WHERE status = $2`, TaskFailed, TaskRunning)
if err != nil {
return 0, fmt.Errorf("store: abandon running tasks: %w", err)
}
return tag.RowsAffected(), nil
}
// LatestTaskRuns is the most recent run per task, in one query — the console draws this
// beside every task and a query per task would grow with the registry.
func (s *Store) LatestTaskRuns(ctx context.Context) (map[string]TaskRun, error) {
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT ON (task_id)
id, task_id, trigger, status, started_at, finished_at, duration_ms, detail, error
FROM scheduled_task_runs
ORDER BY task_id, started_at DESC, id DESC`)
if err != nil {
return nil, fmt.Errorf("store: latest task runs: %w", err)
}
defer rows.Close()
latest := map[string]TaskRun{}
for rows.Next() {
var run TaskRun
if err := rows.Scan(&run.ID, &run.TaskID, &run.Trigger, &run.Status,
&run.StartedAt, &run.FinishedAt, &run.DurationMS, &run.Detail,
&run.Error); err != nil {
return nil, fmt.Errorf("store: scan task run: %w", err)
}
latest[run.TaskID] = run
}
return latest, rows.Err()
}
// TaskRuns is the history for one task, or for every task when taskID is blank.
func (s *Store) TaskRuns(ctx context.Context, taskID string, limit int) ([]TaskRun, error) {
if limit <= 0 || limit > 500 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT id, task_id, trigger, status, started_at, finished_at, duration_ms, detail, error
FROM scheduled_task_runs
WHERE ($1 = '' OR task_id = $1)
ORDER BY started_at DESC, id DESC
LIMIT $2`, taskID, limit)
if err != nil {
return nil, fmt.Errorf("store: list task runs: %w", err)
}
defer rows.Close()
runs := []TaskRun{}
for rows.Next() {
var run TaskRun
if err := rows.Scan(&run.ID, &run.TaskID, &run.Trigger, &run.Status,
&run.StartedAt, &run.FinishedAt, &run.DurationMS, &run.Detail,
&run.Error); err != nil {
return nil, fmt.Errorf("store: scan task run: %w", err)
}
runs = append(runs, run)
}
return runs, rows.Err()
}
// TaskSettingsAll returns every override. A task with no row is absent from the map, and
// absence means "as declared" — see TaskSettings.
func (s *Store) TaskSettingsAll(ctx context.Context) (map[string]TaskSettings, error) {
rows, err := s.pool.Query(ctx,
`SELECT task_id, enabled, interval_seconds, updated_at FROM scheduled_task_settings`)
if err != nil {
return nil, fmt.Errorf("store: task settings: %w", err)
}
defer rows.Close()
settings := map[string]TaskSettings{}
for rows.Next() {
var row TaskSettings
if err := rows.Scan(&row.TaskID, &row.Enabled, &row.IntervalSeconds,
&row.UpdatedAt); err != nil {
return nil, fmt.Errorf("store: scan task settings: %w", err)
}
settings[row.TaskID] = row
}
return settings, rows.Err()
}
// SetTaskSettings records an operator's override.
func (s *Store) SetTaskSettings(ctx context.Context, settings TaskSettings) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO scheduled_task_settings (task_id, enabled, interval_seconds, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (task_id) DO UPDATE SET
enabled = EXCLUDED.enabled,
interval_seconds = EXCLUDED.interval_seconds,
updated_at = now()`,
settings.TaskID, settings.Enabled, settings.IntervalSeconds)
if err != nil {
return fmt.Errorf("store: set task settings: %w", err)
}
return nil
}
// TaskLastSuccess is when each task last finished cleanly, which is what the scheduler
// restores its clock from after a restart — without it a container replaced at 3am would
// re-run every overnight job the moment it came up.
func (s *Store) TaskLastSuccess(ctx context.Context) (map[string]time.Time, error) {
rows, err := s.pool.Query(ctx, `
SELECT task_id, max(started_at) FROM scheduled_task_runs
WHERE status = $1 GROUP BY task_id`, TaskSuccess)
if err != nil {
return nil, fmt.Errorf("store: task last success: %w", err)
}
defer rows.Close()
last := map[string]time.Time{}
for rows.Next() {
var taskID string
var at time.Time
if err := rows.Scan(&taskID, &at); err != nil {
return nil, fmt.Errorf("store: scan task last success: %w", err)
}
last[taskID] = at
}
return last, rows.Err()
}
// PruneTaskRuns drops run history past the retention period.
func (s *Store) PruneTaskRuns(ctx context.Context, retention time.Duration) (int64, error) {
if retention <= 0 {
retention = TaskRunRetention
}
tag, err := s.pool.Exec(ctx,
`DELETE FROM scheduled_task_runs WHERE started_at < now() - $1::interval`,
fmt.Sprintf("%d seconds", int64(retention.Seconds())))
if err != nil {
return 0, fmt.Errorf("store: prune task runs: %w", err)
}
return tag.RowsAffected(), nil
}
// --- integration delivery history -------------------------------------------------
// IntegrationDelivery is one attempt to hand an event to an external service.
type IntegrationDelivery struct {
ID int64 `json:"id"`
IntegrationID string `json:"integrationId"`
EventType string `json:"eventType"`
AttemptedAt time.Time `json:"attemptedAt"`
Success bool `json:"success"`
StatusCode int `json:"statusCode"`
DurationMS int64 `json:"durationMs"`
Error string `json:"error,omitempty"`
}
// IntegrationHealth is the pair of questions an operator actually has about a webhook:
// is it working, and if not, what did it say. Both are "last", not "count", because a
// destination that failed once an hour ago and has worked since is healthy.
type IntegrationHealth struct {
IntegrationID string `json:"integrationId"`
LastSuccess *time.Time `json:"lastSuccess,omitempty"`
LastFailure *time.Time `json:"lastFailure,omitempty"`
LastError string `json:"lastError,omitempty"`
Deliveries int `json:"deliveries"`
Failures int `json:"failures"`
}
func (s *Store) RecordIntegrationDelivery(ctx context.Context, delivery IntegrationDelivery) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO integration_deliveries (
integration_id, event_type, success, status_code, duration_ms, error
) VALUES ($1, $2, $3, $4, $5, $6)`,
delivery.IntegrationID, delivery.EventType, delivery.Success,
delivery.StatusCode, delivery.DurationMS, delivery.Error)
if err != nil {
return fmt.Errorf("store: record integration delivery: %w", err)
}
return nil
}
func (s *Store) IntegrationDeliveries(ctx context.Context, integrationID string, limit int) ([]IntegrationDelivery, error) {
if limit <= 0 || limit > 200 {
limit = 25
}
rows, err := s.pool.Query(ctx, `
SELECT id, integration_id, event_type, attempted_at, success, status_code,
duration_ms, error
FROM integration_deliveries
WHERE ($1 = '' OR integration_id = $1)
ORDER BY attempted_at DESC, id DESC
LIMIT $2`, integrationID, limit)
if err != nil {
return nil, fmt.Errorf("store: list integration deliveries: %w", err)
}
defer rows.Close()
deliveries := []IntegrationDelivery{}
for rows.Next() {
var delivery IntegrationDelivery
if err := rows.Scan(&delivery.ID, &delivery.IntegrationID, &delivery.EventType,
&delivery.AttemptedAt, &delivery.Success, &delivery.StatusCode,
&delivery.DurationMS, &delivery.Error); err != nil {
return nil, fmt.Errorf("store: scan integration delivery: %w", err)
}
deliveries = append(deliveries, delivery)
}
return deliveries, rows.Err()
}
func (s *Store) IntegrationHealthFor(ctx context.Context, integrationID string) (IntegrationHealth, error) {
health := IntegrationHealth{IntegrationID: integrationID}
var lastError *string
err := s.pool.QueryRow(ctx, `
SELECT max(attempted_at) FILTER (WHERE success),
max(attempted_at) FILTER (WHERE NOT success),
(SELECT error FROM integration_deliveries
WHERE integration_id = $1 AND NOT success
ORDER BY attempted_at DESC LIMIT 1),
count(*), count(*) FILTER (WHERE NOT success)
FROM integration_deliveries WHERE integration_id = $1`, integrationID).
Scan(&health.LastSuccess, &health.LastFailure, &lastError,
&health.Deliveries, &health.Failures)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return health, fmt.Errorf("store: integration health: %w", err)
}
if lastError != nil {
health.LastError = *lastError
}
return health, nil
}
// PruneIntegrationDeliveries keeps the newest `keep` attempts per integration.
//
// It is bounded per integration rather than by age, because the useful property of this
// table is "the last few attempts for each destination" — an age cut would empty it
// entirely for a webhook that fires once a month, which is the one whose last delivery an
// operator most wants to see.
func (s *Store) PruneIntegrationDeliveries(ctx context.Context, keep int) (int64, error) {
if keep <= 0 {
keep = 100
}
tag, err := s.pool.Exec(ctx, `
DELETE FROM integration_deliveries WHERE id IN (
SELECT id FROM (
SELECT id, row_number() OVER (
PARTITION BY integration_id ORDER BY attempted_at DESC, id DESC
) AS position
FROM integration_deliveries
) ranked WHERE position > $1
)`, keep)
if err != nil {
return 0, fmt.Errorf("store: prune integration deliveries: %w", err)
}
return tag.RowsAffected(), nil
}
+118
View File
@@ -539,3 +539,121 @@ CREATE TABLE IF NOT EXISTS media_requests (
CREATE INDEX IF NOT EXISTS media_requests_user_requested_idx
ON media_requests (emby_user_id, requested_at DESC);
-- Every sign-in attempt, successful or not.
--
-- The sessions table above holds one row per television and is overwritten by the next
-- sign-in, replaced when a device id moves and deleted when a set is removed — so it can
-- say what is true now and nothing at all about what happened. This is the history: how
-- often a set has connected, at what times, from which addresses, on which build, and
-- whether the attempt got in. Every column records what was true at the moment of the
-- attempt, including the ones that later change, which is why device_name and
-- client_version are copied here rather than joined from the session.
--
-- A failed attempt has no emby_user_id: Emby refused the credentials, so there is no
-- verified identity to attribute it to. The username is what was typed, and it is kept
-- precisely because a run of failures against one name is the thing worth noticing.
CREATE TABLE IF NOT EXISTS login_events (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
emby_user_id TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL DEFAULT '',
device_id TEXT NOT NULL DEFAULT '',
device_name TEXT NOT NULL DEFAULT '',
client_version TEXT NOT NULL DEFAULT '',
client_protocol TEXT NOT NULL DEFAULT '',
ip_address TEXT NOT NULL DEFAULT '',
success BOOLEAN NOT NULL,
method TEXT NOT NULL DEFAULT 'password',
failure_reason TEXT NOT NULL DEFAULT '',
new_device BOOLEAN NOT NULL DEFAULT false
);
CREATE INDEX IF NOT EXISTS login_events_time_idx ON login_events (occurred_at DESC);
CREATE INDEX IF NOT EXISTS login_events_device_time_idx
ON login_events (device_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS login_events_user_time_idx
ON login_events (emby_user_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS login_events_ip_idx ON login_events (ip_address);
-- Administrative events: the operational feed behind the console's notification bell.
--
-- Deliberately generic. A publisher supplies a type, a severity, who or what it concerns
-- and a sentence; nothing here knows about sign-ins, devices or scheduled tasks
-- specifically. That is what lets a service added later publish into the same feed, and
-- what lets one integration deliver every kind of event without a case per kind.
--
-- read_at is a single operator's read state rather than a per-account one: the console is
-- guarded by one shared admin token, so there is one reader by construction.
CREATE TABLE IF NOT EXISTS admin_events (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
type TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'info',
title TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL DEFAULT '',
actor TEXT NOT NULL DEFAULT '',
target TEXT NOT NULL DEFAULT '',
link TEXT NOT NULL DEFAULT '',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
read_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS admin_events_time_idx ON admin_events (occurred_at DESC);
CREATE INDEX IF NOT EXISTS admin_events_unread_idx
ON admin_events (occurred_at DESC) WHERE read_at IS NULL;
CREATE INDEX IF NOT EXISTS admin_events_type_time_idx ON admin_events (type, occurred_at DESC);
-- One row per run of a scheduled task.
--
-- The scheduler keeps its next-run time in memory because it is derived from the schedule
-- and the clock, but what *happened* has to outlive the process: an operator asking why
-- the overnight housekeeping did not run is asking about a container that has since been
-- replaced. Duration is stored rather than derived so a run killed by a restart, which
-- has a start and no finish, is distinguishable from one that took no time.
CREATE TABLE IF NOT EXISTS scheduled_task_runs (
id BIGSERIAL PRIMARY KEY,
task_id TEXT NOT NULL,
trigger TEXT NOT NULL DEFAULT 'schedule', -- schedule | manual | startup
status TEXT NOT NULL, -- running | success | failed | skipped
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
duration_ms BIGINT NOT NULL DEFAULT 0,
detail TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS scheduled_task_runs_task_idx
ON scheduled_task_runs (task_id, started_at DESC);
CREATE INDEX IF NOT EXISTS scheduled_task_runs_time_idx
ON scheduled_task_runs (started_at DESC);
-- An operator's per-task overrides. A task that has never been touched has no row, and
-- absence is "run as the code declares" — reading it the other way would leave every task
-- disabled on the day this shipped.
CREATE TABLE IF NOT EXISTS scheduled_task_settings (
task_id TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT true,
interval_seconds INT NOT NULL DEFAULT 0, -- 0 keeps the task's declared interval
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- What an integration actually managed to deliver.
--
-- Kept because the two questions an operator has about a webhook are "is it working" and
-- "why did that one not arrive", and neither is answerable from configuration. The body
-- is not stored: it is reconstructible from the event, and a webhook payload is the one
-- place a URL containing a secret would otherwise come to rest in the database.
CREATE TABLE IF NOT EXISTS integration_deliveries (
id BIGSERIAL PRIMARY KEY,
integration_id TEXT NOT NULL,
event_type TEXT NOT NULL DEFAULT '',
attempted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
success BOOLEAN NOT NULL,
status_code INT NOT NULL DEFAULT 0,
duration_ms BIGINT NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS integration_deliveries_idx
ON integration_deliveries (integration_id, attempted_at DESC);
+44 -4
View File
@@ -32,10 +32,24 @@ const MDBListSettingsKey = "mdblist_settings"
// HeroPolicy stores only Emby ids and the optional prime-card copy. Names and artwork
// remain library data, so a metadata correction appears without rewriting operator policy.
type HeroPolicy struct {
PinnedItemIDs []string `json:"pinnedItemIds"`
LegacyPinnedMovieIDs []string `json:"pinnedMovieIds,omitempty"`
PrimeSubtitle string `json:"primeSubtitle"`
UpdatedAt time.Time `json:"updatedAt"`
PinnedItemIDs []string `json:"pinnedItemIds"`
LegacyPinnedMovieIDs []string `json:"pinnedMovieIds,omitempty"`
PrimeSubtitle string `json:"primeSubtitle"`
UpdatedAt time.Time `json:"updatedAt"`
Schedules []HeroSchedule `json:"schedules"`
}
// HeroSchedule is resolved by the gateway for every home response. Times are UTC RFC3339;
// weekdays use the local calendar day (Sunday=0) and an empty list means every day.
type HeroSchedule struct {
ID string `json:"id"`
ItemID string `json:"itemId"`
StartAt time.Time `json:"startAt"`
EndAt time.Time `json:"endAt"`
Weekdays []int `json:"weekdays,omitempty"`
Priority int `json:"priority"`
UserID string `json:"userId,omitempty"`
Enabled bool `json:"enabled"`
}
func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
@@ -59,6 +73,32 @@ func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
if len(runes) > 160 {
policy.PrimeSubtitle = string(runes[:160])
}
cleanSchedules := make([]HeroSchedule, 0, len(policy.Schedules))
seenSchedules := map[string]bool{}
for _, schedule := range policy.Schedules {
schedule.ID, schedule.ItemID, schedule.UserID = strings.TrimSpace(schedule.ID), strings.TrimSpace(schedule.ItemID), strings.TrimSpace(schedule.UserID)
if schedule.ID == "" || schedule.ItemID == "" || seenSchedules[schedule.ID] || !schedule.EndAt.After(schedule.StartAt) {
continue
}
seenSchedules[schedule.ID] = true
if schedule.Priority < -1000 {
schedule.Priority = -1000
}
if schedule.Priority > 1000 {
schedule.Priority = 1000
}
weekdays := make([]int, 0, len(schedule.Weekdays))
seenDays := map[int]bool{}
for _, day := range schedule.Weekdays {
if day >= 0 && day <= 6 && !seenDays[day] {
seenDays[day] = true
weekdays = append(weekdays, day)
}
}
schedule.Weekdays = weekdays
cleanSchedules = append(cleanSchedules, schedule)
}
policy.Schedules = cleanSchedules
return policy
}