// Command memby-credits is the credits subsystem's bench, run as // // memby-credits benchmark // // 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 ") 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, ) // The bench measures the path the service actually takes, so it must read bytes from the // same address the service reads them from. Benchmarking the public route and deploying // the internal one would report a number belonging to neither. embyClient.SetMediaURL(cfg.EmbyMediaURL) 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) } }