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
+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