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" }