0.2.68
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 @@
|
||||
0.1.47
|
||||
0.1.48
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user