This commit is contained in:
ponzischeme89
2026-08-28 23:00:02 +12:00
parent 3e89036f7b
commit d5632e844a
66 changed files with 2870 additions and 689 deletions
+22
View File
@@ -66,6 +66,18 @@ type GatewaySettings struct {
// it is still the only way anything is discovered and must stay frequent.
LibrarySyncMinutes int `json:"librarySyncMinutes"`
// HomeTTLSeconds and RecommendTTLHours are how long the launcher payload and the
// personalised recommendation pool stay cached. They cannot be switched off — a TTL of
// nothing means every home load rebuilds — so zero keeps meaning "deployed" and any
// positive value is the override.
HomeTTLSeconds int `json:"homeTtlSeconds"`
RecommendTTLHours int `json:"recommendTtlHours"`
// ForYouRebuildHour is the household-local hour (023) the daily For You rebuild is due
// at. It is a pointer because 0 is a legitimate hour, so nil — not zero — is what means
// "whatever was deployed".
ForYouRebuildHour *int `json:"forYouRebuildHour,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy,omitempty"`
}
@@ -125,6 +137,16 @@ func normalizeGatewaySettings(settings GatewaySettings) GatewaySettings {
// A day is the ceiling rather than a week: however well the webhooks are working, the
// sweep is the only thing that ever notices a file somebody moved by hand.
settings.LibrarySyncMinutes = clampOverride(settings.LibrarySyncMinutes, 5, 24*60, true)
settings.HomeTTLSeconds = clampOverride(settings.HomeTTLSeconds, 5, 3600, false)
settings.RecommendTTLHours = clampOverride(settings.RecommendTTLHours, 1, 168, false)
if settings.ForYouRebuildHour != nil {
hour := *settings.ForYouRebuildHour
if hour < 0 || hour > 23 {
// An hour outside the clock is dropped rather than clamped: 25 is a typo, and
// silently reading it as 23 would run the rebuild at a time nobody asked for.
settings.ForYouRebuildHour = nil
}
}
return settings
}
@@ -2,6 +2,31 @@ package store
import "testing"
func TestNormalizeGatewaySettingsCacheAndRebuildOverrides(t *testing.T) {
hour := 25
settings := normalizeGatewaySettings(GatewaySettings{
HomeTTLSeconds: 2, RecommendTTLHours: 500, ForYouRebuildHour: &hour,
})
// TTLs cannot be switched off, so a sub-floor value clamps up and an over-ceiling one
// clamps down rather than either reading as "deployed".
if settings.HomeTTLSeconds != 5 {
t.Fatalf("home TTL should clamp to the floor, got %d", settings.HomeTTLSeconds)
}
if settings.RecommendTTLHours != 168 {
t.Fatalf("recommend TTL should clamp to the ceiling, got %d", settings.RecommendTTLHours)
}
// An hour outside the clock is a typo, and is dropped rather than clamped.
if settings.ForYouRebuildHour != nil {
t.Fatalf("an out-of-range rebuild hour should be dropped, got %d", *settings.ForYouRebuildHour)
}
valid := 0
kept := normalizeGatewaySettings(GatewaySettings{ForYouRebuildHour: &valid})
if kept.ForYouRebuildHour == nil || *kept.ForYouRebuildHour != 0 {
t.Fatalf("midnight is a legitimate rebuild hour and must survive, got %v", kept.ForYouRebuildHour)
}
}
// 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) {
+141
View File
@@ -180,6 +180,147 @@ func (s *Store) AllMediaRequests(ctx context.Context, limit int) ([]OwnedMediaRe
// MediaRequestSweepLimit caps what one pass of the ready sweep will look at.
const MediaRequestSweepLimit = 500
// RequestReadySurfaceWindow is how long a freshly arrived request stays pinned to the front
// of Continue Watching with a REQUEST READY tag if the viewer never plays it. Past this the
// age-out clears it: an arrival nobody has acted on in a fortnight is no longer news, and a
// pin that outstays its welcome is worse than one that lapses.
const RequestReadySurfaceWindow = 14 * 24 * time.Hour
// SurfacedReadyRequest is one arrival still worth pinning: which title, and the Emby item id
// the card opens and dedupes against.
type SurfacedReadyRequest struct {
MediaType string
ForeignID int
Title string
Year int
PosterURL string
ItemID string
SurfacedAt time.Time
}
// MarkRequestReadySurfaced records that a request has become watchable and should be pinned.
//
// The guard is the whole of the idempotency: the ready sweep calls this on every observed
// transition to "available", and only the first one — before anything has cleared it — takes
// effect. A request cleared by playback or the age-out is never re-pinned here; a genuinely
// new ask is a fresh row with both timestamps null.
func (s *Store) MarkRequestReadySurfaced(
ctx context.Context, userID, mediaType string, foreignID int, itemID string,
) error {
itemID = strings.TrimSpace(itemID)
if itemID == "" {
return fmt.Errorf("store: request ready needs an item id")
}
_, err := s.pool.Exec(ctx, `
UPDATE media_requests
SET ready_surfaced_at = now(), ready_item_id = $4
WHERE emby_user_id = $1 AND media_type = $2 AND foreign_id = $3
AND ready_surfaced_at IS NULL AND ready_cleared_at IS NULL`,
strings.TrimSpace(userID), mediaType, foreignID, itemID)
if err != nil {
return fmt.Errorf("store: mark request ready surfaced: %w", err)
}
return nil
}
// SurfacedReadyRequests reads one viewer's still-pinned arrivals, newest first.
//
// The window and the cleared check are both in the query so the caller never has to think
// about either: a row past RequestReadySurfaceWindow, or one playback has cleared, simply
// does not come back. This is the read Home makes beside its Emby fan-out.
func (s *Store) SurfacedReadyRequests(
ctx context.Context, userID string,
) ([]SurfacedReadyRequest, error) {
cutoff := time.Now().Add(-RequestReadySurfaceWindow)
rows, err := s.pool.Query(ctx, `
SELECT media_type, foreign_id, title, year, poster_url, ready_item_id, ready_surfaced_at
FROM media_requests
WHERE emby_user_id = $1
AND ready_surfaced_at IS NOT NULL
AND ready_cleared_at IS NULL
AND ready_surfaced_at > $2
AND ready_item_id <> ''
ORDER BY ready_surfaced_at DESC`,
strings.TrimSpace(userID), cutoff)
if err != nil {
return nil, fmt.Errorf("store: read surfaced ready requests: %w", err)
}
defer rows.Close()
out := []SurfacedReadyRequest{}
for rows.Next() {
var req SurfacedReadyRequest
if err := rows.Scan(
&req.MediaType, &req.ForeignID, &req.Title, &req.Year,
&req.PosterURL, &req.ItemID, &req.SurfacedAt,
); err != nil {
return nil, fmt.Errorf("store: scan surfaced ready request: %w", err)
}
out = append(out, req)
}
return out, rows.Err()
}
// ClearRequestReadySurfaced retires the pin for whichever of a viewer's requests point at
// the given Emby item id — the film itself, or the series an episode belongs to. It returns
// how many rows it touched so the caller can skip a cache invalidation that would change
// nothing.
func (s *Store) ClearRequestReadySurfaced(
ctx context.Context, userID string, itemIDs ...string,
) (int64, error) {
ids := make([]string, 0, len(itemIDs))
for _, id := range itemIDs {
if id = strings.TrimSpace(id); id != "" {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return 0, nil
}
tag, err := s.pool.Exec(ctx, `
UPDATE media_requests
SET ready_cleared_at = now()
WHERE emby_user_id = $1 AND ready_item_id = ANY($2)
AND ready_surfaced_at IS NOT NULL AND ready_cleared_at IS NULL`,
strings.TrimSpace(userID), ids)
if err != nil {
return 0, fmt.Errorf("store: clear request ready surfaced: %w", err)
}
return tag.RowsAffected(), nil
}
// ExpireStaleReadyRequests clears every pin past RequestReadySurfaceWindow across the whole
// household in one statement, and returns the users whose Home cache is now stale. The ready
// sweep runs this so SurfacedReadyRequests never has to lean on its own window clause to
// hide a row that should have been retired days ago.
func (s *Store) ExpireStaleReadyRequests(ctx context.Context) ([]string, error) {
cutoff := time.Now().Add(-RequestReadySurfaceWindow)
rows, err := s.pool.Query(ctx, `
UPDATE media_requests
SET ready_cleared_at = now()
WHERE ready_surfaced_at IS NOT NULL AND ready_cleared_at IS NULL
AND ready_surfaced_at <= $1
RETURNING emby_user_id`, cutoff)
if err != nil {
return nil, fmt.Errorf("store: expire stale ready requests: %w", err)
}
defer rows.Close()
seen := map[string]bool{}
users := []string{}
for rows.Next() {
var userID string
if err := rows.Scan(&userID); err != nil {
return nil, err
}
if !seen[userID] {
seen[userID] = true
users = append(users, userID)
}
}
return users, rows.Err()
}
// SetMediaRequestStatus records what a request was last seen doing.
//
// Written only when the state actually moved, so a sweep over a household where nothing has
+16
View File
@@ -615,6 +615,22 @@ ALTER TABLE media_requests ADD COLUMN IF NOT EXISTS last_status TEXT NOT NULL DE
CREATE INDEX IF NOT EXISTS media_requests_user_requested_idx
ON media_requests (emby_user_id, requested_at DESC);
-- The arrival is announced once as a notification, and then pinned to the front of that
-- viewer's Continue Watching row with a REQUEST READY tag until they start watching it.
-- These three columns are the memory of that pin: ready_surfaced_at is when the sweep first
-- saw the title become watchable (and only then, when the library also had an item id to
-- open); ready_item_id is what the row pins and dedupes against; ready_cleared_at is set the
-- moment playback starts or the 14-day age-out fires, and a non-null value stops the request
-- ever being surfaced again — asking afresh after a delete is a new row, which is the case
-- where the pin is genuinely new.
ALTER TABLE media_requests ADD COLUMN IF NOT EXISTS ready_surfaced_at TIMESTAMPTZ;
ALTER TABLE media_requests ADD COLUMN IF NOT EXISTS ready_item_id TEXT NOT NULL DEFAULT '';
ALTER TABLE media_requests ADD COLUMN IF NOT EXISTS ready_cleared_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS media_requests_user_surfaced_idx
ON media_requests (emby_user_id)
WHERE ready_surfaced_at IS NOT NULL AND ready_cleared_at IS NULL;
-- Every sign-in attempt, successful or not.
--
-- The sessions table above holds one row per television and is overwritten by the next