180 lines
6.3 KiB
Go
180 lines
6.3 KiB
Go
// 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)
|
||
}
|
||
}
|