Files

184 lines
6.6 KiB
Go
Raw Permalink Normal View History

2026-08-15 09:23:26 +12:00
// 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,
)
// 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)
2026-08-15 09:23:26 +12:00
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)
}
}