Files
memby/server/internal/api/credits_detection.go
T
2026-08-15 09:23:26 +12:00

132 lines
4.6 KiB
Go

package api
import (
"context"
"fmt"
"time"
"github.com/ponzischeme89/memby/server/internal/scheduler"
)
// The gateway's side of credits discovery: where a discovered marker is read, and where the
// two live signals that drive it are picked up.
//
// Both signals are things the gateway was already being told. Nothing new is reported by a
// television and nothing new is written on the playback path — the subsystem is fed entirely
// by reports that existed before it did.
// discoveredCredits reads a stored marker for an item.
//
// One indexed lookup on the playback path, and it only happens for a title Emby had no
// chapter marker for. A failure is silence: this is an optional convenience on a film that is
// already playing, and there is nothing a viewer could do about an error.
func (s *Server) discoveredCredits(ctx context.Context, itemID string) (int64, bool) {
if s.credits == nil {
return 0, false
}
marker, found, err := s.credits.Marker(ctx, itemID)
if err != nil {
s.loggerFor(ctx).Debug("discovered credits unavailable", "item_id", itemID, "error", err)
return 0, false
}
if !found || marker.CreditsStartMs <= 0 {
return 0, false
}
return marker.CreditsStartMs, true
}
// noteCreditsPlayback feeds the two live signals from one playback report.
//
// The load gauge and the scan trigger are updated from the same call because they are two
// readings of one event, and splitting them across call sites is how the two come to
// disagree about what is playing.
//
// A started or progressing playback is the strongest evidence there is that an episode
// matters, but it does not scan anything yet: NotePlayback holds the candidate for its
// settling delay first, so an episode somebody opened and abandoned costs nothing.
func (s *Server) noteCreditsPlayback(ctx context.Context, phase, itemID, sessionKey string) {
if itemID == "" {
return
}
switch phase {
case "started":
s.creditsLoad.Playing(sessionKey)
if s.credits != nil && s.featureEnabled(ctx, featureEndCredits) {
s.credits.NotePlayback(ctx, itemID)
}
case "progress":
// Progress refreshes the gauge's timestamp. Without it a television that stopped
// reporting — a crash, a power cut — would hold the gauge busy for ever and
// speculative scanning would never run again.
s.creditsLoad.Playing(sessionKey)
case "stopped":
s.creditsLoad.Stopped(sessionKey)
// A playback that ended before its delay elapsed is exactly the case the delay
// exists for: somebody looked at the episode and changed their mind, and nothing
// should be read from disk on their account.
if s.credits != nil {
s.credits.AbandonPlayback(itemID)
}
}
}
// RegisterCreditsTasks declares the demand refresh, so its interval is the operator's to
// change and its last run is visible in the console beside every other background job.
//
// Refreshing on a schedule rather than continuously is the point: viewing behaviour changes
// over evenings, not seconds, and polling Tracearr any harder would cost more than the
// scanning it directs. Live playback is immediate and does not come through here.
func (s *Server) RegisterCreditsTasks(sched *scheduler.Scheduler) {
if sched == nil || s.credits == nil {
return
}
sched.Register(scheduler.Task{
ID: "credits-candidates",
Name: "Credits candidate refresh",
Group: "Library",
Description: "Rebuilds the credits-detection queue from what the household has " +
"recently been watching. Only episodes viewers are about to reach are queued.",
Interval: 10 * time.Minute,
Timeout: 2 * time.Minute,
RunOnStart: true,
Run: func(ctx context.Context) (string, error) {
if !s.featureEnabled(ctx, featureEndCredits) {
return "", nil
}
return s.credits.Refresh(ctx)
},
})
}
// creditsQueueDetail is the one line the admin console prints about the queue.
func (s *Server) creditsQueueDetail() string {
if s.credits == nil {
return ""
}
depth := s.credits.QueueDepth()
if depth == 0 {
return ""
}
return fmt.Sprintf("%d episode%s awaiting credits detection", depth, plural(depth))
}
// playbackSessionKey identifies one stream for the load gauge.
//
// The play session where Emby issued one, because that is what distinguishes two
// simultaneous plays from one television reconnecting. A device id is the fallback: an older
// build sends no play session, and counting every one of those as the same stream would make
// a household of old televisions read as permanently idle.
func playbackSessionKey(deviceID, playSessionID string) string {
if playSessionID != "" {
return playSessionID
}
return deviceID
}
func plural(count int) string {
if count == 1 {
return ""
}
return "s"
}