This commit is contained in:
ponzischeme89
2026-08-16 12:13:51 +12:00
parent bd3732fba5
commit b9374baaf1
47 changed files with 2793 additions and 364 deletions
+7
View File
@@ -102,6 +102,8 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("GET /admin/api/tasks", s.adminAuth(s.handleAdminTasks))
mux.Handle("POST /admin/api/tasks/{taskID}/run", s.adminAuth(s.handleAdminRunTask))
mux.Handle("PUT /admin/api/tasks/{taskID}", s.adminAuth(s.handleAdminTaskSettings))
mux.Handle("GET /admin/api/credits", s.adminAuth(s.handleAdminCredits))
mux.Handle("PUT /admin/api/credits", s.adminAuth(s.handleAdminCredits))
// An unmatched API path is a 404, stated rather than left to the catch-all below —
// otherwise a mistyped or removed route would answer with the console's HTML shell,
@@ -229,6 +231,7 @@ type adminStatus struct {
// ServerVersion is what the page's footer reports. An operator reading the live log
// needs to know which build wrote it, and the page is the one place that is asked.
ServerVersion string `json:"serverVersion"`
CurrentUser string `json:"currentUser,omitempty"`
Maintenance store.Maintenance `json:"maintenance"`
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
Library store.LibraryStats `json:"library"`
@@ -293,6 +296,10 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, adminStatus{
ServerVersion: buildinfo.Version(),
CurrentUser: func() string {
_, username, _ := s.browserSession(r, adminSessionPurpose)
return username
}(),
Maintenance: s.maintenance.get(),
UpdatePolicy: s.updatePolicy.get(),
Library: stats,
+4
View File
@@ -153,6 +153,10 @@ func TestAdministratorSignInMintsAnAdminSession(t *testing.T) {
if !s.validAdminSession(req) {
t.Fatal("an administrator's session was not accepted by the console")
}
_, username, ok := s.browserSession(req, adminSessionPurpose)
if !ok || username != "Viewer" {
t.Fatalf("admin session identity = %q, valid %t; want Viewer", username, ok)
}
}
// An Emby that returns no policy at all is a question, not a refusal — reading its silence
+141
View File
@@ -0,0 +1,141 @@
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/ponzischeme89/memby/server/internal/credits"
"github.com/ponzischeme89/memby/server/internal/store"
)
type adminCreditsSettings struct {
CandidateLimit int `json:"candidateLimit"`
PrefetchEpisodes int `json:"prefetchEpisodes"`
MaxPrefetch int `json:"maxPrefetch"`
RetryHours int `json:"retryHours"`
}
type adminCreditsCandidate struct {
ItemID string `json:"itemId"`
SeriesID string `json:"seriesId"`
Season int `json:"season"`
Episode int `json:"episode"`
Priority int `json:"priority"`
Reason string `json:"reason"`
UserCount int `json:"userCount"`
LastViewed time.Time `json:"lastViewed"`
}
type adminCreditsResponse struct {
Enabled bool `json:"enabled"`
Settings adminCreditsSettings `json:"settings"`
QueueDepth int `json:"queueDepth"`
Pending []adminCreditsCandidate `json:"pending"`
History []store.CreditsScanHistoryRow `json:"history"`
}
func creditsAdminSettings(cfg credits.Config) adminCreditsSettings {
cfg = credits.NormaliseConfig(cfg)
return adminCreditsSettings{
CandidateLimit: cfg.QueueLimit, PrefetchEpisodes: cfg.PrefetchEpisodes,
MaxPrefetch: cfg.MaxPrefetchEpisodes,
RetryHours: int(cfg.RetryCooldown / time.Hour),
}
}
func (s *Server) defaultCreditsConfig() credits.Config {
defaults := credits.DefaultConfig()
defaults.PrefetchEpisodes = s.cfg.CreditsPrefetchEpisodes
defaults.MaxPrefetchEpisodes = s.cfg.CreditsMaxPrefetch
defaults.QueueLimit = s.cfg.CreditsQueueLimit
return credits.NormaliseConfig(defaults)
}
func (s *Server) handleAdminCredits(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut {
s.handleAdminCreditsSettings(w, r)
return
}
s.writeAdminCredits(w, r)
}
func (s *Server) writeAdminCredits(w http.ResponseWriter, r *http.Request) {
cfg := s.defaultCreditsConfig()
pending := []adminCreditsCandidate{}
queueDepth := 0
if s.credits != nil {
cfg = s.credits.Configuration()
queueDepth = s.credits.QueueDepth()
for _, candidate := range s.credits.Pending() {
pending = append(pending, adminCreditsCandidate{
ItemID: candidate.ItemID, SeriesID: candidate.SeriesID,
Season: candidate.Season, Episode: candidate.Episode,
Priority: candidate.Priority, Reason: candidate.Reason,
UserCount: candidate.UserCount, LastViewed: candidate.LastViewed,
})
}
} else if saved, found, err := s.store.CreditsSettings(r.Context()); err == nil && found {
cfg.PrefetchEpisodes = saved.PrefetchEpisodes
cfg.MaxPrefetchEpisodes = saved.MaxPrefetch
cfg.QueueLimit = saved.CandidateLimit
cfg.RetryCooldown = time.Duration(saved.RetryHours) * time.Hour
cfg = credits.NormaliseConfig(cfg)
}
history, err := s.store.CreditsScanHistory(
r.Context(), queryInt(r, "limit", 100, 500),
)
if err != nil {
s.loggerFor(r.Context()).Warn("credits scan history read failed", "error", err)
history = []store.CreditsScanHistoryRow{}
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, adminCreditsResponse{
Enabled: s.credits != nil, Settings: creditsAdminSettings(cfg),
QueueDepth: queueDepth, Pending: pending, History: history,
})
}
func (s *Server) handleAdminCreditsSettings(w http.ResponseWriter, r *http.Request) {
var req adminCreditsSettings
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if req.CandidateLimit < 1 || req.CandidateLimit > 100 {
writeError(w, http.StatusBadRequest, "candidate limit must be between 1 and 100")
return
}
if req.PrefetchEpisodes < 1 || req.PrefetchEpisodes > 10 {
writeError(w, http.StatusBadRequest, "ordinary look-ahead must be between 1 and 10 episodes")
return
}
if req.MaxPrefetch < req.PrefetchEpisodes || req.MaxPrefetch > 20 {
writeError(w, http.StatusBadRequest, "maximum look-ahead must be at least the ordinary look-ahead and no more than 20")
return
}
if req.RetryHours < 0 || req.RetryHours > 24*30 {
writeError(w, http.StatusBadRequest, "retry delay must be between 0 and 720 hours")
return
}
settings := store.CreditsSettings{
CandidateLimit: req.CandidateLimit, PrefetchEpisodes: req.PrefetchEpisodes,
MaxPrefetch: req.MaxPrefetch, RetryHours: req.RetryHours,
}
if err := s.store.SetCreditsSettings(r.Context(), settings); err != nil {
writeError(w, http.StatusInternalServerError, "could not save credits settings")
return
}
if s.credits != nil {
cfg := s.credits.Configuration()
cfg.QueueLimit = req.CandidateLimit
cfg.PrefetchEpisodes = req.PrefetchEpisodes
cfg.MaxPrefetchEpisodes = req.MaxPrefetch
cfg.RetryCooldown = time.Duration(req.RetryHours) * time.Hour
s.credits.Configure(cfg)
}
s.loggerFor(r.Context()).Info("credits detection settings changed",
"candidate_limit", req.CandidateLimit, "prefetch", req.PrefetchEpisodes,
"max_prefetch", req.MaxPrefetch, "retry_hours", req.RetryHours)
s.writeAdminCredits(w, r)
}
+39 -13
View File
@@ -11,6 +11,7 @@ import (
"net/url"
"strings"
"time"
"unicode/utf8"
"github.com/ponzischeme89/memby/server/internal/emby"
)
@@ -84,11 +85,24 @@ func (s *Server) newInstallerSession() (string, error) {
}
func (s *Server) newBrowserSession(purpose string, ttl time.Duration) (string, error) {
payload := make([]byte, 8+16)
return s.newBrowserSessionFor(purpose, ttl, "")
}
// newBrowserSessionFor includes the verified account name in an admin session. It remains
// inside the signed, HttpOnly cookie: the console learns who is at the keyboard from the
// status response, without adding a second identity cookie that JavaScript could alter.
// Sessions minted by older gateways had only the 24-byte prefix and remain valid.
func (s *Server) newBrowserSessionFor(purpose string, ttl time.Duration, username string) (string, error) {
username = strings.TrimSpace(username)
if len(username) > 512 || !utf8.ValidString(username) {
username = ""
}
payload := make([]byte, 8+16+len(username))
binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(ttl).Unix()))
if _, err := rand.Read(payload[8:]); err != nil {
if _, err := rand.Read(payload[8:24]); err != nil {
return "", err
}
copy(payload[24:], username)
signature := s.signInstallerValue(purpose, payload)
return base64.RawURLEncoding.EncodeToString(payload) + "." +
base64.RawURLEncoding.EncodeToString(signature), nil
@@ -98,31 +112,39 @@ func (s *Server) newBrowserSession(purpose string, ttl time.Duration) (string, e
// cookie that is missing, malformed, forged, signed for a different purpose or already
// expired is reported the same way: no session.
func (s *Server) browserSessionExpiry(r *http.Request, purpose string) (time.Time, bool) {
expires, _, ok := s.browserSession(r, purpose)
return expires, ok
}
// browserSession verifies the session once and returns the optional identity carried by
// newer cookies. The empty identity is legitimate for a session issued before it was
// added, so validity is reported separately.
func (s *Server) browserSession(r *http.Request, purpose string) (time.Time, string, bool) {
if len(s.installerSecret()) == 0 {
return time.Time{}, false
return time.Time{}, "", false
}
cookie, err := r.Cookie(installerCookieName)
if err != nil {
return time.Time{}, false
return time.Time{}, "", false
}
parts := strings.Split(cookie.Value, ".")
if len(parts) != 2 {
return time.Time{}, false
return time.Time{}, "", false
}
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil || len(payload) != 24 {
return time.Time{}, false
if err != nil || len(payload) < 24 || len(payload) > 536 || !utf8.Valid(payload[24:]) {
return time.Time{}, "", false
}
signature, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil || !hmac.Equal(signature, s.signInstallerValue(purpose, payload)) {
return time.Time{}, false
return time.Time{}, "", false
}
expires := int64(binary.BigEndian.Uint64(payload[:8]))
now := time.Now().Unix()
if expires <= now || expires > now+int64(adminSessionTTL/time.Second)+60 {
return time.Time{}, false
return time.Time{}, "", false
}
return time.Unix(expires, 0), true
return time.Unix(expires, 0), strings.TrimSpace(string(payload[24:])), true
}
// validInstallerSession gates the public installer, which an administrator's own session
@@ -149,11 +171,11 @@ func (s *Server) validAdminSession(r *http.Request) bool {
// an operator actually made — see operatorPresent — or an abandoned tab's own polling
// would keep the session alive indefinitely, which is what the TTL exists to stop.
func (s *Server) renewAdminSession(w http.ResponseWriter, r *http.Request) {
expires, ok := s.browserSessionExpiry(r, adminSessionPurpose)
expires, username, ok := s.browserSession(r, adminSessionPurpose)
if !ok || time.Until(expires) > adminRenewWithin {
return
}
session, err := s.newBrowserSession(adminSessionPurpose, adminSessionTTL)
session, err := s.newBrowserSessionFor(adminSessionPurpose, adminSessionTTL, username)
if err != nil {
s.loggerFor(r.Context()).Error("installer session renewal failed", "error", err)
return
@@ -317,7 +339,11 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
}
purpose, ttl = adminSessionPurpose, adminSessionTTL
}
session, err := s.newBrowserSession(purpose, ttl)
verifiedUsername := strings.TrimSpace(auth.User.Name)
if verifiedUsername == "" {
verifiedUsername = username
}
session, err := s.newBrowserSessionFor(purpose, ttl, verifiedUsername)
if err != nil {
s.loggerFor(r.Context()).Error("installer session generation failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not start installer session")
+55 -2
View File
@@ -94,8 +94,12 @@ type playbackReportResponse struct {
// Emby's own item JSON, forwarded verbatim like every other item the gateway returns, so
// the client decodes it into the same BaseItem it uses everywhere else.
type nextEpisodeResponse struct {
Item json.RawMessage `json:"item"`
Title string `json:"title"`
Item json.RawMessage `json:"item"`
Title string `json:"title"`
// StreamReady says whether this answer carries a negotiated stream. A client asking
// with `stream=0` gets identity, artwork and runtime and nothing that touches Emby's
// PlaybackInfo — see [handleNextEpisode] for why that separation exists.
StreamReady bool `json:"streamReady"`
URL string `json:"url"`
ResumePositionMs int64 `json:"resumePositionMs"`
Subtitles []playableSubtitle `json:"subtitles"`
@@ -302,6 +306,20 @@ func episodeCode(item emby.Summary) string {
//
// "Nothing follows this" is a normal answer, not a failure: a movie, a series finale and an
// unreadable series all come back as 404 and the client simply shows no banner.
//
// It answers in two shapes, and the separation is the whole reason auto-advance stopped
// showing a viewer the machinery. `stream=0` asks for **identity only** — the episode's
// item JSON, its title and its artwork — and touches nothing but the episode list. The
// full answer additionally negotiates PlaybackInfo, which mints a play session and, on a
// title that has to be transcoded, a transcode session at Emby.
//
// The television asks for the cheap shape when the episode it is watching *starts*, which
// is where the banner's picture and wording come from, and for the full one shortly before
// the hand-over. Doing both at once is what the player used to do, and it was wrong twice:
// a session negotiated forty minutes early has long since been torn down by the time it is
// used — so the transition failed, retried, and narrated all of it to the viewer — and the
// transcode it may have started sat there being paid for by a viewer who had not yet
// decided to watch it.
func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
itemID := r.PathValue("id")
@@ -355,6 +373,27 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
if series := strings.TrimSpace(seriesNameOf(raw)); series != "" && title != "" {
title = series + " " + title
}
// The metadata-only shape. Everything below this point is a PlaybackInfo negotiation,
// and a client that said it does not want one yet must not be given one anyway.
if !nextEpisodeWantsStream(r) {
s.playbackTitles.remember(next.ID, title)
s.loggerFor(ctx).Debug("next episode identified",
"title", title, "item", next.ID, "after_item", itemID)
writeJSON(w, http.StatusOK, nextEpisodeResponse{
Item: raw,
Title: title,
StreamReady: false,
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: []playableSubtitle{},
SubtitlesEnabled: true,
TrickplayAvailable: s.trickplayEnabled(ctx),
SkipIntroAvailable: s.skipIntroEnabled(ctx),
EndCreditsAvailable: s.endCreditsEnabled(ctx),
})
return
}
subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles(
ctx, cred, next.ID, next.UserData.PlaybackPositionTicks, nil, "", false,
s.effectivePlaybackCapabilities(ctx, sess),
@@ -380,6 +419,7 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
writeJSON(w, http.StatusOK, nextEpisodeResponse{
Item: raw,
Title: title,
StreamReady: true,
URL: streamURL,
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: subtitles,
@@ -395,6 +435,19 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
})
}
// nextEpisodeWantsStream reads the client's `stream` parameter, and **defaults to yes**.
// An app built before the two-phase lookup existed sends nothing and expects the whole
// answer; reading a missing parameter as "identity only" would hand every one of those
// televisions a next-up banner with no stream behind it.
func nextEpisodeWantsStream(r *http.Request) bool {
switch strings.TrimSpace(strings.ToLower(r.URL.Query().Get("stream"))) {
case "0", "false", "no":
return false
default:
return true
}
}
func (s *Server) playbackSubtitles(
ctx context.Context, cred emby.Credentials, itemID string, startTicks int64,
subtitleIndex *int, currentPlaySessionID string, forceTranscode bool,
+1 -1
View File
@@ -1 +1 @@
0.1.47
0.1.48
+5 -4
View File
@@ -175,12 +175,13 @@ type Config struct {
// markers, on behavioural evidence alone — which on a well-watched show is the better
// signal anyway.
CreditsFFmpeg string
// CreditsPrefetchEpisodes is the look-ahead for an ordinary viewer; velocity moves the
// actual depth either side of it, and CreditsMaxPrefetch is the ceiling nothing exceeds.
// CreditsPrefetchEpisodes is the first-run look-ahead for an ordinary viewer; velocity
// moves the actual depth either side of it, and CreditsMaxPrefetch is the ceiling nothing
// exceeds. The admin console persists later operator choices in app_settings.
CreditsPrefetchEpisodes int
CreditsMaxPrefetch int
// CreditsQueueLimit bounds pending candidates. Past it, low-priority speculation is
// discarded rather than queued.
// CreditsQueueLimit is the first-run bound on pending candidates. Past it, low-priority
// speculation is discarded rather than queued.
CreditsQueueLimit int
// TracearrSyncInterval imports recent changed sessions. FullInterval reconciles
+17 -3
View File
@@ -8,7 +8,7 @@ is in the library" and answers by reading all of it. This asks "what will somebo
on over the next few evenings", which Tracearr already knows, and reads almost nothing:
```
Tracearr demand → priority queue → marker cached? → tiny tail scan → one write → never again
Tracearr demand → priority queue → marker/cooldown check → tiny tail scan → history + marker
```
## Why it exists
@@ -57,6 +57,19 @@ postgres.go the store adapter
load.go whether the server is too busy for speculative work
```
## Operator controls and history
Admin Console → Credits detection controls three different bounds: the number of candidates
allowed to wait, the ordinary and maximum episode look-ahead, and the retry delay after an
inconclusive or failed speculative scan. The environment values are first-run defaults; a
saved console choice is restored on restart.
The retry delay is important. A successful marker excludes an episode naturally, but an old
worker forgot a no-match result and selected the episode again at the next ten-minute
refresh. Completed attempts now have a small durable history and no-match/failed episodes
cool down before prediction may select them again. Live playback may still raise an episode
immediately because a viewer is waiting.
## Two detectors, and the cheap one is often better
**Behavioural** clustering costs nothing: no file is opened, no decoder runs, no new row is
@@ -111,7 +124,7 @@ number belonging to neither.
| Fine pass | 750ms over ±30s — ~80 frames |
| Frame buffer | 14,400 bytes, allocated once and reused for the whole pass |
| Writes, cached marker | **0** |
| Writes, successful scan | **1** |
| Writes, completed scan | **1 history row**, plus **1 marker** only when accepted |
Against scanning the full library: 20,000 items read end to end versus a queue capped at 20
candidates, most of which are rejected by the marker check before any media is touched. Once
@@ -135,7 +148,8 @@ in which demand-driven narrowing is doing nothing, and the design would need rev
- **The queue is deliberately not durable.** Candidate priorities are rebuilt from one
Tracearr query on restart, which is cheaper and simpler than a second persistent job
scheduler. The database holds markers and nothing else.
scheduler. Completed scan history is durable because it is an operator record and the
source of retry cooldowns; it does not preserve or resume queue state.
- **A single worker, and it is not a placeholder for a pool.** Concurrent scans multiply the
two costs this exists to minimise on a machine whose real job is streaming video.
- **Live playback does not scan immediately.** `livePlaybackDelay` (45s) is what stops a
+45
View File
@@ -35,6 +35,10 @@ type Config struct {
// QueueLimit bounds the queue. Past it, low-priority speculation is discarded rather than
// queued — a backlog of candidates for episodes nobody reached is worse than no backlog.
QueueLimit int
// RetryCooldown keeps an unsuccessful speculative scan from returning on every queue
// refresh. Live playback remains immediate; this applies only to predicted work.
RetryCooldown time.Duration
}
func DefaultConfig() Config {
@@ -45,9 +49,50 @@ func DefaultConfig() Config {
UsefulWindow: 3 * 24 * time.Hour,
WeakWindow: 7 * 24 * time.Hour,
QueueLimit: 20,
RetryCooldown: 24 * time.Hour,
}
}
// NormaliseConfig makes both environment and admin-supplied tuning safe. It is exported
// because the gateway restores persisted settings before constructing the service.
func NormaliseConfig(cfg Config) Config {
defaults := DefaultConfig()
if cfg.PrefetchEpisodes <= 0 {
cfg.PrefetchEpisodes = defaults.PrefetchEpisodes
}
if cfg.PrefetchEpisodes > 10 {
cfg.PrefetchEpisodes = 10
}
if cfg.MaxPrefetchEpisodes < cfg.PrefetchEpisodes {
cfg.MaxPrefetchEpisodes = cfg.PrefetchEpisodes
}
if cfg.MaxPrefetchEpisodes > 20 {
cfg.MaxPrefetchEpisodes = 20
}
if cfg.QueueLimit <= 0 {
cfg.QueueLimit = defaults.QueueLimit
}
if cfg.QueueLimit > 100 {
cfg.QueueLimit = 100
}
if cfg.StrongWindow <= 0 {
cfg.StrongWindow = defaults.StrongWindow
}
if cfg.UsefulWindow <= 0 {
cfg.UsefulWindow = defaults.UsefulWindow
}
if cfg.WeakWindow <= 0 {
cfg.WeakWindow = defaults.WeakWindow
}
if cfg.RetryCooldown < 0 {
cfg.RetryCooldown = 0
}
if cfg.RetryCooldown > 30*24*time.Hour {
cfg.RetryCooldown = 30 * 24 * time.Hour
}
return cfg
}
// Watch is one episode one viewer played, reduced to the four things candidate generation
// needs. It is what a Tracearr session becomes on the way in, and keeping it this narrow is
// what lets every rule below be tested with a literal.
+32
View File
@@ -174,6 +174,38 @@ type CandidateSource interface {
Candidates(ctx context.Context) ([]Candidate, error)
}
// ConfigurableCandidateSource accepts live operator tuning without rebuilding the worker.
type ConfigurableCandidateSource interface {
Configure(Config)
}
// ScanAttempt is the durable operational account of one claimed candidate. Unlike the
// queue, this is history rather than scheduling state, so retaining it does not turn the
// worker into a persistent job system.
type ScanAttempt struct {
ItemID string
SeriesID string
Season int
Episode int
Reason string
Priority int
Outcome string
MarkerMs int64
Confidence float64
Method string
Frames int
Error string
StartedAt time.Time
FinishedAt time.Time
}
// HistoryRepository records completed scans and answers which speculative candidates are
// still inside their retry cooldown.
type HistoryRepository interface {
SaveScanAttempt(ctx context.Context, attempt ScanAttempt) error
RecentScanTimes(ctx context.Context, itemIDs []string, since time.Time) (map[string]time.Time, error)
}
// Repository is the marker store. Narrow on purpose — the scheduler must not be able to
// write anything else, because "one row per successful scan and nothing else" is the
// performance claim this subsystem is making.
+17
View File
@@ -40,6 +40,23 @@ func (p Postgres) SaveMarker(ctx context.Context, marker Marker) error {
})
}
func (p Postgres) SaveScanAttempt(ctx context.Context, attempt ScanAttempt) error {
return p.Store.SaveCreditsScanAttempt(ctx, store.CreditsScanHistoryRow{
ItemID: attempt.ItemID, SeriesID: attempt.SeriesID,
Season: attempt.Season, Episode: attempt.Episode,
Reason: attempt.Reason, Priority: attempt.Priority, Outcome: attempt.Outcome,
MarkerMs: attempt.MarkerMs, Confidence: attempt.Confidence,
Method: attempt.Method, Frames: attempt.Frames, Error: attempt.Error,
StartedAt: attempt.StartedAt, FinishedAt: attempt.FinishedAt,
})
}
func (p Postgres) RecentScanTimes(
ctx context.Context, itemIDs []string, since time.Time,
) (map[string]time.Time, error) {
return p.Store.RecentCreditsScanTimes(ctx, itemIDs, since)
}
func (p Postgres) SeasonMarkers(
ctx context.Context, seriesID string, season, limit int,
) ([]Marker, error) {
+19
View File
@@ -136,6 +136,25 @@ func (q *Queue) Len() int {
return len(q.items)
}
// SetLimit applies an operator change immediately. The strongest candidates survive a
// reduction, using the same ordering as Claim, so changing the setting cannot reshuffle
// the meaning of priority.
func (q *Queue) SetLimit(limit int) {
if limit <= 0 {
return
}
q.mu.Lock()
defer q.mu.Unlock()
q.limit = limit
for len(q.items) > q.limit {
weakestID, _ := q.weakestLocked()
if weakestID == "" {
break
}
delete(q.items, weakestID)
}
}
// Snapshot is the queue in worker order, for the log line and the admin console.
func (q *Queue) Snapshot() []Candidate {
q.mu.Lock()
+14
View File
@@ -141,3 +141,17 @@ func TestQueueOrderIsTotalAndStable(t *testing.T) {
t.Fatalf("claimed %q; more recent demand should win a tie", candidate.ItemID)
}
}
func TestReducingQueueLimitKeepsTheStrongestCandidates(t *testing.T) {
queue := NewQueue(5)
queue.Push(queued("weak", PrioritySpeculative))
queue.Push(queued("ahead", PriorityAhead2))
queue.Push(queued("next", PriorityNext))
queue.SetLimit(2)
pending := queue.Snapshot()
if len(pending) != 2 || pending[0].ItemID != "next" || pending[1].ItemID != "ahead" {
t.Fatalf("reduced queue = %+v; want the two strongest candidates", pending)
}
}
+92 -6
View File
@@ -76,6 +76,7 @@ type LoadGauge interface {
// missing from the image.
type Deps struct {
Repository Repository
History HistoryRepository
Resolver MediaResolver
Source CandidateSource
Detector Detector
@@ -87,6 +88,7 @@ type Deps struct {
type Service struct {
repo Repository
history HistoryRepository
resolver MediaResolver
source CandidateSource
detector Detector
@@ -94,6 +96,7 @@ type Service struct {
load LoadGauge
log *slog.Logger
cfg Config
cfgMu sync.RWMutex
queue *Queue
flight *flightGroup
@@ -107,10 +110,7 @@ type Service struct {
}
func New(deps Deps) *Service {
cfg := deps.Config
if cfg.QueueLimit <= 0 {
cfg = DefaultConfig()
}
cfg := NormaliseConfig(deps.Config)
detector := deps.Detector
if detector == nil {
detector = noopDetector{}
@@ -121,6 +121,7 @@ func New(deps Deps) *Service {
}
return &Service{
repo: deps.Repository,
history: deps.History,
resolver: deps.Resolver,
source: deps.Source,
detector: detector,
@@ -181,9 +182,37 @@ func (s *Service) Refresh(ctx context.Context) (string, error) {
}
wanted = append(wanted, candidate)
}
deferred := 0
cfg := s.Configuration()
if s.history != nil && cfg.RetryCooldown > 0 && len(wanted) > 0 {
ids := make([]string, 0, len(wanted))
for _, candidate := range wanted {
ids = append(ids, candidate.ItemID)
}
recent, historyErr := s.history.RecentScanTimes(
ctx, ids, time.Now().UTC().Add(-cfg.RetryCooldown),
)
if historyErr != nil {
s.log.Debug("credits scan history unavailable", "error", historyErr)
} else if len(recent) > 0 {
eligible := wanted[:0]
for _, candidate := range wanted {
if _, coolingDown := recent[candidate.ItemID]; coolingDown {
deferred++
continue
}
eligible = append(eligible, candidate)
}
wanted = eligible
}
}
s.queue.Replace(wanted)
if len(wanted) == 0 {
if deferred > 0 {
return fmt.Sprintf("%d candidate%s cooling down, %d already known",
deferred, plural(deferred), skipped), nil
}
return "", nil
}
for _, candidate := range wanted {
@@ -191,8 +220,8 @@ func (s *Service) Refresh(ctx context.Context) (string, error) {
"item", candidate.ItemID, "reason", candidate.Reason,
"priority", candidate.Priority, "users", candidate.UserCount)
}
return fmt.Sprintf("%d candidate%s queued, %d already known",
len(wanted), plural(len(wanted)), skipped), nil
return fmt.Sprintf("%d candidate%s queued, %d cooling down, %d already known",
len(wanted), plural(len(wanted)), deferred, skipped), nil
}
// NotePlayback is the live signal, and the strongest one there is: somebody is watching this
@@ -303,10 +332,12 @@ func (s *Service) Run(ctx context.Context) {
continue
}
started := time.Now().UTC()
scanCtx, cancel := context.WithTimeout(ctx, scanBudget)
detection, stored, err := s.Process(scanCtx, candidate.ItemID)
cancel()
s.queue.Release(candidate.ItemID)
s.recordAttempt(ctx, candidate, detection, stored, err, started)
switch {
case err != nil && errors.Is(err, context.Canceled):
@@ -496,6 +527,61 @@ func (s *Service) busy(ctx context.Context) bool {
return s.load != nil && s.load.Busy(ctx)
}
// Configuration is a snapshot safe to expose through the admin API.
func (s *Service) Configuration() Config {
s.cfgMu.RLock()
defer s.cfgMu.RUnlock()
return s.cfg
}
// Configure applies persisted operator tuning to future candidate refreshes and trims an
// over-full queue immediately. A scan already in flight is deliberately left alone.
func (s *Service) Configure(cfg Config) {
cfg = NormaliseConfig(cfg)
s.cfgMu.Lock()
s.cfg = cfg
s.cfgMu.Unlock()
if source, ok := s.source.(ConfigurableCandidateSource); ok {
source.Configure(cfg)
}
s.queue.SetLimit(cfg.QueueLimit)
}
func (s *Service) recordAttempt(
ctx context.Context, candidate Candidate, detection Detection, stored bool, scanErr error,
started time.Time,
) {
if s.history == nil {
return
}
outcome := "no_match"
switch {
case scanErr != nil:
outcome = "failed"
case stored:
outcome = "detected"
case detection.Found:
outcome = "unchanged"
}
errorText := ""
if scanErr != nil {
errorText = scanErr.Error()
}
finished := time.Now().UTC()
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
if err := s.history.SaveScanAttempt(writeCtx, ScanAttempt{
ItemID: candidate.ItemID, SeriesID: candidate.SeriesID,
Season: candidate.Season, Episode: candidate.Episode,
Reason: candidate.Reason, Priority: candidate.Priority, Outcome: outcome,
MarkerMs: detection.StartMs, Confidence: detection.Confidence,
Method: detection.Method, Frames: detection.FramesSampled, Error: errorText,
StartedAt: started, FinishedAt: finished,
}); err != nil {
s.log.Debug("credits scan history write failed", "item", candidate.ItemID, "error", err)
}
}
// QueueDepth is what the console reads.
func (s *Service) QueueDepth() int { return s.queue.Len() }
+59
View File
@@ -85,6 +85,26 @@ type fakeBehaviour struct{ stops []StopEvent }
func (b fakeBehaviour) Stops(context.Context, string) ([]StopEvent, error) { return b.stops, nil }
type fixedCandidates []Candidate
func (f fixedCandidates) Candidates(context.Context) ([]Candidate, error) { return f, nil }
type fakeHistory struct {
recent map[string]time.Time
attempts []ScanAttempt
}
func (h *fakeHistory) SaveScanAttempt(_ context.Context, attempt ScanAttempt) error {
h.attempts = append(h.attempts, attempt)
return nil
}
func (h *fakeHistory) RecentScanTimes(
_ context.Context, _ []string, _ time.Time,
) (map[string]time.Time, error) {
return h.recent, nil
}
func quietLog() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
}
@@ -356,3 +376,42 @@ func TestSustainedPlaybackIsQueuedAtLivePriority(t *testing.T) {
t.Fatalf("candidate = %+v, want live playback at priority %d", pending[0], PriorityLive)
}
}
// An inconclusive scan used to be forgotten, so the ten-minute refresh selected the same
// episode for ever. History now makes that episode ineligible until its cooldown expires.
func TestRefreshDefersRecentlyScannedCandidate(t *testing.T) {
media := testMedia()
history := &fakeHistory{recent: map[string]time.Time{media.Version.ItemID: time.Now()}}
service := New(Deps{
Repository: newFakeRepo(), History: history,
Resolver: &fakeResolver{media: map[string]ResolvedMedia{media.Version.ItemID: media}},
Source: fixedCandidates{{ItemID: media.Version.ItemID, Priority: PriorityNext}},
Log: quietLog(), Config: DefaultConfig(),
})
detail, err := service.Refresh(context.Background())
if err != nil {
t.Fatal(err)
}
if service.QueueDepth() != 0 {
t.Fatal("a recently scanned episode returned to the queue")
}
if detail != "1 candidate cooling down, 0 already known" {
t.Fatalf("refresh detail = %q", detail)
}
}
func TestScanAttemptHistoryDescribesNoMatch(t *testing.T) {
history := &fakeHistory{}
service := New(Deps{
Repository: newFakeRepo(), History: history,
Resolver: &fakeResolver{media: map[string]ResolvedMedia{}},
Log: quietLog(), Config: DefaultConfig(),
})
candidate := Candidate{ItemID: "episode-7", Reason: ReasonNext, Priority: PriorityNext}
service.recordAttempt(context.Background(), candidate, Detection{FramesSampled: 42}, false, nil, time.Now().Add(-time.Second))
if len(history.attempts) != 1 || history.attempts[0].Outcome != "no_match" || history.attempts[0].Frames != 42 {
t.Fatalf("history = %+v", history.attempts)
}
}
+15 -4
View File
@@ -3,6 +3,7 @@ package credits
import (
"context"
"sort"
"sync"
"time"
)
@@ -39,6 +40,19 @@ const watchLimit = 500
type TracearrSource struct {
DB Database
Cfg Config
mu sync.RWMutex
}
func (s *TracearrSource) Configure(cfg Config) {
s.mu.Lock()
s.Cfg = NormaliseConfig(cfg)
s.mu.Unlock()
}
func (s *TracearrSource) config() Config {
s.mu.RLock()
defer s.mu.RUnlock()
return NormaliseConfig(s.Cfg)
}
// Candidates is the whole predictive pipeline: read recent demand, group it into per-viewer
@@ -51,10 +65,7 @@ func (s *TracearrSource) Candidates(ctx context.Context) ([]Candidate, error) {
if s == nil || s.DB == nil {
return nil, nil
}
cfg := s.Cfg
if cfg.QueueLimit <= 0 {
cfg = DefaultConfig()
}
cfg := s.config()
now := time.Now().UTC()
// The decay window is the query's window too. Anything older cannot survive
+306
View File
@@ -0,0 +1,306 @@
package recommend
import (
"context"
"math"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
)
// Magic answers "I don't care exactly what — put something good on".
//
// It is deliberately not the same thing as the top of a recommendation row. A row is read,
// compared and chosen from, so its order is the whole product and being stable matters; this
// is pressed instead of choosing, so being *the same answer twice* is the one failure it
// cannot have. What it does share with the rows is the evidence: the same profile, the same
// candidate pool, the same explanation layer. The difference is only what happens after the
// scoring, which is a weighted draw rather than a sort.
//
// The scoring is a sum of stated terms rather than an opaque model, for the same reason the
// rest of this package is: a Magic pick that lands badly is something the household will
// want explained, and [MagicSelection.Signals] is the explanation.
const (
// MagicPoolLimit is how many titles are in the hat. Large enough that a household
// pressing Magic every evening for a fortnight does not exhaust it, small enough that
// nothing genuinely unsuitable can be drawn.
MagicPoolLimit = 40
// magicUnwatchedBonus is the largest single term, because "something I have not seen"
// is most of what somebody means by the button.
magicUnwatchedBonus = 1.4
// magicSeenPenalty applies to a title this viewer has already watched. A penalty and
// not an exclusion: a rewatch is a legitimate answer, and a library whose owner has
// seen most of it must still have something to offer.
magicSeenPenalty = 1.1
// magicFavouriteBonus is for a title the viewer marked themselves. Below the unwatched
// bonus deliberately — a favourite is by definition something already watched.
magicFavouriteBonus = 0.7
// magicRecentlyAddedBonus surfaces what the household has just acquired.
magicRecentlyAddedBonus = 0.5
magicRecentlyAddedDays = 30
// magicRuntimeFitBonus and its penalty are how "there is an hour before bed" gets a
// different answer from "it is Saturday afternoon". Only applied when the television
// said how long it had.
magicRuntimeFitBonus = 0.5
magicRuntimeOverPenalty = 1.2
magicRuntimeSlackMinutes = 10
)
// MagicOptions are the request-scoped constraints on one press.
type MagicOptions struct {
// ExcludeIDs is what must not come back: the film playing right now, and whatever the
// last few presses produced. Repetition protection lives with the caller because it is
// the caller that knows what it already offered.
ExcludeIDs []string
// AvailableMinutes is zero for no limit.
AvailableMinutes int
// Roll is the randomness, in [0,1). Injected rather than read from a global so the
// draw is deterministic under test — and so that the *only* non-deterministic thing
// about this feature sits in one named parameter.
Roll float64
// Now is injectable for the same reason.
Now time.Time
}
// MagicSelection is one drawn title with its evidence.
type MagicSelection struct {
Item Item
// Reasons is viewer-facing wording from the same explanation layer a detail page uses.
Reasons []string
// Signals is why this title was *eligible*, in machine-readable slugs, so the choice
// can be reported and the weighting improved later. Not shown to anybody.
Signals []string
// Score is what it scored, and PoolSize how many it was drawn from. Both are recorded
// rather than displayed: a pool of one is a household that has run out of library, and
// that is a different problem from a bad weighting.
Score float64
PoolSize int
}
// MagicPick gathers the signals and draws. Errors only when the profile cannot be built at
// all and the catalogue is empty with it — every lesser failure degrades, on the principle
// [Engine.RelatedTo] already applies: a button that sometimes does nothing is worse than one
// that occasionally picks less well.
func (e *Engine) MagicPick(
ctx context.Context,
cred emby.Credentials,
opts MagicOptions,
) (MagicSelection, bool) {
if opts.Now.IsZero() {
opts.Now = e.now()
}
history, favorites, err := e.gatherSignals(ctx, cred)
if err != nil {
// A profile that cannot be built costs the weighting, not the button. What is left
// is an unweighted draw over the catalogue, which is still "put something on".
e.log.Warn("magic signals unavailable; drawing without taste", "error", err)
}
profile := BuildProfile(history, favorites)
candidates := e.magicCandidates(ctx, cred, profile)
if len(candidates) == 0 {
return MagicSelection{}, false
}
selection, ok := ChooseMagic(profile, candidates, opts)
if !ok {
return MagicSelection{}, false
}
selection.Reasons = Why(profile, selection.Item, 2)
return selection, true
}
// magicCandidates prefers the imported catalogue, which costs Postgres one read rather than
// costing Emby a library scan per press, and falls back to Emby where there is no library.
// Both paths ask for films only: the button plays something immediately, and a series is a
// question about which episode.
func (e *Engine) magicCandidates(
ctx context.Context, cred emby.Credentials, profile Profile,
) []Item {
// A cold profile has no genres to ask the catalogue for, and LibraryCandidates answers
// nothing when asked for none — so that case goes to Emby, which can list a library
// without being told what the viewer likes.
if genres := profile.TopGenres(12); e.Library != nil && len(genres) > 0 {
if raws, libErr := e.Library.LibraryCandidates(ctx, genres, MagicPoolLimit*8); libErr == nil {
if movies := onlyMovies(Decode(raws)); len(movies) > 0 {
return movies
}
} else {
e.log.Warn("magic library candidates failed; falling back to emby", "error", libErr)
}
}
// Not filtered to unplayed: a rewatch is a legitimate Magic answer and the scoring is
// what decides between them. Sorted by rating so a truncated read keeps the better
// half of the library rather than the alphabetical front of it.
result, err := e.source.Items(ctx, cred, url.Values{
"IncludeItemTypes": {"Movie"},
"Recursive": {"true"},
"SortBy": {"CommunityRating"},
"SortOrder": {"Descending"},
"Limit": {strconv.Itoa(MagicPoolLimit * 8)},
"Fields": {candidateFields},
"ImageTypeLimit": {"1"},
"EnableImages": {"true"},
"EnableImageTypes": {rowImageTypes},
"EnableUserData": {"true"},
})
if err != nil {
e.log.Warn("magic emby candidates failed", "error", err)
return nil
}
return onlyMovies(Decode(result.Items))
}
func onlyMovies(items []Item) []Item {
out := make([]Item, 0, len(items))
for _, item := range items {
if strings.EqualFold(item.Type, "Movie") && item.ID != "" {
out = append(out, item)
}
}
return out
}
// ChooseMagic is the draw, and it is pure so the weighting can be argued with in a test
// rather than on a television.
//
// Ranking then taking the top is what a row does and is exactly wrong here: the same
// household would get the same film every night, which is the one outcome the button cannot
// have. Ranking then *drawing from the ranking* keeps merit deciding which titles are in the
// hat and how many tickets each holds, while leaving the answer genuinely unpredictable.
func ChooseMagic(profile Profile, candidates []Item, opts MagicOptions) (MagicSelection, bool) {
excluded := map[string]bool{}
for _, id := range opts.ExcludeIDs {
if id = strings.TrimSpace(id); id != "" {
excluded[id] = true
}
}
type scored struct {
item Item
score float64
signals []string
}
pool := make([]scored, 0, len(candidates))
seen := map[string]bool{}
for _, candidate := range candidates {
if candidate.ID == "" || excluded[candidate.ID] || seen[candidate.ID] {
continue
}
seen[candidate.ID] = true
score, signals := magicScore(profile, candidate, opts)
pool = append(pool, scored{item: candidate, score: score, signals: signals})
}
if len(pool) == 0 {
return MagicSelection{}, false
}
sort.SliceStable(pool, func(i, j int) bool {
if pool[i].score != pool[j].score {
return pool[i].score > pool[j].score
}
// Ties break by id so the *pool* is reproducible even though the draw is not.
return pool[i].item.ID < pool[j].item.ID
})
if len(pool) > MagicPoolLimit {
pool = pool[:MagicPoolLimit]
}
// Tickets decay linearly with rank rather than by score, so a library whose scores
// happen to be bunched together still favours the front of the pool, and one with a
// runaway leader still gives the rest a real chance.
total := float64(len(pool)*(len(pool)+1)) / 2
roll := opts.Roll
if roll < 0 || roll >= 1 || math.IsNaN(roll) {
roll = 0
}
target := roll * total
var cumulative float64
for index, entry := range pool {
cumulative += float64(len(pool) - index)
if target < cumulative {
return MagicSelection{
Item: entry.item,
Signals: entry.signals,
Score: entry.score,
PoolSize: len(pool),
}, true
}
}
last := pool[len(pool)-1]
return MagicSelection{
Item: last.item,
Signals: last.signals,
Score: last.score,
PoolSize: len(pool),
}, true
}
// magicScore sums the stated terms and reports which of them fired. The signals are the
// point of returning two values: a weighting nobody can see the workings of is a weighting
// nobody can improve.
func magicScore(profile Profile, item Item, opts MagicOptions) (float64, []string) {
signals := make([]string, 0, 6)
score := profile.Affinity(item)
if score > 0 {
signals = append(signals, "taste")
}
if profile.HasSeen(item) {
score -= magicSeenPenalty
signals = append(signals, "seen")
} else {
score += magicUnwatchedBonus
signals = append(signals, "unwatched")
}
if item.UserData.IsFavorite {
score += magicFavouriteBonus
signals = append(signals, "favourite")
}
if addedDays, ok := daysSince(item.DateCreated, opts.Now); ok && addedDays <= magicRecentlyAddedDays {
score += magicRecentlyAddedBonus
signals = append(signals, "recently_added")
}
if opts.AvailableMinutes > 0 {
switch runtime := item.RuntimeMinutes(); {
case runtime <= 0:
// Nothing recorded is not evidence either way, and refusing to draw it would
// quietly delete a slice of the library from the feature.
case runtime > opts.AvailableMinutes+magicRuntimeSlackMinutes:
score -= magicRuntimeOverPenalty
signals = append(signals, "too_long")
default:
score += magicRuntimeFitBonus
signals = append(signals, "fits_time")
}
}
return score, signals
}
// daysSince reads Emby's ISO-8601 DateCreated. A field that is absent or unreadable is not
// an error: it simply cannot earn the recently-added bonus.
func daysSince(value string, now time.Time) (int, bool) {
value = strings.TrimSpace(value)
if value == "" {
return 0, false
}
created, err := time.Parse(time.RFC3339, value)
if err != nil {
return 0, false
}
if created.After(now) {
return 0, true
}
return int(now.Sub(created).Hours() / 24), true
}
+27 -8
View File
@@ -262,21 +262,40 @@ func (p Profile) TopGenres(n int) []string {
return out
}
// Score rates a candidate against the profile. A negative score means "exclude".
func (p Profile) Score(candidate Item) float64 {
// HasSeen reports whether this viewer has already watched or begun the candidate, by any
// of the four things that can say so: the item itself, the series it belongs to, another
// copy of the same title, and Emby's own user data on the payload.
//
// It is the veto half of [Score], separated because one caller needs the two halves apart:
// the Magic pick treats "already seen" as a heavy penalty rather than an exclusion, since a
// library whose owner has watched most of it must still be able to answer "put something
// good on".
func (p Profile) HasSeen(candidate Item) bool {
if p.Seen[candidate.ID] {
return -1
return true
}
if candidate.SeriesID != "" && p.Seen[candidate.SeriesID] {
return -1
return true
}
if p.SeenTitles[candidate.SeenKey()] {
return -1
}
if candidate.UserData.Played || candidate.UserData.PlaybackPositionTicks > 0 {
return -1
return true
}
return candidate.UserData.Played || candidate.UserData.PlaybackPositionTicks > 0
}
// Score rates a candidate against the profile. A negative score means "exclude".
func (p Profile) Score(candidate Item) float64 {
if p.HasSeen(candidate) {
return -1
}
return p.Affinity(candidate)
}
// Affinity is what [Score] measures once the candidate has passed the seen veto: genre and
// studio weight, a mild quality nudge and a small bonus for a recent production. Never
// negative for a plausible candidate, which is what makes it usable as one term of a larger
// sum rather than only as a verdict.
func (p Profile) Affinity(candidate Item) float64 {
var genreScore float64
for _, genre := range candidate.Genres {
genreScore += p.GenreWeights[strings.TrimSpace(genre)]
+152 -4
View File
@@ -2,6 +2,7 @@ package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
@@ -9,12 +10,159 @@ import (
"github.com/jackc/pgx/v5"
)
const CreditsSettingsKey = "credits_settings"
// CreditsSettings is the durable operator tuning for candidate generation. Environment
// values remain the first-run defaults; this row exists only after the console saves an
// explicit choice.
type CreditsSettings struct {
CandidateLimit int `json:"candidateLimit"`
PrefetchEpisodes int `json:"prefetchEpisodes"`
MaxPrefetch int `json:"maxPrefetch"`
RetryHours int `json:"retryHours"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (s *Store) CreditsSettings(ctx context.Context) (CreditsSettings, bool, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, CreditsSettingsKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return CreditsSettings{}, false, nil
}
if err != nil {
return CreditsSettings{}, false, fmt.Errorf("store: read credits settings: %w", err)
}
var settings CreditsSettings
if err := json.Unmarshal(raw, &settings); err != nil {
return CreditsSettings{}, false, fmt.Errorf("store: decode credits settings: %w", err)
}
return settings, true, nil
}
func (s *Store) SetCreditsSettings(ctx context.Context, settings CreditsSettings) error {
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()`,
CreditsSettingsKey, string(raw))
if err != nil {
return fmt.Errorf("store: write credits settings: %w", err)
}
return nil
}
// CreditsScanHistoryRow is one completed scan, enriched with current library names for the
// console. Names are not copied into the history table, so metadata corrections appear here.
type CreditsScanHistoryRow struct {
ID int64 `json:"id"`
ItemID string `json:"itemId"`
ItemName string `json:"itemName"`
SeriesName string `json:"seriesName"`
SeriesID string `json:"seriesId"`
Season int `json:"season"`
Episode int `json:"episode"`
Reason string `json:"reason"`
Priority int `json:"priority"`
Outcome string `json:"outcome"`
MarkerMs int64 `json:"markerMs"`
Confidence float64 `json:"confidence"`
Method string `json:"method"`
Frames int `json:"frames"`
Error string `json:"error"`
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"`
DurationMs int64 `json:"durationMs"`
}
func (s *Store) SaveCreditsScanAttempt(ctx context.Context, row CreditsScanHistoryRow) error {
duration := row.FinishedAt.Sub(row.StartedAt).Milliseconds()
if duration < 0 {
duration = 0
}
_, err := s.pool.Exec(ctx, `
INSERT INTO credits_scan_history (
item_id, series_id, season_number, episode_number, reason, priority, outcome,
marker_ms, confidence, method, frames_sampled, error_text,
started_at, finished_at, duration_ms
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
row.ItemID, row.SeriesID, row.Season, row.Episode, row.Reason, row.Priority,
row.Outcome, row.MarkerMs, row.Confidence, row.Method, row.Frames, row.Error,
row.StartedAt, row.FinishedAt, duration)
if err != nil {
return fmt.Errorf("store: save credits scan history: %w", err)
}
return nil
}
func (s *Store) RecentCreditsScanTimes(
ctx context.Context, itemIDs []string, since time.Time,
) (map[string]time.Time, error) {
out := map[string]time.Time{}
if len(itemIDs) == 0 {
return out, nil
}
rows, err := s.pool.Query(ctx, `
SELECT item_id, max(finished_at)
FROM credits_scan_history
WHERE item_id = ANY($1) AND finished_at >= $2
AND outcome IN ('no_match', 'failed')
GROUP BY item_id`, itemIDs, since.UTC())
if err != nil {
return nil, fmt.Errorf("store: recent credits scans: %w", err)
}
defer rows.Close()
for rows.Next() {
var itemID string
var finished time.Time
if err := rows.Scan(&itemID, &finished); err != nil {
return nil, err
}
out[itemID] = finished
}
return out, rows.Err()
}
func (s *Store) CreditsScanHistory(ctx context.Context, limit int) ([]CreditsScanHistoryRow, error) {
if limit <= 0 {
limit = 100
}
rows, err := s.pool.Query(ctx, `
SELECT h.id, h.item_id,
coalesce(i.payload->>'Name', ''), coalesce(i.payload->>'SeriesName', ''),
h.series_id, h.season_number, h.episode_number, h.reason, h.priority,
h.outcome, h.marker_ms, h.confidence, h.method, h.frames_sampled,
h.error_text, h.started_at, h.finished_at, h.duration_ms
FROM credits_scan_history h
LEFT JOIN library_items i ON i.id = h.item_id
ORDER BY h.finished_at DESC
LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("store: credits scan history: %w", err)
}
defer rows.Close()
out := make([]CreditsScanHistoryRow, 0, limit)
for rows.Next() {
var row CreditsScanHistoryRow
if err := rows.Scan(&row.ID, &row.ItemID, &row.ItemName, &row.SeriesName,
&row.SeriesID, &row.Season, &row.Episode, &row.Reason, &row.Priority,
&row.Outcome, &row.MarkerMs, &row.Confidence, &row.Method, &row.Frames,
&row.Error, &row.StartedAt, &row.FinishedAt, &row.DurationMs); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// The database half of credits marking.
//
// Four queries, and the shape of each one is chosen to keep the promise the subsystem makes
// about database activity: a settled household reads one indexed row per candidate and
// writes nothing at all. Nothing here is written per candidate, per queue transition or per
// scan attempt — only a finished marker.
// Marker queries keep the playback path cheap. Scan history is operational data on the
// background worker path only; queue transitions remain entirely in memory.
// CreditsMarkerRow is one stored marker.
type CreditsMarkerRow struct {
+26
View File
@@ -740,3 +740,29 @@ CREATE TABLE IF NOT EXISTS credits_markers (
CREATE INDEX IF NOT EXISTS credits_markers_season_idx
ON credits_markers (series_id, season_number, confidence DESC)
WHERE series_id <> '';
-- Operational history for credits scans. This is deliberately separate from queue state:
-- the queue remains disposable, while completed attempts explain repeated candidates and
-- provide the cooldown that stops an inconclusive episode being scanned every ten minutes.
CREATE TABLE IF NOT EXISTS credits_scan_history (
id BIGSERIAL PRIMARY KEY,
item_id TEXT NOT NULL,
series_id TEXT NOT NULL DEFAULT '',
season_number INT NOT NULL DEFAULT 0,
episode_number INT NOT NULL DEFAULT 0,
reason TEXT NOT NULL DEFAULT '',
priority INT NOT NULL DEFAULT 0,
outcome TEXT NOT NULL,
marker_ms BIGINT NOT NULL DEFAULT 0,
confidence REAL NOT NULL DEFAULT 0,
method TEXT NOT NULL DEFAULT '',
frames_sampled INT NOT NULL DEFAULT 0,
error_text TEXT NOT NULL DEFAULT '',
started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMPTZ NOT NULL,
duration_ms BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS credits_scan_history_item_time_idx
ON credits_scan_history (item_id, finished_at DESC);
CREATE INDEX IF NOT EXISTS credits_scan_history_time_idx
ON credits_scan_history (finished_at DESC);