0.2.64 update
This commit is contained in:
@@ -40,6 +40,7 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("DELETE /admin/api/accounts/{userID}/sessions", s.adminAuth(s.handleAdminDeleteAccount))
|
||||
mux.Handle("PUT /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminPushPreferences))
|
||||
mux.Handle("DELETE /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminResetPreferences))
|
||||
mux.Handle("PUT /admin/api/accounts/{userID}/notifications", s.adminAuth(s.handleAdminNotificationPreferences))
|
||||
mux.Handle("GET /admin/api/accounts/{userID}/preferences/history", s.adminAuth(s.handleAdminPreferenceHistory))
|
||||
mux.Handle("POST /admin/api/accounts/{userID}/preferences/revisions/{revision}/restore",
|
||||
s.adminAuth(s.handleAdminRestorePreferences))
|
||||
@@ -58,6 +59,8 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
|
||||
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
|
||||
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
|
||||
mux.Handle("GET /admin/api/release-builder", s.adminAuth(s.handleAdminReleaseBuilderStatus))
|
||||
mux.Handle("POST /admin/api/release-builder", s.adminAuth(s.handleAdminReleaseBuilderStart))
|
||||
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
|
||||
mux.Handle("GET /admin/api/media-reports", s.adminAuth(s.handleAdminMediaReports))
|
||||
mux.Handle("POST /admin/api/media-reports/{id}/status", s.adminAuth(s.handleAdminMediaReportStatus))
|
||||
|
||||
@@ -43,7 +43,8 @@ type adminMembyAccount struct {
|
||||
// of it. Saved is false for someone who has never synced — the values shown are then
|
||||
// the defaults, and saying so is the difference between "chose this" and "has not
|
||||
// chosen anything".
|
||||
Settings adminAccountSettings `json:"settings"`
|
||||
Settings adminAccountSettings `json:"settings"`
|
||||
Notifications store.NotificationPreferences `json:"notifications"`
|
||||
// Themes is the ids this person may choose between, and an empty array means every
|
||||
// selectable theme rather than none — the same permissive reading the store and
|
||||
// themeAllowed take. The console renders that as every box ticked, which is what an
|
||||
@@ -100,6 +101,12 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
themes = map[string][]string{}
|
||||
}
|
||||
|
||||
notifications, err := s.store.AllNotificationPreferences(r.Context())
|
||||
if err != nil {
|
||||
s.loggerFor(r.Context()).Warn("account notification preferences read failed", "error", err)
|
||||
notifications = map[string]store.NotificationPreferences{}
|
||||
}
|
||||
|
||||
result := make([]adminMembyAccount, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
pref := preferences[account.ID]
|
||||
@@ -125,10 +132,15 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
if !stored.UpdatedAt.IsZero() {
|
||||
accountSettings.UpdatedAt = stored.UpdatedAt
|
||||
}
|
||||
notificationPrefs, savedNotifications := notifications[account.ID]
|
||||
if !savedNotifications {
|
||||
notificationPrefs = store.DefaultNotificationPreferences()
|
||||
}
|
||||
result = append(result, adminMembyAccount{
|
||||
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
|
||||
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
|
||||
Themes: nonNilStrings(themes[account.ID]),
|
||||
Themes: nonNilStrings(themes[account.ID]),
|
||||
Notifications: notificationPrefs,
|
||||
Recommendations: adminOnboardingPreferences{
|
||||
Completed: pref.Completed, Prompted: pref.Prompted,
|
||||
Updated: len(account.RecommendationPreferences) > 2,
|
||||
@@ -153,6 +165,33 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminNotificationPreferences changes what one person is told without requiring
|
||||
// a television or an app release. The loaded value is decoded in place so a console from
|
||||
// an older gateway generation cannot accidentally turn off fields it does not know.
|
||||
func (s *Server) handleAdminNotificationPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
userID := strings.TrimSpace(r.PathValue("userID"))
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusBadRequest, "user is required")
|
||||
return
|
||||
}
|
||||
prefs, err := s.notificationPreferencesFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load notification settings")
|
||||
return
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&prefs); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
return
|
||||
}
|
||||
if err := s.saveNotificationPreferences(r.Context(), userID, prefs); err != nil {
|
||||
s.loggerFor(r.Context()).Error("admin notification preferences save failed", "user", userID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save notification settings")
|
||||
return
|
||||
}
|
||||
s.loggerFor(r.Context()).Info("notification settings saved for viewer", "user", userID)
|
||||
writeJSON(w, http.StatusOK, prefs)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPromptRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
userID := strings.TrimSpace(r.PathValue("userID"))
|
||||
if userID == "" {
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/bazarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||
"github.com/ponzischeme89/memby/server/internal/config"
|
||||
"github.com/ponzischeme89/memby/server/internal/credits"
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/foryou"
|
||||
"github.com/ponzischeme89/memby/server/internal/integrations"
|
||||
@@ -51,6 +52,12 @@ type Server struct {
|
||||
radarr *radarr.Client
|
||||
bazarr *bazarr.Client
|
||||
mdblist *mdblist.Client
|
||||
// credits discovers where an episode's closing credits begin, for the small number of
|
||||
// episodes the household is about to watch. Nil when the subsystem is switched off, and
|
||||
// every call site tolerates that — a missing marker simply means no Skip Credits button,
|
||||
// which is the same state a library with no chapter markers is already in.
|
||||
credits *credits.Service
|
||||
creditsLoad *credits.PlaybackLoad
|
||||
syncer syncerHandle
|
||||
log *slog.Logger
|
||||
events *serverlogging.Buffer
|
||||
@@ -112,6 +119,8 @@ type Deps struct {
|
||||
Radarr *radarr.Client
|
||||
Bazarr *bazarr.Client
|
||||
MDBList *mdblist.Client
|
||||
Credits *credits.Service
|
||||
CreditsLoad *credits.PlaybackLoad
|
||||
Syncer syncerHandle
|
||||
Log *slog.Logger
|
||||
Events *serverlogging.Buffer
|
||||
@@ -133,6 +142,8 @@ func New(cfg config.Config, deps Deps) *Server {
|
||||
radarr: deps.Radarr,
|
||||
bazarr: deps.Bazarr,
|
||||
mdblist: deps.MDBList,
|
||||
credits: deps.Credits,
|
||||
creditsLoad: deps.CreditsLoad,
|
||||
syncer: deps.Syncer,
|
||||
log: deps.Log,
|
||||
events: deps.Events,
|
||||
@@ -271,9 +282,9 @@ func (s *Server) Routes() http.Handler {
|
||||
// process. Keep it outside authentication and maintenance so offline/start-up fallback
|
||||
// never depends on a session being available.
|
||||
mux.HandleFunc("GET /v1/config", s.handleRemoteConfig)
|
||||
// Update policy is app-scoped, not user-scoped. Keep it outside authentication and
|
||||
// maintenance so a fresh install, a signed-out TV, and a retired build can all learn
|
||||
// whether the server requires an update. A valid session enriches only its log context.
|
||||
// Update compatibility is app-scoped; a signed-in viewer may only mute the optional
|
||||
// prompt. Keep this outside authentication and maintenance so a fresh install, a
|
||||
// signed-out TV, and especially a retired build can still learn what it must do.
|
||||
mux.Handle("GET /v1/update", s.identifyOptionalSession(http.HandlerFunc(s.handleUpdate)))
|
||||
// Exact route outside the maintenance gate: signed-in clients poll this lightweight
|
||||
// status even while every normal /v1 operation is deliberately unavailable.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
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"
|
||||
}
|
||||
+47
-14
@@ -759,7 +759,8 @@ func activeHeroScheduleIDs(schedules []store.HeroSchedule, placement, userID str
|
||||
}
|
||||
matched := []active{}
|
||||
for _, schedule := range schedules {
|
||||
if !schedule.Enabled || (schedule.UserID != "" && schedule.UserID != userID) || now.Before(schedule.StartAt) || !now.Before(schedule.EndAt) {
|
||||
if !schedule.Enabled || (schedule.UserID != "" && schedule.UserID != userID) ||
|
||||
!heroScheduleActiveAt(schedule, now, location) {
|
||||
continue
|
||||
}
|
||||
matchesPlacement := false
|
||||
@@ -775,19 +776,6 @@ func activeHeroScheduleIDs(schedules []store.HeroSchedule, placement, userID str
|
||||
if !matchesPlacement {
|
||||
continue
|
||||
}
|
||||
if len(schedule.Weekdays) > 0 {
|
||||
weekday := int(now.In(location).Weekday())
|
||||
found := false
|
||||
for _, day := range schedule.Weekdays {
|
||||
if day == weekday {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
matched = append(matched, active{schedule.ItemID, schedule.Priority, schedule.StartAt})
|
||||
}
|
||||
sort.SliceStable(matched, func(i, j int) bool {
|
||||
@@ -803,6 +791,51 @@ func activeHeroScheduleIDs(schedules []store.HeroSchedule, placement, userID str
|
||||
return ids
|
||||
}
|
||||
|
||||
func heroScheduleActiveAt(schedule store.HeroSchedule, now time.Time, location *time.Location) bool {
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
if schedule.Frequency != "daily" && schedule.Frequency != "weekly" {
|
||||
if now.Before(schedule.StartAt) || !now.Before(schedule.EndAt) {
|
||||
return false
|
||||
}
|
||||
return len(schedule.Weekdays) == 0 || heroScheduleHasWeekday(schedule.Weekdays, int(now.In(location).Weekday()))
|
||||
}
|
||||
start, startErr := time.Parse("15:04", schedule.StartTime)
|
||||
end, endErr := time.Parse("15:04", schedule.EndTime)
|
||||
if startErr != nil || endErr != nil {
|
||||
return false
|
||||
}
|
||||
local := now.In(location)
|
||||
minute := local.Hour()*60 + local.Minute()
|
||||
startMinute := start.Hour()*60 + start.Minute()
|
||||
endMinute := end.Hour()*60 + end.Minute()
|
||||
if startMinute == endMinute {
|
||||
return false
|
||||
}
|
||||
effectiveDay := int(local.Weekday())
|
||||
active := minute >= startMinute && minute < endMinute
|
||||
if startMinute > endMinute {
|
||||
active = minute >= startMinute || minute < endMinute
|
||||
if minute < endMinute {
|
||||
effectiveDay = (effectiveDay + 6) % 7
|
||||
}
|
||||
}
|
||||
if !active || schedule.Frequency == "daily" || len(schedule.Weekdays) == 0 {
|
||||
return active
|
||||
}
|
||||
return heroScheduleHasWeekday(schedule.Weekdays, effectiveDay)
|
||||
}
|
||||
|
||||
func heroScheduleHasWeekday(days []int, weekday int) bool {
|
||||
for _, day := range days {
|
||||
if day == weekday {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pinnedHeroCandidates resolves policy against the imported catalogue. A deleted or
|
||||
// unsupported id quietly drops out, so an old admin choice can never make Home fail.
|
||||
func (s *Server) pinnedHeroCandidates(ctx context.Context, ids []string) []heroCandidate {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
@@ -22,6 +23,7 @@ type heroAdminPolicy struct {
|
||||
PrimeSubtitle string `json:"primeSubtitle"`
|
||||
Placements map[string]heroAdminPlacement `json:"placements"`
|
||||
Schedules []store.HeroSchedule `json:"schedules"`
|
||||
TimeZone string `json:"timeZone"`
|
||||
}
|
||||
|
||||
type heroAdminPlacement struct {
|
||||
@@ -67,6 +69,7 @@ func (s *Server) heroAdminPolicy(ctx context.Context) heroAdminPolicy {
|
||||
Placements: make(map[string]heroAdminPlacement, len(policy.Placements)),
|
||||
Items: []heroAdminItem{},
|
||||
Schedules: policy.Schedules,
|
||||
TimeZone: heroScheduleTimeZone(s.cfg.RadarrLocation),
|
||||
}
|
||||
for _, id := range allIDs {
|
||||
if item, ok := byID[id]; ok {
|
||||
@@ -87,6 +90,13 @@ func (s *Server) heroAdminPolicy(ctx context.Context) heroAdminPolicy {
|
||||
return out
|
||||
}
|
||||
|
||||
func heroScheduleTimeZone(location *time.Location) string {
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
return location.String()
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminHeroSearch(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.SearchLibrary(r.Context(), r.URL.Query().Get("q"), 20)
|
||||
if err != nil {
|
||||
@@ -165,6 +175,26 @@ func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
for _, schedule := range request.Schedules {
|
||||
frequency := strings.ToLower(strings.TrimSpace(schedule.Frequency))
|
||||
if frequency != "" && frequency != "once" && frequency != "daily" && frequency != "weekly" {
|
||||
writeError(w, http.StatusBadRequest, "hero schedule frequency must be once, daily or weekly")
|
||||
return
|
||||
}
|
||||
if frequency == "daily" || frequency == "weekly" {
|
||||
start, startErr := time.Parse("15:04", strings.TrimSpace(schedule.StartTime))
|
||||
end, endErr := time.Parse("15:04", strings.TrimSpace(schedule.EndTime))
|
||||
if startErr != nil || endErr != nil || start.Equal(end) {
|
||||
writeError(w, http.StatusBadRequest, "repeating hero schedules need different start and end times")
|
||||
return
|
||||
}
|
||||
if frequency == "weekly" && len(schedule.Weekdays) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "weekly hero schedules need at least one day")
|
||||
return
|
||||
}
|
||||
} else if !schedule.EndAt.After(schedule.StartAt) {
|
||||
writeError(w, http.StatusBadRequest, "one-time hero schedules need an end after their start")
|
||||
return
|
||||
}
|
||||
item, ok := valid[strings.TrimSpace(schedule.ItemID)]
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "every scheduled hero must be a playable library film or series")
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
func TestRecurringHeroScheduleUsesLocalWeekdayAndTime(t *testing.T) {
|
||||
zone := time.FixedZone("NZST", 12*60*60)
|
||||
schedule := store.HeroSchedule{
|
||||
Frequency: "weekly", StartTime: "18:00", EndTime: "22:00",
|
||||
Weekdays: []int{1},
|
||||
}
|
||||
monday := time.Date(2026, 8, 17, 19, 0, 0, 0, zone)
|
||||
if !heroScheduleActiveAt(schedule, monday, zone) {
|
||||
t.Fatal("Monday evening schedule was not active")
|
||||
}
|
||||
if heroScheduleActiveAt(schedule, monday.Add(24*time.Hour), zone) {
|
||||
t.Fatal("Monday schedule remained active on Tuesday")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOvernightHeroScheduleBelongsToStartingDay(t *testing.T) {
|
||||
zone := time.FixedZone("NZST", 12*60*60)
|
||||
schedule := store.HeroSchedule{
|
||||
Frequency: "weekly", StartTime: "22:00", EndTime: "02:00",
|
||||
Weekdays: []int{5}, // Friday night through early Saturday.
|
||||
}
|
||||
fridayNight := time.Date(2026, 8, 21, 23, 0, 0, 0, zone)
|
||||
saturdayEarly := time.Date(2026, 8, 22, 1, 0, 0, 0, zone)
|
||||
if !heroScheduleActiveAt(schedule, fridayNight, zone) || !heroScheduleActiveAt(schedule, saturdayEarly, zone) {
|
||||
t.Fatal("overnight Friday schedule did not span midnight")
|
||||
}
|
||||
if heroScheduleActiveAt(schedule, saturdayEarly.Add(24*time.Hour), zone) {
|
||||
t.Fatal("overnight Friday schedule was active on Sunday morning")
|
||||
}
|
||||
}
|
||||
@@ -161,8 +161,16 @@ func (s *Server) handleIntro(w http.ResponseWriter, r *http.Request, sess store.
|
||||
// optional conveniences on a film that is already playing, and a failure the viewer
|
||||
// cannot act on is not worth a red line in the log for every episode watched.
|
||||
s.loggerFor(ctx).Debug("chapter markers unavailable", "item_id", itemID, "error", err)
|
||||
writeJSON(w, http.StatusOK, introResponse{})
|
||||
return
|
||||
markers = chapterMarkers{}
|
||||
}
|
||||
// A discovered marker fills in where Emby has none, which on 4.10 is nearly everywhere:
|
||||
// a survey of this household's library found no CreditsStart markers at all. Emby still
|
||||
// wins where it has an answer — it is the media server's own reading of its own file, and
|
||||
// this subsystem exists to cover the case where there is nothing to defer to.
|
||||
if credits && !markers.creditsFound {
|
||||
if start, found := s.discoveredCredits(ctx, itemID); found {
|
||||
markers.creditsStart, markers.creditsFound = start, true
|
||||
}
|
||||
}
|
||||
if !markers.introFound && !markers.creditsFound {
|
||||
writeJSON(w, http.StatusOK, introResponse{})
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
// handler has returned by the time the middleware reads this, so no lock is needed.
|
||||
type requestIdentity struct {
|
||||
component string
|
||||
userID string
|
||||
user string
|
||||
device string
|
||||
client string
|
||||
@@ -55,6 +56,7 @@ func identify(ctx context.Context, sess store.Session) {
|
||||
if identity == nil {
|
||||
return
|
||||
}
|
||||
identity.userID = sess.EmbyUserID
|
||||
if sess.Username != "" {
|
||||
identity.user = sess.Username
|
||||
}
|
||||
|
||||
@@ -103,11 +103,20 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
|
||||
// Nothing to celebrate while the service is down, and the client is showing the
|
||||
// maintenance screen anyway.
|
||||
if !state.Enabled {
|
||||
if found := mergeAlerts(
|
||||
s.publishedAlerts(r.Context()),
|
||||
s.sonarrAiredAlerts(r.Context()),
|
||||
); len(found) > 0 {
|
||||
alerts = found
|
||||
published, sonarr := s.publishedAlerts(r.Context()), s.sonarrAiredAlerts(r.Context())
|
||||
if len(published) > 0 || len(sonarr) > 0 {
|
||||
prefs, err := s.notificationPreferencesFor(r.Context(), sess.EmbyUserID)
|
||||
if err != nil {
|
||||
s.loggerFor(r.Context()).Warn("notification preferences unavailable", "error", err)
|
||||
alerts = mergeAlerts(published, sonarr)
|
||||
} else {
|
||||
// Filter before trimming so three muted Sonarr stories cannot crowd a
|
||||
// permitted Radarr or service notice out of the small client queue.
|
||||
alerts = mergeAlerts(
|
||||
filterClientAlerts(published, prefs),
|
||||
filterClientAlerts(sonarr, prefs),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
compatible, compatibilityMessage := compatibilityFor(r)
|
||||
|
||||
@@ -155,7 +155,7 @@ func sonarrStatus(series sonarr.Series) string {
|
||||
}
|
||||
|
||||
func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
prefs, err := s.store.NotificationPreferences(r.Context(), sess.EmbyUserID)
|
||||
prefs, err := s.notificationPreferencesFor(r.Context(), sess.EmbyUserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load notification preferences")
|
||||
return
|
||||
@@ -164,12 +164,12 @@ func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request, ses
|
||||
if !decodeJSON(w, r, &prefs) {
|
||||
return
|
||||
}
|
||||
if err := s.store.SetNotificationPreferences(r.Context(), sess.EmbyUserID, prefs); err != nil {
|
||||
if err := s.saveNotificationPreferences(r.Context(), sess.EmbyUserID, prefs); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not save notification preferences")
|
||||
return
|
||||
}
|
||||
}
|
||||
if prefs.Enabled && prefs.ShowReturnAlerts {
|
||||
if prefs.Enabled && prefs.SonarrAlerts && prefs.ShowReturnAlerts {
|
||||
s.syncReturnNotifications(r, sess, prefs)
|
||||
}
|
||||
notifications, err := s.store.UserNotifications(r.Context(), sess.EmbyUserID)
|
||||
@@ -178,7 +178,7 @@ func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request, ses
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, notificationsResponse{
|
||||
Notifications: notifications,
|
||||
Notifications: filterStoredNotifications(notifications, prefs),
|
||||
Preferences: prefs,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
const notificationPreferencesTTL = 5 * time.Minute
|
||||
|
||||
func notificationPreferencesKey(userID string) string {
|
||||
return cache.UserKey(userID, "notification-preferences:v2")
|
||||
}
|
||||
|
||||
// notificationPreferencesFor keeps the ten-second status poll off Postgres. Redis also
|
||||
// shares the answer if the gateway is ever run with more than one replica; writes replace
|
||||
// it immediately, while the short TTL still picks up an operator's direct database edit.
|
||||
func (s *Server) notificationPreferencesFor(ctx context.Context, userID string) (store.NotificationPreferences, error) {
|
||||
defaults := store.DefaultNotificationPreferences()
|
||||
if userID == "" || s.store == nil {
|
||||
return defaults, nil
|
||||
}
|
||||
if s.cache != nil {
|
||||
if raw, err := s.cache.Get(ctx, notificationPreferencesKey(userID)); err == nil {
|
||||
prefs := defaults
|
||||
if json.Unmarshal(raw, &prefs) == nil {
|
||||
return prefs, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
prefs, err := s.store.NotificationPreferences(ctx, userID)
|
||||
if err != nil {
|
||||
return defaults, err
|
||||
}
|
||||
s.cacheNotificationPreferences(ctx, userID, prefs)
|
||||
return prefs, nil
|
||||
}
|
||||
|
||||
func (s *Server) cacheNotificationPreferences(ctx context.Context, userID string, prefs store.NotificationPreferences) {
|
||||
if s.cache == nil || userID == "" {
|
||||
return
|
||||
}
|
||||
if raw, err := json.Marshal(prefs); err == nil {
|
||||
_ = s.cache.Set(ctx, notificationPreferencesKey(userID), raw, notificationPreferencesTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) saveNotificationPreferences(ctx context.Context, userID string, prefs store.NotificationPreferences) error {
|
||||
if err := s.store.SetNotificationPreferences(ctx, userID, prefs); err != nil {
|
||||
return err
|
||||
}
|
||||
s.cacheNotificationPreferences(ctx, userID, prefs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func filterClientAlerts(alerts []clientAlert, prefs store.NotificationPreferences) []clientAlert {
|
||||
if !prefs.Enabled {
|
||||
return []clientAlert{}
|
||||
}
|
||||
filtered := make([]clientAlert, 0, len(alerts))
|
||||
for _, alert := range alerts {
|
||||
allowed := true
|
||||
switch alert.Kind {
|
||||
case alertKindSonarrAired:
|
||||
allowed = prefs.SonarrAlerts
|
||||
case alertKindRadarrImport:
|
||||
allowed = prefs.RadarrAlerts
|
||||
case alertKindLibrarySync:
|
||||
allowed = prefs.LibraryAlerts
|
||||
case alertKindServerDown, alertKindServerUp, alertKindDeploying:
|
||||
allowed = prefs.SystemAlerts
|
||||
}
|
||||
if allowed {
|
||||
filtered = append(filtered, alert)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func filterStoredNotifications(notifications []store.UserNotification, prefs store.NotificationPreferences) []store.UserNotification {
|
||||
if !prefs.Enabled {
|
||||
return []store.UserNotification{}
|
||||
}
|
||||
if prefs.SonarrAlerts {
|
||||
return notifications
|
||||
}
|
||||
filtered := make([]store.UserNotification, 0, len(notifications))
|
||||
for _, notification := range notifications {
|
||||
switch notification.Kind {
|
||||
case "show-return", "show-added", "show-cancelled", "auto-follow":
|
||||
continue
|
||||
default:
|
||||
filtered = append(filtered, notification)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/appupdate"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
func TestFilterClientAlertsByCategory(t *testing.T) {
|
||||
prefs := store.DefaultNotificationPreferences()
|
||||
prefs.SonarrAlerts = false
|
||||
prefs.LibraryAlerts = false
|
||||
alerts := []clientAlert{
|
||||
{ID: "sonarr", Kind: alertKindSonarrAired},
|
||||
{ID: "radarr", Kind: alertKindRadarrImport},
|
||||
{ID: "library", Kind: alertKindLibrarySync},
|
||||
{ID: "system", Kind: alertKindServerDown},
|
||||
}
|
||||
|
||||
filtered := filterClientAlerts(alerts, prefs)
|
||||
if len(filtered) != 2 || filtered[0].ID != "radarr" || filtered[1].ID != "system" {
|
||||
t.Fatalf("filtered alerts = %#v", filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterClientAlertsMasterSwitch(t *testing.T) {
|
||||
prefs := store.DefaultNotificationPreferences()
|
||||
prefs.Enabled = false
|
||||
if got := filterClientAlerts([]clientAlert{{ID: "one", Kind: alertKindRadarrImport}}, prefs); len(got) != 0 {
|
||||
t.Fatalf("master switch returned %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterStoredSonarrNotifications(t *testing.T) {
|
||||
prefs := store.DefaultNotificationPreferences()
|
||||
prefs.SonarrAlerts = false
|
||||
notifications := []store.UserNotification{
|
||||
{ID: 1, Kind: "show-return"},
|
||||
{ID: 2, Kind: "future-category"},
|
||||
}
|
||||
filtered := filterStoredNotifications(notifications, prefs)
|
||||
if len(filtered) != 1 || filtered[0].ID != 2 {
|
||||
t.Fatalf("filtered notifications = %#v", filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePreferenceNeverSuppressesMandatoryUpdate(t *testing.T) {
|
||||
mandatory := appupdate.Decision{Status: appupdate.StatusMandatory, Version: "2.0.0"}
|
||||
if got := updateDecisionForPreferences(mandatory, false); got.Status != appupdate.StatusMandatory {
|
||||
t.Fatalf("mandatory update was suppressed: %#v", got)
|
||||
}
|
||||
optional := appupdate.Decision{Status: appupdate.StatusOptional, Version: "2.0.0"}
|
||||
if got := updateDecisionForPreferences(optional, false); got.Status != appupdate.StatusNone {
|
||||
t.Fatalf("optional update was not suppressed: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -812,6 +812,12 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
|
||||
)
|
||||
}
|
||||
|
||||
// Credits discovery rides the reports that were already being made. The session key is
|
||||
// the play session where the television gave one and the device otherwise, so two
|
||||
// televisions playing the same episode count as two streams rather than one.
|
||||
s.noteCreditsPlayback(r.Context(), phase, report.ItemID,
|
||||
playbackSessionKey(sess.DeviceID, report.PlaySessionID))
|
||||
|
||||
if phase == "stopped" {
|
||||
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
|
||||
s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var releaseBuilderTagPattern = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
|
||||
|
||||
type releaseBuilderRequest struct {
|
||||
Tag string `json:"tag"`
|
||||
Notes string `json:"notes"`
|
||||
Mandatory bool `json:"mandatory"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminReleaseBuilderStatus(w http.ResponseWriter, r *http.Request) {
|
||||
s.relayReleaseBuilder(w, r, http.MethodGet, "/v1/status", nil)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminReleaseBuilderStart(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
|
||||
var request releaseBuilderRequest
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid release request")
|
||||
return
|
||||
}
|
||||
request.Tag = strings.TrimSpace(request.Tag)
|
||||
request.Notes = strings.TrimSpace(request.Notes)
|
||||
if request.Tag != "" && !releaseBuilderTagPattern.MatchString(request.Tag) {
|
||||
writeError(w, http.StatusBadRequest, "tag must be blank or look like v0.2.64")
|
||||
return
|
||||
}
|
||||
if len(request.Notes) > 4000 {
|
||||
writeError(w, http.StatusBadRequest, "release notes are too long")
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not prepare release request")
|
||||
return
|
||||
}
|
||||
s.relayReleaseBuilder(w, r, http.MethodPost, "/v1/releases", body)
|
||||
}
|
||||
|
||||
func (s *Server) relayReleaseBuilder(w http.ResponseWriter, incoming *http.Request, method, path string, body []byte) {
|
||||
if s.cfg.ReleaseBuilderURL == "" || s.cfg.ReleasePublishToken == "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "the Docker release builder is not configured")
|
||||
return
|
||||
}
|
||||
request, err := http.NewRequestWithContext(incoming.Context(), method,
|
||||
s.cfg.ReleaseBuilderURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not prepare builder request")
|
||||
return
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+s.cfg.ReleasePublishToken)
|
||||
if len(body) > 0 {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "the Docker release builder is not available")
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
payload, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "could not read the Docker release builder response")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
_, _ = w.Write(payload)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/config"
|
||||
)
|
||||
|
||||
func TestAdminReleaseBuilderRelaysWithoutExposingToken(t *testing.T) {
|
||||
var receivedAuth string
|
||||
builder := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedAuth = r.Header.Get("Authorization")
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/releases" {
|
||||
t.Fatalf("builder request = %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if !strings.Contains(string(body), `"tag":"v0.2.64"`) || !strings.Contains(string(body), `"mandatory":true`) {
|
||||
t.Fatalf("builder body = %s", body)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = io.WriteString(w, `{"state":"running","logs":[]}`)
|
||||
}))
|
||||
defer builder.Close()
|
||||
|
||||
s := &Server{cfg: config.Config{ReleaseBuilderURL: builder.URL, ReleasePublishToken: "release-secret"}}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/api/release-builder",
|
||||
strings.NewReader(`{"tag":"v0.2.64","notes":"Living room polish","mandatory":true}`))
|
||||
s.handleAdminReleaseBuilderStart(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusAccepted || receivedAuth != "Bearer release-secret" {
|
||||
t.Fatalf("response/auth = %d/%q", recorder.Code, receivedAuth)
|
||||
}
|
||||
if strings.Contains(recorder.Body.String(), "release-secret") {
|
||||
t.Fatal("release token was exposed to the browser")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminReleaseBuilderValidatesTagBeforeRelay(t *testing.T) {
|
||||
s := &Server{cfg: config.Config{ReleaseBuilderURL: "http://builder", ReleasePublishToken: "secret"}}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/api/release-builder",
|
||||
strings.NewReader(`{"tag":"latest; rm -rf /"}`))
|
||||
s.handleAdminReleaseBuilderStart(recorder, request)
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid tag status = %d, want 400", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminReleaseBuilderIsUnavailableWhenUnconfigured(t *testing.T) {
|
||||
s := &Server{}
|
||||
recorder := httptest.NewRecorder()
|
||||
s.handleAdminReleaseBuilderStatus(recorder, httptest.NewRequest(http.MethodGet, "/admin/api/release-builder", nil))
|
||||
if recorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("unconfigured builder status = %d, want 503", recorder.Code)
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
|
||||
}
|
||||
preferences[user.ID] = prefs
|
||||
}
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled {
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
|
||||
continue
|
||||
}
|
||||
eventAt := change.Current.ObservedAt
|
||||
@@ -122,7 +122,7 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
|
||||
}
|
||||
preferences[user.ID] = prefs
|
||||
}
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled {
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
|
||||
continue
|
||||
}
|
||||
eventAt := change.Current.ObservedAt
|
||||
|
||||
@@ -148,11 +148,24 @@ func (s *Server) requireSupportedClient(next http.HandlerFunc) http.HandlerFunc
|
||||
|
||||
// handleUpdate answers the client's version check.
|
||||
//
|
||||
// Its own public endpoint rather than a field on /v1/home: update policy belongs to the
|
||||
// app build, not a viewer or login. The verdict comes from memory; when a bearer token is
|
||||
// present the route resolves it only to attribute an offered update to the affected viewer.
|
||||
// Its own public endpoint rather than a field on /v1/home: compatibility belongs to the
|
||||
// app build, not a viewer or login. The verdict comes from memory; a valid bearer token
|
||||
// also lets one viewer mute an optional prompt, but never a mandatory update.
|
||||
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
decision := s.updateDecision(r)
|
||||
// A signed-in viewer may decline optional update prompts. Mandatory decisions remain
|
||||
// authoritative: this preference is about notifications, not compatibility or the
|
||||
// operator's ability to retire an unsafe build. Signed-out checks have no person to
|
||||
// consult and retain the ordinary app-scoped policy.
|
||||
if decision.Status == appupdate.StatusOptional {
|
||||
if identity := identityFrom(r.Context()); identity != nil && identity.userID != "" {
|
||||
if prefs, err := s.notificationPreferencesFor(r.Context(), identity.userID); err != nil {
|
||||
s.loggerFor(r.Context()).Warn("update notification preferences unavailable", "error", err)
|
||||
} else {
|
||||
decision = updateDecisionForPreferences(decision, prefs.Enabled && prefs.UpdateAlerts)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only a verdict that asks a television to do something is worth a line. Every TV
|
||||
// checks on every launch, and "nothing to say" logged each time would bury the
|
||||
// launch where an update was actually offered — or forced.
|
||||
@@ -165,3 +178,10 @@ func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, decision)
|
||||
}
|
||||
|
||||
func updateDecisionForPreferences(decision appupdate.Decision, updateAlerts bool) appupdate.Decision {
|
||||
if decision.Status == appupdate.StatusOptional && !updateAlerts {
|
||||
return appupdate.Decision{Status: appupdate.StatusNone}
|
||||
}
|
||||
return decision
|
||||
}
|
||||
|
||||
@@ -81,6 +81,9 @@ type Config struct {
|
||||
// ReleasePublishToken authorizes the CI-only release upload endpoint. It is separate
|
||||
// from AdminToken so a compromised build runner cannot change maintenance settings.
|
||||
ReleasePublishToken string
|
||||
// ReleaseBuilderURL is the private Compose address of the Android release controller.
|
||||
// It is never given to the browser; the authenticated admin API relays requests to it.
|
||||
ReleaseBuilderURL string
|
||||
|
||||
// SyncInterval is how often the library import runs. Zero disables the schedule.
|
||||
SyncInterval time.Duration
|
||||
@@ -144,6 +147,26 @@ type Config struct {
|
||||
TracearrURL string
|
||||
TracearrAPIKey string
|
||||
TracearrServerID string
|
||||
// Credits detection discovers where an episode's closing credits begin, for the small
|
||||
// number of episodes the household is about to watch. It is demand-driven — Tracearr
|
||||
// says what is worth scanning — so these settings shape how far ahead of a viewer it
|
||||
// prepares, never how much of the library it reads.
|
||||
//
|
||||
// CreditsEnabled is off by default: it is the only thing in the gateway that reads media
|
||||
// bytes, and switching that on is an operator's decision rather than a default.
|
||||
CreditsEnabled bool
|
||||
// CreditsFFmpeg is the decoder. Absent, the subsystem still runs and still writes
|
||||
// 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 int
|
||||
CreditsMaxPrefetch int
|
||||
// CreditsQueueLimit bounds pending candidates. Past it, low-priority speculation is
|
||||
// discarded rather than queued.
|
||||
CreditsQueueLimit int
|
||||
|
||||
// TracearrSyncInterval imports recent changed sessions. FullInterval reconciles
|
||||
// late/out-of-order updates and deletions without needing a source cursor.
|
||||
TracearrSyncInterval time.Duration
|
||||
@@ -160,6 +183,10 @@ func Load() (Config, error) {
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
releasePublishToken, err := secret("MEMBY_RELEASE_PUBLISH_TOKEN")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
c := Config{
|
||||
ListenAddr: env("MEMBY_LISTEN_ADDR", ":8080"),
|
||||
EmbyURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_URL"), "/"),
|
||||
@@ -181,42 +208,46 @@ func Load() (Config, error) {
|
||||
),
|
||||
RemoteConfig: remoteConfig,
|
||||
|
||||
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")),
|
||||
AdminUIURL: env("MEMBY_ADMIN_UI_URL", "http://memby-admin:80"),
|
||||
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"),
|
||||
ReleaseDir: env("MEMBY_RELEASE_DIR", "/data/releases"),
|
||||
ReleasePublishToken: strings.TrimSpace(
|
||||
os.Getenv("MEMBY_RELEASE_PUBLISH_TOKEN"),
|
||||
),
|
||||
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
|
||||
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
|
||||
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
|
||||
SyncUserID: strings.TrimSpace(os.Getenv("MEMBY_SYNC_USER_ID")),
|
||||
SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")),
|
||||
AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour),
|
||||
UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second),
|
||||
EmbyHealthInterval: duration("MEMBY_EMBY_HEALTH_INTERVAL", 60*time.Second),
|
||||
SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"),
|
||||
SonarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SONARR_API_KEY")),
|
||||
SonarrTTL: duration("MEMBY_SONARR_TTL", 5*time.Minute),
|
||||
SonarrAlertWindow: duration("MEMBY_SONARR_ALERT_WINDOW", 3*time.Hour),
|
||||
RadarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_RADARR_URL")), "/"),
|
||||
RadarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_RADARR_API_KEY")),
|
||||
RadarrTTL: duration("MEMBY_RADARR_TTL", 5*time.Minute),
|
||||
RadarrWebhookToken: strings.TrimSpace(os.Getenv("MEMBY_RADARR_WEBHOOK_TOKEN")),
|
||||
RadarrAlertWindow: duration("MEMBY_RADARR_ALERT_WINDOW", 3*time.Hour),
|
||||
BazarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_BAZARR_URL")), "/"),
|
||||
BazarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_BAZARR_API_KEY")),
|
||||
BazarrTTL: duration("MEMBY_BAZARR_TTL", 5*time.Minute),
|
||||
BazarrTimeout: duration("MEMBY_BAZARR_TIMEOUT", 45*time.Second),
|
||||
TracearrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_URL")), "/"),
|
||||
TracearrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_API_KEY")),
|
||||
TracearrServerID: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_SERVER_ID")),
|
||||
TracearrSyncInterval: duration("MEMBY_TRACEARR_SYNC_INTERVAL", 5*time.Minute),
|
||||
TracearrFullInterval: duration("MEMBY_TRACEARR_FULL_INTERVAL", 24*time.Hour),
|
||||
ForYouMinRebuildAge: duration("MEMBY_FOR_YOU_MIN_REBUILD_AGE", 24*time.Hour),
|
||||
ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 24*time.Hour),
|
||||
ForYouRebuildHour: integer("MEMBY_FOR_YOU_REBUILD_HOUR", 4),
|
||||
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")),
|
||||
AdminUIURL: env("MEMBY_ADMIN_UI_URL", "http://memby-admin:80"),
|
||||
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"),
|
||||
ReleaseDir: env("MEMBY_RELEASE_DIR", "/data/releases"),
|
||||
ReleasePublishToken: releasePublishToken,
|
||||
ReleaseBuilderURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_RELEASE_BUILDER_URL")), "/"),
|
||||
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
|
||||
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
|
||||
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
|
||||
SyncUserID: strings.TrimSpace(os.Getenv("MEMBY_SYNC_USER_ID")),
|
||||
SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")),
|
||||
AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour),
|
||||
UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second),
|
||||
EmbyHealthInterval: duration("MEMBY_EMBY_HEALTH_INTERVAL", 60*time.Second),
|
||||
SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"),
|
||||
SonarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SONARR_API_KEY")),
|
||||
SonarrTTL: duration("MEMBY_SONARR_TTL", 5*time.Minute),
|
||||
SonarrAlertWindow: duration("MEMBY_SONARR_ALERT_WINDOW", 3*time.Hour),
|
||||
RadarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_RADARR_URL")), "/"),
|
||||
RadarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_RADARR_API_KEY")),
|
||||
RadarrTTL: duration("MEMBY_RADARR_TTL", 5*time.Minute),
|
||||
RadarrWebhookToken: strings.TrimSpace(os.Getenv("MEMBY_RADARR_WEBHOOK_TOKEN")),
|
||||
RadarrAlertWindow: duration("MEMBY_RADARR_ALERT_WINDOW", 3*time.Hour),
|
||||
BazarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_BAZARR_URL")), "/"),
|
||||
BazarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_BAZARR_API_KEY")),
|
||||
BazarrTTL: duration("MEMBY_BAZARR_TTL", 5*time.Minute),
|
||||
BazarrTimeout: duration("MEMBY_BAZARR_TIMEOUT", 45*time.Second),
|
||||
CreditsEnabled: boolean("MEMBY_CREDITS_ENABLED", false),
|
||||
CreditsFFmpeg: strings.TrimSpace(os.Getenv("MEMBY_CREDITS_FFMPEG")),
|
||||
CreditsPrefetchEpisodes: integer("MEMBY_CREDITS_PREFETCH_EPISODES", 3),
|
||||
CreditsMaxPrefetch: integer("MEMBY_CREDITS_MAX_PREFETCH", 5),
|
||||
CreditsQueueLimit: integer("MEMBY_CREDITS_QUEUE_LIMIT", 20),
|
||||
TracearrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_URL")), "/"),
|
||||
TracearrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_API_KEY")),
|
||||
TracearrServerID: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_SERVER_ID")),
|
||||
TracearrSyncInterval: duration("MEMBY_TRACEARR_SYNC_INTERVAL", 5*time.Minute),
|
||||
TracearrFullInterval: duration("MEMBY_TRACEARR_FULL_INTERVAL", 24*time.Hour),
|
||||
ForYouMinRebuildAge: duration("MEMBY_FOR_YOU_MIN_REBUILD_AGE", 24*time.Hour),
|
||||
ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 24*time.Hour),
|
||||
ForYouRebuildHour: integer("MEMBY_FOR_YOU_REBUILD_HOUR", 4),
|
||||
}
|
||||
if c.AnalyticsRetention < 30*24*time.Hour {
|
||||
c.AnalyticsRetention = 30 * 24 * time.Hour
|
||||
@@ -268,6 +299,26 @@ func env(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// secret reads a Docker/Kubernetes-style file-backed secret when KEY_FILE is set,
|
||||
// falling back to KEY for existing non-Compose deployments. The file's contents are
|
||||
// never included in an error, and Compose uses only the file form so `docker inspect`
|
||||
// cannot reveal the release-publish credential.
|
||||
func secret(key string) (string, error) {
|
||||
path := strings.TrimSpace(os.Getenv(key + "_FILE"))
|
||||
if path == "" {
|
||||
return strings.TrimSpace(os.Getenv(key)), nil
|
||||
}
|
||||
value, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s_FILE: %w", key, err)
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(value))
|
||||
if trimmed == "" {
|
||||
return "", fmt.Errorf("%s_FILE is empty", key)
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
func boolean(key string, fallback bool) bool {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReleasePublishTokenCanComeFromSecretFile(t *testing.T) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
|
||||
t.Setenv("MEMBY_PUBLIC_URL", "https://memby.example")
|
||||
t.Setenv("MEMBY_RELEASE_PUBLISH_TOKEN", "legacy-environment-value")
|
||||
path := filepath.Join(t.TempDir(), "release-token")
|
||||
if err := os.WriteFile(path, []byte("file-backed-token\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("MEMBY_RELEASE_PUBLISH_TOKEN_FILE", path)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.ReleasePublishToken != "file-backed-token" {
|
||||
t.Fatalf("release token = %q, want the file-backed value", cfg.ReleasePublishToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredReleaseSecretFileMustBeReadable(t *testing.T) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
|
||||
t.Setenv("MEMBY_RELEASE_PUBLISH_TOKEN_FILE", filepath.Join(t.TempDir(), "missing"))
|
||||
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("expected a missing release token secret to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTracearrURLAndKeyMustBeConfiguredTogether(t *testing.T) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# Credits detection
|
||||
|
||||
Where an episode's closing credits begin, discovered for the small number of episodes a
|
||||
household is actually about to watch.
|
||||
|
||||
This is not a library scanner and the distinction is the whole design. A scanner asks "what
|
||||
is in the library" and answers by reading all of it. This asks "what will somebody press Play
|
||||
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
|
||||
```
|
||||
|
||||
## Why it exists
|
||||
|
||||
Emby's `CreditsStart` marker is in its `MarkerType` enumeration and Emby 4.10 does not write
|
||||
it. A survey of this household's 20,000-item library found `Chapter`, `IntroStart` and
|
||||
`IntroEnd` and **no `CreditsStart` at all**. The existing feature's coverage comes entirely
|
||||
from chapters *named* like credits — about 5% of items. This fills in the rest.
|
||||
|
||||
Emby still wins wherever it has an answer. `api/intro.go` reads its chapters first and only
|
||||
falls through to a discovered marker when there is nothing to defer to.
|
||||
|
||||
## What was reused rather than built
|
||||
|
||||
The investigation that preceded this found more already in place than expected, and none of
|
||||
it was duplicated:
|
||||
|
||||
| Needed | Already there |
|
||||
|---|---|
|
||||
| Tracearr ingestion | `internal/tracearr` + the `tracearr_sessions` table, imported by the For You pipeline |
|
||||
| Emby item ids for sessions | `emby_item_id` / `emby_series_id`, backfilled by `UpdateTracearrSessionMapping` |
|
||||
| Stop positions and next-episode transitions | `progress_ms`, `total_duration_ms`, `started_at`/`stopped_at` on the same table |
|
||||
| Episode numbering | `library_items`, via `ParentIndexNumber`/`IndexNumber` in the payload |
|
||||
| Delivery to Android TV | `introResponse.creditsAvailable` / `creditsStartMs` — **no client changes at all** |
|
||||
| Background scheduling | `internal/scheduler`, so the interval is the operator's and the last run is in the console |
|
||||
| Live playback signal | the playback reports televisions already send |
|
||||
|
||||
Two database reads produce the entire candidate queue for the whole household, whatever it
|
||||
is watching. Nothing here is per-candidate and nothing polls Tracearr directly.
|
||||
|
||||
## The files
|
||||
|
||||
```
|
||||
service.go the worker, the cache check, and when scanning is allowed
|
||||
queue.go bounded in-RAM priority queue; nothing about it is persisted
|
||||
candidates.go velocity, look-ahead, decay, multi-user merging — all pure
|
||||
tracearr.go demand → candidates, and the in-memory episode index
|
||||
behaviour.go stop clustering; can write a marker with no media access at all
|
||||
confidence.go signal combination and the rule that stops repeated rewrites
|
||||
window.go where to scan, and how season history narrows it
|
||||
detector.go the two-stage sparse visual scan and its changepoint
|
||||
sampler.go ffmpeg, used surgically
|
||||
fingerprint.go media version identity, so a replaced file invalidates itself
|
||||
resolver.go one Emby request per candidate, cached
|
||||
postgres.go the store adapter
|
||||
load.go whether the server is too busy for speculative work
|
||||
```
|
||||
|
||||
## Two detectors, and the cheap one is often better
|
||||
|
||||
**Behavioural** clustering costs nothing: no file is opened, no decoder runs, no new row is
|
||||
written anywhere. Where three viewers independently stopped an episode within seconds of each
|
||||
other, near the end but not at it, that agreement is evidence no decoder can produce. Two
|
||||
viewers is enough only when both rolled into the next episode, which is unambiguous about why
|
||||
they left.
|
||||
|
||||
**Visual** scanning finds a sustained structural transition — dark, flat, textured with thin
|
||||
text, and staying that way for a minute. Darkness is *multiplied* rather than added into the
|
||||
frame score, which is the one modelling decision worth defending: under a weighted sum a
|
||||
night exterior reaches the credit-like floor on darkness and flatness alone, which is exactly
|
||||
how a final scene comes to be reported as a credits roll. A unit test pins that case.
|
||||
|
||||
The two fail in unrelated ways, so agreement between them is worth far more than either
|
||||
alone — hence a probabilistic union rather than an average. Disagreement beyond 20 seconds
|
||||
costs a 0.3 penalty, which usually means storing nothing. **Prefer no marker to a wrong
|
||||
marker** is the governing rule throughout: a missing Skip Credits button is an absence nobody
|
||||
notices, a button during the final scene is a fault they remember.
|
||||
|
||||
## Cost, and what is still unmeasured
|
||||
|
||||
**Derived arithmetic** (checkable without hardware):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Generic tail window | `clamp(runtime × 0.20, 5min, 12min)` — 8m50s on a 44-minute episode, **20% of the file** |
|
||||
| Season-narrowed window | ±90s around the expected position — **3 minutes, 6.8% of the file** |
|
||||
| Coarse pass | one 160×90 grey frame every 4s — ~45 frames over a narrowed window |
|
||||
| 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** |
|
||||
|
||||
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
|
||||
a household settles, the steady state is one indexed read per candidate and nothing else.
|
||||
|
||||
**Not yet measured, and it needs the NAS.** Bytes actually read, wall-clock analysis time,
|
||||
CPU and peak RSS all depend on the container, the network path to Emby and the media itself,
|
||||
and no figure taken anywhere else would mean anything. The tool is built and ships in the
|
||||
image:
|
||||
|
||||
```
|
||||
docker compose exec server /app/memby-credits benchmark <emby-item-id>
|
||||
```
|
||||
|
||||
It prints the runtime, the scan region **and its provenance**, an estimated byte count,
|
||||
frames sampled, allocation, elapsed time, the detection and what would be stored. The
|
||||
provenance line is the one to read: a run reporting `generic-tail-window` every time is a run
|
||||
in which demand-driven narrowing is doing nothing, and the design would need revisiting.
|
||||
|
||||
## Things to preserve
|
||||
|
||||
- **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.
|
||||
- **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
|
||||
curious button press becoming disk activity; `AbandonPlayback` withdraws it.
|
||||
- **The marker is keyed on a media fingerprint**, so a file Sonarr replaces stops matching
|
||||
with nothing having to notice the swap. A fingerprint too weak to detect a replacement
|
||||
(runtime only) causes the marker to be *withheld* rather than stored un-invalidatable.
|
||||
- **Season history needs two markers, not one.** One is an anecdote and may itself be the
|
||||
mis-detection; narrowing a scan onto it is how one wrong marker propagates through a season.
|
||||
- **`ShouldRewrite` is the only thing between "one write per episode, ever" and a row updated
|
||||
on every playback.** Readings wobble by seconds; a ±12s difference is not news.
|
||||
- **The detector never learns why an episode was chosen.** That boundary is what stops it
|
||||
being tuned to agree with the predictor rather than with the media.
|
||||
@@ -0,0 +1,186 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What the household already told us, without anything having to read a media file.
|
||||
//
|
||||
// This is the cheapest evidence in the package and on a well-watched show it is also the
|
||||
// best. Where three people independently stopped an episode within a few seconds of each
|
||||
// other, near the end but not at it, they stopped for a reason, and the reason is that the
|
||||
// story finished and the credits started. Tracearr has recorded every one of those stops
|
||||
// already — so this costs one indexed query, no file access, and no new writes.
|
||||
|
||||
const (
|
||||
// behaviourFloor is how far into the file a stop has to be before it says anything about
|
||||
// credits. The same three-quarters the television's own chapter-name rule uses, and for
|
||||
// the same reason: somebody abandoning an episode twenty minutes in is telling us they
|
||||
// did not like it, not where the credits are.
|
||||
behaviourFloor = 0.75
|
||||
|
||||
// behaviourTailGuard is how close to the end counts as "watched it out". Those sessions
|
||||
// are the majority and they carry no positional information at all — a viewer who sat
|
||||
// through the credits stopped at the end of the file, wherever the credits began.
|
||||
behaviourTailGuard = 15 * time.Second
|
||||
|
||||
// behaviourCluster is how far apart two stops can be and still be the same moment.
|
||||
// Players report progress on a timer, so two people leaving at the same cut are
|
||||
// routinely a few seconds apart in the record.
|
||||
behaviourCluster = 30 * time.Second
|
||||
|
||||
// BehaviourMinUsers is what it takes to write a marker on behaviour alone. Three
|
||||
// independent people agreeing to within half a minute is not a coincidence; two might
|
||||
// be a couple watching one television twice, or one person on two devices.
|
||||
BehaviourMinUsers = 3
|
||||
|
||||
// behaviourNarrowMinUsers is the weaker bar for merely *narrowing* a scan. Being wrong
|
||||
// about where to look costs a wider scan; being wrong about a marker costs somebody the
|
||||
// end of the episode, so the two bars are deliberately different.
|
||||
behaviourNarrowMinUsers = 2
|
||||
)
|
||||
|
||||
// StopEvent is one viewer leaving one episode, reduced to what matters. Built from Tracearr
|
||||
// sessions that are already in Postgres — this subsystem records nothing of its own.
|
||||
type StopEvent struct {
|
||||
UserKey string
|
||||
PositionMs int64
|
||||
RuntimeMs int64
|
||||
// NextEpisode marks a stop that ran straight into the following episode. It is the
|
||||
// strongest form of this signal: somebody who pressed next was unambiguously looking at
|
||||
// credits rather than deciding to go to bed.
|
||||
NextEpisode bool
|
||||
}
|
||||
|
||||
// BehaviourEvidence is what a set of stops came to.
|
||||
type BehaviourEvidence struct {
|
||||
Found bool
|
||||
StartMs int64
|
||||
// UserCount is distinct viewers in the winning cluster, which is the whole strength of
|
||||
// the finding: the same person stopping in the same place four times is one observation.
|
||||
UserCount int
|
||||
// NextEpisodeCount is how many of them rolled into the next episode.
|
||||
NextEpisodeCount int
|
||||
// SpreadMs is how tightly they agreed. A cluster three seconds wide is worth more than
|
||||
// one twenty-eight seconds wide, and confidence reads it.
|
||||
SpreadMs int64
|
||||
}
|
||||
|
||||
// Usable means this is good enough to steer a scan.
|
||||
func (e BehaviourEvidence) Usable() bool {
|
||||
return e.Found && e.UserCount >= behaviourNarrowMinUsers && e.StartMs > 0
|
||||
}
|
||||
|
||||
// StandaloneMarker means this is good enough to *be* a marker, with no media read at all.
|
||||
// The bar is higher than Usable's, and a next-episode transition is what lets a two-viewer
|
||||
// cluster clear it: pressing next is an explicit statement that the episode had ended.
|
||||
func (e BehaviourEvidence) StandaloneMarker() bool {
|
||||
if !e.Found || e.StartMs <= 0 {
|
||||
return false
|
||||
}
|
||||
if e.UserCount >= BehaviourMinUsers {
|
||||
return true
|
||||
}
|
||||
return e.UserCount >= behaviourNarrowMinUsers && e.NextEpisodeCount >= e.UserCount
|
||||
}
|
||||
|
||||
// AnalyseStops finds the moment a household agrees an episode ends.
|
||||
//
|
||||
// A sliding cluster rather than an average of everything: the tail of an episode contains
|
||||
// two quite different populations — people who left at the credits and people who left part
|
||||
// way through them — and averaging the two produces a position that is inside the credits
|
||||
// but later than they began, which is the one failure mode that shows a Skip Credits button
|
||||
// too late to be useful. The tightest agreement is the transition; the stragglers are noise.
|
||||
func AnalyseStops(stops []StopEvent, runtimeMs int64) BehaviourEvidence {
|
||||
if runtimeMs <= 0 || len(stops) == 0 {
|
||||
return BehaviourEvidence{}
|
||||
}
|
||||
floor := int64(float64(runtimeMs) * behaviourFloor)
|
||||
ceiling := runtimeMs - behaviourTailGuard.Milliseconds()
|
||||
if ceiling <= floor {
|
||||
return BehaviourEvidence{}
|
||||
}
|
||||
|
||||
// One observation per viewer: the earliest qualifying stop they made. Somebody who
|
||||
// stopped at the credits, resumed, and stopped again at the very end has told us where
|
||||
// the credits were exactly once.
|
||||
earliest := map[string]StopEvent{}
|
||||
for _, stop := range stops {
|
||||
if stop.PositionMs < floor || stop.PositionMs > ceiling {
|
||||
continue
|
||||
}
|
||||
previous, seen := earliest[stop.UserKey]
|
||||
if !seen || stop.PositionMs < previous.PositionMs {
|
||||
// A next-episode transition is never downgraded by a later plain stop from the
|
||||
// same viewer, but an earlier plain stop still wins on position.
|
||||
stop.NextEpisode = stop.NextEpisode || (seen && previous.NextEpisode &&
|
||||
previous.PositionMs == stop.PositionMs)
|
||||
earliest[stop.UserKey] = stop
|
||||
} else if stop.NextEpisode && previous.PositionMs-stop.PositionMs <= 0 {
|
||||
previous.NextEpisode = true
|
||||
earliest[stop.UserKey] = previous
|
||||
}
|
||||
}
|
||||
if len(earliest) < behaviourNarrowMinUsers {
|
||||
return BehaviourEvidence{}
|
||||
}
|
||||
|
||||
observations := make([]StopEvent, 0, len(earliest))
|
||||
for _, stop := range earliest {
|
||||
observations = append(observations, stop)
|
||||
}
|
||||
sort.Slice(observations, func(a, b int) bool {
|
||||
return observations[a].PositionMs < observations[b].PositionMs
|
||||
})
|
||||
|
||||
// The widest cluster wins, and the earliest of equally wide ones — the credits began at
|
||||
// the first moment the household agreed on, not the last.
|
||||
tolerance := behaviourCluster.Milliseconds()
|
||||
best := BehaviourEvidence{}
|
||||
for start := range observations {
|
||||
end := start
|
||||
for end+1 < len(observations) &&
|
||||
observations[end+1].PositionMs-observations[start].PositionMs <= tolerance {
|
||||
end++
|
||||
}
|
||||
count := end - start + 1
|
||||
if count < behaviourNarrowMinUsers || count < best.UserCount {
|
||||
continue
|
||||
}
|
||||
spread := observations[end].PositionMs - observations[start].PositionMs
|
||||
if count == best.UserCount && spread >= best.SpreadMs {
|
||||
continue
|
||||
}
|
||||
nextCount := 0
|
||||
for _, stop := range observations[start : end+1] {
|
||||
if stop.NextEpisode {
|
||||
nextCount++
|
||||
}
|
||||
}
|
||||
best = BehaviourEvidence{
|
||||
Found: true,
|
||||
StartMs: medianPosition(observations[start : end+1]),
|
||||
UserCount: count,
|
||||
NextEpisodeCount: nextCount,
|
||||
SpreadMs: spread,
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// medianPosition is the middle of a cluster. The median rather than the mean for the reason
|
||||
// the season estimate uses one: a single straggler inside the tolerance must not move the
|
||||
// answer, and with an even count the lower middle is taken because looking slightly early is
|
||||
// the harmless direction to be wrong in.
|
||||
func medianPosition(stops []StopEvent) int64 {
|
||||
if len(stops) == 0 {
|
||||
return 0
|
||||
}
|
||||
positions := make([]int64, 0, len(stops))
|
||||
for _, stop := range stops {
|
||||
positions = append(positions, stop.PositionMs)
|
||||
}
|
||||
sort.Slice(positions, func(a, b int) bool { return positions[a] < positions[b] })
|
||||
return positions[(len(positions)-1)/2]
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package credits
|
||||
|
||||
import "testing"
|
||||
|
||||
// The behavioural detector, which is the half of this subsystem that costs nothing at all.
|
||||
// The tests that matter are the ones about refusing to answer: this can write a marker with
|
||||
// no media ever being opened, so the bar for it doing so has to be real.
|
||||
|
||||
const episodeRuntime = 47*60*1000 + 12*1000 // 47:12, the brief's example
|
||||
|
||||
func stop(user string, minutes, seconds int, next bool) StopEvent {
|
||||
return StopEvent{
|
||||
UserKey: user,
|
||||
PositionMs: int64(minutes*60+seconds) * 1000,
|
||||
RuntimeMs: episodeRuntime,
|
||||
NextEpisode: next,
|
||||
}
|
||||
}
|
||||
|
||||
// The worked example from the brief: three viewers leaving within seconds of each other.
|
||||
func TestClusteredStopsFindTheCredits(t *testing.T) {
|
||||
evidence := AnalyseStops([]StopEvent{
|
||||
stop("paul", 44, 3, true),
|
||||
stop("david", 44, 9, false),
|
||||
stop("matt", 44, 5, true),
|
||||
}, episodeRuntime)
|
||||
|
||||
if !evidence.Found {
|
||||
t.Fatal("three agreeing viewers produced no evidence")
|
||||
}
|
||||
if evidence.UserCount != 3 {
|
||||
t.Fatalf("UserCount = %d, want 3", evidence.UserCount)
|
||||
}
|
||||
// The brief's own answer is "credits ≈ 44:05" — the middle of the cluster.
|
||||
if evidence.StartMs != stop("", 44, 5, false).PositionMs {
|
||||
t.Fatalf("StartMs = %d, want the 44:05 median", evidence.StartMs)
|
||||
}
|
||||
if !evidence.StandaloneMarker() {
|
||||
t.Fatal("three viewers agreeing to within six seconds should be enough on its own")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopsBeforeTheTailAreNotEvidence(t *testing.T) {
|
||||
// Three people abandoning an episode twenty minutes in agree about the episode, not
|
||||
// about where its credits are.
|
||||
evidence := AnalyseStops([]StopEvent{
|
||||
stop("paul", 20, 0, false),
|
||||
stop("david", 20, 4, false),
|
||||
stop("matt", 20, 2, false),
|
||||
}, episodeRuntime)
|
||||
if evidence.Found {
|
||||
t.Fatalf("a cluster below the tail floor was treated as credits: %+v", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchingToTheEndSaysNothing(t *testing.T) {
|
||||
// Sitting through the credits stops at the end of the file, wherever they began. These
|
||||
// are the majority of sessions and they carry no positional information.
|
||||
evidence := AnalyseStops([]StopEvent{
|
||||
stop("paul", 47, 12, false),
|
||||
stop("david", 47, 10, false),
|
||||
stop("matt", 47, 11, false),
|
||||
}, episodeRuntime)
|
||||
if evidence.Found {
|
||||
t.Fatalf("stops at the very end were read as a transition: %+v", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnePersonIsNotAHousehold(t *testing.T) {
|
||||
// The same viewer stopping in the same place repeatedly is one observation, not four.
|
||||
evidence := AnalyseStops([]StopEvent{
|
||||
stop("paul", 44, 3, false),
|
||||
stop("paul", 44, 5, false),
|
||||
stop("paul", 44, 4, false),
|
||||
stop("paul", 44, 6, false),
|
||||
}, episodeRuntime)
|
||||
if evidence.Found {
|
||||
t.Fatalf("one viewer produced evidence: %+v", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
// Two viewers is enough only when both pressed next, which is unambiguous about why they
|
||||
// left. Two plain stops might be one couple, or one person on two devices.
|
||||
func TestTwoViewersNeedNextEpisodeTransitions(t *testing.T) {
|
||||
plain := AnalyseStops([]StopEvent{
|
||||
stop("paul", 44, 3, false),
|
||||
stop("david", 44, 7, false),
|
||||
}, episodeRuntime)
|
||||
if !plain.Usable() {
|
||||
t.Fatal("two agreeing viewers should at least be good enough to narrow a scan")
|
||||
}
|
||||
if plain.StandaloneMarker() {
|
||||
t.Fatal("two plain stops must not be enough to write a marker on their own")
|
||||
}
|
||||
|
||||
advanced := AnalyseStops([]StopEvent{
|
||||
stop("paul", 44, 3, true),
|
||||
stop("david", 44, 7, true),
|
||||
}, episodeRuntime)
|
||||
if !advanced.StandaloneMarker() {
|
||||
t.Fatal("two viewers who both rolled into the next episode should be enough")
|
||||
}
|
||||
}
|
||||
|
||||
// The tail contains two populations: people who left at the credits, and people who left
|
||||
// part way through them. The tightest agreement is the transition.
|
||||
func TestScatteredStragglersDoNotMoveTheAnswer(t *testing.T) {
|
||||
evidence := AnalyseStops([]StopEvent{
|
||||
stop("paul", 44, 3, false),
|
||||
stop("david", 44, 6, false),
|
||||
stop("matt", 44, 4, false),
|
||||
stop("jane", 46, 30, false), // gave up half way through the credits
|
||||
}, episodeRuntime)
|
||||
|
||||
if !evidence.Found {
|
||||
t.Fatal("expected the tight cluster to win")
|
||||
}
|
||||
if evidence.StartMs > stop("", 44, 10, false).PositionMs {
|
||||
t.Fatalf("StartMs = %d; a straggler dragged the answer later", evidence.StartMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoStopsIsNoEvidence(t *testing.T) {
|
||||
if evidence := AnalyseStops(nil, episodeRuntime); evidence.Found {
|
||||
t.Fatal("no stops produced evidence")
|
||||
}
|
||||
if evidence := AnalyseStops([]StopEvent{stop("paul", 44, 0, false)}, 0); evidence.Found {
|
||||
t.Fatal("a zero runtime produced evidence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTighterAgreementScoresHigher(t *testing.T) {
|
||||
tight := AnalyseStops([]StopEvent{
|
||||
stop("paul", 44, 3, false),
|
||||
stop("david", 44, 4, false),
|
||||
stop("matt", 44, 5, false),
|
||||
}, episodeRuntime)
|
||||
loose := AnalyseStops([]StopEvent{
|
||||
stop("paul", 44, 0, false),
|
||||
stop("david", 44, 14, false),
|
||||
stop("matt", 44, 28, false),
|
||||
}, episodeRuntime)
|
||||
|
||||
if BehaviourConfidence(tight) <= BehaviourConfidence(loose) {
|
||||
t.Fatalf("tight agreement (%.2f) did not beat loose agreement (%.2f)",
|
||||
BehaviourConfidence(tight), BehaviourConfidence(loose))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBehaviourConfidenceNeverReachesCertainty(t *testing.T) {
|
||||
stops := []StopEvent{}
|
||||
for index := 0; index < 20; index++ {
|
||||
stops = append(stops, stop("viewer"+string(rune('a'+index)), 44, 4, true))
|
||||
}
|
||||
evidence := AnalyseStops(stops, episodeRuntime)
|
||||
if score := BehaviourConfidence(evidence); score > behaviourCeiling {
|
||||
t.Fatalf("confidence %.2f exceeded the behavioural ceiling %.2f",
|
||||
score, behaviourCeiling)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Candidate generation, and all of it pure.
|
||||
//
|
||||
// The rule this file exists to enforce is the one in the brief that matters most: a viewer
|
||||
// watching Blue Bloods is *not* a reason to scan 293 episodes of Blue Bloods. It is a reason
|
||||
// to scan the three they are about to reach. Everything here is arithmetic on where somebody
|
||||
// has got to and how fast they are moving, and the output is a handful of episodes.
|
||||
|
||||
// Config is the tuning. Defaults come from DefaultConfig; an operator changes them through
|
||||
// the environment rather than by editing constants, because the right look-ahead depends on
|
||||
// how a particular household watches television.
|
||||
type Config struct {
|
||||
// PrefetchEpisodes is the look-ahead for an ordinary viewer — the "credits.prefetchEpisodes
|
||||
// = 3" of the brief. Velocity moves the actual depth either side of it.
|
||||
PrefetchEpisodes int
|
||||
// MaxPrefetchEpisodes is the ceiling nothing may exceed, however fast somebody watches.
|
||||
// The objective is useful precomputation, not speculative scanning, and this is the line
|
||||
// between the two.
|
||||
MaxPrefetchEpisodes int
|
||||
|
||||
// The decay thresholds. Demand is evidence with a shelf life: three nights of Blue Bloods
|
||||
// followed by a week of Slow Horses must stop producing Blue Bloods candidates on its own,
|
||||
// without anything having to notice that the household changed its mind.
|
||||
StrongWindow time.Duration
|
||||
UsefulWindow time.Duration
|
||||
WeakWindow time.Duration
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
PrefetchEpisodes: 3,
|
||||
MaxPrefetchEpisodes: 5,
|
||||
StrongWindow: 24 * time.Hour,
|
||||
UsefulWindow: 3 * 24 * time.Hour,
|
||||
WeakWindow: 7 * 24 * time.Hour,
|
||||
QueueLimit: 20,
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
type Watch struct {
|
||||
UserKey string
|
||||
SeriesID string
|
||||
Season int
|
||||
Episode int
|
||||
// WatchedAt is when the session ended, or started where it never ended. Recency is
|
||||
// measured from it and so is velocity.
|
||||
WatchedAt time.Time
|
||||
// Completed distinguishes "finished this episode, will start the next" from "is
|
||||
// part-way through it". The two produce different look-ahead windows, and getting it
|
||||
// wrong costs a scan of an episode somebody is already watching.
|
||||
Completed bool
|
||||
}
|
||||
|
||||
// EpisodeRef is a position in a series resolved to something scannable.
|
||||
type EpisodeRef struct {
|
||||
ItemID string
|
||||
Season int
|
||||
Episode int
|
||||
}
|
||||
|
||||
// EpisodeIndex resolves "the episodes after this one" — across a season boundary, since a
|
||||
// season finale is exactly when somebody is most likely to keep going. Backed by the imported
|
||||
// library, which already holds every episode with its numbering.
|
||||
type EpisodeIndex interface {
|
||||
Following(seriesID string, season, episode, count int) []EpisodeRef
|
||||
}
|
||||
|
||||
// SeriesActivity is one viewer's relationship with one series right now: where they have got
|
||||
// to, when they were last there, and how fast they are moving.
|
||||
type SeriesActivity struct {
|
||||
UserKey string
|
||||
SeriesID string
|
||||
|
||||
Season int
|
||||
Episode int
|
||||
// InProgress means the furthest episode was not finished, so it is itself a candidate
|
||||
// rather than something to look past.
|
||||
InProgress bool
|
||||
|
||||
LastViewed time.Time
|
||||
// EpisodesPerDay over the recent run. See viewingVelocity.
|
||||
EpisodesPerDay float64
|
||||
}
|
||||
|
||||
// Activities groups raw watches into one activity per viewer and series.
|
||||
//
|
||||
// "Where they have got to" is the *furthest* episode watched recently, not the most recent
|
||||
// session: somebody who dips back to rewatch an earlier episode has not un-watched the ones
|
||||
// after it, and predicting from the rewatch would queue episodes they finished a fortnight
|
||||
// ago. Recency still comes from the latest session, because that is what decay measures.
|
||||
func Activities(watches []Watch, now time.Time, cfg Config) []SeriesActivity {
|
||||
type key struct{ user, series string }
|
||||
grouped := map[key][]Watch{}
|
||||
for _, watch := range watches {
|
||||
if strings.TrimSpace(watch.SeriesID) == "" || watch.Season < 0 || watch.Episode <= 0 {
|
||||
continue
|
||||
}
|
||||
// Anything past the discard threshold is not evidence about tonight. Dropping it
|
||||
// here rather than at the end keeps it out of the velocity calculation too, where
|
||||
// a six-month-old session would otherwise drag an active binge down to a crawl.
|
||||
if now.Sub(watch.WatchedAt) > cfg.WeakWindow {
|
||||
continue
|
||||
}
|
||||
group := key{user: watch.UserKey, series: watch.SeriesID}
|
||||
grouped[group] = append(grouped[group], watch)
|
||||
}
|
||||
|
||||
out := make([]SeriesActivity, 0, len(grouped))
|
||||
for group, items := range grouped {
|
||||
activity := SeriesActivity{UserKey: group.user, SeriesID: group.series}
|
||||
for _, watch := range items {
|
||||
if watch.WatchedAt.After(activity.LastViewed) {
|
||||
activity.LastViewed = watch.WatchedAt
|
||||
}
|
||||
if watch.Season > activity.Season ||
|
||||
(watch.Season == activity.Season && watch.Episode > activity.Episode) {
|
||||
activity.Season, activity.Episode = watch.Season, watch.Episode
|
||||
activity.InProgress = !watch.Completed
|
||||
}
|
||||
}
|
||||
activity.EpisodesPerDay = viewingVelocity(items, now)
|
||||
out = append(out, activity)
|
||||
}
|
||||
// Sorted so a queue built from identical input is identical, which is what makes the
|
||||
// scheduler's behaviour reproducible in a test and its log readable in production.
|
||||
sort.Slice(out, func(a, b int) bool {
|
||||
if !out[a].LastViewed.Equal(out[b].LastViewed) {
|
||||
return out[a].LastViewed.After(out[b].LastViewed)
|
||||
}
|
||||
if out[a].SeriesID != out[b].SeriesID {
|
||||
return out[a].SeriesID < out[b].SeriesID
|
||||
}
|
||||
return out[a].UserKey < out[b].UserKey
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// viewingVelocity is episodes per day over the run somebody is actually on.
|
||||
//
|
||||
// Measured across the span from the first watch in the window to now, rather than to the
|
||||
// last watch: a viewer who watched four episodes on Monday and nothing since is not still
|
||||
// watching four a day, and rating them as a binge would queue five episodes for somebody
|
||||
// who has moved on. The span floors at a day, so one evening's four episodes reads as four
|
||||
// a day rather than as an infinite rate.
|
||||
func viewingVelocity(watches []Watch, now time.Time) float64 {
|
||||
if len(watches) == 0 {
|
||||
return 0
|
||||
}
|
||||
// Distinct episodes: a session resumed three times is one episode watched, and counting
|
||||
// the resumes would read an unreliable connection as enthusiasm.
|
||||
type slot struct{ season, episode int }
|
||||
seen := map[slot]bool{}
|
||||
earliest := now
|
||||
for _, watch := range watches {
|
||||
seen[slot{watch.Season, watch.Episode}] = true
|
||||
if watch.WatchedAt.Before(earliest) {
|
||||
earliest = watch.WatchedAt
|
||||
}
|
||||
}
|
||||
days := now.Sub(earliest).Hours() / 24
|
||||
if days < 1 {
|
||||
days = 1
|
||||
}
|
||||
return float64(len(seen)) / days
|
||||
}
|
||||
|
||||
// prefetchDepth turns a rate into a number of episodes to prepare.
|
||||
//
|
||||
// Deliberately a step function rather than anything cleverer. The brief's own guidance is
|
||||
// slow → 1, normal → 2-3, binge → 4-5, and prediction beyond that is not worth the risk of
|
||||
// being confidently wrong about somebody's evening: every episode of depth is a real scan.
|
||||
func prefetchDepth(episodesPerDay float64, cfg Config) int {
|
||||
base := cfg.PrefetchEpisodes
|
||||
if base <= 0 {
|
||||
base = 3
|
||||
}
|
||||
ceiling := cfg.MaxPrefetchEpisodes
|
||||
if ceiling <= 0 {
|
||||
ceiling = 5
|
||||
}
|
||||
|
||||
depth := base
|
||||
switch {
|
||||
case episodesPerDay < 0.5:
|
||||
// One episode every few days. The next one is all that is worth preparing, and
|
||||
// there will be plenty of time to prepare the one after it.
|
||||
depth = 1
|
||||
case episodesPerDay < 1.5:
|
||||
depth = base - 1
|
||||
case episodesPerDay < 3:
|
||||
depth = base
|
||||
case episodesPerDay < 5:
|
||||
depth = base + 1
|
||||
default:
|
||||
depth = base + 2
|
||||
}
|
||||
if depth < 1 {
|
||||
depth = 1
|
||||
}
|
||||
if depth > ceiling {
|
||||
depth = ceiling
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
// decayWeight is how much a piece of demand still counts for.
|
||||
//
|
||||
// A multiplier rather than a filter because the queue is ordered, not gated: a three-day-old
|
||||
// binge is still worth preparing when the machine is idle, it simply must not outrank
|
||||
// somebody who watched an episode an hour ago.
|
||||
func decayWeight(lastViewed, now time.Time, cfg Config) float64 {
|
||||
age := now.Sub(lastViewed)
|
||||
switch {
|
||||
case age < 0:
|
||||
// A clock disagreement, not a prediction about the future.
|
||||
return 1
|
||||
case age <= cfg.StrongWindow:
|
||||
return 1
|
||||
case age <= cfg.UsefulWindow:
|
||||
return 0.75
|
||||
case age <= cfg.WeakWindow:
|
||||
return 0.4
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// priorityForOffset is the look-ahead hierarchy: how much less an episode is worth for each
|
||||
// step further from where somebody actually is.
|
||||
func priorityForOffset(offset int) (int, string) {
|
||||
switch offset {
|
||||
case 0, 1:
|
||||
return PriorityNext, ReasonNext
|
||||
case 2:
|
||||
return PriorityAhead2, ReasonBinge
|
||||
case 3:
|
||||
return PriorityAhead3, ReasonBinge
|
||||
default:
|
||||
return PrioritySpeculative, ReasonBinge
|
||||
}
|
||||
}
|
||||
|
||||
// BuildCandidates is the whole predictor: activities in, a small deduplicated queue out.
|
||||
//
|
||||
// Three things happen here and they are easy to conflate. Each viewer produces a *window* of
|
||||
// episodes ahead of where they are, sized by how fast they watch. Those windows are then
|
||||
// merged across the household, so an episode two people are approaching is one candidate
|
||||
// rather than two. And the merged candidate is boosted for the agreement, because one scan
|
||||
// now serves both of them.
|
||||
func BuildCandidates(
|
||||
activities []SeriesActivity, index EpisodeIndex, now time.Time, cfg Config,
|
||||
) []Candidate {
|
||||
if index == nil {
|
||||
return nil
|
||||
}
|
||||
merged := map[string]*Candidate{}
|
||||
users := map[string]map[string]bool{}
|
||||
|
||||
for _, activity := range activities {
|
||||
weight := decayWeight(activity.LastViewed, now, cfg)
|
||||
if weight <= 0 {
|
||||
continue
|
||||
}
|
||||
depth := prefetchDepth(activity.EpisodesPerDay, cfg)
|
||||
|
||||
// An unfinished episode is itself the first candidate; a finished one means the
|
||||
// window starts at the episode after it. Asking the index for one extra and dropping
|
||||
// the head is wrong here — the unfinished episode has an item id of its own that the
|
||||
// index would not return as "following".
|
||||
refs := index.Following(activity.SeriesID, activity.Season, activity.Episode, depth)
|
||||
window := make([]EpisodeRef, 0, depth+1)
|
||||
if activity.InProgress {
|
||||
window = append(window, EpisodeRef{
|
||||
ItemID: currentItemID(index, activity),
|
||||
Season: activity.Season,
|
||||
Episode: activity.Episode,
|
||||
})
|
||||
}
|
||||
window = append(window, refs...)
|
||||
|
||||
for offset, ref := range window {
|
||||
if strings.TrimSpace(ref.ItemID) == "" {
|
||||
continue
|
||||
}
|
||||
if offset >= depth+boolToInt(activity.InProgress) {
|
||||
break
|
||||
}
|
||||
base, reason := priorityForOffset(offset)
|
||||
priority := int(float64(base) * weight)
|
||||
|
||||
existing, found := merged[ref.ItemID]
|
||||
if !found {
|
||||
existing = &Candidate{
|
||||
ItemID: ref.ItemID,
|
||||
SeriesID: activity.SeriesID,
|
||||
Season: ref.Season,
|
||||
Episode: ref.Episode,
|
||||
Priority: priority,
|
||||
Reason: reason,
|
||||
LastViewed: activity.LastViewed,
|
||||
}
|
||||
merged[ref.ItemID] = existing
|
||||
users[ref.ItemID] = map[string]bool{}
|
||||
}
|
||||
// The strongest case for an episode wins; a second, weaker viewer never lowers
|
||||
// a candidate somebody else is about to reach.
|
||||
if priority > existing.Priority {
|
||||
existing.Priority, existing.Reason = priority, reason
|
||||
}
|
||||
if activity.LastViewed.After(existing.LastViewed) {
|
||||
existing.LastViewed = activity.LastViewed
|
||||
}
|
||||
users[ref.ItemID][activity.UserKey] = true
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]Candidate, 0, len(merged))
|
||||
for itemID, candidate := range merged {
|
||||
candidate.UserCount = len(users[itemID])
|
||||
if candidate.UserCount > 1 {
|
||||
// Agreement is the cheapest signal there is: the same single scan now serves
|
||||
// more than one person, so it is worth doing sooner.
|
||||
candidate.Priority += MultiUserBonus * (candidate.UserCount - 1)
|
||||
candidate.Reason = ReasonMultiUser
|
||||
}
|
||||
// Nothing predicted may reach live playback's priority. Live playback is a viewer
|
||||
// waiting; everything here is a guess about one.
|
||||
if candidate.Priority > MaxPredictedPriority {
|
||||
candidate.Priority = MaxPredictedPriority
|
||||
}
|
||||
out = append(out, *candidate)
|
||||
}
|
||||
sortCandidates(out)
|
||||
|
||||
if cfg.QueueLimit > 0 && len(out) > cfg.QueueLimit {
|
||||
out = out[:cfg.QueueLimit]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// currentItemID resolves the episode a viewer is part-way through. The index is asked for
|
||||
// the episode at that exact position by requesting the one before it and taking the head,
|
||||
// which keeps EpisodeIndex to a single method.
|
||||
func currentItemID(index EpisodeIndex, activity SeriesActivity) string {
|
||||
refs := index.Following(activity.SeriesID, activity.Season, activity.Episode-1, 1)
|
||||
for _, ref := range refs {
|
||||
if ref.Season == activity.Season && ref.Episode == activity.Episode {
|
||||
return ref.ItemID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sortCandidates puts the queue in the order the worker should take it: priority first, then
|
||||
// the most recent demand, then item id so the order is total and a test can assert on it.
|
||||
func sortCandidates(candidates []Candidate) {
|
||||
sort.Slice(candidates, func(a, b int) bool {
|
||||
if candidates[a].Priority != candidates[b].Priority {
|
||||
return candidates[a].Priority > candidates[b].Priority
|
||||
}
|
||||
if !candidates[a].LastViewed.Equal(candidates[b].LastViewed) {
|
||||
return candidates[a].LastViewed.After(candidates[b].LastViewed)
|
||||
}
|
||||
return candidates[a].ItemID < candidates[b].ItemID
|
||||
})
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Candidate generation is the half of this subsystem that decides how much work exists at
|
||||
// all, so these tests are mostly about what it refuses to queue. A predictor that is
|
||||
// enthusiastic is indistinguishable from a library scanner.
|
||||
|
||||
var now = time.Date(2026, 8, 15, 21, 0, 0, 0, time.UTC)
|
||||
|
||||
// blueBloods is the worked example from the brief: a long-running procedural, so a rule that
|
||||
// over-reaches here queues hundreds of episodes.
|
||||
func blueBloods() EpisodeIndex {
|
||||
episodes := []SeriesEpisode{}
|
||||
for season := 1; season <= 8; season++ {
|
||||
for episode := 1; episode <= 22; episode++ {
|
||||
episodes = append(episodes, SeriesEpisode{
|
||||
ItemID: itemID(season, episode),
|
||||
SeriesID: "bb",
|
||||
Season: season,
|
||||
Episode: episode,
|
||||
})
|
||||
}
|
||||
}
|
||||
// One special, which must never be predicted as "next".
|
||||
episodes = append(episodes, SeriesEpisode{
|
||||
ItemID: "bb-s00e01", SeriesID: "bb", Season: 0, Episode: 1,
|
||||
})
|
||||
return NewEpisodeIndex(episodes)
|
||||
}
|
||||
|
||||
func itemID(season, episode int) string {
|
||||
return "bb-s" + twoDigit(season) + "e" + twoDigit(episode)
|
||||
}
|
||||
|
||||
func twoDigit(value int) string {
|
||||
if value < 10 {
|
||||
return "0" + string(rune('0'+value))
|
||||
}
|
||||
return string(rune('0'+value/10)) + string(rune('0'+value%10))
|
||||
}
|
||||
|
||||
func watch(user string, season, episode int, ago time.Duration, completed bool) Watch {
|
||||
return Watch{
|
||||
UserKey: user,
|
||||
SeriesID: "bb",
|
||||
Season: season,
|
||||
Episode: episode,
|
||||
WatchedAt: now.Add(-ago),
|
||||
Completed: completed,
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveSeriesProducesCandidates(t *testing.T) {
|
||||
watches := []Watch{
|
||||
watch("paul", 6, 5, 72*time.Hour, true),
|
||||
watch("paul", 6, 6, 48*time.Hour, true),
|
||||
watch("paul", 6, 7, 20*time.Minute, true),
|
||||
}
|
||||
candidates := BuildCandidates(
|
||||
Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
if len(candidates) == 0 {
|
||||
t.Fatal("an actively watched series produced no candidates")
|
||||
}
|
||||
// The brief's own example: having just finished S06E07, the next episodes are what is
|
||||
// worth preparing — and nothing before them.
|
||||
if candidates[0].ItemID != itemID(6, 8) {
|
||||
t.Fatalf("highest-priority candidate = %q, want S06E08", candidates[0].ItemID)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Season == 6 && candidate.Episode <= 7 {
|
||||
t.Fatalf("queued an already-watched episode: S%02dE%02d",
|
||||
candidate.Season, candidate.Episode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The rule the brief calls out as critical: watching a show is not a reason to scan the show.
|
||||
func TestDoesNotQueueTheWholeSeries(t *testing.T) {
|
||||
watches := []Watch{watch("paul", 6, 7, time.Hour, true)}
|
||||
candidates := BuildCandidates(
|
||||
Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
if len(candidates) > DefaultConfig().MaxPrefetchEpisodes {
|
||||
t.Fatalf("queued %d episodes for one viewer; the ceiling is %d",
|
||||
len(candidates), DefaultConfig().MaxPrefetchEpisodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldHistoryProducesNothing(t *testing.T) {
|
||||
watches := []Watch{
|
||||
watch("paul", 6, 7, 180*24*time.Hour, true),
|
||||
watch("paul", 6, 6, 181*24*time.Hour, true),
|
||||
}
|
||||
candidates := BuildCandidates(
|
||||
Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
if len(candidates) != 0 {
|
||||
t.Fatalf("six-month-old history produced %d candidates; want none", len(candidates))
|
||||
}
|
||||
}
|
||||
|
||||
// Decay is the mechanism that lets a household change its mind without anything noticing.
|
||||
func TestCandidateExpiryDecaysPriority(t *testing.T) {
|
||||
recent := BuildCandidates(
|
||||
Activities([]Watch{watch("paul", 6, 7, time.Hour, true)}, now, DefaultConfig()),
|
||||
blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
stale := BuildCandidates(
|
||||
Activities([]Watch{watch("paul", 6, 7, 5*24*time.Hour, true)}, now, DefaultConfig()),
|
||||
blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
if len(recent) == 0 || len(stale) == 0 {
|
||||
t.Fatal("expected candidates in both windows")
|
||||
}
|
||||
if stale[0].Priority >= recent[0].Priority {
|
||||
t.Fatalf("five-day-old demand (%d) did not rank below one-hour-old demand (%d)",
|
||||
stale[0].Priority, recent[0].Priority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInProgressEpisodeIsItselfACandidate(t *testing.T) {
|
||||
watches := []Watch{watch("paul", 6, 7, 10*time.Minute, false)}
|
||||
candidates := BuildCandidates(
|
||||
Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
if len(candidates) == 0 || candidates[0].ItemID != itemID(6, 7) {
|
||||
t.Fatalf("a part-watched episode was not the top candidate; got %+v", candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBingeExpandsAndSlowViewingContractsLookAhead(t *testing.T) {
|
||||
// Four episodes in one evening.
|
||||
binge := []Watch{
|
||||
watch("paul", 6, 4, 4*time.Hour, true),
|
||||
watch("paul", 6, 5, 3*time.Hour, true),
|
||||
watch("paul", 6, 6, 2*time.Hour, true),
|
||||
watch("paul", 6, 7, time.Hour, true),
|
||||
}
|
||||
// One episode every few days.
|
||||
slow := []Watch{
|
||||
watch("dave", 6, 6, 6*24*time.Hour, true),
|
||||
watch("dave", 6, 7, 2*24*time.Hour, true),
|
||||
}
|
||||
bingeCount := len(BuildCandidates(
|
||||
Activities(binge, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
))
|
||||
slowCount := len(BuildCandidates(
|
||||
Activities(slow, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
))
|
||||
if bingeCount <= slowCount {
|
||||
t.Fatalf("binge look-ahead (%d) did not exceed slow look-ahead (%d)",
|
||||
bingeCount, slowCount)
|
||||
}
|
||||
if slowCount > 2 {
|
||||
t.Fatalf("slow viewer got %d candidates; one or two is the whole point", slowCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrefetchDepthRespectsCeiling(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if depth := prefetchDepth(50, cfg); depth > cfg.MaxPrefetchEpisodes {
|
||||
t.Fatalf("an implausible velocity produced depth %d, past the ceiling of %d",
|
||||
depth, cfg.MaxPrefetchEpisodes)
|
||||
}
|
||||
if depth := prefetchDepth(0.01, cfg); depth < 1 {
|
||||
t.Fatalf("depth %d; even the slowest viewer gets the next episode", depth)
|
||||
}
|
||||
}
|
||||
|
||||
// Two viewers approaching the same episode is one scan that serves both, so it should be
|
||||
// done sooner — never twice.
|
||||
func TestMultipleUsersCollapseAndRaisePriority(t *testing.T) {
|
||||
shared := []Watch{
|
||||
watch("paul", 6, 7, time.Hour, true),
|
||||
{UserKey: "dave", SeriesID: "bb", Season: 6, Episode: 7,
|
||||
WatchedAt: now.Add(-2 * time.Hour), Completed: true},
|
||||
}
|
||||
solo := []Watch{watch("paul", 6, 7, time.Hour, true)}
|
||||
|
||||
sharedCandidates := BuildCandidates(
|
||||
Activities(shared, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
soloCandidates := BuildCandidates(
|
||||
Activities(solo, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
|
||||
seen := map[string]int{}
|
||||
for _, candidate := range sharedCandidates {
|
||||
seen[candidate.ItemID]++
|
||||
}
|
||||
for id, count := range seen {
|
||||
if count > 1 {
|
||||
t.Fatalf("%s appeared %d times; duplicate candidates must collapse", id, count)
|
||||
}
|
||||
}
|
||||
if sharedCandidates[0].UserCount != 2 {
|
||||
t.Fatalf("UserCount = %d, want 2", sharedCandidates[0].UserCount)
|
||||
}
|
||||
if sharedCandidates[0].Priority <= soloCandidates[0].Priority {
|
||||
t.Fatalf("two interested viewers (%d) did not outrank one (%d)",
|
||||
sharedCandidates[0].Priority, soloCandidates[0].Priority)
|
||||
}
|
||||
if sharedCandidates[0].Reason != ReasonMultiUser {
|
||||
t.Fatalf("reason = %q, want %q", sharedCandidates[0].Reason, ReasonMultiUser)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing the predictor produces may reach live playback's priority. Somebody watching now
|
||||
// outranks every guess about somebody who might watch later.
|
||||
func TestPredictionNeverOutranksLivePlayback(t *testing.T) {
|
||||
watches := []Watch{}
|
||||
for index := 0; index < 8; index++ {
|
||||
watches = append(watches, Watch{
|
||||
UserKey: "viewer" + twoDigit(index), SeriesID: "bb", Season: 6, Episode: 7,
|
||||
WatchedAt: now.Add(-time.Minute), Completed: true,
|
||||
})
|
||||
}
|
||||
candidates := BuildCandidates(
|
||||
Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Priority >= PriorityLive {
|
||||
t.Fatalf("predicted candidate reached %d; live playback is %d",
|
||||
candidate.Priority, PriorityLive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookAheadCrossesSeasonBoundary(t *testing.T) {
|
||||
// A season finale is exactly when somebody carries on.
|
||||
watches := []Watch{
|
||||
watch("paul", 6, 20, 3*time.Hour, true),
|
||||
watch("paul", 6, 21, 2*time.Hour, true),
|
||||
watch("paul", 6, 22, time.Hour, true),
|
||||
}
|
||||
candidates := BuildCandidates(
|
||||
Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
if len(candidates) == 0 || candidates[0].Season != 7 || candidates[0].Episode != 1 {
|
||||
t.Fatalf("after a finale the next candidate was %+v, want S07E01", candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecialsAreNeverPredicted(t *testing.T) {
|
||||
watches := []Watch{watch("paul", 6, 22, time.Hour, true)}
|
||||
candidates := BuildCandidates(
|
||||
Activities(watches, now, DefaultConfig()), blueBloods(), now, DefaultConfig(),
|
||||
)
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Season == 0 {
|
||||
t.Fatal("queued a special; nobody follows a finale with a featurette")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rewatching an earlier episode does not un-watch the later ones.
|
||||
func TestProgressIsTheFurthestEpisodeNotTheLatestSession(t *testing.T) {
|
||||
watches := []Watch{
|
||||
watch("paul", 6, 10, 24*time.Hour, true),
|
||||
watch("paul", 6, 2, time.Minute, true),
|
||||
}
|
||||
activities := Activities(watches, now, DefaultConfig())
|
||||
if len(activities) != 1 {
|
||||
t.Fatalf("expected one activity, got %d", len(activities))
|
||||
}
|
||||
if activities[0].Episode != 10 {
|
||||
t.Fatalf("progress = E%02d; a rewatch must not rewind it", activities[0].Episode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package credits
|
||||
|
||||
import "time"
|
||||
|
||||
// How sure we are, and when that is sure enough to act.
|
||||
//
|
||||
// The governing rule is the brief's: prefer no marker to a wrong marker. A missing Skip
|
||||
// Credits button is an absence nobody notices; a button that appears during the final scene
|
||||
// and throws somebody past the ending is a fault they will remember, and one they cannot
|
||||
// undo without seeking back and finding their place. Every threshold here is set on that
|
||||
// asymmetry rather than on getting the most coverage.
|
||||
|
||||
const (
|
||||
// ConfidenceThreshold is the bar for storing a marker at all. Anything under it is
|
||||
// discarded — not stored with a low score for something later to filter, because a
|
||||
// stored marker is one a future season estimate will narrow a scan onto.
|
||||
ConfidenceThreshold = 0.70
|
||||
|
||||
// agreementTolerance is how far apart two independent findings can be and still be
|
||||
// describing the same transition. A visual detector reads the first frame of the roll;
|
||||
// a viewer presses stop a few seconds into it.
|
||||
agreementTolerance = 20 * time.Second
|
||||
|
||||
// disagreementPenalty is what a contradiction costs. Two signals pointing at different
|
||||
// places are not one strong finding and a weak one — they are evidence that at least one
|
||||
// detector is wrong about this file, and usually the right answer is to store nothing.
|
||||
disagreementPenalty = 0.3
|
||||
|
||||
// combinedCeiling caps agreement. Nothing here observes the credits directly, so a
|
||||
// certainty of 1 would be a claim the method cannot support.
|
||||
combinedCeiling = 0.98
|
||||
|
||||
// behaviourCeiling caps behaviour on its own. A household can agree precisely and still
|
||||
// be agreeing about the moment the last line of dialogue lands rather than the cut.
|
||||
behaviourCeiling = 0.90
|
||||
|
||||
// RewriteTolerance is how much a new reading may differ from a stored one before it is
|
||||
// worth a write. Readings of the same episode wobble by a few seconds; rewriting the row
|
||||
// each time would turn a subsystem whose whole claim is "one write per episode, ever"
|
||||
// into one that writes on every playback.
|
||||
RewriteTolerance = 12 * time.Second
|
||||
|
||||
// rewriteImprovement is how much better the evidence has to be for a rewrite *within*
|
||||
// tolerance to be worth doing. Inside the tolerance the position is not meaningfully
|
||||
// different, so the only reason to write is that the confidence changed enough to matter
|
||||
// to a later season estimate.
|
||||
rewriteImprovement = 0.15
|
||||
)
|
||||
|
||||
// BehaviourConfidence scores a stop cluster.
|
||||
//
|
||||
// Three things move it and they are independent: how many people agreed, how tightly they
|
||||
// agreed, and whether they pressed next rather than simply stopping. A next-episode
|
||||
// transition is the only one of the three that is unambiguous about *why* they left.
|
||||
func BehaviourConfidence(evidence BehaviourEvidence) float64 {
|
||||
if !evidence.Found || evidence.UserCount < behaviourNarrowMinUsers {
|
||||
return 0
|
||||
}
|
||||
score := 0.45
|
||||
// Each viewer past the second is worth less than the one before it: the step from two to
|
||||
// three is the one that rules out a coincidence, and the step from six to seven adds
|
||||
// almost nothing.
|
||||
for extra := 0; extra < evidence.UserCount-behaviourNarrowMinUsers; extra++ {
|
||||
score += 0.12 / float64(extra+1)
|
||||
}
|
||||
if evidence.NextEpisodeCount > 0 {
|
||||
score += 0.10 * float64(evidence.NextEpisodeCount) / float64(evidence.UserCount)
|
||||
}
|
||||
// Tightness, measured against the cluster tolerance: agreeing to within three seconds
|
||||
// earns nearly all of this, agreeing to within the full half-minute earns none of it.
|
||||
if tolerance := behaviourCluster.Milliseconds(); tolerance > 0 {
|
||||
tightness := 1 - float64(evidence.SpreadMs)/float64(tolerance)
|
||||
if tightness > 0 {
|
||||
score += 0.15 * tightness
|
||||
}
|
||||
}
|
||||
if score > behaviourCeiling {
|
||||
score = behaviourCeiling
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
// Combine reconciles a visual detection with behavioural evidence into the marker that will
|
||||
// be stored, or into nothing.
|
||||
//
|
||||
// The interesting case is agreement, and the reason to want it is that the two signals fail
|
||||
// in unrelated ways: a visual detector is fooled by a dark, static final scene, and a stop
|
||||
// cluster is fooled by an episode everybody happened to abandon at the same point. Neither
|
||||
// failure makes the other more likely, so agreement between them is worth far more than
|
||||
// either on its own — which is why the combination is a probabilistic union rather than an
|
||||
// average, and why an average would have been the wrong shape entirely.
|
||||
func Combine(visual Detection, evidence BehaviourEvidence) (Detection, bool) {
|
||||
behaviourScore := BehaviourConfidence(evidence)
|
||||
hasBehaviour := evidence.Found && behaviourScore > 0
|
||||
|
||||
switch {
|
||||
case visual.Found && hasBehaviour:
|
||||
gap := visual.StartMs - evidence.StartMs
|
||||
if gap < 0 {
|
||||
gap = -gap
|
||||
}
|
||||
if gap <= agreementTolerance.Milliseconds() {
|
||||
combined := visual
|
||||
combined.Method = MethodCombined
|
||||
// The earlier of the two. A skip that begins at the first frame of the roll is
|
||||
// correct; one that begins a few seconds in has already shown the viewer the
|
||||
// thing they asked to skip.
|
||||
if evidence.StartMs < combined.StartMs {
|
||||
combined.StartMs = evidence.StartMs
|
||||
}
|
||||
combined.Confidence = union(visual.Confidence, behaviourScore)
|
||||
if combined.Confidence > combinedCeiling {
|
||||
combined.Confidence = combinedCeiling
|
||||
}
|
||||
return combined, combined.Confidence >= ConfidenceThreshold
|
||||
}
|
||||
// They disagree. Take whichever is stronger, pay the penalty, and let the threshold
|
||||
// decide — which, with a penalty this size, usually means storing nothing.
|
||||
stronger := visual
|
||||
stronger.Method = MethodVisual
|
||||
if behaviourScore > visual.Confidence {
|
||||
stronger = Detection{
|
||||
Found: true,
|
||||
StartMs: evidence.StartMs,
|
||||
Confidence: behaviourScore,
|
||||
Method: MethodBehaviour,
|
||||
}
|
||||
}
|
||||
stronger.Confidence -= disagreementPenalty
|
||||
return stronger, stronger.Confidence >= ConfidenceThreshold
|
||||
|
||||
case visual.Found:
|
||||
visual.Method = MethodVisual
|
||||
return visual, visual.Confidence >= ConfidenceThreshold
|
||||
|
||||
case hasBehaviour && evidence.StandaloneMarker():
|
||||
detection := Detection{
|
||||
Found: true,
|
||||
StartMs: evidence.StartMs,
|
||||
Confidence: behaviourScore,
|
||||
Method: MethodBehaviour,
|
||||
}
|
||||
return detection, detection.Confidence >= ConfidenceThreshold
|
||||
|
||||
default:
|
||||
return Detection{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// union combines two independent probabilities: the chance that at least one of them is
|
||||
// right, which is what independence buys.
|
||||
func union(a, b float64) float64 {
|
||||
if a < 0 {
|
||||
a = 0
|
||||
}
|
||||
if b < 0 {
|
||||
b = 0
|
||||
}
|
||||
return 1 - (1-a)*(1-b)
|
||||
}
|
||||
|
||||
// ShouldRewrite decides whether new evidence justifies touching an existing row.
|
||||
//
|
||||
// The default answer is no. Once an episode has a good marker it should never be written
|
||||
// again, and this function is the only thing standing between that promise and a row that is
|
||||
// updated every time somebody watches the episode.
|
||||
func ShouldRewrite(existing Marker, candidate Detection) bool {
|
||||
// An operator's correction is final. Nothing automatic may overwrite a position somebody
|
||||
// set by hand, whatever it thinks it has found.
|
||||
if existing.DetectionMethod == MethodManual {
|
||||
return false
|
||||
}
|
||||
if !candidate.Found || candidate.Confidence < ConfidenceThreshold {
|
||||
return false
|
||||
}
|
||||
gap := candidate.StartMs - existing.CreditsStartMs
|
||||
if gap < 0 {
|
||||
gap = -gap
|
||||
}
|
||||
if gap <= RewriteTolerance.Milliseconds() {
|
||||
// Same position, within the wobble. Only a materially better score is worth a write,
|
||||
// and only because a later season estimate will weigh this row by its confidence.
|
||||
return candidate.Confidence >= existing.Confidence+rewriteImprovement
|
||||
}
|
||||
// A genuinely different position. It has to be better evidence than what is already
|
||||
// there, not merely different — otherwise two detectors that disagree would rewrite the
|
||||
// row past each other for ever.
|
||||
return candidate.Confidence > existing.Confidence
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package credits
|
||||
|
||||
import "testing"
|
||||
|
||||
// Confidence, and the stability rule that decides whether anything is written at all.
|
||||
//
|
||||
// The governing principle under test throughout: prefer no marker to a wrong marker. Almost
|
||||
// every case here is about the combination refusing to answer.
|
||||
|
||||
func visual(startMs int64, confidence float64) Detection {
|
||||
return Detection{Found: true, StartMs: startMs, Confidence: confidence, Method: MethodVisual}
|
||||
}
|
||||
|
||||
func cluster(startMs int64, users int, next bool) BehaviourEvidence {
|
||||
nextCount := 0
|
||||
if next {
|
||||
nextCount = users
|
||||
}
|
||||
return BehaviourEvidence{
|
||||
Found: true, StartMs: startMs, UserCount: users,
|
||||
NextEpisodeCount: nextCount, SpreadMs: 4000,
|
||||
}
|
||||
}
|
||||
|
||||
// The brief's worked example: a visual detector at 44:01 and a Tracearr cluster at 44:05
|
||||
// should produce a marker around 44:03 with very high confidence.
|
||||
func TestAgreeingSignalsStrengthenEachOther(t *testing.T) {
|
||||
detection, ok := Combine(visual(2_641_000, 0.80), cluster(2_645_000, 3, true))
|
||||
if !ok {
|
||||
t.Fatal("two agreeing signals were rejected")
|
||||
}
|
||||
if detection.Method != MethodCombined {
|
||||
t.Fatalf("method = %q, want %q", detection.Method, MethodCombined)
|
||||
}
|
||||
if detection.Confidence <= 0.80 {
|
||||
t.Fatalf("confidence %.2f; agreement must beat either signal alone", detection.Confidence)
|
||||
}
|
||||
// The earlier of the two. A skip that starts at the first frame of the roll is right;
|
||||
// one that starts a few seconds in has already shown what it was asked to skip.
|
||||
if detection.StartMs != 2_641_000 {
|
||||
t.Fatalf("StartMs = %d, want the earlier reading", detection.StartMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisagreeingSignalsUsuallyProduceNothing(t *testing.T) {
|
||||
// Three minutes apart is not two readings of one transition.
|
||||
_, ok := Combine(visual(2_400_000, 0.80), cluster(2_580_000, 3, true))
|
||||
if ok {
|
||||
t.Fatal("contradicting signals produced a marker; nothing is the right answer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeakVisualAloneIsRejected(t *testing.T) {
|
||||
if _, ok := Combine(visual(2_600_000, 0.55), BehaviourEvidence{}); ok {
|
||||
t.Fatal("a detection below the confidence threshold was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBehaviourAloneCanWriteAMarker(t *testing.T) {
|
||||
// The whole reason the behavioural half is worth having: on a well-watched show it
|
||||
// settles the question with no media access at all.
|
||||
detection, ok := Combine(Detection{}, cluster(2_645_000, 4, true))
|
||||
if !ok {
|
||||
t.Fatal("a four-viewer cluster produced no marker")
|
||||
}
|
||||
if detection.Method != MethodBehaviour {
|
||||
t.Fatalf("method = %q, want %q", detection.Method, MethodBehaviour)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinBehaviourAloneIsRejected(t *testing.T) {
|
||||
if _, ok := Combine(Detection{}, cluster(2_645_000, 2, false)); ok {
|
||||
t.Fatal("two plain stops wrote a marker with nothing else agreeing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingFoundIsNotAMarker(t *testing.T) {
|
||||
if _, ok := Combine(Detection{}, BehaviourEvidence{}); ok {
|
||||
t.Fatal("no evidence at all produced a marker")
|
||||
}
|
||||
}
|
||||
|
||||
// Marker stability: readings of the same episode wobble by a few seconds, and rewriting the
|
||||
// row each time would break the "one write per episode, ever" promise.
|
||||
func TestSmallVariationDoesNotRewrite(t *testing.T) {
|
||||
existing := Marker{CreditsStartMs: 2_644_000, Confidence: 0.90, DetectionMethod: MethodCombined}
|
||||
for _, offset := range []int64{-4000, -1000, 2000, 3000} {
|
||||
if ShouldRewrite(existing, visual(existing.CreditsStartMs+offset, 0.91)) {
|
||||
t.Fatalf("a %dms difference triggered a rewrite", offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstantiallyBetterEvidenceRewrites(t *testing.T) {
|
||||
existing := Marker{CreditsStartMs: 2_644_000, Confidence: 0.71, DetectionMethod: MethodVisual}
|
||||
better := Detection{
|
||||
Found: true, StartMs: 2_644_500, Confidence: 0.95, Method: MethodCombined,
|
||||
}
|
||||
if !ShouldRewrite(existing, better) {
|
||||
t.Fatal("a much stronger reading of the same position did not rewrite")
|
||||
}
|
||||
}
|
||||
|
||||
func TestADifferentPositionNeedsBetterEvidence(t *testing.T) {
|
||||
existing := Marker{CreditsStartMs: 2_644_000, Confidence: 0.90, DetectionMethod: MethodCombined}
|
||||
// A different answer that is no better must not win, or two detectors that disagree
|
||||
// would rewrite the row past each other for ever.
|
||||
if ShouldRewrite(existing, visual(2_500_000, 0.85)) {
|
||||
t.Fatal("a weaker reading at a different position overwrote a strong marker")
|
||||
}
|
||||
if !ShouldRewrite(existing, Detection{
|
||||
Found: true, StartMs: 2_500_000, Confidence: 0.96, Method: MethodCombined,
|
||||
}) {
|
||||
t.Fatal("stronger evidence at a different position should win")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualMarkersAreNeverOverwritten(t *testing.T) {
|
||||
existing := Marker{CreditsStartMs: 2_600_000, Confidence: 0.5, DetectionMethod: MethodManual}
|
||||
if ShouldRewrite(existing, visual(2_400_000, 0.98)) {
|
||||
t.Fatal("an automatic detection overwrote an operator's correction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBelowThresholdNeverRewrites(t *testing.T) {
|
||||
existing := Marker{CreditsStartMs: 2_644_000, Confidence: 0.72, DetectionMethod: MethodVisual}
|
||||
if ShouldRewrite(existing, visual(2_644_100, 0.50)) {
|
||||
t.Fatal("a sub-threshold detection was written over an accepted marker")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Package credits discovers where an episode's closing credits begin, for the small number
|
||||
// of episodes a household is actually about to watch.
|
||||
//
|
||||
// It is deliberately not a library scanner. A scanner asks "what is in the library" and
|
||||
// answers by reading all of it; this asks "what will somebody press Play on in the next few
|
||||
// evenings" and reads almost nothing. Tracearr already records what the house watches and
|
||||
// when, so the question has an answer that costs one indexed query — and the whole design
|
||||
// follows from taking that answer seriously:
|
||||
//
|
||||
// Tracearr demand → priority queue → marker cached? → tiny tail scan → one write → never again
|
||||
//
|
||||
// The two halves are kept apart on purpose. Candidate generation knows about viewers,
|
||||
// series and velocity and nothing about video; the detector is handed a file and a window
|
||||
// and never learns why that episode was chosen. That is what lets the scheduler be tested
|
||||
// with no media and the detector be benchmarked with no database.
|
||||
//
|
||||
// Emby's own chapter markers still win where they exist (`api/intro.go`). This fills in the
|
||||
// rest of a library, which on Emby 4.10 is most of it: a survey of the 20,000-item library
|
||||
// this ships to found no CreditsStart markers at all.
|
||||
package credits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Detection methods, stored on the marker so a later reading can tell what produced it and
|
||||
// whether new evidence is worth preferring.
|
||||
const (
|
||||
// MethodVisual is a tail scan alone: the picture changed into something structurally
|
||||
// credit-shaped and nothing else agreed or disagreed.
|
||||
MethodVisual = "visual_tail"
|
||||
// MethodBehaviour is viewers alone. Where enough people stopped an episode, or pressed
|
||||
// next, within a few seconds of each other, that agreement is evidence no decoder can
|
||||
// produce — and it costs no media access whatsoever.
|
||||
MethodBehaviour = "behaviour"
|
||||
// MethodCombined is both, agreeing. This is the marker worth trusting.
|
||||
MethodCombined = "combined"
|
||||
// MethodManual is an operator's correction, which nothing automatic may overwrite.
|
||||
MethodManual = "manual"
|
||||
// MethodEmby is a chapter marker Emby detected itself. Recorded for completeness where
|
||||
// this subsystem stores one; the live path reads Emby's chapters directly.
|
||||
MethodEmby = "emby"
|
||||
)
|
||||
|
||||
// Why a candidate was generated. It travels with the candidate purely so a log line and the
|
||||
// benchmark can say whether demand-driven selection is earning its keep — nothing dispatches
|
||||
// on it, and the detector never sees it.
|
||||
const (
|
||||
ReasonLivePlayback = "live-playback"
|
||||
ReasonNext = "tracearr-next"
|
||||
ReasonBinge = "tracearr-binge-prefetch"
|
||||
ReasonMultiUser = "multi-user-demand"
|
||||
)
|
||||
|
||||
// The priority model. Every number the scheduler orders by comes from here rather than
|
||||
// being written into the rule that produced it, so the hierarchy can be read in one place
|
||||
// and changed without hunting through the candidate builder.
|
||||
const (
|
||||
// PriorityLive is playback that has already begun with no marker to offer. It outranks
|
||||
// everything because the viewer is in front of the television now, and the alternative
|
||||
// to answering is a Skip Credits button that never appears.
|
||||
PriorityLive = 1000
|
||||
// PriorityNext is the episode an active viewer is most likely to start next.
|
||||
PriorityNext = 800
|
||||
// PriorityAhead2 and PriorityAhead3 are the tail of the look-ahead window. They decay
|
||||
// fast on purpose: being three episodes wrong about somebody's evening is ordinary.
|
||||
PriorityAhead2 = 500
|
||||
PriorityAhead3 = 300
|
||||
// PrioritySpeculative is the floor — a candidate worth doing if the machine is otherwise
|
||||
// idle and worth discarding the moment the queue is under pressure.
|
||||
PrioritySpeculative = 100
|
||||
|
||||
// MultiUserBonus is added once per additional viewer approaching the same episode, so
|
||||
// two households converging on one episode outrank a single viewer's next episode
|
||||
// without ever overtaking live playback.
|
||||
MultiUserBonus = 100
|
||||
// MaxPredictedPriority caps everything the predictor can produce. Live playback sits
|
||||
// above it by construction, which is the one ordering guarantee worth enforcing rather
|
||||
// than hoping the arithmetic preserves.
|
||||
MaxPredictedPriority = PriorityLive - 1
|
||||
)
|
||||
|
||||
// Candidate is one episode somebody is likely to watch, and the case for scanning it.
|
||||
//
|
||||
// Deliberately small and deliberately transient: candidates live in RAM, are rebuilt from
|
||||
// Tracearr on every cycle, and are never written to the database. A restart rebuilds the
|
||||
// queue from the same query that built it the first time, which is cheaper and far simpler
|
||||
// than keeping a second job scheduler durable.
|
||||
type Candidate struct {
|
||||
ItemID string
|
||||
SeriesID string
|
||||
// Season and Episode are carried for the log line and for season-history narrowing;
|
||||
// nothing routes on them.
|
||||
Season int
|
||||
Episode int
|
||||
|
||||
Priority int
|
||||
Reason string
|
||||
// UserCount is how many distinct viewers this candidate was raised for. It is what
|
||||
// MultiUserBonus is computed from, and it is the honest measure of how much one scan is
|
||||
// worth.
|
||||
UserCount int
|
||||
// LastViewed is the most recent activity that justified this candidate. Decay is
|
||||
// measured from it, so a series somebody abandoned falls out of the queue on its own
|
||||
// rather than needing anything to remember that they did.
|
||||
LastViewed time.Time
|
||||
}
|
||||
|
||||
// MediaInfo is everything the detector is told. No item id, no viewer, no reason — a
|
||||
// detector that knew why an episode was chosen would be one that could be tuned to agree
|
||||
// with the predictor rather than with the media.
|
||||
type MediaInfo struct {
|
||||
// URL is where the bytes are. The gateway has no filesystem access to the media, so in
|
||||
// practice this is Emby's direct stream route and every read of it is a ranged request.
|
||||
URL string
|
||||
// RuntimeMs is the file's duration as Emby records it. The scan window is derived from
|
||||
// it, so a title with no runtime cannot be scanned at all — which is correct: without
|
||||
// one there is no tail to seek to.
|
||||
RuntimeMs int64
|
||||
// Window is where to look. Its provenance rides along because "did season history
|
||||
// actually narrow this" is the question the benchmark exists to answer.
|
||||
Window ScanWindow
|
||||
}
|
||||
|
||||
// Detection is what a detector came to. A detector that found nothing returns Found false
|
||||
// rather than an error: most of a library legitimately has no detectable credits roll, and
|
||||
// treating the common case as a failure would fill the log with news of nothing.
|
||||
type Detection struct {
|
||||
Found bool
|
||||
StartMs int64
|
||||
Confidence float64
|
||||
Method string
|
||||
|
||||
// Window is where the detector was told to look, carried back out so the benchmark can
|
||||
// report whether season history or behaviour actually narrowed anything. Nothing
|
||||
// downstream routes on it.
|
||||
Window ScanWindow
|
||||
|
||||
// The accounting the benchmark prints. Zero is a fine value for any of them.
|
||||
FramesSampled int
|
||||
BytesRead int64
|
||||
Elapsed time.Duration
|
||||
}
|
||||
|
||||
// Marker is what is stored, and it is the only durable output of this whole subsystem.
|
||||
//
|
||||
// Keyed by item *and* fingerprint: if Sonarr replaces the file, the fingerprint moves, the
|
||||
// old row stops matching and the episode becomes a candidate again with no invalidation
|
||||
// pass having to notice.
|
||||
type Marker struct {
|
||||
ItemID string
|
||||
MediaFingerprint string
|
||||
CreditsStartMs int64
|
||||
Confidence float64
|
||||
DetectionMethod string
|
||||
// SeriesID and Season are stored only so a season's markers can be read back as one
|
||||
// indexed query. That read is what narrows the next episode's scan from ten minutes of
|
||||
// file to two, which makes them the cheapest two columns in the schema.
|
||||
SeriesID string
|
||||
Season int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Detector turns a file and a window into a position, or into nothing.
|
||||
type Detector interface {
|
||||
Detect(ctx context.Context, media MediaInfo) (Detection, error)
|
||||
}
|
||||
|
||||
// CandidateSource produces the queue's input. Tracearr is the implementation that matters;
|
||||
// live playback pushes candidates in directly rather than through this.
|
||||
type CandidateSource interface {
|
||||
Candidates(ctx context.Context) ([]Candidate, 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.
|
||||
type Repository interface {
|
||||
// GetMarker answers whether this exact media version has already been decided. It is
|
||||
// the first thing every candidate hits and, once a library has settled, is the only
|
||||
// thing most of them ever hit.
|
||||
GetMarker(ctx context.Context, itemID, fingerprint string) (Marker, bool, error)
|
||||
// SaveMarker upserts. Called at most once per scan, and skipped entirely when
|
||||
// ShouldRewrite says the new evidence is not worth a write.
|
||||
SaveMarker(ctx context.Context, marker Marker) error
|
||||
// SeasonMarkers returns what is already known about a season, newest first. This is the
|
||||
// single most valuable optimisation in the package: it turns a ten-minute tail scan
|
||||
// into a two-minute one.
|
||||
SeasonMarkers(ctx context.Context, seriesID string, season int, limit int) ([]Marker, error)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The visual detector: two sparse passes and a changepoint.
|
||||
//
|
||||
// What is being looked for is a *sustained structural transition*, not an understanding of
|
||||
// the picture. A credits roll is dark, textured with thin text, and — the part that makes it
|
||||
// findable — it stays that way for a minute or more, where a dark night scene does not. So
|
||||
// the detector does not try to recognise credits at all. It scores every sampled frame for
|
||||
// how credit-like it is, then finds the single point in the window where everything after it
|
||||
// scores markedly higher than everything before it, and refuses to answer when no such point
|
||||
// is clearly better than its neighbours.
|
||||
//
|
||||
// This is deliberately the least clever component in the package. Anything heavier — a model,
|
||||
// OCR, a full OpenCV pipeline — would be a dependency and a hardware requirement out of all
|
||||
// proportion to a feature whose fallback is simply not showing a button.
|
||||
|
||||
const (
|
||||
// sustainSeconds is how long the credit-like state has to persist to count. Shorter than
|
||||
// this and a dark establishing shot at the end of an act qualifies.
|
||||
sustainSeconds = 45
|
||||
|
||||
// leadSeconds is how much ordinary programme has to precede the transition. Without it
|
||||
// the changepoint can sit at the very first sampled frame, which is not evidence of a
|
||||
// transition — it is evidence that the window started too late.
|
||||
leadSeconds = 12
|
||||
|
||||
// creditLikeFloor is how credit-like the tail has to look in absolute terms. The
|
||||
// separation test alone would happily report a transition from "slightly less dark" to
|
||||
// "slightly more dark" in the middle of a night scene.
|
||||
creditLikeFloor = 0.45
|
||||
|
||||
// minSeparation is how much better the tail has to score than the head. This is the
|
||||
// primary guard against answering on noise, and it is set high because the cost of a
|
||||
// wrong marker is somebody losing the end of an episode.
|
||||
minSeparation = 0.18
|
||||
)
|
||||
|
||||
// VisualDetector implements Detector over the ffmpeg sampler.
|
||||
type VisualDetector struct {
|
||||
Sampler *Sampler
|
||||
}
|
||||
|
||||
// Detect runs the coarse pass, then a fine pass around whatever it found.
|
||||
//
|
||||
// The second pass is what makes the answer usable. A frame every four seconds locates the
|
||||
// transition to within four seconds, and a Skip Credits button four seconds early clips the
|
||||
// last line of dialogue. Refining costs a second pass over a single minute of file — about
|
||||
// eighty more tiny frames — which is a good trade for a marker that will be served for the
|
||||
// life of the media version.
|
||||
func (d *VisualDetector) Detect(ctx context.Context, media MediaInfo) (Detection, error) {
|
||||
if d == nil || d.Sampler == nil || !media.Window.Valid() {
|
||||
return Detection{}, nil
|
||||
}
|
||||
started := time.Now()
|
||||
|
||||
from := time.Duration(media.Window.StartMs) * time.Millisecond
|
||||
to := time.Duration(media.Window.EndMs) * time.Millisecond
|
||||
|
||||
coarse, err := d.Sampler.Sample(ctx, media.URL, from, to, coarseInterval)
|
||||
if err != nil {
|
||||
return Detection{}, err
|
||||
}
|
||||
frames := len(coarse)
|
||||
index, separation, found := findTransition(coarse, coarseInterval)
|
||||
if !found {
|
||||
// No transition is a perfectly ordinary answer, and the common one on a film. It is
|
||||
// reported as "nothing found" rather than as an error so it can be cached: without
|
||||
// that, every playback of a credit-less title would re-scan it.
|
||||
return Detection{
|
||||
FramesSampled: frames,
|
||||
Elapsed: time.Since(started),
|
||||
}, nil
|
||||
}
|
||||
startMs := coarse[index].PositionMs
|
||||
|
||||
// Refine, but never let the refinement move the answer outside the span the coarse pass
|
||||
// actually pointed at — a fine pass that disagreed wildly would be finding a different
|
||||
// transition, and the coarse one had the whole window to consider.
|
||||
fineFrom := time.Duration(startMs)*time.Millisecond - fineSpan
|
||||
if fineFrom < from {
|
||||
fineFrom = from
|
||||
}
|
||||
fineTo := time.Duration(startMs)*time.Millisecond + fineSpan
|
||||
if fineTo > to {
|
||||
fineTo = to
|
||||
}
|
||||
if fine, fineErr := d.Sampler.Sample(ctx, media.URL, fineFrom, fineTo, fineInterval); fineErr == nil {
|
||||
frames += len(fine)
|
||||
if refined, refinedSeparation, ok := findTransition(fine, fineInterval); ok {
|
||||
startMs = fine[refined].PositionMs
|
||||
if refinedSeparation > separation {
|
||||
separation = refinedSeparation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Detection{
|
||||
Found: true,
|
||||
StartMs: startMs,
|
||||
Confidence: visualConfidence(separation),
|
||||
Method: MethodVisual,
|
||||
FramesSampled: frames,
|
||||
Elapsed: time.Since(started),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// creditScore is how credit-like one frame looks, from 0 to 1.
|
||||
//
|
||||
// Three properties, and the way they are combined is the substance of the detector. Darkness
|
||||
// is *multiplied* rather than added, which is what separates credits from the one thing that
|
||||
// most resembles them: a night exterior is dark and flat and, under a weighted sum, scores
|
||||
// most of the way to the floor on those two properties alone — which is exactly how a final
|
||||
// scene comes to be reported as a credits roll. Text is not optional evidence that tops a
|
||||
// dark frame up; it is the thing being detected, and a dark frame without it must score near
|
||||
// zero however dark and however flat it is.
|
||||
func creditScore(frame frameStats) float64 {
|
||||
darkness := frame.DarkFraction
|
||||
|
||||
// Text produces a narrow *band* of edge density: a flat black frame has almost none, and
|
||||
// a detailed photograph has far more than titles do. Scoring the band rather than the
|
||||
// magnitude is what stops a bright, busy scene outscoring the credits.
|
||||
const idealEdges = 0.030
|
||||
text := frame.EdgeDensity / idealEdges
|
||||
if text > 1 {
|
||||
text = 2 - text
|
||||
}
|
||||
text = clamp01(text)
|
||||
|
||||
// Flatness: a credits background is uniform, so nearly all the variance in the frame
|
||||
// comes from the text itself and is small. A supporting property, never a deciding one.
|
||||
flatness := clamp01(1 - frame.Variance/0.04)
|
||||
|
||||
return clamp01(darkness * (0.75*text + 0.25*flatness))
|
||||
}
|
||||
|
||||
// findTransition locates the point where the window stops looking like programme and starts
|
||||
// looking like credits.
|
||||
//
|
||||
// Pure, and the piece worth testing hardest: it is the whole of the visual decision, and
|
||||
// everything above it is plumbing. It returns the index of the first credit-like frame, and
|
||||
// how much better the tail scored than the head — which is what confidence is derived from.
|
||||
func findTransition(frames []frameStats, interval time.Duration) (int, float64, bool) {
|
||||
if interval <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
perSecond := float64(time.Second) / float64(interval)
|
||||
lead := int(float64(leadSeconds) * perSecond)
|
||||
if lead < 1 {
|
||||
lead = 1
|
||||
}
|
||||
sustain := int(float64(sustainSeconds) * perSecond)
|
||||
if sustain < 2 {
|
||||
sustain = 2
|
||||
}
|
||||
if len(frames) < lead+sustain {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
scores := make([]float64, len(frames))
|
||||
for index, frame := range frames {
|
||||
scores[index] = creditScore(frame)
|
||||
}
|
||||
// Prefix sums so every candidate split is evaluated in constant time. The window is only
|
||||
// a few hundred frames, but the quadratic version is the kind of thing that stops being
|
||||
// free the moment somebody widens the window.
|
||||
prefix := make([]float64, len(scores)+1)
|
||||
for index, score := range scores {
|
||||
prefix[index+1] = prefix[index] + score
|
||||
}
|
||||
segmentMean := func(from, to int) float64 {
|
||||
if to <= from {
|
||||
return 0
|
||||
}
|
||||
return (prefix[to] - prefix[from]) / float64(to-from)
|
||||
}
|
||||
|
||||
bestIndex, bestSeparation, found := 0, 0.0, false
|
||||
for split := lead; split+sustain <= len(frames); split++ {
|
||||
head := segmentMean(0, split)
|
||||
tail := segmentMean(split, len(frames))
|
||||
// The sustained window immediately after the split has to qualify on its own, not
|
||||
// merely drag the average of a long tail up. This is what refuses a dark final shot
|
||||
// followed by genuine credits: the split belongs at the credits, not at the shot.
|
||||
if segmentMean(split, split+sustain) < creditLikeFloor {
|
||||
continue
|
||||
}
|
||||
separation := tail - head
|
||||
if separation < minSeparation || separation <= bestSeparation {
|
||||
continue
|
||||
}
|
||||
bestIndex, bestSeparation, found = split, separation, true
|
||||
}
|
||||
return bestIndex, bestSeparation, found
|
||||
}
|
||||
|
||||
// visualConfidence maps separation onto a score.
|
||||
//
|
||||
// Capped below certainty because a single detector agreeing with itself is not corroboration:
|
||||
// the ceiling is what forces a genuinely ambiguous file to wait for behavioural agreement
|
||||
// before a marker is written, rather than being decided by one reading of one window.
|
||||
func visualConfidence(separation float64) float64 {
|
||||
const ceiling = 0.88
|
||||
// minSeparation earns the threshold exactly; twice it earns nearly the ceiling. A
|
||||
// detector that cleared the bar by a hair should produce a marker that a disagreement
|
||||
// can still overturn.
|
||||
score := ConfidenceThreshold +
|
||||
(ceiling-ConfidenceThreshold)*clamp01((separation-minSeparation)/minSeparation)
|
||||
return score
|
||||
}
|
||||
|
||||
func clamp01(value float64) float64 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
if value > 1 {
|
||||
return 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// noopDetector stands in when ffmpeg is absent. It finds nothing, which lets the service run
|
||||
// on behavioural evidence alone rather than needing a second code path for the case.
|
||||
type noopDetector struct{}
|
||||
|
||||
func (noopDetector) Detect(context.Context, MediaInfo) (Detection, error) {
|
||||
return Detection{}, nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The visual changepoint, tested on synthesised frame statistics rather than on media.
|
||||
//
|
||||
// findTransition is the whole of the visual decision — everything above it is ffmpeg
|
||||
// plumbing — and it is pure, so the cases that matter can be built as literals: a clean
|
||||
// transition, a dark final scene that must not be mistaken for one, and a file with no
|
||||
// credits at all.
|
||||
|
||||
// programmeFrame is an ordinary scene: mid-bright, varied, detailed.
|
||||
func programmeFrame(position time.Duration) frameStats {
|
||||
return frameStats{
|
||||
PositionMs: position.Milliseconds(),
|
||||
Mean: 0.42,
|
||||
Variance: 0.055,
|
||||
DarkFraction: 0.10,
|
||||
EdgeDensity: 0.075,
|
||||
Diff: 0.08,
|
||||
}
|
||||
}
|
||||
|
||||
// creditsFrame is text on black: dark, flat, and textured in the narrow band text produces.
|
||||
func creditsFrame(position time.Duration) frameStats {
|
||||
return frameStats{
|
||||
PositionMs: position.Milliseconds(),
|
||||
Mean: 0.05,
|
||||
Variance: 0.006,
|
||||
DarkFraction: 0.94,
|
||||
EdgeDensity: 0.030,
|
||||
Diff: 0.02,
|
||||
}
|
||||
}
|
||||
|
||||
// darkSceneFrame is the trap: a night exterior is dark and flat but carries none of the fine
|
||||
// detail text does.
|
||||
func darkSceneFrame(position time.Duration) frameStats {
|
||||
return frameStats{
|
||||
PositionMs: position.Milliseconds(),
|
||||
Mean: 0.08,
|
||||
Variance: 0.010,
|
||||
DarkFraction: 0.88,
|
||||
EdgeDensity: 0.004,
|
||||
Diff: 0.05,
|
||||
}
|
||||
}
|
||||
|
||||
func window(start time.Duration, kinds ...func(time.Duration) frameStats) []frameStats {
|
||||
frames := make([]frameStats, 0, len(kinds))
|
||||
for index, build := range kinds {
|
||||
frames = append(frames, build(start+time.Duration(index)*coarseInterval))
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
func repeatFrames(count int, build func(time.Duration) frameStats) []func(time.Duration) frameStats {
|
||||
out := make([]func(time.Duration) frameStats, 0, count)
|
||||
for index := 0; index < count; index++ {
|
||||
out = append(out, build)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestFindsACleanTransition(t *testing.T) {
|
||||
// Two minutes of programme, then two minutes of credits, at one frame every four seconds.
|
||||
kinds := append(repeatFrames(30, programmeFrame), repeatFrames(30, creditsFrame)...)
|
||||
frames := window(39*time.Minute, kinds...)
|
||||
|
||||
index, separation, found := findTransition(frames, coarseInterval)
|
||||
if !found {
|
||||
t.Fatal("a clean transition was not found")
|
||||
}
|
||||
if index != 30 {
|
||||
t.Fatalf("transition at frame %d, want 30", index)
|
||||
}
|
||||
if separation < minSeparation {
|
||||
t.Fatalf("separation %.3f below the threshold %.3f", separation, minSeparation)
|
||||
}
|
||||
if score := visualConfidence(separation); score < ConfidenceThreshold {
|
||||
t.Fatalf("confidence %.2f below the threshold", score)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of not answering on darkness alone.
|
||||
func TestDarkSceneIsNotCredits(t *testing.T) {
|
||||
kinds := append(repeatFrames(30, programmeFrame), repeatFrames(30, darkSceneFrame)...)
|
||||
frames := window(39*time.Minute, kinds...)
|
||||
|
||||
if _, _, found := findTransition(frames, coarseInterval); found {
|
||||
t.Fatal("a dark night scene was reported as credits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoTransitionInOrdinaryProgramme(t *testing.T) {
|
||||
frames := window(39*time.Minute, repeatFrames(60, programmeFrame)...)
|
||||
if _, _, found := findTransition(frames, coarseInterval); found {
|
||||
t.Fatal("found a transition in a window with no transition in it")
|
||||
}
|
||||
}
|
||||
|
||||
// A window that started too late shows credits from its first frame, which is evidence about
|
||||
// the window rather than about the file.
|
||||
func TestCreditsFromTheFirstFrameAreNotATransition(t *testing.T) {
|
||||
frames := window(41*time.Minute, repeatFrames(50, creditsFrame)...)
|
||||
if _, _, found := findTransition(frames, coarseInterval); found {
|
||||
t.Fatal("an all-credits window was read as a transition")
|
||||
}
|
||||
}
|
||||
|
||||
// A dark beat at the end of an act is not sustained; a credits roll is.
|
||||
func TestBriefDarkBeatIsIgnored(t *testing.T) {
|
||||
kinds := append(repeatFrames(20, programmeFrame), repeatFrames(3, creditsFrame)...)
|
||||
kinds = append(kinds, repeatFrames(30, programmeFrame)...)
|
||||
frames := window(39*time.Minute, kinds...)
|
||||
|
||||
if _, _, found := findTransition(frames, coarseInterval); found {
|
||||
t.Fatal("a three-frame dark beat was reported as credits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTooFewFramesAnswerNothing(t *testing.T) {
|
||||
frames := window(41*time.Minute, repeatFrames(4, creditsFrame)...)
|
||||
if _, _, found := findTransition(frames, coarseInterval); found {
|
||||
t.Fatal("four frames were enough to claim a transition")
|
||||
}
|
||||
if _, _, found := findTransition(nil, coarseInterval); found {
|
||||
t.Fatal("no frames produced a transition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreditScoreSeparatesTheThreeCases(t *testing.T) {
|
||||
credits := creditScore(creditsFrame(0))
|
||||
dark := creditScore(darkSceneFrame(0))
|
||||
programme := creditScore(programmeFrame(0))
|
||||
|
||||
if credits <= dark || dark <= programme {
|
||||
t.Fatalf("scores do not separate: credits %.2f, dark scene %.2f, programme %.2f",
|
||||
credits, dark, programme)
|
||||
}
|
||||
if credits < creditLikeFloor {
|
||||
t.Fatalf("a textbook credits frame scored %.2f, below the floor %.2f",
|
||||
credits, creditLikeFloor)
|
||||
}
|
||||
if dark >= creditLikeFloor {
|
||||
t.Fatalf("a dark scene scored %.2f, at or above the credits floor %.2f",
|
||||
dark, creditLikeFloor)
|
||||
}
|
||||
}
|
||||
|
||||
// Confidence from one detector agreeing with itself is not corroboration.
|
||||
func TestVisualConfidenceIsCapped(t *testing.T) {
|
||||
if score := visualConfidence(10); score >= 1 {
|
||||
t.Fatalf("visual confidence reached %.2f", score)
|
||||
}
|
||||
if score := visualConfidence(minSeparation); score < ConfidenceThreshold {
|
||||
t.Fatalf("a detection at the separation threshold scored %.2f, below the bar", score)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Identifying a media *version*, not an item.
|
||||
//
|
||||
// An Emby item id survives a file being replaced, which is exactly the case this subsystem
|
||||
// must not get wrong: Sonarr swapping a 720p rip for a 1080p one leaves the id alone, and a
|
||||
// marker measured against the old file would put Skip Credits somewhere arbitrary in the new
|
||||
// one. Keying the marker on item *and* fingerprint means a replaced file simply stops
|
||||
// matching — the episode becomes a candidate again, and nothing had to notice the swap or
|
||||
// run an invalidation pass to make that happen.
|
||||
|
||||
// MediaVersion is what a fingerprint is computed from. Every field is optional except the
|
||||
// item id: a source that can only offer runtime still produces a usable fingerprint, it is
|
||||
// just a coarser one.
|
||||
type MediaVersion struct {
|
||||
ItemID string
|
||||
RuntimeMs int64
|
||||
SizeBytes int64
|
||||
ModifiedAt time.Time
|
||||
// ETag is Emby's own version token where it offers one. It is the strongest field here
|
||||
// because it changes for reasons the other three can miss — a remux to the same length
|
||||
// and near-enough the same size.
|
||||
ETag string
|
||||
}
|
||||
|
||||
// Fingerprint is a short, stable digest of a media version.
|
||||
//
|
||||
// Truncated to sixteen bytes because it is an equality key rather than a security claim: it
|
||||
// is only ever compared against another fingerprint of the same item, and a full digest
|
||||
// would double the width of the busiest index in the schema for no gain.
|
||||
func Fingerprint(version MediaVersion) string {
|
||||
parts := []string{
|
||||
strings.TrimSpace(version.ItemID),
|
||||
strconv.FormatInt(version.RuntimeMs, 10),
|
||||
strconv.FormatInt(version.SizeBytes, 10),
|
||||
strings.TrimSpace(version.ETag),
|
||||
}
|
||||
if !version.ModifiedAt.IsZero() {
|
||||
// Second resolution. Filesystems and Emby disagree about sub-second timestamps often
|
||||
// enough that finer granularity would invalidate markers on files nothing had
|
||||
// touched, which is the expensive direction to be wrong in.
|
||||
parts = append(parts, strconv.FormatInt(version.ModifiedAt.UTC().Unix(), 10))
|
||||
}
|
||||
sum := sha256.Sum256([]byte(strings.Join(parts, "|")))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
// Weak reports a fingerprint built from too little to detect a file swap.
|
||||
//
|
||||
// Runtime alone does not distinguish a re-encode of the same episode, so a marker stored
|
||||
// against a weak fingerprint is one that could survive a replacement it should not have. The
|
||||
// service refuses to store those rather than storing a marker it cannot invalidate — the
|
||||
// alternative is a wrong Skip Credits position that nothing will ever correct.
|
||||
func (v MediaVersion) Weak() bool {
|
||||
return v.SizeBytes <= 0 && strings.TrimSpace(v.ETag) == "" && v.ModifiedAt.IsZero()
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package credits
|
||||
|
||||
import "sync"
|
||||
|
||||
// A minimal single-flight, because four signals can name the same episode.
|
||||
//
|
||||
// Live playback, a predicted next episode, another viewer's prediction and a refresh cycle
|
||||
// all legitimately want the same media version at the same moment, and each of them arriving
|
||||
// as its own scan is the one way this subsystem could become expensive. The queue already
|
||||
// deduplicates by item; this closes the gap for anything that reaches Process directly — a
|
||||
// forced scan from the console, the benchmark, or a live push racing the worker.
|
||||
//
|
||||
// Written rather than taken from golang.org/x/sync so the gateway keeps its two direct
|
||||
// dependencies. It is thirty lines and the semantics are not subtle: the first caller does
|
||||
// the work, everybody else waits and receives the same answer.
|
||||
type flightGroup struct {
|
||||
mu sync.Mutex
|
||||
calls map[string]*flightCall
|
||||
}
|
||||
|
||||
type flightCall struct {
|
||||
done chan struct{}
|
||||
value any
|
||||
err error
|
||||
}
|
||||
|
||||
func newFlightGroup() *flightGroup {
|
||||
return &flightGroup{calls: map[string]*flightCall{}}
|
||||
}
|
||||
|
||||
// Do runs fn unless an identical key is already in flight, in which case it waits for that
|
||||
// one and returns its result.
|
||||
func (g *flightGroup) Do(key string, fn func() (any, error)) (any, error) {
|
||||
g.mu.Lock()
|
||||
if existing, found := g.calls[key]; found {
|
||||
g.mu.Unlock()
|
||||
<-existing.done
|
||||
return existing.value, existing.err
|
||||
}
|
||||
call := &flightCall{done: make(chan struct{})}
|
||||
g.calls[key] = call
|
||||
g.mu.Unlock()
|
||||
|
||||
// The deferred cleanup matters more than it looks: a detector that panics must not leave
|
||||
// a key permanently in flight, which would make that episode unscannable for the life of
|
||||
// the process and give every later caller a channel that never closes.
|
||||
defer func() {
|
||||
g.mu.Lock()
|
||||
delete(g.calls, key)
|
||||
g.mu.Unlock()
|
||||
close(call.done)
|
||||
}()
|
||||
|
||||
call.value, call.err = fn()
|
||||
return call.value, call.err
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Whether the server is too busy to be reading media speculatively.
|
||||
//
|
||||
// Deliberately not a telemetry subsystem. The gateway already knows the only thing that
|
||||
// actually matters here — how many televisions are playing something right now — because
|
||||
// every one of them reports playback started, progress and stopped. A scan competes with
|
||||
// those streams for the same disk and the same Emby, so the count is a better predictor of
|
||||
// harm than a CPU figure would be, and it costs nothing to keep.
|
||||
|
||||
const (
|
||||
// busyPlaybacks is how many concurrent streams make speculative scanning unwelcome. Two
|
||||
// televisions is an ordinary evening in a household and the NAS copes; a third is the
|
||||
// point at which adding ranged reads of a fourth file stops being free.
|
||||
busyPlaybacks = 2
|
||||
|
||||
// playbackStale is how long a playback is believed without a progress report. Reports
|
||||
// arrive every ten seconds, so a minute of silence means the television went away
|
||||
// without saying so — a crash, a power cut, a network drop. Without this the gauge would
|
||||
// latch busy for ever on one lost stop report and speculative scanning would never run
|
||||
// again until the container restarted.
|
||||
playbackStale = 90 * time.Second
|
||||
)
|
||||
|
||||
// PlaybackLoad counts what is playing, from the reports televisions already send.
|
||||
type PlaybackLoad struct {
|
||||
mu sync.Mutex
|
||||
active map[string]time.Time
|
||||
}
|
||||
|
||||
func NewPlaybackLoad() *PlaybackLoad {
|
||||
return &PlaybackLoad{active: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
// Playing records that a stream is alive. Called from playback started and from each
|
||||
// progress report, which is what makes the staleness sweep work: a live stream keeps
|
||||
// refreshing its own timestamp.
|
||||
func (l *PlaybackLoad) Playing(sessionKey string) {
|
||||
if l == nil || sessionKey == "" {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
l.active[sessionKey] = time.Now()
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// Stopped records that a stream ended.
|
||||
func (l *PlaybackLoad) Stopped(sessionKey string) {
|
||||
if l == nil || sessionKey == "" {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
delete(l.active, sessionKey)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// Busy is the one question the scheduler asks.
|
||||
func (l *PlaybackLoad) Busy(context.Context) bool {
|
||||
return l.Count() >= busyPlaybacks
|
||||
}
|
||||
|
||||
// Count is the live stream count, sweeping anything that stopped reporting. The sweep is
|
||||
// here rather than on a ticker because this is the only reader, and a background goroutine
|
||||
// to expire at most a handful of map entries would cost more than it tidies.
|
||||
func (l *PlaybackLoad) Count() int {
|
||||
if l == nil {
|
||||
return 0
|
||||
}
|
||||
cutoff := time.Now().Add(-playbackStale)
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
for key, seen := range l.active {
|
||||
if seen.Before(cutoff) {
|
||||
delete(l.active, key)
|
||||
}
|
||||
}
|
||||
return len(l.active)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// The adapter between this package's vocabulary and the store's.
|
||||
//
|
||||
// It exists so every rule above it — candidate generation, decay, clustering, confidence,
|
||||
// the queue — can be tested against literals with no database, which is most of why this
|
||||
// subsystem could be built without a Postgres to hand.
|
||||
|
||||
// Postgres implements Repository, Database and BehaviourSource over the gateway's store.
|
||||
type Postgres struct {
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
func (p Postgres) GetMarker(
|
||||
ctx context.Context, itemID, fingerprint string,
|
||||
) (Marker, bool, error) {
|
||||
row, found, err := p.Store.CreditsMarker(ctx, itemID, fingerprint)
|
||||
if err != nil || !found {
|
||||
return Marker{}, false, err
|
||||
}
|
||||
return markerFrom(row), true, nil
|
||||
}
|
||||
|
||||
func (p Postgres) SaveMarker(ctx context.Context, marker Marker) error {
|
||||
return p.Store.SaveCreditsMarker(ctx, store.CreditsMarkerRow{
|
||||
ItemID: marker.ItemID,
|
||||
MediaFingerprint: marker.MediaFingerprint,
|
||||
CreditsStartMs: marker.CreditsStartMs,
|
||||
Confidence: marker.Confidence,
|
||||
DetectionMethod: marker.DetectionMethod,
|
||||
SeriesID: marker.SeriesID,
|
||||
Season: marker.Season,
|
||||
})
|
||||
}
|
||||
|
||||
func (p Postgres) SeasonMarkers(
|
||||
ctx context.Context, seriesID string, season, limit int,
|
||||
) ([]Marker, error) {
|
||||
rows, err := p.Store.CreditsSeasonMarkers(ctx, seriesID, season, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Marker, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, markerFrom(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p Postgres) RecentWatches(
|
||||
ctx context.Context, since time.Time, limit int,
|
||||
) ([]Watch, error) {
|
||||
rows, err := p.Store.CreditsRecentWatches(ctx, since, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Watch, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, Watch{
|
||||
UserKey: row.UserKey,
|
||||
SeriesID: row.SeriesID,
|
||||
Season: row.Season,
|
||||
Episode: row.Episode,
|
||||
WatchedAt: row.WatchedAt,
|
||||
Completed: row.Completed,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p Postgres) SeriesEpisodes(
|
||||
ctx context.Context, seriesIDs []string,
|
||||
) ([]SeriesEpisode, error) {
|
||||
rows, err := p.Store.CreditsSeriesEpisodes(ctx, seriesIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SeriesEpisode, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, SeriesEpisode{
|
||||
ItemID: row.ItemID,
|
||||
SeriesID: row.SeriesID,
|
||||
Season: row.Season,
|
||||
Episode: row.Episode,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p Postgres) Stops(ctx context.Context, itemID string) ([]StopEvent, error) {
|
||||
rows, err := p.Store.CreditsStops(ctx, itemID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]StopEvent, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, StopEvent{
|
||||
UserKey: row.UserKey,
|
||||
PositionMs: row.PositionMs,
|
||||
RuntimeMs: row.RuntimeMs,
|
||||
NextEpisode: row.NextEpisode,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func markerFrom(row store.CreditsMarkerRow) Marker {
|
||||
return Marker{
|
||||
ItemID: row.ItemID,
|
||||
MediaFingerprint: row.MediaFingerprint,
|
||||
CreditsStartMs: row.CreditsStartMs,
|
||||
Confidence: row.Confidence,
|
||||
DetectionMethod: row.DetectionMethod,
|
||||
SeriesID: row.SeriesID,
|
||||
Season: row.Season,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The queue, which lives entirely in RAM and is deliberately allowed to be lost.
|
||||
//
|
||||
// A durable job queue would need rows written for every state transition, and the whole
|
||||
// point of this subsystem is that the database sees one write per *scan*, not one per
|
||||
// candidate. Nothing here is expensive to rebuild: the priorities came from a single indexed
|
||||
// query against Tracearr sessions, and on restart that query simply runs again. A persistent
|
||||
// scheduler would cost more to maintain than it could ever save.
|
||||
|
||||
// Queue is a bounded priority queue of candidates, deduplicated by item.
|
||||
//
|
||||
// Bounded because saturation is a real state and the right response to it is to throw
|
||||
// speculation away: a queue holding forty episodes nobody has reached is not a queue that
|
||||
// will eventually catch up, it is one that has stopped describing demand.
|
||||
type Queue struct {
|
||||
limit int
|
||||
|
||||
mu sync.Mutex
|
||||
items map[string]Candidate
|
||||
claimed map[string]bool
|
||||
}
|
||||
|
||||
func NewQueue(limit int) *Queue {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
return &Queue{
|
||||
limit: limit,
|
||||
items: map[string]Candidate{},
|
||||
claimed: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
// Push offers a candidate. It is merged with anything already queued for the same item — the
|
||||
// strongest case wins — and dropped if the queue is full and it is not strong enough to
|
||||
// displace what is already there.
|
||||
//
|
||||
// Returns whether the candidate is now queued, which is what lets a caller log a discard
|
||||
// rather than believe it queued something.
|
||||
func (q *Queue) Push(candidate Candidate) bool {
|
||||
if candidate.ItemID == "" {
|
||||
return false
|
||||
}
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
// Already being scanned. Single-flight lives in the service, but refusing to re-queue
|
||||
// what a worker is holding keeps the queue honest about what is outstanding.
|
||||
if q.claimed[candidate.ItemID] {
|
||||
return false
|
||||
}
|
||||
if existing, found := q.items[candidate.ItemID]; found {
|
||||
if candidate.Priority > existing.Priority {
|
||||
existing.Priority = candidate.Priority
|
||||
existing.Reason = candidate.Reason
|
||||
}
|
||||
if candidate.UserCount > existing.UserCount {
|
||||
existing.UserCount = candidate.UserCount
|
||||
}
|
||||
if candidate.LastViewed.After(existing.LastViewed) {
|
||||
existing.LastViewed = candidate.LastViewed
|
||||
}
|
||||
q.items[candidate.ItemID] = existing
|
||||
return true
|
||||
}
|
||||
|
||||
if len(q.items) >= q.limit {
|
||||
weakestID, weakest := q.weakestLocked()
|
||||
// Ties go to what is already queued. A candidate that has been waiting is one the
|
||||
// worker is closer to reaching, and swapping equals would let a busy refresh cycle
|
||||
// churn the queue without ever finishing anything.
|
||||
if weakestID == "" || candidate.Priority <= weakest.Priority {
|
||||
return false
|
||||
}
|
||||
delete(q.items, weakestID)
|
||||
}
|
||||
q.items[candidate.ItemID] = candidate
|
||||
return true
|
||||
}
|
||||
|
||||
// Replace swaps the speculative contents of the queue for a freshly built set, which is what
|
||||
// a refresh cycle does. Anything a worker has claimed is untouched — cancelling a scan that
|
||||
// is already reading a file to replace it with a marginally better candidate would waste
|
||||
// exactly the disk activity this package exists to avoid.
|
||||
func (q *Queue) Replace(candidates []Candidate) {
|
||||
q.mu.Lock()
|
||||
claimed := q.claimed
|
||||
q.items = map[string]Candidate{}
|
||||
q.mu.Unlock()
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if claimed[candidate.ItemID] {
|
||||
continue
|
||||
}
|
||||
q.Push(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
// Claim takes the highest-priority candidate and marks it in flight. Release must follow.
|
||||
func (q *Queue) Claim() (Candidate, bool) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
best, found := Candidate{}, false
|
||||
for _, candidate := range q.items {
|
||||
if !found || betterCandidate(candidate, best) {
|
||||
best, found = candidate, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Candidate{}, false
|
||||
}
|
||||
delete(q.items, best.ItemID)
|
||||
q.claimed[best.ItemID] = true
|
||||
return best, true
|
||||
}
|
||||
|
||||
// Release ends a claim. Called from a defer so a panicking detector cannot wedge an item out
|
||||
// of the queue for the life of the process.
|
||||
func (q *Queue) Release(itemID string) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
delete(q.claimed, itemID)
|
||||
}
|
||||
|
||||
// Len is the number waiting, not counting anything in flight.
|
||||
func (q *Queue) Len() int {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return len(q.items)
|
||||
}
|
||||
|
||||
// Snapshot is the queue in worker order, for the log line and the admin console.
|
||||
func (q *Queue) Snapshot() []Candidate {
|
||||
q.mu.Lock()
|
||||
out := make([]Candidate, 0, len(q.items))
|
||||
for _, candidate := range q.items {
|
||||
out = append(out, candidate)
|
||||
}
|
||||
q.mu.Unlock()
|
||||
sortCandidates(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// weakestLocked finds the candidate to discard under saturation. Caller holds the lock.
|
||||
func (q *Queue) weakestLocked() (string, Candidate) {
|
||||
weakestID, weakest, found := "", Candidate{}, false
|
||||
for id, candidate := range q.items {
|
||||
if !found || betterCandidate(weakest, candidate) {
|
||||
weakestID, weakest, found = id, candidate, true
|
||||
}
|
||||
}
|
||||
return weakestID, weakest
|
||||
}
|
||||
|
||||
// betterCandidate is the one ordering rule, shared by Claim and the saturation discard so
|
||||
// the thing taken first and the thing thrown away first can never disagree.
|
||||
func betterCandidate(a, b Candidate) bool {
|
||||
if a.Priority != b.Priority {
|
||||
return a.Priority > b.Priority
|
||||
}
|
||||
if !a.LastViewed.Equal(b.LastViewed) {
|
||||
return a.LastViewed.After(b.LastViewed)
|
||||
}
|
||||
return a.ItemID < b.ItemID
|
||||
}
|
||||
|
||||
// pending is a live-playback candidate waiting out its settling delay.
|
||||
type pending struct {
|
||||
candidate Candidate
|
||||
due time.Time
|
||||
cancel func()
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The queue is bounded, and saturation is a real state with a right answer: throw the
|
||||
// speculation away. A queue holding forty episodes nobody has reached has stopped describing
|
||||
// demand.
|
||||
|
||||
func queued(id string, priority int) Candidate {
|
||||
return Candidate{ItemID: id, Priority: priority, LastViewed: now}
|
||||
}
|
||||
|
||||
func TestQueueOrdersByPriority(t *testing.T) {
|
||||
queue := NewQueue(10)
|
||||
queue.Push(queued("ahead3", PriorityAhead3))
|
||||
queue.Push(queued("live", PriorityLive))
|
||||
queue.Push(queued("next", PriorityNext))
|
||||
|
||||
candidate, found := queue.Claim()
|
||||
if !found || candidate.ItemID != "live" {
|
||||
t.Fatalf("claimed %+v, want the live candidate first", candidate)
|
||||
}
|
||||
candidate, _ = queue.Claim()
|
||||
if candidate.ItemID != "next" {
|
||||
t.Fatalf("claimed %q second, want next", candidate.ItemID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaturationDiscardsTheWeakestSpeculation(t *testing.T) {
|
||||
queue := NewQueue(3)
|
||||
queue.Push(queued("spec-a", PrioritySpeculative))
|
||||
queue.Push(queued("spec-b", PrioritySpeculative+1))
|
||||
queue.Push(queued("spec-c", PrioritySpeculative+2))
|
||||
|
||||
if queued := queue.Push(queued("live", PriorityLive)); !queued {
|
||||
t.Fatal("a full queue refused live playback")
|
||||
}
|
||||
if queue.Len() != 3 {
|
||||
t.Fatalf("queue length %d, want the limit of 3", queue.Len())
|
||||
}
|
||||
for _, candidate := range queue.Snapshot() {
|
||||
if candidate.ItemID == "spec-a" {
|
||||
t.Fatal("the weakest candidate survived saturation")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaturationRefusesWeakerWork(t *testing.T) {
|
||||
queue := NewQueue(2)
|
||||
queue.Push(queued("next-a", PriorityNext))
|
||||
queue.Push(queued("next-b", PriorityNext))
|
||||
|
||||
if queue.Push(queued("spec", PrioritySpeculative)) {
|
||||
t.Fatal("a full queue accepted work weaker than everything in it")
|
||||
}
|
||||
if queue.Len() != 2 {
|
||||
t.Fatalf("queue length %d, want 2", queue.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushMergesRatherThanDuplicating(t *testing.T) {
|
||||
queue := NewQueue(10)
|
||||
queue.Push(queued("bb-s06e08", PriorityAhead2))
|
||||
queue.Push(Candidate{
|
||||
ItemID: "bb-s06e08", Priority: PriorityNext, Reason: ReasonMultiUser,
|
||||
UserCount: 2, LastViewed: now,
|
||||
})
|
||||
|
||||
if queue.Len() != 1 {
|
||||
t.Fatalf("queue length %d; the same episode must not be queued twice", queue.Len())
|
||||
}
|
||||
candidate, _ := queue.Claim()
|
||||
if candidate.Priority != PriorityNext {
|
||||
t.Fatalf("priority = %d; the stronger case should have won", candidate.Priority)
|
||||
}
|
||||
if candidate.UserCount != 2 {
|
||||
t.Fatalf("UserCount = %d, want 2", candidate.UserCount)
|
||||
}
|
||||
}
|
||||
|
||||
// A weaker second viewer must never lower a candidate somebody else is about to reach.
|
||||
func TestPushNeverLowersPriority(t *testing.T) {
|
||||
queue := NewQueue(10)
|
||||
queue.Push(queued("bb-s06e08", PriorityNext))
|
||||
queue.Push(queued("bb-s06e08", PrioritySpeculative))
|
||||
|
||||
candidate, _ := queue.Claim()
|
||||
if candidate.Priority != PriorityNext {
|
||||
t.Fatalf("priority = %d, want %d", candidate.Priority, PriorityNext)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancelling a scan that is already reading a file, to replace it with a marginally better
|
||||
// candidate, would waste exactly the disk activity this package exists to avoid.
|
||||
func TestReplaceLeavesClaimedWorkAlone(t *testing.T) {
|
||||
queue := NewQueue(10)
|
||||
queue.Push(queued("in-flight", PriorityNext))
|
||||
claimed, _ := queue.Claim()
|
||||
|
||||
queue.Replace([]Candidate{queued("fresh", PriorityNext), queued(claimed.ItemID, PriorityLive)})
|
||||
|
||||
for _, candidate := range queue.Snapshot() {
|
||||
if candidate.ItemID == claimed.ItemID {
|
||||
t.Fatal("a refresh re-queued an episode already being scanned")
|
||||
}
|
||||
}
|
||||
queue.Release(claimed.ItemID)
|
||||
if queue.Push(queued(claimed.ItemID, PriorityNext)); queue.Len() != 2 {
|
||||
t.Fatalf("queue length %d after release, want 2", queue.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimedItemsAreNotReQueued(t *testing.T) {
|
||||
queue := NewQueue(10)
|
||||
queue.Push(queued("bb-s06e08", PriorityNext))
|
||||
claimed, _ := queue.Claim()
|
||||
|
||||
if queue.Push(queued(claimed.ItemID, PriorityLive)) {
|
||||
t.Fatal("an episode being scanned was queued a second time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyQueueClaimsNothing(t *testing.T) {
|
||||
if _, found := NewQueue(5).Claim(); found {
|
||||
t.Fatal("an empty queue produced work")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueOrderIsTotalAndStable(t *testing.T) {
|
||||
queue := NewQueue(10)
|
||||
older := now.Add(-2 * time.Hour)
|
||||
queue.Push(Candidate{ItemID: "b", Priority: PriorityNext, LastViewed: older})
|
||||
queue.Push(Candidate{ItemID: "a", Priority: PriorityNext, LastViewed: now})
|
||||
|
||||
// Equal priority: the more recent demand goes first.
|
||||
candidate, _ := queue.Claim()
|
||||
if candidate.ItemID != "a" {
|
||||
t.Fatalf("claimed %q; more recent demand should win a tie", candidate.ItemID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
)
|
||||
|
||||
// Turning an item id into something scannable.
|
||||
//
|
||||
// One Emby request per candidate, cached, and it answers four questions at once: how long the
|
||||
// file is, what version of it this is, whether Emby has already found the credits itself, and
|
||||
// where the bytes are. Asking them separately would be four round trips on the one path that
|
||||
// runs before every scan — including the scans that are then skipped because the marker
|
||||
// already exists.
|
||||
|
||||
// resolverTTL is how long a resolution is remembered. Short enough that a file replaced this
|
||||
// afternoon is noticed today, long enough that the refresh cycle checking twenty candidates
|
||||
// every ten minutes does not become twenty Emby requests every ten minutes.
|
||||
const resolverTTL = 30 * time.Minute
|
||||
|
||||
// EmbyResolver implements MediaResolver.
|
||||
//
|
||||
// It uses the gateway's own sync credentials rather than a viewer's, because a scan is
|
||||
// something the server does on its own behalf: it must work when nobody is signed in, and a
|
||||
// request made as a viewer would put a playback-adjacent lookup in that person's Emby history.
|
||||
type EmbyResolver struct {
|
||||
Client *emby.Client
|
||||
Credentials emby.Credentials
|
||||
|
||||
mu sync.Mutex
|
||||
cached map[string]cachedResolution
|
||||
}
|
||||
|
||||
type cachedResolution struct {
|
||||
media ResolvedMedia
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func NewEmbyResolver(client *emby.Client, cred emby.Credentials) *EmbyResolver {
|
||||
return &EmbyResolver{
|
||||
Client: client,
|
||||
Credentials: cred,
|
||||
cached: map[string]cachedResolution{},
|
||||
}
|
||||
}
|
||||
|
||||
// embyResolveFields is everything one request has to bring back.
|
||||
//
|
||||
// Chapters is here for the same reason it is on the live path: if Emby has already detected
|
||||
// the credits, this subsystem must do nothing at all, and finding that out after opening a
|
||||
// decoder would be finding it out too late. MediaSources carries the size and version token
|
||||
// the fingerprint is built from — without them a marker could survive the file it describes.
|
||||
const embyResolveFields = "MediaSources,Chapters,ParentIndexNumber,IndexNumber,SeriesId"
|
||||
|
||||
func (r *EmbyResolver) Resolve(ctx context.Context, itemID string) (ResolvedMedia, error) {
|
||||
if r == nil || r.Client == nil || itemID == "" {
|
||||
return ResolvedMedia{}, nil
|
||||
}
|
||||
r.mu.Lock()
|
||||
entry, found := r.cached[itemID]
|
||||
r.mu.Unlock()
|
||||
if found && time.Now().Before(entry.expires) {
|
||||
return entry.media, nil
|
||||
}
|
||||
|
||||
raw, err := r.Client.Item(ctx, r.Credentials, itemID, embyResolveFields)
|
||||
if err != nil {
|
||||
return ResolvedMedia{}, err
|
||||
}
|
||||
var parsed struct {
|
||||
ID string `json:"Id"`
|
||||
Etag string `json:"Etag"`
|
||||
SeriesID string `json:"SeriesId"`
|
||||
ParentIndexNumber int `json:"ParentIndexNumber"`
|
||||
IndexNumber int `json:"IndexNumber"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||
DateModified string `json:"DateModified"`
|
||||
Chapters []struct {
|
||||
StartPositionTicks int64 `json:"StartPositionTicks"`
|
||||
MarkerType string `json:"MarkerType"`
|
||||
Name string `json:"Name"`
|
||||
} `json:"Chapters"`
|
||||
MediaSources []struct {
|
||||
ID string `json:"Id"`
|
||||
Size int64 `json:"Size"`
|
||||
ETag string `json:"ETag"`
|
||||
} `json:"MediaSources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return ResolvedMedia{}, err
|
||||
}
|
||||
|
||||
media := ResolvedMedia{
|
||||
URL: r.Client.InternalStreamURL(r.Credentials, itemID),
|
||||
SeriesID: parsed.SeriesID,
|
||||
Season: parsed.ParentIndexNumber,
|
||||
Episode: parsed.IndexNumber,
|
||||
RuntimeMs: parsed.RunTimeTicks / 10_000,
|
||||
Version: MediaVersion{
|
||||
ItemID: itemID,
|
||||
RuntimeMs: parsed.RunTimeTicks / 10_000,
|
||||
ETag: strings.TrimSpace(parsed.Etag),
|
||||
},
|
||||
}
|
||||
if len(parsed.MediaSources) > 0 {
|
||||
source := parsed.MediaSources[0]
|
||||
media.Version.SizeBytes = source.Size
|
||||
if media.Version.ETag == "" {
|
||||
media.Version.ETag = strings.TrimSpace(source.ETag)
|
||||
}
|
||||
}
|
||||
if parsed.DateModified != "" {
|
||||
if modified, err := time.Parse(time.RFC3339, parsed.DateModified); err == nil {
|
||||
media.Version.ModifiedAt = modified
|
||||
}
|
||||
}
|
||||
// A CreditsStart marker Emby wrote itself. Rare on 4.10 — a survey of this household's
|
||||
// twenty-thousand-item library found none — but where it exists it is authoritative and
|
||||
// free, and nothing here should spend a decoder on a question already answered.
|
||||
for _, chapter := range parsed.Chapters {
|
||||
if strings.EqualFold(chapter.MarkerType, "CreditsStart") && chapter.StartPositionTicks > 0 {
|
||||
media.EmbyCreditsMs = chapter.StartPositionTicks / 10_000
|
||||
}
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
// Bounded rather than unbounded: the working set is the queue plus whatever is playing,
|
||||
// so a cache that grew with the library would be holding resolutions for episodes nobody
|
||||
// has looked at since the container started.
|
||||
if len(r.cached) > 256 {
|
||||
r.cached = map[string]cachedResolution{}
|
||||
}
|
||||
r.cached[itemID] = cachedResolution{media: media, expires: time.Now().Add(resolverTTL)}
|
||||
r.mu.Unlock()
|
||||
|
||||
return media, nil
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FFmpeg, used surgically and never as a transcoder.
|
||||
//
|
||||
// The rules this file exists to keep are the ones that make the difference between reading a
|
||||
// couple of megabytes and reading a whole film: seek before opening the input so the decoder
|
||||
// starts at the credits rather than reading its way there, bound the read with -t, throw away
|
||||
// audio and subtitles, downscale to a thumbnail, drop to a frame every few seconds, and take
|
||||
// the result as raw grayscale on stdout. Nothing is ever written to disk — no JPEGs, no
|
||||
// temporary transcode, no scratch file. The frames exist only as bytes in a buffer that is
|
||||
// reused between passes.
|
||||
//
|
||||
// The gateway has no filesystem access to the media (docker-compose mounts no media share),
|
||||
// so the input is Emby's own stream route over HTTP. That is not a compromise: -ss before -i
|
||||
// makes ffmpeg issue a ranged request, so the bytes that cross the network are the bytes of
|
||||
// the window and not of the file.
|
||||
|
||||
const (
|
||||
// The sampling grid. Small enough that a frame is fourteen kilobytes and the statistics
|
||||
// are computed in a few microseconds, large enough that a credits roll still reads as
|
||||
// structured rather than as noise.
|
||||
sampleWidth = 160
|
||||
sampleHeight = 90
|
||||
frameBytes = sampleWidth * sampleHeight
|
||||
|
||||
// coarseInterval is the first pass: one frame every four seconds, which is enough to
|
||||
// find a transition to within a few seconds while sampling a two-minute window in about
|
||||
// thirty frames.
|
||||
coarseInterval = 4 * time.Second
|
||||
// fineInterval is the second pass, run only over the span the first pass pointed at.
|
||||
fineInterval = 750 * time.Millisecond
|
||||
// fineSpan is how much of the file either side of the coarse estimate the fine pass
|
||||
// covers.
|
||||
fineSpan = 30 * time.Second
|
||||
|
||||
// maxFrames is a hard ceiling on one pass. It bounds memory (frames are held only one at
|
||||
// a time, but the statistics slice is not) and, more importantly, bounds the damage a
|
||||
// mis-computed window can do: without it a bad runtime could turn a tail scan into a
|
||||
// full decode.
|
||||
maxFrames = 400
|
||||
)
|
||||
|
||||
// ErrNoFFmpeg means the binary is absent. Reported distinctly so the service can stand the
|
||||
// visual detector down and run on behaviour alone rather than logging a decoder failure per
|
||||
// candidate for the life of the container.
|
||||
var ErrNoFFmpeg = errors.New("credits: ffmpeg is not available")
|
||||
|
||||
// Sampler decodes a span of a file into frame statistics.
|
||||
type Sampler struct {
|
||||
// Binary is the ffmpeg executable. Configurable because a NAS may carry it somewhere
|
||||
// other than the path.
|
||||
Binary string
|
||||
// Timeout bounds one pass. A decoder that hangs on a malformed file must not hold the
|
||||
// single worker for ever.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// frameStats is one sampled frame reduced to the handful of cheap properties a credits
|
||||
// transition shows up in. Deliberately not the frame: nothing downstream needs the picture,
|
||||
// and keeping four hundred thumbnails would be most of the package's memory budget.
|
||||
type frameStats struct {
|
||||
PositionMs int64
|
||||
// Mean luminance, 0-1. Credits are dark.
|
||||
Mean float64
|
||||
// Variance of luminance, 0-1 scaled. A credits roll is mostly flat background with thin
|
||||
// text, so its variance is low and, more usefully, *stable*.
|
||||
Variance float64
|
||||
// DarkFraction is the proportion of pixels below the dark threshold.
|
||||
DarkFraction float64
|
||||
// EdgeDensity approximates how much fine detail there is, which is what separates a
|
||||
// credits roll from a dark night scene: text has edges, darkness does not.
|
||||
EdgeDensity float64
|
||||
// Diff is the mean absolute difference from the previous sampled frame. Scrolling text
|
||||
// changes steadily; a held black frame does not.
|
||||
Diff float64
|
||||
}
|
||||
|
||||
// Available reports whether the decoder can be used at all.
|
||||
func (s *Sampler) Available() bool {
|
||||
_, err := exec.LookPath(s.binary())
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Sampler) binary() string {
|
||||
if strings.TrimSpace(s.Binary) != "" {
|
||||
return s.Binary
|
||||
}
|
||||
return "ffmpeg"
|
||||
}
|
||||
|
||||
// Sample decodes one span and returns its frame statistics.
|
||||
//
|
||||
// The byte count it reports is an estimate — ffmpeg does not report how much of its input it
|
||||
// read, and adding a proxy to find out would cost more than the number is worth. It is
|
||||
// derived from the span and the file's bitrate, which is accurate enough for the question it
|
||||
// answers: whether this subsystem is reading a couple of minutes or the whole file.
|
||||
func (s *Sampler) Sample(
|
||||
ctx context.Context, url string, from, to time.Duration, interval time.Duration,
|
||||
) ([]frameStats, error) {
|
||||
if to <= from || url == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = coarseInterval
|
||||
}
|
||||
timeout := s.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 60 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
// -ss ahead of -i is the whole optimisation: it seeks in the container before opening a
|
||||
// decoder, so the input starts near the credits. Behind -i it would decode from zero and
|
||||
// discard, which is the full read this package exists to avoid.
|
||||
args := []string{
|
||||
"-hide_banner", "-loglevel", "error", "-nostdin",
|
||||
"-ss", formatSeconds(from),
|
||||
"-i", url,
|
||||
"-t", formatSeconds(to - from),
|
||||
"-an", "-sn", "-dn",
|
||||
"-vf", fmt.Sprintf("fps=%s,scale=%d:%d,format=gray",
|
||||
formatRate(interval), sampleWidth, sampleHeight),
|
||||
"-frames:v", strconv.Itoa(maxFrames),
|
||||
"-f", "rawvideo", "-pix_fmt", "gray",
|
||||
"pipe:1",
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, s.binary(), args...)
|
||||
// Cancel and WaitDelay together are what stop an orphan. CommandContext's default is to
|
||||
// send Kill and then wait for the pipes to close, which a stuck HTTP read can hold open
|
||||
// indefinitely; WaitDelay puts a bound on that and closes the descriptors itself.
|
||||
cmd.Cancel = func() error { return cmd.Process.Kill() }
|
||||
cmd.WaitDelay = 5 * time.Second
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return nil, ErrNoFFmpeg
|
||||
}
|
||||
return nil, fmt.Errorf("credits: start ffmpeg: %w", err)
|
||||
}
|
||||
|
||||
stats, readErr := readFrames(stdout, from, interval)
|
||||
// Drain whatever is left so ffmpeg is never blocked writing into a pipe nobody is
|
||||
// reading, which is how a "finished" scan comes to sit in Wait for its full timeout.
|
||||
_, _ = io.Copy(io.Discard, stdout)
|
||||
waitErr := cmd.Wait()
|
||||
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
if waitErr != nil && len(stats) == 0 {
|
||||
// A pass that produced frames and then failed is a truncated read, not a failure:
|
||||
// the statistics that arrived are still usable. One that produced nothing is a
|
||||
// genuine problem worth reporting with whatever ffmpeg said about it.
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return nil, fmt.Errorf("credits: ffmpeg: %w: %s",
|
||||
waitErr, strings.TrimSpace(truncate(stderr.String(), 300)))
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// readFrames pulls fixed-size grayscale frames off the pipe and reduces each one as it
|
||||
// arrives. The frame buffer is allocated once and reused, so a four-hundred-frame pass
|
||||
// allocates fourteen kilobytes rather than five and a half megabytes.
|
||||
func readFrames(reader io.Reader, from, interval time.Duration) ([]frameStats, error) {
|
||||
frame := make([]byte, frameBytes)
|
||||
stats := make([]frameStats, 0, 64)
|
||||
var previous []byte
|
||||
previousBuffer := make([]byte, frameBytes)
|
||||
|
||||
for index := 0; index < maxFrames; index++ {
|
||||
if _, err := io.ReadFull(reader, frame); err != nil {
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
break
|
||||
}
|
||||
return stats, err
|
||||
}
|
||||
position := from + time.Duration(index)*interval
|
||||
stats = append(stats, analyseFrame(frame, previous, position))
|
||||
copy(previousBuffer, frame)
|
||||
previous = previousBuffer
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// analyseFrame reduces one thumbnail to its statistics in a single pass over the pixels,
|
||||
// which at 14,400 bytes is a few microseconds. Nothing here allocates.
|
||||
func analyseFrame(frame, previous []byte, position time.Duration) frameStats {
|
||||
const darkThreshold = 48 // out of 255
|
||||
|
||||
var sum, sumSquares, dark, diff float64
|
||||
for index, pixel := range frame {
|
||||
value := float64(pixel)
|
||||
sum += value
|
||||
sumSquares += value * value
|
||||
if pixel < darkThreshold {
|
||||
dark++
|
||||
}
|
||||
if previous != nil {
|
||||
delta := value - float64(previous[index])
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
diff += delta
|
||||
}
|
||||
}
|
||||
count := float64(len(frame))
|
||||
mean := sum / count
|
||||
variance := sumSquares/count - mean*mean
|
||||
if variance < 0 {
|
||||
variance = 0
|
||||
}
|
||||
|
||||
// Edge density as a horizontal gradient: credits are text, and text on a flat background
|
||||
// is almost entirely horizontal transitions. A proper Sobel would cost a second pass and
|
||||
// a second buffer for a distinction nothing downstream makes.
|
||||
var edges float64
|
||||
for row := 0; row < sampleHeight; row++ {
|
||||
base := row * sampleWidth
|
||||
for column := 1; column < sampleWidth; column++ {
|
||||
delta := float64(frame[base+column]) - float64(frame[base+column-1])
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
if delta > 24 {
|
||||
edges++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return frameStats{
|
||||
PositionMs: position.Milliseconds(),
|
||||
Mean: mean / 255,
|
||||
Variance: variance / (255 * 255),
|
||||
DarkFraction: dark / count,
|
||||
EdgeDensity: edges / count,
|
||||
Diff: diff / count / 255,
|
||||
}
|
||||
}
|
||||
|
||||
// formatSeconds writes a duration the way ffmpeg's -ss wants it, with millisecond precision
|
||||
// and no unit suffix.
|
||||
func formatSeconds(value time.Duration) string {
|
||||
return strconv.FormatFloat(value.Seconds(), 'f', 3, 64)
|
||||
}
|
||||
|
||||
// formatRate turns a sampling interval into an fps filter argument. Expressed as a fraction
|
||||
// rather than a decimal because one frame every four seconds is 1/4 exactly and 0.25 is not,
|
||||
// on a filter that accumulates rounding across a long window.
|
||||
func formatRate(interval time.Duration) string {
|
||||
return fmt.Sprintf("1000/%d", interval.Milliseconds())
|
||||
}
|
||||
|
||||
func truncate(value string, limit int) string {
|
||||
if len(value) <= limit {
|
||||
return value
|
||||
}
|
||||
return value[:limit] + "…"
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The service: one worker, one queue, and the rules about when it is allowed to run.
|
||||
//
|
||||
// Everything expensive in this package funnels through Process, and the shape of that
|
||||
// function is the performance claim in miniature — resolve, check the marker, and in the
|
||||
// common case stop there having touched no media at all. Only a candidate that survives the
|
||||
// cache check reaches a decoder.
|
||||
|
||||
const (
|
||||
// livePlaybackDelay is how long a playback has to survive before it is worth scanning
|
||||
// for. Somebody browsing the launcher starts and abandons episodes constantly, and
|
||||
// scanning on the Play press would turn every one of those into disk activity. Waiting
|
||||
// costs nothing: the credits are forty minutes away.
|
||||
livePlaybackDelay = 45 * time.Second
|
||||
|
||||
// idlePoll is how often the worker looks for work when the queue is empty. Coarse on
|
||||
// purpose — nothing here is urgent, and a tight loop on an idle NAS is exactly the sort
|
||||
// of background cost this package is supposed not to have.
|
||||
idlePoll = 30 * time.Second
|
||||
|
||||
// busyBackoff is how long the worker stands down when the server is under load.
|
||||
busyBackoff = 2 * time.Minute
|
||||
|
||||
// scanBudget bounds one candidate end to end. Past it the answer is not worth the
|
||||
// resources, and a scan that overruns is far more likely to be a pathological file than
|
||||
// a slow one.
|
||||
scanBudget = 90 * time.Second
|
||||
)
|
||||
|
||||
// ResolvedMedia is everything the service needs to turn a candidate into a scan.
|
||||
type ResolvedMedia struct {
|
||||
Version MediaVersion
|
||||
// URL is where the bytes are — Emby's direct stream route, since the gateway has no
|
||||
// filesystem access to the media.
|
||||
URL string
|
||||
SeriesID string
|
||||
Season int
|
||||
Episode int
|
||||
RuntimeMs int64
|
||||
// EmbyCreditsMs is a chapter marker Emby found itself, if any. When Emby already knows,
|
||||
// this subsystem must do nothing at all.
|
||||
EmbyCreditsMs int64
|
||||
}
|
||||
|
||||
// MediaResolver turns an item id into something scannable. Backed by Emby; kept an interface
|
||||
// so the scheduler, the queue and every rule above them can be tested with no server.
|
||||
type MediaResolver interface {
|
||||
Resolve(ctx context.Context, itemID string) (ResolvedMedia, error)
|
||||
}
|
||||
|
||||
// BehaviourSource reads the stops a household already made. Backed by the tracearr_sessions
|
||||
// table, which is written by the For You import and by nothing here.
|
||||
type BehaviourSource interface {
|
||||
Stops(ctx context.Context, itemID string) ([]StopEvent, error)
|
||||
}
|
||||
|
||||
// LoadGauge answers whether the server is too busy for speculative work. Deliberately a
|
||||
// single boolean: building a telemetry subsystem to answer it would cost more than the
|
||||
// scanning it is meant to defer.
|
||||
type LoadGauge interface {
|
||||
Busy(ctx context.Context) bool
|
||||
}
|
||||
|
||||
// Deps is the service's wiring. Only Repository and MediaResolver are required; a service
|
||||
// with no detector runs on behavioural evidence alone, which is what happens when ffmpeg is
|
||||
// missing from the image.
|
||||
type Deps struct {
|
||||
Repository Repository
|
||||
Resolver MediaResolver
|
||||
Source CandidateSource
|
||||
Detector Detector
|
||||
Behaviour BehaviourSource
|
||||
Load LoadGauge
|
||||
Log *slog.Logger
|
||||
Config Config
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
resolver MediaResolver
|
||||
source CandidateSource
|
||||
detector Detector
|
||||
behaviour BehaviourSource
|
||||
load LoadGauge
|
||||
log *slog.Logger
|
||||
cfg Config
|
||||
|
||||
queue *Queue
|
||||
flight *flightGroup
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[string]*pending
|
||||
|
||||
// liveDelay is a field rather than the constant so a test does not have to wait
|
||||
// three quarters of a minute to prove that abandonment cancels a scan.
|
||||
liveDelay time.Duration
|
||||
}
|
||||
|
||||
func New(deps Deps) *Service {
|
||||
cfg := deps.Config
|
||||
if cfg.QueueLimit <= 0 {
|
||||
cfg = DefaultConfig()
|
||||
}
|
||||
detector := deps.Detector
|
||||
if detector == nil {
|
||||
detector = noopDetector{}
|
||||
}
|
||||
log := deps.Log
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Service{
|
||||
repo: deps.Repository,
|
||||
resolver: deps.Resolver,
|
||||
source: deps.Source,
|
||||
detector: detector,
|
||||
behaviour: deps.Behaviour,
|
||||
load: deps.Load,
|
||||
log: log.With("component", "credits"),
|
||||
cfg: cfg,
|
||||
queue: NewQueue(cfg.QueueLimit),
|
||||
flight: newFlightGroup(),
|
||||
pending: map[string]*pending{},
|
||||
liveDelay: livePlaybackDelay,
|
||||
}
|
||||
}
|
||||
|
||||
// Marker is the read path, and the one the API calls. It is a single indexed lookup and it
|
||||
// is what makes the steady state free: once a household's viewing has settled, almost every
|
||||
// call to this subsystem is this function returning a row.
|
||||
func (s *Service) Marker(ctx context.Context, itemID string) (Marker, bool, error) {
|
||||
if s == nil || s.repo == nil || itemID == "" {
|
||||
return Marker{}, false, nil
|
||||
}
|
||||
resolved, err := s.resolver.Resolve(ctx, itemID)
|
||||
if err != nil {
|
||||
return Marker{}, false, err
|
||||
}
|
||||
return s.repo.GetMarker(ctx, itemID, Fingerprint(resolved.Version))
|
||||
}
|
||||
|
||||
// Refresh rebuilds the speculative queue from demand. Registered as a scheduled task, so its
|
||||
// interval is the operator's to change and its last run is visible in the console.
|
||||
//
|
||||
// The returned detail is the scheduler's one-line summary and is empty when nothing changed,
|
||||
// which is what stops a task running every ten minutes announcing itself every ten minutes.
|
||||
func (s *Service) Refresh(ctx context.Context) (string, error) {
|
||||
if s.source == nil {
|
||||
return "", nil
|
||||
}
|
||||
candidates, err := s.source.Candidates(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// The cache check happens here as well as in the worker, and that is the point: an
|
||||
// episode whose marker already exists must never occupy a queue slot, or a household
|
||||
// that has been watching one show for a week ends up with a permanently full queue of
|
||||
// work that will all turn out to be unnecessary.
|
||||
wanted := make([]Candidate, 0, len(candidates))
|
||||
skipped := 0
|
||||
for _, candidate := range candidates {
|
||||
known, err := s.known(ctx, candidate.ItemID)
|
||||
if err != nil {
|
||||
// Trouble reading a marker is not a reason to drop the candidate; the worker
|
||||
// will check again and is the one that can afford to be wrong.
|
||||
s.log.Debug("marker lookup failed", "item_id", candidate.ItemID, "error", err)
|
||||
}
|
||||
if known {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
wanted = append(wanted, candidate)
|
||||
}
|
||||
s.queue.Replace(wanted)
|
||||
|
||||
if len(wanted) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
for _, candidate := range wanted {
|
||||
s.log.Debug("credits_candidate",
|
||||
"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
|
||||
}
|
||||
|
||||
// NotePlayback is the live signal, and the strongest one there is: somebody is watching this
|
||||
// episode now, and if there is no marker they will reach the credits without a button.
|
||||
//
|
||||
// It does not scan immediately. A Play press is not yet a viewing — the launcher makes it
|
||||
// trivially easy to start something and change your mind — so the candidate is held for
|
||||
// livePlaybackDelay and raised only if the playback is still going. That is the difference
|
||||
// between a subsystem that reads a file per curious button press and one that reads a file
|
||||
// per episode actually watched.
|
||||
func (s *Service) NotePlayback(ctx context.Context, itemID string) {
|
||||
if s == nil || itemID == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
if _, waiting := s.pending[itemID]; waiting {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
// Detached from the request context deliberately: the HTTP request that reported the
|
||||
// playback is over in milliseconds, and hanging the timer off it would cancel every
|
||||
// delayed scan the instant it returned.
|
||||
timerCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
|
||||
s.pending[itemID] = &pending{
|
||||
candidate: Candidate{
|
||||
ItemID: itemID,
|
||||
Priority: PriorityLive,
|
||||
Reason: ReasonLivePlayback,
|
||||
UserCount: 1,
|
||||
LastViewed: time.Now().UTC(),
|
||||
},
|
||||
due: time.Now().Add(s.liveDelay),
|
||||
cancel: cancel,
|
||||
}
|
||||
delay := s.liveDelay
|
||||
s.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer cancel()
|
||||
select {
|
||||
case <-timerCtx.Done():
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
s.mu.Lock()
|
||||
entry, waiting := s.pending[itemID]
|
||||
delete(s.pending, itemID)
|
||||
s.mu.Unlock()
|
||||
if !waiting {
|
||||
return
|
||||
}
|
||||
// One last cache check before queueing. In the settled case the marker was already
|
||||
// there and this costs one indexed read instead of a queue slot.
|
||||
if known, err := s.known(timerCtx, itemID); err == nil && known {
|
||||
return
|
||||
}
|
||||
if s.queue.Push(entry.candidate) {
|
||||
s.log.Debug("credits_candidate",
|
||||
"item", itemID, "reason", ReasonLivePlayback, "priority", PriorityLive)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// AbandonPlayback withdraws a playback that stopped before its delay elapsed. This is what
|
||||
// makes the delay worth having: without it the timer would fire regardless and the episode
|
||||
// somebody looked at for ten seconds would be scanned anyway.
|
||||
func (s *Service) AbandonPlayback(itemID string) {
|
||||
if s == nil || itemID == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
entry, waiting := s.pending[itemID]
|
||||
delete(s.pending, itemID)
|
||||
s.mu.Unlock()
|
||||
if waiting && entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Run is the worker. One goroutine, for the whole gateway, for ever.
|
||||
//
|
||||
// A single worker is not a placeholder for a pool. Concurrent scans would multiply exactly
|
||||
// the two costs this package is built to minimise — disk reads and decoder CPU — on a machine
|
||||
// whose primary job is streaming video to televisions. Depth here buys nothing: the queue is
|
||||
// a handful of episodes, and there are hours before anybody reaches them.
|
||||
func (s *Service) Run(ctx context.Context) {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
candidate, found := s.queue.Claim()
|
||||
if !found {
|
||||
if !sleep(ctx, idlePoll) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Live playback goes ahead regardless of load: somebody is waiting, and the scan is
|
||||
// a couple of minutes of ranged reads against a file the server is already serving.
|
||||
// Everything else stands down.
|
||||
if candidate.Priority < PriorityLive && s.busy(ctx) {
|
||||
s.queue.Release(candidate.ItemID)
|
||||
s.queue.Push(candidate)
|
||||
s.log.Debug("credits scan deferred; server busy", "item", candidate.ItemID)
|
||||
if !sleep(ctx, busyBackoff) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
scanCtx, cancel := context.WithTimeout(ctx, scanBudget)
|
||||
detection, stored, err := s.Process(scanCtx, candidate.ItemID)
|
||||
cancel()
|
||||
s.queue.Release(candidate.ItemID)
|
||||
|
||||
switch {
|
||||
case err != nil && errors.Is(err, context.Canceled):
|
||||
return
|
||||
case err != nil:
|
||||
s.log.Debug("credits scan failed",
|
||||
"item", candidate.ItemID, "reason", candidate.Reason, "error", err)
|
||||
case stored:
|
||||
s.log.Info("credits_detected",
|
||||
"item", candidate.ItemID,
|
||||
"marker_ms", detection.StartMs,
|
||||
"confidence", round2(detection.Confidence),
|
||||
"method", detection.Method,
|
||||
"frames", detection.FramesSampled,
|
||||
"duration_ms", detection.Elapsed.Milliseconds())
|
||||
default:
|
||||
s.log.Debug("credits not detected",
|
||||
"item", candidate.ItemID, "frames", detection.FramesSampled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process is one candidate, start to finish. Single-flighted on the media version, so the
|
||||
// four ways an episode can be asked for — live playback, a predicted next, another viewer's
|
||||
// prediction, a refresh cycle — collapse into one scan.
|
||||
//
|
||||
// Returns whether anything was stored, which is not the same as whether anything was found:
|
||||
// a detection below the confidence threshold, or one too close to an existing marker to be
|
||||
// worth a write, is a successful scan that deliberately produces no database activity.
|
||||
func (s *Service) Process(ctx context.Context, itemID string) (Detection, bool, error) {
|
||||
resolved, err := s.resolver.Resolve(ctx, itemID)
|
||||
if err != nil {
|
||||
return Detection{}, false, err
|
||||
}
|
||||
fingerprint := Fingerprint(resolved.Version)
|
||||
|
||||
result, err := s.flight.Do(itemID+":"+fingerprint, func() (any, error) {
|
||||
detection, stored, err := s.process(ctx, itemID, fingerprint, resolved)
|
||||
return processResult{detection: detection, stored: stored}, err
|
||||
})
|
||||
outcome, _ := result.(processResult)
|
||||
return outcome.detection, outcome.stored, err
|
||||
}
|
||||
|
||||
type processResult struct {
|
||||
detection Detection
|
||||
stored bool
|
||||
}
|
||||
|
||||
func (s *Service) process(
|
||||
ctx context.Context, itemID, fingerprint string, resolved ResolvedMedia,
|
||||
) (Detection, bool, error) {
|
||||
// Emby already knows. Nothing to do, and nothing to store: the live path reads Emby's
|
||||
// chapters directly, so duplicating the answer here would be a row that could only ever
|
||||
// go stale.
|
||||
if resolved.EmbyCreditsMs > 0 {
|
||||
return Detection{Found: true, StartMs: resolved.EmbyCreditsMs, Method: MethodEmby}, false, nil
|
||||
}
|
||||
existing, found, err := s.repo.GetMarker(ctx, itemID, fingerprint)
|
||||
if err != nil {
|
||||
return Detection{}, false, err
|
||||
}
|
||||
// The common path, and the one everything is optimised for: already decided, no media
|
||||
// access, no decoder, no write.
|
||||
if found && existing.Confidence >= ConfidenceThreshold {
|
||||
return Detection{
|
||||
Found: true, StartMs: existing.CreditsStartMs,
|
||||
Confidence: existing.Confidence, Method: existing.DetectionMethod,
|
||||
}, false, nil
|
||||
}
|
||||
if resolved.RuntimeMs <= 0 {
|
||||
return Detection{}, false, nil
|
||||
}
|
||||
|
||||
// Behaviour first, because it is free. On a well-watched show it can settle the question
|
||||
// without any media being opened at all, and where it cannot it still narrows the window
|
||||
// the decoder has to read.
|
||||
evidence := s.evidence(ctx, itemID, resolved.RuntimeMs)
|
||||
|
||||
var visual Detection
|
||||
if !evidence.StandaloneMarker() || !evidence.Usable() {
|
||||
history, runtimes := s.seasonHistory(ctx, resolved)
|
||||
window := NarrowWindow(resolved.RuntimeMs, history, runtimes, evidence)
|
||||
visual, err = s.detector.Detect(ctx, MediaInfo{
|
||||
URL: resolved.URL, RuntimeMs: resolved.RuntimeMs, Window: window,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNoFFmpeg) {
|
||||
// Run on behaviour alone rather than failing. An image without a decoder is
|
||||
// a deliberate deployment, not a fault.
|
||||
s.log.Debug("visual credits detection unavailable")
|
||||
} else {
|
||||
return Detection{}, false, err
|
||||
}
|
||||
}
|
||||
visual.Window = window
|
||||
}
|
||||
|
||||
detection, acceptable := Combine(visual, evidence)
|
||||
if !acceptable {
|
||||
return visual, false, nil
|
||||
}
|
||||
if found && !ShouldRewrite(existing, detection) {
|
||||
// Stability: the same answer, or a marginally different one, is not worth a write.
|
||||
return detection, false, nil
|
||||
}
|
||||
|
||||
// A fingerprint too weak to detect a file replacement is one whose marker could outlive
|
||||
// the file it describes. Better to keep re-deriving it than to store something nothing
|
||||
// will ever invalidate.
|
||||
if resolved.Version.Weak() {
|
||||
s.log.Debug("credits marker withheld; weak fingerprint", "item", itemID)
|
||||
return detection, false, nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
marker := Marker{
|
||||
ItemID: itemID,
|
||||
MediaFingerprint: fingerprint,
|
||||
CreditsStartMs: detection.StartMs,
|
||||
Confidence: detection.Confidence,
|
||||
DetectionMethod: detection.Method,
|
||||
SeriesID: resolved.SeriesID,
|
||||
Season: resolved.Season,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.repo.SaveMarker(ctx, marker); err != nil {
|
||||
return detection, false, err
|
||||
}
|
||||
return detection, true, nil
|
||||
}
|
||||
|
||||
// evidence reads the household's stops. Failures are swallowed: behaviour is an optimisation
|
||||
// and an enhancement, never a precondition.
|
||||
func (s *Service) evidence(ctx context.Context, itemID string, runtimeMs int64) BehaviourEvidence {
|
||||
if s.behaviour == nil {
|
||||
return BehaviourEvidence{}
|
||||
}
|
||||
stops, err := s.behaviour.Stops(ctx, itemID)
|
||||
if err != nil {
|
||||
s.log.Debug("behavioural evidence unavailable", "item", itemID, "error", err)
|
||||
return BehaviourEvidence{}
|
||||
}
|
||||
return AnalyseStops(stops, runtimeMs)
|
||||
}
|
||||
|
||||
// seasonHistory reads what is already known about this episode's neighbours, and the runtime
|
||||
// of each so the tail offsets can be compared. Failures degrade to a generic tail window.
|
||||
func (s *Service) seasonHistory(
|
||||
ctx context.Context, resolved ResolvedMedia,
|
||||
) ([]Marker, func(Marker) int64) {
|
||||
if resolved.SeriesID == "" || resolved.Season <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
const historyLimit = 6
|
||||
history, err := s.repo.SeasonMarkers(ctx, resolved.SeriesID, resolved.Season, historyLimit)
|
||||
if err != nil || len(history) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// The neighbours' runtimes come from resolving them, which would be a request each. The
|
||||
// episodes of one season are within a minute or two of each other, so this episode's own
|
||||
// runtime is a good enough stand-in — and being slightly wrong here only widens or
|
||||
// narrows a window that carries a ninety-second margin either way.
|
||||
runtime := resolved.RuntimeMs
|
||||
return history, func(Marker) int64 { return runtime }
|
||||
}
|
||||
|
||||
// known is the cache check in its cheapest form: does a marker exist for the current version
|
||||
// of this item. One resolve, one indexed read.
|
||||
func (s *Service) known(ctx context.Context, itemID string) (bool, error) {
|
||||
resolved, err := s.resolver.Resolve(ctx, itemID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if resolved.EmbyCreditsMs > 0 {
|
||||
return true, nil
|
||||
}
|
||||
marker, found, err := s.repo.GetMarker(ctx, itemID, Fingerprint(resolved.Version))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return found && marker.Confidence >= ConfidenceThreshold, nil
|
||||
}
|
||||
|
||||
func (s *Service) busy(ctx context.Context) bool {
|
||||
return s.load != nil && s.load.Busy(ctx)
|
||||
}
|
||||
|
||||
// QueueDepth is what the console reads.
|
||||
func (s *Service) QueueDepth() int { return s.queue.Len() }
|
||||
|
||||
// Pending is the queue in worker order, for the admin console.
|
||||
func (s *Service) Pending() []Candidate { return s.queue.Snapshot() }
|
||||
|
||||
func sleep(ctx context.Context, duration time.Duration) bool {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func plural(count int) string {
|
||||
if count == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
|
||||
func round2(value float64) float64 {
|
||||
return float64(int(value*100+0.5)) / 100
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The service, and the claim it exists to keep: in the settled case a candidate costs one
|
||||
// indexed read and touches no media at all.
|
||||
//
|
||||
// The fake detector counts every call, so "no file access" is an assertion rather than a
|
||||
// hope — which is the only way to test a property whose failure is invisible.
|
||||
|
||||
type fakeRepo struct {
|
||||
mu sync.Mutex
|
||||
markers map[string]Marker
|
||||
season []Marker
|
||||
writes int32
|
||||
reads int32
|
||||
}
|
||||
|
||||
func newFakeRepo() *fakeRepo { return &fakeRepo{markers: map[string]Marker{}} }
|
||||
|
||||
func (r *fakeRepo) key(itemID, fingerprint string) string { return itemID + "|" + fingerprint }
|
||||
|
||||
func (r *fakeRepo) GetMarker(_ context.Context, itemID, fingerprint string) (Marker, bool, error) {
|
||||
atomic.AddInt32(&r.reads, 1)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
marker, found := r.markers[r.key(itemID, fingerprint)]
|
||||
return marker, found, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) SaveMarker(_ context.Context, marker Marker) error {
|
||||
atomic.AddInt32(&r.writes, 1)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.markers[r.key(marker.ItemID, marker.MediaFingerprint)] = marker
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) SeasonMarkers(context.Context, string, int, int) ([]Marker, error) {
|
||||
return r.season, nil
|
||||
}
|
||||
|
||||
type fakeDetector struct {
|
||||
calls int32
|
||||
result Detection
|
||||
err error
|
||||
}
|
||||
|
||||
func (d *fakeDetector) Detect(ctx context.Context, _ MediaInfo) (Detection, error) {
|
||||
atomic.AddInt32(&d.calls, 1)
|
||||
if d.err != nil {
|
||||
return Detection{}, d.err
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return Detection{}, ctx.Err()
|
||||
}
|
||||
return d.result, nil
|
||||
}
|
||||
|
||||
type fakeResolver struct {
|
||||
mu sync.Mutex
|
||||
media map[string]ResolvedMedia
|
||||
}
|
||||
|
||||
func (r *fakeResolver) Resolve(_ context.Context, itemID string) (ResolvedMedia, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
media, found := r.media[itemID]
|
||||
if !found {
|
||||
return ResolvedMedia{}, errors.New("unknown item")
|
||||
}
|
||||
return media, nil
|
||||
}
|
||||
|
||||
type fakeBehaviour struct{ stops []StopEvent }
|
||||
|
||||
func (b fakeBehaviour) Stops(context.Context, string) ([]StopEvent, error) { return b.stops, nil }
|
||||
|
||||
func quietLog() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
|
||||
func testMedia() ResolvedMedia {
|
||||
return ResolvedMedia{
|
||||
URL: "http://emby/Videos/bb-s06e08/stream", SeriesID: "bb", Season: 6, Episode: 8,
|
||||
RuntimeMs: bbRuntime,
|
||||
Version: MediaVersion{
|
||||
ItemID: "bb-s06e08", RuntimeMs: bbRuntime, SizeBytes: 1_400_000_000,
|
||||
ETag: "abc123",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestService(repo *fakeRepo, detector Detector, media ResolvedMedia) *Service {
|
||||
return New(Deps{
|
||||
Repository: repo,
|
||||
Resolver: &fakeResolver{media: map[string]ResolvedMedia{media.Version.ItemID: media}},
|
||||
Detector: detector,
|
||||
Log: quietLog(),
|
||||
Config: DefaultConfig(),
|
||||
})
|
||||
}
|
||||
|
||||
// The steady state the whole subsystem is optimised for.
|
||||
func TestCachedMarkerPreventsAnyMediaAccess(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
media := testMedia()
|
||||
repo.markers[repo.key(media.Version.ItemID, Fingerprint(media.Version))] = Marker{
|
||||
ItemID: media.Version.ItemID, CreditsStartMs: 2_450_000,
|
||||
Confidence: 0.93, DetectionMethod: MethodCombined,
|
||||
}
|
||||
detector := &fakeDetector{result: visual(2_450_000, 0.9)}
|
||||
service := newTestService(repo, detector, media)
|
||||
|
||||
detection, stored, err := service.Process(context.Background(), media.Version.ItemID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detector.calls != 0 {
|
||||
t.Fatalf("the detector ran %d times for an episode that was already decided", detector.calls)
|
||||
}
|
||||
if stored {
|
||||
t.Fatal("a cached marker caused a database write")
|
||||
}
|
||||
if atomic.LoadInt32(&repo.writes) != 0 {
|
||||
t.Fatalf("%d writes on the cached path; the promise is zero", repo.writes)
|
||||
}
|
||||
if detection.StartMs != 2_450_000 {
|
||||
t.Fatalf("StartMs = %d, want the stored marker", detection.StartMs)
|
||||
}
|
||||
}
|
||||
|
||||
// A successful scan is one write, and exactly one.
|
||||
func TestSuccessfulScanWritesOnce(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
media := testMedia()
|
||||
detector := &fakeDetector{result: visual(2_450_000, 0.85)}
|
||||
service := newTestService(repo, detector, media)
|
||||
|
||||
if _, stored, err := service.Process(context.Background(), media.Version.ItemID); err != nil || !stored {
|
||||
t.Fatalf("first scan stored = %v, err = %v", stored, err)
|
||||
}
|
||||
if repo.writes != 1 {
|
||||
t.Fatalf("%d writes for one scan, want 1", repo.writes)
|
||||
}
|
||||
// And the second time the episode comes round, nothing at all.
|
||||
if _, stored, err := service.Process(context.Background(), media.Version.ItemID); err != nil || stored {
|
||||
t.Fatalf("second pass stored = %v, err = %v; the answer was already known", stored, err)
|
||||
}
|
||||
if repo.writes != 1 {
|
||||
t.Fatalf("%d writes after re-processing, want 1", repo.writes)
|
||||
}
|
||||
if detector.calls != 1 {
|
||||
t.Fatalf("the detector ran %d times; the second pass should never reach it", detector.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// The reason the marker is keyed on a fingerprint rather than an item id.
|
||||
func TestReplacedFileInvalidatesTheMarker(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
media := testMedia()
|
||||
detector := &fakeDetector{result: visual(2_450_000, 0.85)}
|
||||
service := newTestService(repo, detector, media)
|
||||
|
||||
if _, _, err := service.Process(context.Background(), media.Version.ItemID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Sonarr swaps the file: same episode, same item id, different media.
|
||||
replaced := media
|
||||
replaced.Version.SizeBytes = 2_900_000_000
|
||||
replaced.Version.ETag = "def456"
|
||||
service = newTestService(repo, detector, replaced)
|
||||
|
||||
if _, stored, err := service.Process(context.Background(), replaced.Version.ItemID); err != nil || !stored {
|
||||
t.Fatalf("a replaced file was not rescanned: stored = %v, err = %v", stored, err)
|
||||
}
|
||||
if detector.calls != 2 {
|
||||
t.Fatalf("the detector ran %d times; the replacement should have been scanned", detector.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A marker that could outlive the file it describes must not be stored.
|
||||
func TestWeakFingerprintWithholdsTheMarker(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
media := testMedia()
|
||||
media.Version = MediaVersion{ItemID: media.Version.ItemID, RuntimeMs: bbRuntime}
|
||||
service := newTestService(repo, &fakeDetector{result: visual(2_450_000, 0.85)}, media)
|
||||
|
||||
if _, stored, err := service.Process(context.Background(), media.Version.ItemID); err != nil || stored {
|
||||
t.Fatalf("stored = %v, err = %v; a runtime-only fingerprint cannot be invalidated",
|
||||
stored, err)
|
||||
}
|
||||
if repo.writes != 0 {
|
||||
t.Fatalf("%d writes for a weak fingerprint, want 0", repo.writes)
|
||||
}
|
||||
}
|
||||
|
||||
// Emby's own answer wins, and costs nothing.
|
||||
func TestEmbyChapterMarkerSkipsTheScanEntirely(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
media := testMedia()
|
||||
media.EmbyCreditsMs = 2_460_000
|
||||
detector := &fakeDetector{result: visual(2_450_000, 0.9)}
|
||||
service := newTestService(repo, detector, media)
|
||||
|
||||
detection, stored, err := service.Process(context.Background(), media.Version.ItemID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detector.calls != 0 || stored || repo.writes != 0 {
|
||||
t.Fatalf("Emby had already answered but the scan ran anyway "+
|
||||
"(calls=%d stored=%v writes=%d)", detector.calls, stored, repo.writes)
|
||||
}
|
||||
if detection.StartMs != 2_460_000 || detection.Method != MethodEmby {
|
||||
t.Fatalf("detection = %+v, want Emby's own marker", detection)
|
||||
}
|
||||
}
|
||||
|
||||
// A weak reading is a scan that deliberately produces no database activity.
|
||||
func TestSubThresholdDetectionIsNotStored(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
media := testMedia()
|
||||
service := newTestService(repo, &fakeDetector{result: visual(2_450_000, 0.4)}, media)
|
||||
|
||||
if _, stored, err := service.Process(context.Background(), media.Version.ItemID); err != nil || stored {
|
||||
t.Fatalf("stored = %v, err = %v; a doubtful reading must not be written", stored, err)
|
||||
}
|
||||
if repo.writes != 0 {
|
||||
t.Fatalf("%d writes for a rejected detection, want 0", repo.writes)
|
||||
}
|
||||
}
|
||||
|
||||
// No decoder is a deliberate deployment, not a fault: behaviour alone still answers.
|
||||
func TestMissingFFmpegFallsBackToBehaviour(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
media := testMedia()
|
||||
service := New(Deps{
|
||||
Repository: repo,
|
||||
Resolver: &fakeResolver{media: map[string]ResolvedMedia{media.Version.ItemID: media}},
|
||||
Detector: &fakeDetector{err: ErrNoFFmpeg},
|
||||
Behaviour: fakeBehaviour{stops: []StopEvent{
|
||||
{UserKey: "paul", PositionMs: 2_450_000, RuntimeMs: bbRuntime, NextEpisode: true},
|
||||
{UserKey: "david", PositionMs: 2_453_000, RuntimeMs: bbRuntime, NextEpisode: true},
|
||||
{UserKey: "matt", PositionMs: 2_451_000, RuntimeMs: bbRuntime, NextEpisode: true},
|
||||
}},
|
||||
Log: quietLog(),
|
||||
Config: DefaultConfig(),
|
||||
})
|
||||
|
||||
detection, stored, err := service.Process(context.Background(), media.Version.ItemID)
|
||||
if err != nil {
|
||||
t.Fatalf("a missing decoder was reported as an error: %v", err)
|
||||
}
|
||||
if !stored || detection.Method != MethodBehaviour {
|
||||
t.Fatalf("stored = %v, method = %q; behaviour alone should have answered",
|
||||
stored, detection.Method)
|
||||
}
|
||||
}
|
||||
|
||||
// Four signals naming one episode must collapse into one scan.
|
||||
func TestConcurrentRequestsCollapseIntoOneScan(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
media := testMedia()
|
||||
slow := &slowDetector{result: visual(2_450_000, 0.85)}
|
||||
service := newTestService(repo, slow, media)
|
||||
|
||||
var group sync.WaitGroup
|
||||
for index := 0; index < 6; index++ {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
_, _, _ = service.Process(context.Background(), media.Version.ItemID)
|
||||
}()
|
||||
}
|
||||
group.Wait()
|
||||
|
||||
if calls := atomic.LoadInt32(&slow.calls); calls != 1 {
|
||||
t.Fatalf("six simultaneous requests produced %d scans, want 1", calls)
|
||||
}
|
||||
if repo.writes != 1 {
|
||||
t.Fatalf("%d writes, want 1", repo.writes)
|
||||
}
|
||||
}
|
||||
|
||||
type slowDetector struct {
|
||||
calls int32
|
||||
result Detection
|
||||
}
|
||||
|
||||
func (d *slowDetector) Detect(ctx context.Context, _ MediaInfo) (Detection, error) {
|
||||
atomic.AddInt32(&d.calls, 1)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Detection{}, ctx.Err()
|
||||
case <-time.After(40 * time.Millisecond):
|
||||
}
|
||||
return d.result, nil
|
||||
}
|
||||
|
||||
func TestCancellationStopsAScan(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
media := testMedia()
|
||||
service := newTestService(repo, &slowDetector{result: visual(2_450_000, 0.9)}, media)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, stored, err := service.Process(ctx, media.Version.ItemID); err == nil || stored {
|
||||
t.Fatalf("a cancelled scan returned stored = %v, err = %v", stored, err)
|
||||
}
|
||||
if repo.writes != 0 {
|
||||
t.Fatalf("%d writes after cancellation, want 0", repo.writes)
|
||||
}
|
||||
}
|
||||
|
||||
// The delay is what stops a curious button press becoming disk activity.
|
||||
func TestAbandonedPlaybackIsNeverQueued(t *testing.T) {
|
||||
media := testMedia()
|
||||
service := newTestService(newFakeRepo(), &fakeDetector{}, media)
|
||||
service.liveDelay = 50 * time.Millisecond
|
||||
|
||||
service.NotePlayback(context.Background(), media.Version.ItemID)
|
||||
service.AbandonPlayback(media.Version.ItemID)
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
|
||||
if depth := service.QueueDepth(); depth != 0 {
|
||||
t.Fatalf("queue depth = %d; an abandoned playback was queued anyway", depth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSustainedPlaybackIsQueuedAtLivePriority(t *testing.T) {
|
||||
media := testMedia()
|
||||
service := newTestService(newFakeRepo(), &fakeDetector{}, media)
|
||||
service.liveDelay = 20 * time.Millisecond
|
||||
|
||||
service.NotePlayback(context.Background(), media.Version.ItemID)
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for service.QueueDepth() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
pending := service.Pending()
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("queue holds %d candidates, want 1", len(pending))
|
||||
}
|
||||
if pending[0].Priority != PriorityLive || pending[0].Reason != ReasonLivePlayback {
|
||||
t.Fatalf("candidate = %+v, want live playback at priority %d", pending[0], PriorityLive)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Tracearr as a candidate source, kept deliberately apart from the detector.
|
||||
//
|
||||
// Everything about *why* an episode is worth scanning lives on this side of the boundary,
|
||||
// and nothing about it crosses. The detector is handed a file and a window; it never learns
|
||||
// that Paul watched four episodes of Blue Bloods this week, and it must not, or it would
|
||||
// become possible to tune the detector to agree with the predictor rather than with the
|
||||
// media.
|
||||
|
||||
// Database is the narrow slice of the store this package reads. Narrow so the whole candidate
|
||||
// pipeline can be exercised against a map in a test, and so it is obvious at a glance that
|
||||
// nothing here writes anything but a marker.
|
||||
type Database interface {
|
||||
RecentWatches(ctx context.Context, since time.Time, limit int) ([]Watch, error)
|
||||
SeriesEpisodes(ctx context.Context, seriesIDs []string) ([]SeriesEpisode, error)
|
||||
}
|
||||
|
||||
// SeriesEpisode is an episode with its series and numbering, which is what an index is built from.
|
||||
type SeriesEpisode struct {
|
||||
ItemID string
|
||||
SeriesID string
|
||||
Season int
|
||||
Episode int
|
||||
}
|
||||
|
||||
// watchLimit bounds the demand query. A household producing more than this many episode
|
||||
// sessions inside the decay window is one whose oldest sessions cannot possibly still be
|
||||
// predictive, so the newest-first ordering makes the cap harmless.
|
||||
const watchLimit = 500
|
||||
|
||||
// TracearrSource builds candidates from what the household has actually been watching.
|
||||
type TracearrSource struct {
|
||||
DB Database
|
||||
Cfg Config
|
||||
}
|
||||
|
||||
// Candidates is the whole predictive pipeline: read recent demand, group it into per-viewer
|
||||
// activities, load the numbering for the few series involved, and project a small window
|
||||
// ahead of each viewer.
|
||||
//
|
||||
// Two database reads for the entire household, whatever it is watching. Nothing here is
|
||||
// per-candidate and nothing is per-episode.
|
||||
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()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
|
||||
// The decay window is the query's window too. Anything older cannot survive
|
||||
// decayWeight, so fetching it would be reading rows in order to discard them.
|
||||
watches, err := s.DB.RecentWatches(ctx, now.Add(-cfg.WeakWindow), watchLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
activities := Activities(watches, now, cfg)
|
||||
if len(activities) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
seriesIDs := make([]string, 0, len(activities))
|
||||
seen := map[string]bool{}
|
||||
for _, activity := range activities {
|
||||
if !seen[activity.SeriesID] {
|
||||
seen[activity.SeriesID] = true
|
||||
seriesIDs = append(seriesIDs, activity.SeriesID)
|
||||
}
|
||||
}
|
||||
episodes, err := s.DB.SeriesEpisodes(ctx, seriesIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return BuildCandidates(activities, NewEpisodeIndex(episodes), now, cfg), nil
|
||||
}
|
||||
|
||||
// episodeIndex is an in-memory ordering of each series' episodes.
|
||||
//
|
||||
// Built once per refresh cycle from one query, so stepping forward — including across a
|
||||
// season boundary, which is exactly where somebody is most likely to keep watching — is
|
||||
// arithmetic rather than a lookup per candidate.
|
||||
type episodeIndex struct {
|
||||
bySeries map[string][]SeriesEpisode
|
||||
}
|
||||
|
||||
// NewEpisodeIndex orders the episodes of each series by season and number.
|
||||
func NewEpisodeIndex(episodes []SeriesEpisode) EpisodeIndex {
|
||||
index := &episodeIndex{bySeries: map[string][]SeriesEpisode{}}
|
||||
for _, episode := range episodes {
|
||||
if episode.ItemID == "" || episode.SeriesID == "" || episode.Episode <= 0 {
|
||||
continue
|
||||
}
|
||||
index.bySeries[episode.SeriesID] = append(index.bySeries[episode.SeriesID], episode)
|
||||
}
|
||||
for _, list := range index.bySeries {
|
||||
sort.Slice(list, func(a, b int) bool {
|
||||
if list[a].Season != list[b].Season {
|
||||
return list[a].Season < list[b].Season
|
||||
}
|
||||
return list[a].Episode < list[b].Episode
|
||||
})
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// Following returns the next count episodes after a position.
|
||||
//
|
||||
// Specials are skipped. Season 0 is a real season and its episodes are real files, but
|
||||
// nobody finishing S06E07 goes on to a behind-the-scenes featurette, and queueing one would
|
||||
// spend a scan on an episode that will not be watched.
|
||||
func (i *episodeIndex) Following(seriesID string, season, episode, count int) []EpisodeRef {
|
||||
if count <= 0 {
|
||||
return nil
|
||||
}
|
||||
list := i.bySeries[seriesID]
|
||||
out := make([]EpisodeRef, 0, count)
|
||||
for _, candidate := range list {
|
||||
if candidate.Season <= 0 {
|
||||
continue
|
||||
}
|
||||
after := candidate.Season > season ||
|
||||
(candidate.Season == season && candidate.Episode > episode)
|
||||
if !after {
|
||||
continue
|
||||
}
|
||||
out = append(out, EpisodeRef{
|
||||
ItemID: candidate.ItemID,
|
||||
Season: candidate.Season,
|
||||
Episode: candidate.Episode,
|
||||
})
|
||||
if len(out) == count {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Where the scan looks, and how it is narrowed.
|
||||
//
|
||||
// This is the file that decides how much of a media file is ever read, so it is where the
|
||||
// performance claim of the whole subsystem is either kept or given away. A generic tail
|
||||
// window is a fallback; the interesting case is the second episode of a season onwards,
|
||||
// where the first one has already told us where this show puts its credits.
|
||||
|
||||
// Provenance of a window, reported by the benchmark so "is demand-driven narrowing actually
|
||||
// saving work" is a question with a printed answer rather than an opinion.
|
||||
const (
|
||||
WindowGeneric = "generic-tail-window"
|
||||
WindowSeason = "season-history"
|
||||
WindowBehaviour = "tracearr-behaviour"
|
||||
)
|
||||
|
||||
const (
|
||||
// The generic tail, when nothing is known: a fifth of the runtime, floored at five
|
||||
// minutes so a short episode still has somewhere to look and capped at twelve so a
|
||||
// three-hour film does not turn into a thirty-six minute scan.
|
||||
genericTailFraction = 0.20
|
||||
genericTailMinimum = 5 * time.Minute
|
||||
genericTailMaximum = 12 * time.Minute
|
||||
|
||||
// The margin either side of an expected position. Wide enough to absorb a cold open
|
||||
// that ran long or a differently-cut episode, narrow enough that the scan is a couple of
|
||||
// minutes rather than ten.
|
||||
narrowMargin = 90 * time.Second
|
||||
|
||||
// seasonSpread is how much disagreement between known episodes is tolerated before the
|
||||
// season stops being evidence. A show that puts its credits at a consistent point is
|
||||
// usable; one whose known markers are three minutes apart is telling us the episodes are
|
||||
// not structurally alike, and narrowing on their average would look in the wrong place.
|
||||
seasonSpread = 2 * time.Minute
|
||||
|
||||
// seasonMinimumSamples is how many known episodes it takes. One is an anecdote — it may
|
||||
// itself be the mis-detection — and narrowing a scan onto a single unconfirmed reading is
|
||||
// how one wrong marker propagates through a whole season.
|
||||
seasonMinimumSamples = 2
|
||||
)
|
||||
|
||||
// ScanWindow is a span of the file, in milliseconds from its start.
|
||||
type ScanWindow struct {
|
||||
StartMs int64
|
||||
EndMs int64
|
||||
Source string
|
||||
}
|
||||
|
||||
func (w ScanWindow) DurationMs() int64 {
|
||||
if w.EndMs <= w.StartMs {
|
||||
return 0
|
||||
}
|
||||
return w.EndMs - w.StartMs
|
||||
}
|
||||
|
||||
func (w ScanWindow) Valid() bool { return w.DurationMs() > 0 }
|
||||
|
||||
// GenericTailWindow is where to look when nothing at all is known about the show.
|
||||
func GenericTailWindow(runtimeMs int64) ScanWindow {
|
||||
if runtimeMs <= 0 {
|
||||
return ScanWindow{}
|
||||
}
|
||||
tail := int64(float64(runtimeMs) * genericTailFraction)
|
||||
if minimum := genericTailMinimum.Milliseconds(); tail < minimum {
|
||||
tail = minimum
|
||||
}
|
||||
if maximum := genericTailMaximum.Milliseconds(); tail > maximum {
|
||||
tail = maximum
|
||||
}
|
||||
start := runtimeMs - tail
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
return ScanWindow{StartMs: start, EndMs: runtimeMs, Source: WindowGeneric}
|
||||
}
|
||||
|
||||
// aroundWindow is a narrow span centred on an expected position, clamped to the file.
|
||||
func aroundWindow(runtimeMs, expectedMs int64, source string) ScanWindow {
|
||||
margin := narrowMargin.Milliseconds()
|
||||
start := expectedMs - margin
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := expectedMs + margin
|
||||
if runtimeMs > 0 && end > runtimeMs {
|
||||
end = runtimeMs
|
||||
}
|
||||
if end <= start {
|
||||
return ScanWindow{}
|
||||
}
|
||||
return ScanWindow{StartMs: start, EndMs: end, Source: source}
|
||||
}
|
||||
|
||||
// seasonExpectation is where this show's credits are expected to start, learned from
|
||||
// episodes of the same season that have already been decided.
|
||||
//
|
||||
// Measured as a *tail offset* — how long the credits run for — rather than as an absolute
|
||||
// position, which is the one modelling decision in this file worth defending. Episodes of a
|
||||
// season vary in length by a minute or two; the credits sequence itself does not vary at all,
|
||||
// because it is the same sequence. Averaging absolute positions would smear that variation
|
||||
// into the estimate and force a wider margin to cover it.
|
||||
func seasonExpectation(runtimeMs int64, history []Marker, runtimeOf func(Marker) int64) (int64, bool) {
|
||||
if runtimeMs <= 0 || len(history) < seasonMinimumSamples {
|
||||
return 0, false
|
||||
}
|
||||
offsets := make([]int64, 0, len(history))
|
||||
for _, marker := range history {
|
||||
episodeRuntime := runtimeOf(marker)
|
||||
if episodeRuntime <= 0 || marker.CreditsStartMs <= 0 || marker.CreditsStartMs >= episodeRuntime {
|
||||
continue
|
||||
}
|
||||
// Only markers we would trust ourselves are allowed to steer a scan. A low-confidence
|
||||
// reading is exactly the one that should be re-examined, not the one that decides
|
||||
// where everything else looks.
|
||||
if marker.Confidence < ConfidenceThreshold {
|
||||
continue
|
||||
}
|
||||
offsets = append(offsets, episodeRuntime-marker.CreditsStartMs)
|
||||
}
|
||||
if len(offsets) < seasonMinimumSamples {
|
||||
return 0, false
|
||||
}
|
||||
sort.Slice(offsets, func(a, b int) bool { return offsets[a] < offsets[b] })
|
||||
if offsets[len(offsets)-1]-offsets[0] > seasonSpread.Milliseconds() {
|
||||
return 0, false
|
||||
}
|
||||
// The median, not the mean: one episode with a long "next time on" trailer after the
|
||||
// credits must not drag the estimate, and with two samples the median of the sorted pair
|
||||
// is the lower — the safer end, since looking slightly early costs nothing but looking
|
||||
// late misses the transition entirely.
|
||||
median := offsets[len(offsets)/2]
|
||||
expected := runtimeMs - median
|
||||
if expected <= 0 || expected >= runtimeMs {
|
||||
return 0, false
|
||||
}
|
||||
return expected, true
|
||||
}
|
||||
|
||||
// NarrowWindow decides where to look, preferring the strongest evidence available.
|
||||
//
|
||||
// Order matters and is deliberate. Behaviour first: where enough viewers stopped is a direct
|
||||
// observation of this exact episode, where season history is an inference from its
|
||||
// neighbours. Season history second. The generic tail last, and only because something has
|
||||
// to be.
|
||||
func NarrowWindow(
|
||||
runtimeMs int64,
|
||||
history []Marker,
|
||||
runtimeOf func(Marker) int64,
|
||||
behaviour BehaviourEvidence,
|
||||
) ScanWindow {
|
||||
if runtimeMs <= 0 {
|
||||
return ScanWindow{}
|
||||
}
|
||||
if behaviour.Usable() {
|
||||
if window := aroundWindow(runtimeMs, behaviour.StartMs, WindowBehaviour); window.Valid() {
|
||||
return window
|
||||
}
|
||||
}
|
||||
if runtimeOf != nil {
|
||||
if expected, ok := seasonExpectation(runtimeMs, history, runtimeOf); ok {
|
||||
if window := aroundWindow(runtimeMs, expected, WindowSeason); window.Valid() {
|
||||
return window
|
||||
}
|
||||
}
|
||||
}
|
||||
return GenericTailWindow(runtimeMs)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The scan window is where the performance claim is kept or given away, so these tests are
|
||||
// about size: how much of a file each source of evidence lets us avoid reading.
|
||||
|
||||
const bbRuntime = 44*60*1000 + 12*1000 // 44:12
|
||||
|
||||
func TestGenericTailIsBoundedBothWays(t *testing.T) {
|
||||
// A short episode still gets a usable window.
|
||||
short := GenericTailWindow(18 * 60 * 1000)
|
||||
if short.DurationMs() < genericTailMinimum.Milliseconds() {
|
||||
t.Fatalf("short episode window was %v, below the floor", short.DurationMs())
|
||||
}
|
||||
// A three-hour film does not turn into a thirty-six minute scan.
|
||||
long := GenericTailWindow(3 * 60 * 60 * 1000)
|
||||
if long.DurationMs() > genericTailMaximum.Milliseconds() {
|
||||
t.Fatalf("film window was %v, past the ceiling", long.DurationMs())
|
||||
}
|
||||
if long.EndMs != 3*60*60*1000 {
|
||||
t.Fatal("the window must run to the end of the file")
|
||||
}
|
||||
if long.Source != WindowGeneric {
|
||||
t.Fatalf("source = %q, want %q", long.Source, WindowGeneric)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoRuntimeMeansNoWindow(t *testing.T) {
|
||||
if GenericTailWindow(0).Valid() {
|
||||
t.Fatal("a file with no runtime produced a scannable window")
|
||||
}
|
||||
}
|
||||
|
||||
// The optimisation the brief calls one of the most important: knowing where this show puts
|
||||
// its credits should turn a ten-minute tail into a couple of minutes.
|
||||
func TestSeasonHistoryNarrowsTheScan(t *testing.T) {
|
||||
// Blue Bloods S06E04–06, credits around 40:50 of a ~44 minute episode.
|
||||
history := []Marker{
|
||||
{CreditsStartMs: 40*60*1000 + 51*1000, Confidence: 0.92},
|
||||
{CreditsStartMs: 40*60*1000 + 47*1000, Confidence: 0.90},
|
||||
{CreditsStartMs: 40*60*1000 + 50*1000, Confidence: 0.94},
|
||||
}
|
||||
runtimeOf := func(Marker) int64 { return bbRuntime }
|
||||
|
||||
generic := GenericTailWindow(bbRuntime)
|
||||
narrowed := NarrowWindow(bbRuntime, history, runtimeOf, BehaviourEvidence{})
|
||||
|
||||
if narrowed.Source != WindowSeason {
|
||||
t.Fatalf("source = %q, want %q", narrowed.Source, WindowSeason)
|
||||
}
|
||||
if narrowed.DurationMs() >= generic.DurationMs() {
|
||||
t.Fatalf("season history did not narrow anything: %v vs generic %v",
|
||||
narrowed.DurationMs(), generic.DurationMs())
|
||||
}
|
||||
// The brief's example expects roughly 39:30–42:00 for an expected 40:45.
|
||||
expected := int64(40*60+50) * 1000
|
||||
if narrowed.StartMs > expected || narrowed.EndMs < expected {
|
||||
t.Fatalf("window %v–%v does not contain the expected position %v",
|
||||
narrowed.StartMs, narrowed.EndMs, expected)
|
||||
}
|
||||
}
|
||||
|
||||
// The rule that stops one wrong marker propagating through a whole season.
|
||||
func TestASingleMarkerIsNotEnoughToNarrow(t *testing.T) {
|
||||
history := []Marker{{CreditsStartMs: 40*60*1000 + 51*1000, Confidence: 0.95}}
|
||||
window := NarrowWindow(bbRuntime, history, func(Marker) int64 { return bbRuntime },
|
||||
BehaviourEvidence{})
|
||||
if window.Source != WindowGeneric {
|
||||
t.Fatalf("source = %q; one marker is an anecdote, not a pattern", window.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInconsistentSeasonFallsBackToTheGenericTail(t *testing.T) {
|
||||
// A show whose known markers are minutes apart is telling us its episodes are not
|
||||
// structurally alike, so their average points nowhere useful.
|
||||
history := []Marker{
|
||||
{CreditsStartMs: 36 * 60 * 1000, Confidence: 0.9},
|
||||
{CreditsStartMs: 41 * 60 * 1000, Confidence: 0.9},
|
||||
{CreditsStartMs: 39 * 60 * 1000, Confidence: 0.9},
|
||||
}
|
||||
window := NarrowWindow(bbRuntime, history, func(Marker) int64 { return bbRuntime },
|
||||
BehaviourEvidence{})
|
||||
if window.Source != WindowGeneric {
|
||||
t.Fatalf("source = %q, want the generic fallback", window.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowConfidenceHistoryDoesNotSteerAScan(t *testing.T) {
|
||||
history := []Marker{
|
||||
{CreditsStartMs: 40*60*1000 + 51*1000, Confidence: 0.4},
|
||||
{CreditsStartMs: 40*60*1000 + 47*1000, Confidence: 0.3},
|
||||
}
|
||||
window := NarrowWindow(bbRuntime, history, func(Marker) int64 { return bbRuntime },
|
||||
BehaviourEvidence{})
|
||||
if window.Source != WindowGeneric {
|
||||
t.Fatalf("source = %q; a doubtful marker must not decide where everything else looks",
|
||||
window.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// Behaviour is a direct observation of this episode, where season history is an inference
|
||||
// from its neighbours, so it wins.
|
||||
func TestBehaviourOutranksSeasonHistory(t *testing.T) {
|
||||
history := []Marker{
|
||||
{CreditsStartMs: 40*60*1000 + 51*1000, Confidence: 0.92},
|
||||
{CreditsStartMs: 40*60*1000 + 47*1000, Confidence: 0.90},
|
||||
}
|
||||
evidence := BehaviourEvidence{
|
||||
Found: true, StartMs: 41*60*1000 + 30*1000, UserCount: 3, SpreadMs: 3000,
|
||||
}
|
||||
window := NarrowWindow(bbRuntime, history, func(Marker) int64 { return bbRuntime }, evidence)
|
||||
if window.Source != WindowBehaviour {
|
||||
t.Fatalf("source = %q, want %q", window.Source, WindowBehaviour)
|
||||
}
|
||||
if window.StartMs > evidence.StartMs || window.EndMs < evidence.StartMs {
|
||||
t.Fatal("the window does not contain the observed cluster")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrowedWindowsCarryAMargin(t *testing.T) {
|
||||
evidence := BehaviourEvidence{
|
||||
Found: true, StartMs: 40 * 60 * 1000, UserCount: 3, SpreadMs: 2000,
|
||||
}
|
||||
window := NarrowWindow(bbRuntime, nil, nil, evidence)
|
||||
// Nothing here is precise enough to scan a single instant, and being slightly early is
|
||||
// the harmless direction — but the margin has to exist on both sides.
|
||||
if window.StartMs >= evidence.StartMs || window.EndMs <= evidence.StartMs {
|
||||
t.Fatalf("window %v–%v has no margin around %v",
|
||||
window.StartMs, window.EndMs, evidence.StartMs)
|
||||
}
|
||||
if window.DurationMs() > 2*narrowMargin.Milliseconds()+time.Second.Milliseconds() {
|
||||
t.Fatalf("narrowed window is %v; wider than the margin allows", window.DurationMs())
|
||||
}
|
||||
}
|
||||
@@ -593,6 +593,21 @@ func (c *Client) StreamURL(cred Credentials, itemID string) string {
|
||||
return fmt.Sprintf("%s/Videos/%s/stream?%s", c.publicURL, url.PathEscape(itemID), params.Encode())
|
||||
}
|
||||
|
||||
// InternalStreamURL is the same file, addressed the way the *gateway* reaches Emby rather
|
||||
// than the way a television does.
|
||||
//
|
||||
// It exists for credits detection, which is the only thing that reads media bytes on the
|
||||
// server side. Using StreamURL there would send a ranged read out to the public address and
|
||||
// back in through the reverse proxy — the two containers are on the same network, and a scan
|
||||
// has no business leaving it. Nothing here is ever handed to a client.
|
||||
func (c *Client) InternalStreamURL(cred Credentials, itemID string) string {
|
||||
params := url.Values{}
|
||||
params.Set("static", "true")
|
||||
params.Set("api_key", cred.Token)
|
||||
params.Set("DeviceId", cred.DeviceID)
|
||||
return fmt.Sprintf("%s/Videos/%s/stream?%s", c.baseURL, url.PathEscape(itemID), params.Encode())
|
||||
}
|
||||
|
||||
// DeliveryURL converts a PlaybackInfo URL into a TV-reachable, authenticated URL.
|
||||
func (c *Client) DeliveryURL(cred Credentials, delivery string) string {
|
||||
delivery = strings.TrimSpace(delivery)
|
||||
|
||||
@@ -328,8 +328,10 @@ func (b *Buffer) append(event Event) {
|
||||
b.events = append(b.events, event)
|
||||
}
|
||||
if b.history != nil {
|
||||
ordered := b.orderedEventsLocked()
|
||||
_ = b.history.append(event, ordered)
|
||||
// append asks for the ordered ring only when a compaction is actually due.
|
||||
// Previously this copied every retained event for every log line, even though
|
||||
// 4,999 out of 5,000 appends only write the new JSONL record.
|
||||
_ = b.history.append(event, b)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,11 +343,13 @@ func (b *Buffer) orderedEventsLocked() []Event {
|
||||
return ordered
|
||||
}
|
||||
|
||||
func (h *historyFile) append(event Event, retained []Event) error {
|
||||
// append is called while buffer.mu is held. This lock order is always buffer then history;
|
||||
// compaction may therefore read the already-locked ring without another lock or snapshot.
|
||||
func (h *historyFile) append(event Event, buffer *Buffer) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if event.Sequence-h.lastCompacted >= int64(h.capacity) {
|
||||
if err := h.rewriteLocked(retained); err != nil {
|
||||
if err := h.rewriteLocked(buffer.orderedEventsLocked()); err != nil {
|
||||
return err
|
||||
}
|
||||
h.lastCompacted = event.Sequence
|
||||
@@ -419,13 +423,12 @@ func (b *Buffer) Events(after int64, limit int) EventPage {
|
||||
page.Dropped = page.Oldest - after - 1
|
||||
after = page.Oldest - 1
|
||||
}
|
||||
start := len(b.events)
|
||||
for i := range b.events {
|
||||
event := b.events[(b.start+i)%len(b.events)]
|
||||
if event.Sequence > after {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
// Sequences are contiguous, so cursor-to-ring position is arithmetic. The old scan
|
||||
// walked the full ring on every no-news poll—the most common request this endpoint
|
||||
// receives while an operator has the log open.
|
||||
start := 0
|
||||
if after >= page.Oldest {
|
||||
start = int(min(after-page.Oldest+1, int64(len(b.events))))
|
||||
}
|
||||
end := min(start+limit, len(b.events))
|
||||
for i := start; i < end; i++ {
|
||||
|
||||
@@ -2,6 +2,7 @@ package logging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -138,6 +139,22 @@ func TestBufferedLoggerRetainsStructuredEventsWithCursorPagination(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailCursorReadDoesNotCopyTheRing(t *testing.T) {
|
||||
logger, buffer := NewBuffered(io.Discard, slog.LevelInfo, 5_000, FormatConsole)
|
||||
for range 5_000 {
|
||||
logger.Info("request complete")
|
||||
}
|
||||
allocations := testing.AllocsPerRun(100, func() {
|
||||
page := buffer.Events(5_000, 1_000)
|
||||
if len(page.Events) != 0 || page.HasMore {
|
||||
t.Fatalf("tail cursor unexpectedly returned events: %+v", page)
|
||||
}
|
||||
})
|
||||
if allocations > 2 {
|
||||
t.Fatalf("tail cursor allocated %.1f objects; the ring may be getting copied", allocations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistentBufferRestoresTheRetainedTail(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "events.jsonl")
|
||||
var output bytes.Buffer
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// 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.
|
||||
|
||||
// CreditsMarkerRow is one stored marker.
|
||||
type CreditsMarkerRow struct {
|
||||
ItemID string
|
||||
MediaFingerprint string
|
||||
CreditsStartMs int64
|
||||
Confidence float64
|
||||
DetectionMethod string
|
||||
SeriesID string
|
||||
Season int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// CreditsMarker reads the marker for one media version. Absence is an ordinary answer.
|
||||
func (s *Store) CreditsMarker(
|
||||
ctx context.Context, itemID, fingerprint string,
|
||||
) (CreditsMarkerRow, bool, error) {
|
||||
var row CreditsMarkerRow
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT item_id, media_fingerprint, credits_start_ms, confidence, detection_method,
|
||||
series_id, season_number, created_at, updated_at
|
||||
FROM credits_markers
|
||||
WHERE item_id = $1 AND media_fingerprint = $2`, itemID, fingerprint).
|
||||
Scan(&row.ItemID, &row.MediaFingerprint, &row.CreditsStartMs, &row.Confidence,
|
||||
&row.DetectionMethod, &row.SeriesID, &row.Season, &row.CreatedAt, &row.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return CreditsMarkerRow{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return CreditsMarkerRow{}, false, fmt.Errorf("store: credits marker: %w", err)
|
||||
}
|
||||
return row, true, nil
|
||||
}
|
||||
|
||||
// SaveCreditsMarker upserts one marker. This is the single write the whole subsystem makes,
|
||||
// and the caller has already decided that the new evidence is worth it — the stability rule
|
||||
// lives in the credits package beside the confidence model it depends on, not here.
|
||||
//
|
||||
// created_at is preserved on conflict so a marker's age remains the age of the finding rather
|
||||
// than of the last time something confirmed it.
|
||||
func (s *Store) SaveCreditsMarker(ctx context.Context, row CreditsMarkerRow) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO credits_markers (
|
||||
item_id, media_fingerprint, credits_start_ms, confidence, detection_method,
|
||||
series_id, season_number, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now())
|
||||
ON CONFLICT (item_id, media_fingerprint) DO UPDATE SET
|
||||
credits_start_ms = EXCLUDED.credits_start_ms,
|
||||
confidence = EXCLUDED.confidence,
|
||||
detection_method = EXCLUDED.detection_method,
|
||||
series_id = EXCLUDED.series_id,
|
||||
season_number = EXCLUDED.season_number,
|
||||
updated_at = now()`,
|
||||
row.ItemID, row.MediaFingerprint, row.CreditsStartMs, row.Confidence,
|
||||
row.DetectionMethod, row.SeriesID, row.Season)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: save credits marker: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreditsSeasonMarkers reads what is already known about a season, best evidence first.
|
||||
//
|
||||
// This is the single most valuable query in the subsystem. Credits within a season begin at
|
||||
// a consistent point, so two decided episodes turn the next one's ten-minute tail scan into a
|
||||
// three-minute one — which is most of the difference between a feature that is affordable on
|
||||
// a NAS and one that is not.
|
||||
func (s *Store) CreditsSeasonMarkers(
|
||||
ctx context.Context, seriesID string, season, limit int,
|
||||
) ([]CreditsMarkerRow, error) {
|
||||
if seriesID == "" || limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT item_id, media_fingerprint, credits_start_ms, confidence, detection_method,
|
||||
series_id, season_number, created_at, updated_at
|
||||
FROM credits_markers
|
||||
WHERE series_id = $1 AND season_number = $2
|
||||
ORDER BY confidence DESC, updated_at DESC
|
||||
LIMIT $3`, seriesID, season, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: credits season markers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]CreditsMarkerRow, 0, limit)
|
||||
for rows.Next() {
|
||||
var row CreditsMarkerRow
|
||||
if err := rows.Scan(&row.ItemID, &row.MediaFingerprint, &row.CreditsStartMs,
|
||||
&row.Confidence, &row.DetectionMethod, &row.SeriesID, &row.Season,
|
||||
&row.CreatedAt, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreditsWatchRow is one episode one viewer played, as candidate generation needs it.
|
||||
type CreditsWatchRow struct {
|
||||
UserKey string
|
||||
SeriesID string
|
||||
Season int
|
||||
Episode int
|
||||
WatchedAt time.Time
|
||||
Completed bool
|
||||
}
|
||||
|
||||
// CreditsRecentWatches is the demand signal, and the whole of it: one indexed read of
|
||||
// sessions Tracearr has already imported.
|
||||
//
|
||||
// Nothing is written here and no new ingestion exists — the For You import already maintains
|
||||
// this table and already resolves its rows to Emby ids. That reuse is why demand-driven
|
||||
// candidate generation costs the gateway a single query every ten minutes rather than a
|
||||
// second Tracearr integration.
|
||||
//
|
||||
// Episodes only, and only where the series resolved to something in Emby: a session that
|
||||
// could not be matched cannot produce a scannable candidate, and filtering in SQL keeps the
|
||||
// unmatched majority of an old library out of Go entirely.
|
||||
func (s *Store) CreditsRecentWatches(
|
||||
ctx context.Context, since time.Time, limit int,
|
||||
) ([]CreditsWatchRow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
coalesce(nullif(tracearr_user_id, ''), lower(username)) AS user_key,
|
||||
emby_series_id,
|
||||
coalesce(season_number, 0),
|
||||
coalesce(episode_number, 0),
|
||||
coalesce(stopped_at, started_at) AS watched_at,
|
||||
watched OR (total_duration_ms > 0
|
||||
AND progress_ms::float8 / total_duration_ms::float8 >= 0.9) AS completed
|
||||
FROM tracearr_sessions
|
||||
WHERE lower(media_type) = 'episode'
|
||||
AND emby_series_id <> ''
|
||||
AND episode_number IS NOT NULL AND episode_number > 0
|
||||
AND coalesce(stopped_at, started_at) >= $1
|
||||
AND coalesce(nullif(tracearr_user_id, ''), lower(username)) <> ''
|
||||
ORDER BY coalesce(stopped_at, started_at) DESC
|
||||
LIMIT $2`, since.UTC(), limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: credits recent watches: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]CreditsWatchRow, 0, 64)
|
||||
for rows.Next() {
|
||||
var row CreditsWatchRow
|
||||
if err := rows.Scan(&row.UserKey, &row.SeriesID, &row.Season,
|
||||
&row.Episode, &row.WatchedAt, &row.Completed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreditsStopRow is one viewer leaving one episode.
|
||||
type CreditsStopRow struct {
|
||||
UserKey string
|
||||
PositionMs int64
|
||||
RuntimeMs int64
|
||||
NextEpisode bool
|
||||
}
|
||||
|
||||
// CreditsStops reads where the household stopped one episode.
|
||||
//
|
||||
// The behavioural detector's entire input, and it needs no new table: Tracearr already
|
||||
// records progress and completion per session. NextEpisode is derived rather than stored —
|
||||
// a session for the following episode of the same series starting within a couple of minutes
|
||||
// of this one ending is an auto-advance, which is the strongest form of this signal because
|
||||
// it says the viewer was unambiguously looking at credits rather than deciding to stop.
|
||||
func (s *Store) CreditsStops(ctx context.Context, itemID string) ([]CreditsStopRow, error) {
|
||||
if itemID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH plays AS (
|
||||
SELECT
|
||||
coalesce(nullif(tracearr_user_id, ''), lower(username)) AS user_key,
|
||||
emby_series_id,
|
||||
season_number,
|
||||
episode_number,
|
||||
progress_ms,
|
||||
total_duration_ms,
|
||||
coalesce(stopped_at, started_at) AS ended_at
|
||||
FROM tracearr_sessions
|
||||
WHERE emby_item_id = $1
|
||||
AND progress_ms > 0
|
||||
AND total_duration_ms > 0
|
||||
AND coalesce(nullif(tracearr_user_id, ''), lower(username)) <> ''
|
||||
)
|
||||
SELECT
|
||||
plays.user_key,
|
||||
plays.progress_ms,
|
||||
plays.total_duration_ms,
|
||||
EXISTS (
|
||||
SELECT 1 FROM tracearr_sessions following
|
||||
WHERE following.emby_series_id = plays.emby_series_id
|
||||
AND coalesce(nullif(following.tracearr_user_id, ''), lower(following.username))
|
||||
= plays.user_key
|
||||
AND following.season_number = plays.season_number
|
||||
AND following.episode_number = plays.episode_number + 1
|
||||
AND following.started_at BETWEEN plays.ended_at - interval '30 seconds'
|
||||
AND plays.ended_at + interval '3 minutes'
|
||||
) AS next_episode
|
||||
FROM plays
|
||||
LIMIT 200`, itemID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: credits stops: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]CreditsStopRow, 0, 16)
|
||||
for rows.Next() {
|
||||
var row CreditsStopRow
|
||||
if err := rows.Scan(&row.UserKey, &row.PositionMs, &row.RuntimeMs,
|
||||
&row.NextEpisode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreditsEpisodeRow is one episode's position in its series.
|
||||
type CreditsEpisodeRow struct {
|
||||
ItemID string
|
||||
SeriesID string
|
||||
Season int
|
||||
Episode int
|
||||
}
|
||||
|
||||
// CreditsSeriesEpisodes reads the numbering of every episode of the given series.
|
||||
//
|
||||
// One query for the handful of series a household is currently watching, rather than a
|
||||
// lookup per candidate. The result becomes an in-memory index, so the look-ahead arithmetic
|
||||
// — including stepping across a season boundary, which is exactly when somebody is most
|
||||
// likely to keep going — is pure and testable with no database at all.
|
||||
func (s *Store) CreditsSeriesEpisodes(
|
||||
ctx context.Context, seriesIDs []string,
|
||||
) ([]CreditsEpisodeRow, error) {
|
||||
if len(seriesIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
id,
|
||||
series_id,
|
||||
coalesce((payload->>'ParentIndexNumber')::int, 0),
|
||||
coalesce((payload->>'IndexNumber')::int, 0)
|
||||
FROM library_items
|
||||
WHERE type = 'Episode'
|
||||
AND series_id = ANY($1)
|
||||
AND payload->>'IndexNumber' IS NOT NULL`, seriesIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: credits series episodes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]CreditsEpisodeRow, 0, 128)
|
||||
for rows.Next() {
|
||||
var row CreditsEpisodeRow
|
||||
if err := rows.Scan(&row.ItemID, &row.SeriesID, &row.Season, &row.Episode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.Episode <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -18,9 +18,21 @@ type UserShow struct {
|
||||
type NotificationPreferences struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ShowReturnAlerts bool `json:"showReturnAlerts"`
|
||||
SonarrAlerts bool `json:"sonarrAlerts"`
|
||||
RadarrAlerts bool `json:"radarrAlerts"`
|
||||
UpdateAlerts bool `json:"updateAlerts"`
|
||||
LibraryAlerts bool `json:"libraryAlerts"`
|
||||
SystemAlerts bool `json:"systemAlerts"`
|
||||
LeadDays int `json:"leadDays"`
|
||||
}
|
||||
|
||||
func DefaultNotificationPreferences() NotificationPreferences {
|
||||
return NotificationPreferences{
|
||||
Enabled: true, ShowReturnAlerts: true, SonarrAlerts: true, RadarrAlerts: true,
|
||||
UpdateAlerts: true, LibraryAlerts: true, SystemAlerts: true, LeadDays: 7,
|
||||
}
|
||||
}
|
||||
|
||||
type UserNotification struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
@@ -195,11 +207,13 @@ func (s *Store) RecordSonarrSeriesStatuses(
|
||||
}
|
||||
|
||||
func (s *Store) NotificationPreferences(ctx context.Context, userID string) (NotificationPreferences, error) {
|
||||
prefs := NotificationPreferences{Enabled: true, ShowReturnAlerts: true, LeadDays: 7}
|
||||
prefs := DefaultNotificationPreferences()
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT enabled, show_return_alerts, lead_days
|
||||
SELECT enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
|
||||
update_alerts, library_alerts, system_alerts, lead_days
|
||||
FROM user_notification_preferences WHERE emby_user_id = $1`, userID).
|
||||
Scan(&prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.LeadDays)
|
||||
Scan(&prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.SonarrAlerts, &prefs.RadarrAlerts,
|
||||
&prefs.UpdateAlerts, &prefs.LibraryAlerts, &prefs.SystemAlerts, &prefs.LeadDays)
|
||||
if err != nil && !isNoRows(err) {
|
||||
return prefs, fmt.Errorf("store: notification preferences: %w", err)
|
||||
}
|
||||
@@ -217,17 +231,47 @@ func (s *Store) SetNotificationPreferences(
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO user_notification_preferences
|
||||
(emby_user_id, enabled, show_return_alerts, lead_days)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
(emby_user_id, enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
|
||||
update_alerts, library_alerts, system_alerts, lead_days)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (emby_user_id) DO UPDATE SET
|
||||
enabled = EXCLUDED.enabled,
|
||||
show_return_alerts = EXCLUDED.show_return_alerts,
|
||||
sonarr_alerts = EXCLUDED.sonarr_alerts,
|
||||
radarr_alerts = EXCLUDED.radarr_alerts,
|
||||
update_alerts = EXCLUDED.update_alerts,
|
||||
library_alerts = EXCLUDED.library_alerts,
|
||||
system_alerts = EXCLUDED.system_alerts,
|
||||
lead_days = EXCLUDED.lead_days,
|
||||
updated_at = now()`,
|
||||
userID, prefs.Enabled, prefs.ShowReturnAlerts, prefs.LeadDays)
|
||||
userID, prefs.Enabled, prefs.ShowReturnAlerts, prefs.SonarrAlerts, prefs.RadarrAlerts,
|
||||
prefs.UpdateAlerts, prefs.LibraryAlerts, prefs.SystemAlerts, prefs.LeadDays)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AllNotificationPreferences(ctx context.Context) (map[string]NotificationPreferences, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT emby_user_id, enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
|
||||
update_alerts, library_alerts, system_alerts, lead_days
|
||||
FROM user_notification_preferences`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list notification preferences: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
result := map[string]NotificationPreferences{}
|
||||
for rows.Next() {
|
||||
prefs := DefaultNotificationPreferences()
|
||||
var userID string
|
||||
if err := rows.Scan(&userID, &prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.SonarrAlerts,
|
||||
&prefs.RadarrAlerts, &prefs.UpdateAlerts, &prefs.LibraryAlerts, &prefs.SystemAlerts,
|
||||
&prefs.LeadDays); err != nil {
|
||||
return nil, fmt.Errorf("store: scan notification preferences: %w", err)
|
||||
}
|
||||
result[userID] = prefs
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertNotification(
|
||||
ctx context.Context, userID, sourceKey, kind, itemID, title, message string, eventAt *time.Time,
|
||||
) error {
|
||||
|
||||
@@ -217,10 +217,24 @@ CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
emby_user_id TEXT PRIMARY KEY,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
show_return_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
sonarr_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
radarr_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
update_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
library_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
system_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
lead_days INT NOT NULL DEFAULT 7 CHECK (lead_days BETWEEN 1 AND 30),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- CREATE TABLE IF NOT EXISTS does not add fields to an existing installation. These
|
||||
-- additive, permissive defaults make the feature safe to roll out without changing what
|
||||
-- any current viewer receives.
|
||||
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS sonarr_alerts BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS radarr_alerts BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS update_alerts BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS library_alerts BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS system_alerts BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- Notifications are materialised so read/dismissed state follows the user to every TV.
|
||||
-- source_key is deterministic, preventing the same return date from being announced
|
||||
-- again whenever the app refreshes.
|
||||
@@ -696,3 +710,33 @@ CREATE TABLE IF NOT EXISTS media_report_actions (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS media_report_actions_report_idx
|
||||
ON media_report_actions (report_id, created_at ASC);
|
||||
|
||||
-- Where an episode's closing credits begin, for the small number of episodes a household is
|
||||
-- actually about to watch. Written by internal/credits.
|
||||
--
|
||||
-- The primary key is the whole design. Keyed on the media *version* rather than on the item,
|
||||
-- so a file Sonarr replaces stops matching its old marker and becomes a scan candidate again
|
||||
-- with nothing having to notice the swap — no invalidation pass, no staleness check, and no
|
||||
-- possibility of a Skip Credits button positioned against a file that no longer exists.
|
||||
--
|
||||
-- Deliberately the only durable output of that subsystem. Queue state, candidate priorities
|
||||
-- and scan progress are all held in RAM and rebuilt from Tracearr on restart, because a
|
||||
-- persistent job scheduler would cost more writes than the scanning it coordinates.
|
||||
CREATE TABLE IF NOT EXISTS credits_markers (
|
||||
item_id TEXT NOT NULL,
|
||||
media_fingerprint TEXT NOT NULL,
|
||||
credits_start_ms BIGINT NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 0,
|
||||
detection_method TEXT NOT NULL DEFAULT '',
|
||||
-- Only so a season can be read back in one query. That read is what narrows the next
|
||||
-- episode's scan from ten minutes of file to two.
|
||||
series_id TEXT NOT NULL DEFAULT '',
|
||||
season_number INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (item_id, media_fingerprint)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS credits_markers_season_idx
|
||||
ON credits_markers (series_id, season_number, confidence DESC)
|
||||
WHERE series_id <> '';
|
||||
|
||||
@@ -100,14 +100,18 @@ type HeroPlacementPolicy struct {
|
||||
PrimeSubtitle string `json:"primeSubtitle"`
|
||||
}
|
||||
|
||||
// HeroSchedule is resolved by the gateway for every home response. Times are UTC RFC3339;
|
||||
// weekdays use the local calendar day (Sunday=0) and an empty list means every day.
|
||||
// HeroSchedule is resolved by the gateway for every home response. An empty Frequency is
|
||||
// the original absolute start/end shape and remains valid. Daily and weekly schedules use
|
||||
// the gateway's local clock; a window such as 22:00–02:00 belongs to the day it starts.
|
||||
type HeroSchedule struct {
|
||||
ID string `json:"id"`
|
||||
ItemID string `json:"itemId"`
|
||||
StartAt time.Time `json:"startAt"`
|
||||
EndAt time.Time `json:"endAt"`
|
||||
Weekdays []int `json:"weekdays,omitempty"`
|
||||
Frequency string `json:"frequency,omitempty"`
|
||||
StartTime string `json:"startTime,omitempty"`
|
||||
EndTime string `json:"endTime,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -205,9 +209,25 @@ func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
|
||||
seenSchedules := map[string]bool{}
|
||||
for _, schedule := range policy.Schedules {
|
||||
schedule.ID, schedule.ItemID, schedule.UserID = strings.TrimSpace(schedule.ID), strings.TrimSpace(schedule.ItemID), strings.TrimSpace(schedule.UserID)
|
||||
if schedule.ID == "" || schedule.ItemID == "" || seenSchedules[schedule.ID] || !schedule.EndAt.After(schedule.StartAt) {
|
||||
schedule.Frequency = strings.ToLower(strings.TrimSpace(schedule.Frequency))
|
||||
if schedule.Frequency == "once" {
|
||||
// "once" is explicit in the new console; empty is the compatible legacy form.
|
||||
schedule.Frequency = ""
|
||||
}
|
||||
recurring := schedule.Frequency == "daily" || schedule.Frequency == "weekly"
|
||||
if schedule.ID == "" || schedule.ItemID == "" || seenSchedules[schedule.ID] ||
|
||||
(!recurring && !schedule.EndAt.After(schedule.StartAt)) {
|
||||
continue
|
||||
}
|
||||
if recurring {
|
||||
schedule.StartTime = normaliseHeroClock(schedule.StartTime)
|
||||
schedule.EndTime = normaliseHeroClock(schedule.EndTime)
|
||||
if schedule.StartTime == "" || schedule.EndTime == "" || schedule.StartTime == schedule.EndTime {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
schedule.StartTime, schedule.EndTime = "", ""
|
||||
}
|
||||
seenSchedules[schedule.ID] = true
|
||||
if schedule.Priority < -1000 {
|
||||
schedule.Priority = -1000
|
||||
@@ -224,6 +244,9 @@ func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
|
||||
}
|
||||
}
|
||||
schedule.Weekdays = weekdays
|
||||
if schedule.Frequency == "daily" {
|
||||
schedule.Weekdays = []int{}
|
||||
}
|
||||
placements := make([]string, 0, len(schedule.Placements))
|
||||
seenPlacements := map[string]bool{}
|
||||
for _, placement := range schedule.Placements {
|
||||
@@ -243,6 +266,14 @@ func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
|
||||
return policy
|
||||
}
|
||||
|
||||
func normaliseHeroClock(value string) string {
|
||||
parsed, err := time.Parse("15:04", strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.Format("15:04")
|
||||
}
|
||||
|
||||
func (s *Store) HeroPolicy(ctx context.Context) (HeroPolicy, error) {
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, HeroPolicyKey).Scan(&raw)
|
||||
|
||||
@@ -84,6 +84,22 @@ func TestHeroSchedulesDefaultToHomeAndNormalisePlacementNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeroPolicyKeepsRecurringSchedulesWithoutAbsoluteDates(t *testing.T) {
|
||||
got := normalizeHeroPolicy(HeroPolicy{Schedules: []HeroSchedule{
|
||||
{ID: "daily", ItemID: "one", Frequency: "DAILY", StartTime: " 18:00 ", EndTime: "22:30", Enabled: true},
|
||||
{ID: "weekly", ItemID: "two", Frequency: "weekly", StartTime: "22:00", EndTime: "02:00", Weekdays: []int{5, 5, 7}, Enabled: true},
|
||||
}})
|
||||
if len(got.Schedules) != 2 {
|
||||
t.Fatalf("recurring schedules = %+v", got.Schedules)
|
||||
}
|
||||
if got.Schedules[0].Frequency != "daily" || len(got.Schedules[0].Weekdays) != 0 {
|
||||
t.Fatalf("daily schedule = %+v", got.Schedules[0])
|
||||
}
|
||||
if got.Schedules[1].StartTime != "22:00" || len(got.Schedules[1].Weekdays) != 1 || got.Schedules[1].Weekdays[0] != 5 {
|
||||
t.Fatalf("weekly schedule = %+v", got.Schedules[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMDBListSettingsAreOptionalAndNormalizeSources(t *testing.T) {
|
||||
defaults := DefaultMDBListSettings()
|
||||
if defaults.Enabled || defaults.APIKey != "" || len(defaults.Sources) == 0 {
|
||||
|
||||
Reference in New Issue
Block a user