0.2.64 update
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user