Files
memby/server/cmd/memby-credits/main.go
T
2026-08-15 09:23:26 +12:00

180 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}
}