0.2.64 update

This commit is contained in:
ponzischeme89
2026-08-15 09:23:26 +12:00
parent a2ca7e8061
commit d5d47473a2
90 changed files with 9188 additions and 451 deletions
+21 -3
View File
@@ -11,12 +11,30 @@ COPY . .
RUN mkdir -p /out/releases /out/logs
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \
-ldflags="-s -w" -o /out/memby-server ./cmd/memby-server
# The credits bench ships in the image because the number it prints is only meaningful
# against the household's own media on its own hardware — a figure measured anywhere else
# proves nothing about whether a scan on this NAS is cheap.
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \
-ldflags="-s -w" -o /out/memby-credits ./cmd/memby-credits
# distroless/static carries CA certificates, which the gateway needs to reach an
# HTTPS Emby server, and runs as a non-root user by default.
FROM gcr.io/distroless/static-debian12:nonroot
# Alpine rather than distroless/static, and the reason is ffmpeg.
#
# Credits detection is the only thing in the gateway that reads media bytes, and it does so
# through ffmpeg used surgically — seek to the tail, downscale to a thumbnail, one frame every
# few seconds, raw grayscale on stdout, nothing written to disk. A static distroless image
# cannot carry a decoder, so the choice is this base or no visual detection at all.
#
# What is preserved from distroless: CA certificates, so an HTTPS Emby is reachable, and a
# non-root user. What is given up: about a hundred megabytes, and a shell existing in the
# image. The container healthcheck still re-runs the binary with -healthcheck rather than
# using curl, so it is unchanged by the move and stays honest if the base ever goes back.
FROM alpine:3.21
RUN apk add --no-cache ca-certificates ffmpeg \
&& addgroup -g 65532 -S nonroot \
&& adduser -u 65532 -S -G nonroot nonroot
WORKDIR /app
COPY --from=build /out/memby-server /app/memby-server
COPY --from=build /out/memby-credits /app/memby-credits
COPY --from=build --chown=nonroot:nonroot /out/releases /data/releases
COPY --from=build --chown=nonroot:nonroot /out/logs /data/logs
EXPOSE 8080
+179
View File
@@ -0,0 +1,179 @@
// Command memby-credits is the credits subsystem's bench, run as
//
// memby-credits benchmark <emby-item-id>
//
// It exists because "is this cheap" is not a question a unit test can answer and not one an
// opinion should settle. Every claim the package makes — that a scan reads a couple of
// minutes rather than a file, that season history narrows the window, that analysis finishes
// in seconds on modest hardware — is a number, and this is what prints them against real
// media on the NAS.
//
// The line that matters most is the window's provenance. A run reporting
// "generic-tail-window" every time is a run in which demand-driven narrowing is doing
// nothing, and the whole design would need revisiting.
package main
import (
"context"
"fmt"
"os"
"os/signal"
"runtime"
"syscall"
"time"
"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/store"
)
func main() {
if len(os.Args) < 3 || os.Args[1] != "benchmark" {
fmt.Fprintln(os.Stderr, "usage: memby-credits benchmark <emby-item-id>")
os.Exit(2)
}
if err := run(os.Args[2]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func run(itemID string) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
cfg, err := config.Load()
if err != nil {
return err
}
embyClient := emby.New(
cfg.EmbyURL, cfg.EmbyPublicURL, cfg.ClientName, cfg.GatewayClientName,
cfg.UpstreamTimeout,
)
resolver := credits.NewEmbyResolver(embyClient, emby.Credentials{
UserID: cfg.SyncUserID, Token: cfg.SyncAPIKey,
DeviceID: "memby-credits-bench", DeviceName: "MbyGateway Credits", Gateway: true,
})
resolved, err := resolver.Resolve(ctx, itemID)
if err != nil {
return fmt.Errorf("resolve %s: %w", itemID, err)
}
if resolved.RuntimeMs <= 0 {
return fmt.Errorf("item %s reports no runtime; nothing to scan", itemID)
}
// The database is optional here on purpose. A benchmark that could not run without a
// Postgres would be one nobody runs, and the two things it contributes — season history
// and behavioural evidence — are exactly the two the report is meant to show the value
// of, so their absence is worth being able to measure as the baseline.
var (
history []credits.Marker
runtimeOf func(credits.Marker) int64
evidence credits.BehaviourEvidence
)
if st, err := store.Open(ctx, cfg.DatabaseURL); err == nil {
defer st.Close()
database := credits.Postgres{Store: st}
if resolved.SeriesID != "" && resolved.Season > 0 {
if markers, err := database.SeasonMarkers(
ctx, resolved.SeriesID, resolved.Season, 6,
); err == nil {
history = markers
episodeRuntime := resolved.RuntimeMs
runtimeOf = func(credits.Marker) int64 { return episodeRuntime }
}
}
if stops, err := database.Stops(ctx, itemID); err == nil {
evidence = credits.AnalyseStops(stops, resolved.RuntimeMs)
}
} else {
fmt.Println("note: no database; measuring the un-narrowed baseline")
}
window := credits.NarrowWindow(resolved.RuntimeMs, history, runtimeOf, evidence)
sampler := &credits.Sampler{Binary: cfg.CreditsFFmpeg}
if !sampler.Available() {
return fmt.Errorf("ffmpeg not found; set MEMBY_CREDITS_FFMPEG")
}
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
started := time.Now()
detector := &credits.VisualDetector{Sampler: sampler}
detection, err := detector.Detect(ctx, credits.MediaInfo{
URL: resolved.URL, RuntimeMs: resolved.RuntimeMs, Window: window,
})
elapsed := time.Since(started)
if err != nil {
return err
}
runtime.ReadMemStats(&after)
combined, acceptable := credits.Combine(detection, evidence)
fmt.Printf("Episode runtime: %s\n", clock(resolved.RuntimeMs))
if evidence.Found {
fmt.Printf("Tracearr cluster: %s (%d viewers, spread %ds)\n",
clock(evidence.StartMs), evidence.UserCount, evidence.SpreadMs/1000)
}
fmt.Printf("Scan region: %s %s (%s)\n",
clock(window.StartMs), clock(window.EndMs), window.Source)
fmt.Printf("Season history: %d marker(s)\n", len(history))
// Estimated rather than measured: ffmpeg does not report how much of its input it read,
// and a proxy to find out would cost more than the number is worth. Derived from the
// window as a fraction of the file, which is the ratio the whole design turns on.
fmt.Printf("Bytes read: ~%s (estimated, %.1f%% of file)\n",
bytesLabel(estimateBytes(resolved.Version.SizeBytes, window, resolved.RuntimeMs)),
100*float64(window.DurationMs())/float64(resolved.RuntimeMs))
fmt.Printf("Frames sampled: %d\n", detection.FramesSampled)
fmt.Printf("Peak memory: ~%s\n", bytesLabel(int64(after.TotalAlloc-before.TotalAlloc)))
fmt.Printf("Analysis time: %.1fs\n", elapsed.Seconds())
if !detection.Found {
fmt.Println("Detected marker: none (visual)")
} else {
fmt.Printf("Detected marker: %s\n", clock(detection.StartMs))
fmt.Printf("Confidence: %.2f\n", detection.Confidence)
}
if acceptable {
fmt.Printf("Stored marker would be: %s confidence %.2f method %s\n",
clock(combined.StartMs), combined.Confidence, combined.Method)
} else {
fmt.Println("Stored marker would be: none — below the confidence threshold")
}
return nil
}
// estimateBytes is the window as a share of the file. Crude, and honest about being crude:
// the point of the figure is the order of magnitude, and a scan reading two minutes of a
// forty-four minute episode reads about five percent of it whatever the container does.
func estimateBytes(sizeBytes int64, window credits.ScanWindow, runtimeMs int64) int64 {
if sizeBytes <= 0 || runtimeMs <= 0 {
return 0
}
return int64(float64(sizeBytes) * float64(window.DurationMs()) / float64(runtimeMs))
}
func clock(ms int64) string {
total := ms / 1000
if hours := total / 3600; hours > 0 {
return fmt.Sprintf("%dh %02dm %02ds", hours, (total%3600)/60, total%60)
}
return fmt.Sprintf("%dm %02ds", total/60, total%60)
}
func bytesLabel(value int64) string {
switch {
case value <= 0:
return "unknown"
case value > 1<<20:
return fmt.Sprintf("%.1f MB", float64(value)/(1<<20))
case value > 1<<10:
return fmt.Sprintf("%.1f KB", float64(value)/(1<<10))
default:
return fmt.Sprintf("%d B", value)
}
}
+51 -2
View File
@@ -22,6 +22,7 @@ import (
"github.com/ponzischeme89/memby/server/internal/buildinfo"
"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"
@@ -175,6 +176,53 @@ func run(log *slog.Logger, events *logging.Buffer) error {
})
}
// Credits discovery. Deliberately built here rather than inside the API server, because
// it owns a long-running worker and a queue whose lifetime is the process's — the server
// only reads its markers and feeds it the two live signals it already receives.
creditsLoad := credits.NewPlaybackLoad()
var creditsService *credits.Service
if cfg.CreditsEnabled {
sampler := &credits.Sampler{Binary: cfg.CreditsFFmpeg}
var detector credits.Detector
if sampler.Available() {
detector = &credits.VisualDetector{Sampler: sampler}
} else {
// No decoder in the image is a deliberate deployment, not a fault. The subsystem
// still runs and still writes markers, on the household's own stop positions —
// which cost no media access at all and, on a well-watched show, agree more
// closely than any single reading of the picture.
log.Warn("credits: ffmpeg not found; running on behavioural evidence only")
}
database := credits.Postgres{Store: st}
creditsConfig := credits.Config{
PrefetchEpisodes: cfg.CreditsPrefetchEpisodes,
MaxPrefetchEpisodes: cfg.CreditsMaxPrefetch,
QueueLimit: cfg.CreditsQueueLimit,
StrongWindow: credits.DefaultConfig().StrongWindow,
UsefulWindow: credits.DefaultConfig().UsefulWindow,
WeakWindow: credits.DefaultConfig().WeakWindow,
}
creditsService = credits.New(credits.Deps{
Repository: database,
Resolver: credits.NewEmbyResolver(embyClient, emby.Credentials{
UserID: cfg.SyncUserID, Token: cfg.SyncAPIKey,
DeviceID: "memby-credits", DeviceName: "MbyGateway Credits",
Gateway: true,
}),
Source: &credits.TracearrSource{DB: database, Cfg: creditsConfig},
Detector: detector,
Behaviour: database,
Load: creditsLoad,
Log: log,
Config: creditsConfig,
})
go creditsService.Run(ctx)
log.Info("credits detection enabled",
"prefetch", creditsConfig.PrefetchEpisodes,
"queue_limit", creditsConfig.QueueLimit,
"visual", detector != nil)
}
// The administrative event bus, the integration dispatcher that subscribes to it and
// the scheduler that publishes into it are built before the server, because the server
// takes all three: a handler that could not publish would have to check for nil at
@@ -195,6 +243,8 @@ func run(log *slog.Logger, events *logging.Buffer) error {
Radarr: radarrClient,
Bazarr: bazarrClient,
MDBList: mdblistClient,
Credits: creditsService,
CreditsLoad: creditsLoad,
Syncer: syncer,
Log: log,
Events: events,
@@ -207,6 +257,7 @@ func run(log *slog.Logger, events *logging.Buffer) error {
// Registration is separate from construction so the task list reads as a declaration
// of what the gateway does in the background rather than as more wiring in here.
server.RegisterHousekeeping(sched)
server.RegisterCreditsTasks(sched)
sched.Start(ctx)
// Installed after the server exists, because both halves of a finished import are
@@ -348,5 +399,3 @@ func openStore(ctx context.Context, databaseURL string, log *slog.Logger) (*stor
}
return nil, lastErr
}
+3
View File
@@ -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))
+41 -2
View File
@@ -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 == "" {
+14 -3
View File
@@ -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.
+131
View File
@@ -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
View File
@@ -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 {
+30
View File
@@ -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")
+39
View File
@@ -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")
}
}
+10 -2
View File
@@ -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{})
+2
View File
@@ -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
}
+14 -5
View File
@@ -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)
+4 -4
View File
@@ -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)
}
}
+6
View File
@@ -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)
+83
View File
@@ -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)
}
}
+2 -2
View File
@@ -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
+23 -3
View File
@@ -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
}
+87 -36
View File
@@ -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 == "" {
+32
View File
@@ -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")
+129
View File
@@ -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.
+186
View File
@@ -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]
}
+160
View File
@@ -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)
}
}
+387
View File
@@ -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
}
+274
View File
@@ -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)
}
}
+189
View File
@@ -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
}
+130
View File
@@ -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")
}
}
+192
View File
@@ -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)
}
+232
View File
@@ -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
}
+162
View File
@@ -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)
}
}
+64
View File
@@ -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()
}
+56
View File
@@ -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
}
+84
View File
@@ -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)
}
+126
View File
@@ -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,
}
}
+179
View File
@@ -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()
}
+143
View File
@@ -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)
}
}
+142
View File
@@ -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
}
+281
View File
@@ -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] + "…"
}
+525
View File
@@ -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
}
+358
View File
@@ -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)
}
}
+145
View File
@@ -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
}
+173
View File
@@ -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)
}
+138
View File
@@ -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 S06E0406, 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:3042: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())
}
}
+15
View File
@@ -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)
+14 -11
View File
@@ -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++ {
+17
View File
@@ -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
+293
View File
@@ -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()
}
+50 -6
View File
@@ -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 {
+44
View File
@@ -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 <> '';
+34 -3
View File
@@ -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:0002: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)
+16
View File
@@ -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 {