0.2.64 update

This commit is contained in:
ponzischeme89
2026-08-15 09:23:26 +12:00
parent a2ca7e8061
commit d5d47473a2
90 changed files with 9188 additions and 451 deletions
+179
View File
@@ -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)
}
}
+51 -2
View File
@@ -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
}