feat(observability): OpenTelemetry logs+traces with secret redaction and slog standardization (#290)
* feat(observability): OpenTelemetry logs+traces with secret redaction Part of #265. Adds opt-in OpenTelemetry (logs + traces) alongside the existing stderr + opslog pipeline, plus secret redaction on all sinks. Default-off: with no OTEL_* / SILO_OTEL_ENABLED config, behavior is unchanged. Bootstrap (internal/telemetry): - Setup() builds one shared resource, a TracerProvider (parent-based trace-id ratio sampler), a LoggerProvider, and the W3C TraceContext+Baggage propagator from env. It installs NO MeterProvider — metrics stay on Prometheus, and the built-in no-op global MeterProvider keeps the trace instrumentation libs from double-emitting. Shutdown is deferred with a flush timeout. - Logs are bridged via otelslog fan-out (slog.MultiHandler), level-gated by the shared LevelVar and best-effort so a failing collector can't break the console or DB branches. stderr + opslog stay untouched. Secret redaction (internal/logredact): - A slog.Handler masks secret-keyed attributes (password, token, api_key, authorization, cookie, ...) — including .With-bound attrs, nested groups, secret-keyed group subtrees, and values behind a LogValuer — on the console and OTLP sinks, with a no-op fast path when a record has no secret keys. opslog.shouldRedact delegates to logredact.SecretKey so all sinks share one marker list. Rotation is infra-managed (no custom file sink): container runtime for stderr, collector/backend for OTLP, opslog partition-pruning for the DB. Documented in docs/architecture/observability.md. Verification: go build ./..., go vet, gofmt -l — clean; go test ./internal/telemetry/ ./internal/logredact/ -race pass. AI-use disclosure: implemented with AI assistance (Claude Code), including adversarial reviews that hardened the bootstrap and fixed two redaction leak paths; reviewed by the author. * refactor(observability): slog context+component sweep, sloglint gate (phase 3) Part of #265. Builds on the OTel bootstrap + redaction commit. Standardizes every log call site onto the context-carrying slog variants so records correlate with the active OpenTelemetry trace, and locks the standard in with a machine gate so future code (human- or AI-authored) can't drift back. - Call-site sweep: converted the remaining slog.<Level>(...) calls to the slog.<Level>Context(ctx, ...) form wherever a context.Context is in scope (background/init calls with no ctx are left as-is), across 183 files. Applied via a type-aware AST codemod. Log levels and message strings are preserved verbatim; a component attr (canonical per-package name) is added to direct package-level slog calls. Bound-logger calls keep their existing .With bindings. The main.go and telemetry package conversions rode with their file in the previous commit to keep each file within a single commit. - Enforcement (.golangci.yml): enable sloglint with context=scope, static-msg, key-naming-case=snake, no-mixed-args. After the sweep all four report zero violations repo-wide (tests included), so make lint / CI now blocks any regression to the non-context form. The gate ships with the sweep because it cannot be green until the legacy sites are converted. Metrics remain on Prometheus; no behavior change to /metrics or Grafana. Verification: go build ./..., go vet ./..., gofmt -l — clean; sloglint (all 4 rules) 0 violations repo-wide; log levels verified unchanged. AI-use disclosure: implemented with AI assistance (Claude Code), including the codemod; reviewed by the author. * fix(observability): honor per-signal OTLP protocol and secret WithGroup names Two Codex review findings on PR #290: - telemetry: OTEL_EXPORTER_OTLP_{TRACES,LOGS}_PROTOCOL now override the generic OTEL_EXPORTER_OTLP_PROTOCOL per signal, so mixed collector setups (e.g. HTTP logs + gRPC traces) build the right exporter. - logredact: entering a group whose name is secret-bearing (e.g. WithGroup("authorization")) now masks every leaf in that subtree, matching how slog.Group("authorization", ...) is masked as a whole. * fix(observability): address review feedback on telemetry bootstrap - Telemetry setup failure no longer kills boot: Setup returns usable no-op providers alongside the error and main logs and continues with telemetry disabled, honoring the best-effort contract. - Honor OTEL_TRACES_SAMPLER (always_on/off, traceidratio, parentbased_* variants); unsupported values fall back to parentbased_traceidratio. - Attach node identity as semconv service.instance.id instead of the non-semconv node.name. - Rename opslog retention-scope log attrs to target_component/target_level so they no longer collide with the canonical component routing key, and tag those lines with component=opslog. - Fix stale levelGated comment casing; use WarnContext in the telemetry shutdown defer; document the LogValuer double-resolve on the redaction slow path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
co-authored by
Quick
Claude Fable 5
parent
76b10b822e
commit
203a18ae83
@@ -23,12 +23,27 @@ linters:
|
||||
- ineffassign
|
||||
- misspell
|
||||
- nilerr
|
||||
- sloglint
|
||||
- staticcheck
|
||||
- unconvert
|
||||
- unused
|
||||
- wastedassign
|
||||
|
||||
settings:
|
||||
sloglint:
|
||||
# Require the context-carrying slog variants (InfoContext, …) wherever a
|
||||
# context.Context is in scope, so log records correlate with the active
|
||||
# OpenTelemetry trace. "scope" (not "all") allows the plain Info/… form in
|
||||
# background/init code where no ctx exists. Enforced repo-wide after the
|
||||
# phase-3 sweep converted the ~1,159 legacy call sites.
|
||||
context: "scope"
|
||||
# Message must be a constant, not a built string; move dynamic parts to attrs.
|
||||
static-msg: true
|
||||
# Attribute keys are snake_case (component, request_id, trace_id, …).
|
||||
key-naming-case: snake
|
||||
# Don't mix key-value pairs and slog.Attr in one call.
|
||||
no-mixed-args: true
|
||||
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
|
||||
|
||||
+75
-32
@@ -60,6 +60,7 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/libraryingest"
|
||||
"github.com/Silo-Server/silo-server/internal/literaryworks"
|
||||
"github.com/Silo-Server/silo-server/internal/logfilter"
|
||||
"github.com/Silo-Server/silo-server/internal/logredact"
|
||||
"github.com/Silo-Server/silo-server/internal/logstream"
|
||||
"github.com/Silo-Server/silo-server/internal/mail"
|
||||
"github.com/Silo-Server/silo-server/internal/manga"
|
||||
@@ -93,6 +94,7 @@ import (
|
||||
taskrepository "github.com/Silo-Server/silo-server/internal/taskmanager/repository"
|
||||
"github.com/Silo-Server/silo-server/internal/taskmanager/tasks"
|
||||
"github.com/Silo-Server/silo-server/internal/taskmanager/triggers"
|
||||
"github.com/Silo-Server/silo-server/internal/telemetry"
|
||||
"github.com/Silo-Server/silo-server/internal/transcodenode"
|
||||
"github.com/Silo-Server/silo-server/internal/usercollections"
|
||||
"github.com/Silo-Server/silo-server/internal/userdb"
|
||||
@@ -130,12 +132,26 @@ func resolvePluginCacheDir() string {
|
||||
return filepath.Join(os.TempDir(), "silo-plugins")
|
||||
}
|
||||
|
||||
func buildBaseHandler(format string, level slog.Leveler) slog.Handler {
|
||||
func buildBaseHandler(format string, level slog.Leveler, otelHandler slog.Handler) slog.Handler {
|
||||
opts := &slog.HandlerOptions{Level: level}
|
||||
var console slog.Handler
|
||||
if strings.EqualFold(format, "json") {
|
||||
return slog.NewJSONHandler(os.Stderr, opts)
|
||||
console = slog.NewJSONHandler(os.Stderr, opts)
|
||||
} else {
|
||||
console = slog.NewTextHandler(os.Stderr, opts)
|
||||
}
|
||||
return slog.NewTextHandler(os.Stderr, opts)
|
||||
if otelHandler == nil {
|
||||
// Redact secrets before they reach stderr (the opslog DB path redacts
|
||||
// separately when flattening rows).
|
||||
return logredact.New(console)
|
||||
}
|
||||
// Fan out to the console and the OTel bridge. The OTel branch is level-gated
|
||||
// by the shared level var so console and OTLP share one verbosity knob (see
|
||||
// telemetry.LevelGated) — otherwise slog.MultiHandler.Enabled would OR the
|
||||
// branches and export Debug records while stderr stays silent. The whole
|
||||
// fan-out is wrapped in secret redaction so console and OTLP both emit
|
||||
// masked output (the opslog DB path redacts separately).
|
||||
return logredact.New(telemetry.FanOut(console, telemetry.LevelGated(otelHandler, level)))
|
||||
}
|
||||
|
||||
func parseLogLevel(level string) slog.Level {
|
||||
@@ -178,7 +194,7 @@ func configureOperationalLogging(
|
||||
// Non-fatal: a partition hiccup must not crash-loop the server (see the
|
||||
// operational_logs partition incident). Writes fall back to the default
|
||||
// partition and the periodic cleanup retries EnsureFuturePartitions.
|
||||
slog.Warn("ensure operational log partitions; continuing in degraded mode", "error", err)
|
||||
slog.WarnContext(ctx, "ensure operational log partitions; continuing in degraded mode", "component", "app", "error", err)
|
||||
}
|
||||
|
||||
var operationalWriter opslog.Writer
|
||||
@@ -221,7 +237,7 @@ func maybeApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, appMaxCon
|
||||
|
||||
opts, err := database.LoadPostgresTuneOptionsFromEnv(appMaxConnections)
|
||||
if err != nil {
|
||||
slog.Warn("postgres auto-tuning disabled", "error", err)
|
||||
slog.WarnContext(ctx, "postgres auto-tuning disabled", "component", "app", "error", err)
|
||||
return
|
||||
}
|
||||
if !opts.Enabled {
|
||||
@@ -233,14 +249,14 @@ func maybeApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, appMaxCon
|
||||
|
||||
result, err := database.ApplyPostgresTuning(tuneCtx, pool, opts)
|
||||
for _, failure := range result.Failures {
|
||||
slog.Warn("postgres auto-tuning setting failed",
|
||||
slog.WarnContext(ctx, "postgres auto-tuning setting failed", "component", "app",
|
||||
"name", failure.Name,
|
||||
"value", failure.Value,
|
||||
"error", failure.Err,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("postgres auto-tuning failed",
|
||||
slog.WarnContext(ctx, "postgres auto-tuning failed", "component", "app",
|
||||
"error", err,
|
||||
"applied", result.Applied,
|
||||
"failures", len(result.Failures),
|
||||
@@ -248,7 +264,7 @@ func maybeApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, appMaxCon
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("postgres auto-tuning applied",
|
||||
slog.InfoContext(ctx, "postgres auto-tuning applied", "component", "app",
|
||||
"profile", opts.Profile,
|
||||
"postgres_major", result.PostgresMajorVersion,
|
||||
"settings", result.Applied,
|
||||
@@ -265,12 +281,12 @@ func maybeApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, appMaxCon
|
||||
"database_size_bytes", result.DatabaseSizeBytes,
|
||||
)
|
||||
if len(result.RestartRequired) > 0 {
|
||||
slog.Warn("postgres restart required to finish applying auto-tuned settings",
|
||||
slog.WarnContext(ctx, "postgres restart required to finish applying auto-tuned settings", "component", "app",
|
||||
"settings", strings.Join(result.RestartRequired, ","),
|
||||
)
|
||||
}
|
||||
if len(result.Reset) > 0 {
|
||||
slog.Info("postgres auto-tuning reset stale settings",
|
||||
slog.InfoContext(ctx, "postgres auto-tuning reset stale settings", "component", "app",
|
||||
"settings", strings.Join(result.Reset, ","),
|
||||
)
|
||||
}
|
||||
@@ -285,25 +301,25 @@ func maybeApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, appMaxCon
|
||||
func runCredentialBackfills(ctx context.Context, pool *pgxpool.Pool, cipher *secret.Cipher, settings *catalog.EncryptedSettingsRepo) {
|
||||
settingsN, err := settings.BackfillSensitiveSettings(ctx)
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: sensitive settings", "error", err)
|
||||
slog.ErrorContext(ctx, "secret backfill: sensitive settings", "component", "app", "error", err)
|
||||
}
|
||||
columnsN, err := secret.BackfillColumns(ctx, pool, cipher, secret.ColumnBackfillTargets())
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: credential columns", "error", err)
|
||||
slog.ErrorContext(ctx, "secret backfill: credential columns", "component", "app", "error", err)
|
||||
}
|
||||
historyServersN, err := historyimport.NewRepository(pool, cipher).BackfillSessionServerSecrets(ctx)
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: history import session server credentials", "error", err)
|
||||
slog.ErrorContext(ctx, "secret backfill: history import session server credentials", "component", "app", "error", err)
|
||||
}
|
||||
// The arr resolver is the encrypting settings decorator: it decrypts a
|
||||
// sensitive target (e.g. requests.radarr.api_key) or passes through a
|
||||
// plaintext custom key, exactly replicating the deleted resolveAPIKey.
|
||||
arrN, err := secret.BackfillReferencedColumns(ctx, pool, cipher, settings.Get, secret.ArrKeyBackfillTargets())
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: arr api keys", "error", err)
|
||||
slog.ErrorContext(ctx, "secret backfill: arr api keys", "component", "app", "error", err)
|
||||
}
|
||||
if total := settingsN + columnsN + historyServersN + arrN; total > 0 {
|
||||
slog.Info("secret backfill: encrypted plaintext credentials at rest",
|
||||
slog.InfoContext(ctx, "secret backfill: encrypted plaintext credentials at rest", "component", "app",
|
||||
"settings", settingsN, "columns", columnsN, "history_session_servers", historyServersN, "arr_keys", arrN, "total", total)
|
||||
}
|
||||
}
|
||||
@@ -541,7 +557,34 @@ func main() {
|
||||
// the config watcher in integrated mode.
|
||||
logLevelVar := new(slog.LevelVar)
|
||||
logLevelVar.Set(parseLogLevel(cfg.Server.LogLevel))
|
||||
baseHandler := buildBaseHandler(cfg.Server.LogFormat, logLevelVar)
|
||||
|
||||
// Bootstrap OpenTelemetry (logs + traces) before installing the log handler
|
||||
// chain. Setup depends only on OTEL_* / SILO_OTEL_ENABLED env (not the DB),
|
||||
// so it is safe to call here. When disabled, this is fully dormant: no
|
||||
// providers are installed and telemetryShutdown is a no-op.
|
||||
telemetryCfg := telemetry.LoadConfig(nodeID)
|
||||
telemetryProviders, telemetryShutdown, err := telemetry.Setup(ctx, telemetryCfg)
|
||||
if err != nil {
|
||||
// Telemetry is best-effort: a malformed OTEL_* environment must not
|
||||
// crash-loop the server. Setup installed no globals and returned no-op
|
||||
// providers, so continue with telemetry disabled.
|
||||
slog.ErrorContext(ctx, "telemetry setup failed; continuing with telemetry disabled", "component", "app", "error", err)
|
||||
telemetryCfg.Enabled = false
|
||||
}
|
||||
defer func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := telemetryShutdown(shutdownCtx); err != nil {
|
||||
slog.WarnContext(shutdownCtx, "telemetry shutdown error", "component", "app", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var otelLogHandler slog.Handler
|
||||
if telemetryCfg.Enabled {
|
||||
otelLogHandler = telemetry.NewOTelHandler(telemetryProviders.LoggerProvider)
|
||||
}
|
||||
|
||||
baseHandler := buildBaseHandler(cfg.Server.LogFormat, logLevelVar, otelLogHandler)
|
||||
quietFilter := logfilter.New(baseHandler, cfg.Server.LogQuiet)
|
||||
slog.SetDefault(slog.New(quietFilter))
|
||||
|
||||
@@ -576,7 +619,7 @@ func main() {
|
||||
if mode == "proxy" || mode == "transcode" {
|
||||
redisClient, err := cache.NewRedisClient(cfg.Redis)
|
||||
if err != nil || redisClient == nil {
|
||||
slog.Error("redis is required for "+mode+" mode", "error", err)
|
||||
slog.Error("redis is required for this mode", "mode", mode, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -715,7 +758,7 @@ func main() {
|
||||
// must not be skipped because the admin request was canceled.
|
||||
if key == clientip.SettingTrustedProxies && ipResolver != nil {
|
||||
if cidrs, loadErr := clientip.LoadTrustedCIDRs(context.Background(), settingsRepo); loadErr != nil {
|
||||
slog.Warn("clientip config reload failed", "error", loadErr)
|
||||
slog.WarnContext(context.Background(), "clientip config reload failed", "component", "app", "error", loadErr)
|
||||
} else {
|
||||
ipResolver.UpdateTrustedCIDRs(cidrs)
|
||||
}
|
||||
@@ -1007,7 +1050,7 @@ func main() {
|
||||
settingsRepo,
|
||||
markerPluginResolver,
|
||||
); err != nil {
|
||||
slog.Warn("reload marker plugin providers failed", "error", err)
|
||||
slog.WarnContext(ctx, "reload marker plugin providers failed", "component", "app", "error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1116,7 +1159,7 @@ func main() {
|
||||
if pluginService != nil && pluginInstallationStore != nil {
|
||||
reloadImageResolvers := func(ctx context.Context) {
|
||||
if err := reloadPluginImageResolvers(ctx, pluginInstallationStore, imageResolver, pluginService); err != nil {
|
||||
slog.Warn("failed to reload plugin image resolvers", "error", err)
|
||||
slog.WarnContext(ctx, "failed to reload plugin image resolvers", "component", "app", "error", err)
|
||||
}
|
||||
}
|
||||
pluginService.AddLifecycleHook(reloadImageResolvers)
|
||||
@@ -1267,9 +1310,9 @@ func main() {
|
||||
matchWorker.SetSeriesRootClaimer(seriesQueueRepo, cfg.Matcher.TVSeriesRootQueueEnabled())
|
||||
backgroundInit = append(backgroundInit, func(ctx context.Context) {
|
||||
if cleaned, err := seriesQueueRepo.CleanupLegacySeriesGroupQueue(ctx); err != nil {
|
||||
slog.Warn("failed to clean legacy series group queue rows", "error", err)
|
||||
slog.WarnContext(ctx, "failed to clean legacy series group queue rows", "component", "app", "error", err)
|
||||
} else if cleaned > 0 {
|
||||
slog.Info("cleaned legacy series group queue rows", "count", cleaned)
|
||||
slog.InfoContext(ctx, "cleaned legacy series group queue rows", "component", "app", "count", cleaned)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1278,7 +1321,7 @@ func main() {
|
||||
start := time.Now()
|
||||
enabledFolders, err := deps.FolderRepo.GetEnabled(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("failed to seed metadata queues", "error", err)
|
||||
slog.WarnContext(ctx, "failed to seed metadata queues", "component", "app", "error", err)
|
||||
return
|
||||
}
|
||||
seedMovieQueue := func(folderID int) {
|
||||
@@ -1286,7 +1329,7 @@ func main() {
|
||||
return
|
||||
}
|
||||
if err := movieQueueRepo.SyncForFolder(ctx, folderID); err != nil {
|
||||
slog.Warn("failed to seed movie match queue", "folder_id", folderID, "error", err)
|
||||
slog.WarnContext(ctx, "failed to seed movie match queue", "component", "app", "folder_id", folderID, "error", err)
|
||||
}
|
||||
}
|
||||
seedSeriesQueue := func(folderID int) {
|
||||
@@ -1294,7 +1337,7 @@ func main() {
|
||||
return
|
||||
}
|
||||
if err := seriesQueueRepo.SyncForFolder(ctx, folderID); err != nil {
|
||||
slog.Warn("failed to seed series root queue", "folder_id", folderID, "error", err)
|
||||
slog.WarnContext(ctx, "failed to seed series root queue", "component", "app", "folder_id", folderID, "error", err)
|
||||
}
|
||||
}
|
||||
for _, folder := range enabledFolders {
|
||||
@@ -1311,7 +1354,7 @@ func main() {
|
||||
seedMovieQueue(folder.ID)
|
||||
}
|
||||
}
|
||||
slog.Info("deferred init: metadata match queues seeded", "folders", len(enabledFolders), "duration", time.Since(start))
|
||||
slog.InfoContext(ctx, "deferred init: metadata match queues seeded", "component", "app", "folders", len(enabledFolders), "duration", time.Since(start))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1539,7 +1582,7 @@ func main() {
|
||||
WithUserStoreProvider(userStoreProvider)
|
||||
backgroundInit = append(backgroundInit, func(ctx context.Context) {
|
||||
if err := watchProviderService.SweepOpenScrobbles(ctx); err != nil {
|
||||
slog.Warn("failed to sweep open watch provider scrobbles", "error", err)
|
||||
slog.WarnContext(ctx, "failed to sweep open watch provider scrobbles", "component", "app", "error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1655,7 +1698,7 @@ func main() {
|
||||
}
|
||||
cidrs, loadErr := clientip.LoadTrustedCIDRs(context.Background(), settingsRepo)
|
||||
if loadErr != nil {
|
||||
slog.Warn("clientip config reload failed", "error", loadErr)
|
||||
slog.WarnContext(context.Background(), "clientip config reload failed", "component", "app", "error", loadErr)
|
||||
return
|
||||
}
|
||||
ipResolver.UpdateTrustedCIDRs(cidrs)
|
||||
@@ -1672,7 +1715,7 @@ func main() {
|
||||
}
|
||||
cidrs, parseErr := clientip.ParseCIDRs(raw)
|
||||
if parseErr != nil {
|
||||
slog.Warn("clientip config reload failed", "error", parseErr)
|
||||
slog.WarnContext(context.Background(), "clientip config reload failed", "component", "app", "error", parseErr)
|
||||
return
|
||||
}
|
||||
ipResolver.UpdateTrustedCIDRs(cidrs)
|
||||
@@ -2763,7 +2806,7 @@ func reloadPluginImageResolvers(
|
||||
case sdkcapability.ImageResolver:
|
||||
schemes, priority := imageResolverCapabilityConfig(capability)
|
||||
if len(schemes) == 0 {
|
||||
slog.Warn("plugin image resolver capability has no valid schemes",
|
||||
slog.WarnContext(ctx, "plugin image resolver capability has no valid schemes", "component", "app",
|
||||
"installation_id", installation.ID,
|
||||
"capability_id", capability.ID)
|
||||
continue
|
||||
@@ -2786,7 +2829,7 @@ func reloadPluginImageResolvers(
|
||||
case sdkcapability.MetadataProvider:
|
||||
scheme := strings.TrimSpace(capability.ID)
|
||||
if !metadata.ValidImageResolverScheme(scheme) {
|
||||
slog.Warn("skipping legacy metadata image resolver with invalid scheme",
|
||||
slog.WarnContext(ctx, "skipping legacy metadata image resolver with invalid scheme", "component", "app",
|
||||
"installation_id", installation.ID,
|
||||
"capability_id", capability.ID)
|
||||
continue
|
||||
@@ -2808,7 +2851,7 @@ func reloadPluginImageResolvers(
|
||||
}
|
||||
|
||||
resolver.ReplaceSources(registrations)
|
||||
slog.Info("reloaded plugin image resolvers", "sources", len(registrations))
|
||||
slog.InfoContext(ctx, "reloaded plugin image resolvers", "component", "app", "sources", len(registrations))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# Observability (OpenTelemetry logs + traces)
|
||||
|
||||
Silo emits structured **logs** and distributed **traces** via OpenTelemetry (OTLP),
|
||||
in addition to the existing stderr and `opslog` database pipeline. The feature is
|
||||
**opt-in and default-off**: with no `OTEL_*` / `SILO_OTEL_ENABLED` configuration the
|
||||
server behaves exactly as before (stderr + `opslog` only).
|
||||
|
||||
**Metrics are not part of OpenTelemetry here.** They remain on Prometheus
|
||||
(`client_golang`, the `/metrics` endpoint, and the domain metrics in
|
||||
`internal/api/middleware`). See [Metrics](#metrics-stay-on-prometheus) below.
|
||||
|
||||
## Enabling it
|
||||
|
||||
Telemetry turns on when **either** `SILO_OTEL_ENABLED` is truthy **or**
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT` is set.
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `SILO_OTEL_ENABLED` | Master gate (`1`/`true`/`yes`/`on`). | off |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Collector endpoint; also implicitly enables telemetry. | — |
|
||||
| `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc` (default) or `http/protobuf`. | `grpc` |
|
||||
| `OTEL_SERVICE_NAME` | `service.name` resource attribute. | `silo-server` |
|
||||
| `OTEL_SERVICE_VERSION` | `service.version` resource attribute. | unset |
|
||||
| `OTEL_TRACES_SAMPLER` | `always_on`, `always_off`, `traceidratio`, `parentbased_always_on`, `parentbased_always_off`, or `parentbased_traceidratio`. Unsupported values (e.g. `jaeger_remote`) fall back to the default. | `parentbased_traceidratio` |
|
||||
| `OTEL_TRACES_SAMPLER_ARG` | Trace-id ratio for the ratio-based samplers (0–1; clamps >1 to 1). | `1.0` |
|
||||
|
||||
The node identity is attached as the `service.instance.id` resource attribute, so
|
||||
multiple Silo nodes sharing one `service.name` stay distinguishable in the backend.
|
||||
|
||||
All other `OTEL_EXPORTER_OTLP_*` knobs (headers, TLS, per-signal endpoints) are read
|
||||
directly from the environment by the OTLP exporters — the environment is the single
|
||||
source of truth for exporter wiring.
|
||||
|
||||
The endpoint/exporter connection is lazy and non-blocking: an unreachable collector
|
||||
does **not** delay or crash startup. Setup failure is also non-fatal: if the `OTEL_*`
|
||||
environment is malformed (e.g. an unparseable endpoint URL), the server logs an error
|
||||
and keeps running with telemetry disabled rather than crash-looping.
|
||||
|
||||
## Architecture
|
||||
|
||||
The bootstrap lives in `internal/telemetry` (`Setup` in `telemetry.go`). When enabled
|
||||
it builds one shared `resource.Resource`, a `TracerProvider` (sampler per
|
||||
`OTEL_TRACES_SAMPLER`, batched OTLP exporter), a `LoggerProvider` (batched OTLP exporter), and the W3C
|
||||
`TraceContext + Baggage` propagator. Shutdown is deferred in `cmd/silo/main.go` with a
|
||||
flush timeout so buffered spans/logs drain on exit.
|
||||
|
||||
### Log handler chain
|
||||
|
||||
Logs are bridged, not rerouted. The OTel `otelslog` handler is added to the existing
|
||||
`slog` handler chain via **fan-out**, so every record still reaches stderr and the
|
||||
`opslog` DB/admin-stream pipeline unchanged:
|
||||
|
||||
```
|
||||
slog.<Level>Context(ctx, …)
|
||||
→ opslog.Handler (DB capture + admin stream, when level ≥ capture)
|
||||
→ logfilter.Handler (drops quieted subsystem prefixes)
|
||||
→ slog.MultiHandler
|
||||
├── stderr (json/text)
|
||||
└── otelslog → OTLP (level-gated + best-effort)
|
||||
```
|
||||
|
||||
Three properties matter:
|
||||
|
||||
- **Level-gated.** `slog.MultiHandler.Enabled` ORs its children, so the OTel branch is
|
||||
wrapped in a level gate bound to the shared `LevelVar`; console and OTLP share one
|
||||
verbosity knob. Without this, Debug records would be built and exported even at
|
||||
`log_level=info`.
|
||||
- **Best-effort.** The OTel branch never propagates an export error, so a failing
|
||||
collector cannot break the console or DB branches.
|
||||
- **Redacted.** The whole fan-out is wrapped in `internal/logredact`, so console and OTLP
|
||||
emit secret-masked output. Redaction is key-based (`password`, `secret`, `token`,
|
||||
`api_key`, `authorization`, `cookie`, …); a matching attribute's value becomes
|
||||
`[REDACTED]`, including attrs bound via `.With(...)` and nested groups. The marker list
|
||||
is shared with the opslog DB path (`opslog.shouldRedact` → `logredact.SecretKey`) so all
|
||||
sinks agree. Limitation: values are not scanned, so a secret embedded in a free-text
|
||||
message or under a non-secret key is not caught.
|
||||
|
||||
## Logging conventions (enforced)
|
||||
|
||||
Use the **context-carrying** slog variants and tag the subsystem:
|
||||
|
||||
```go
|
||||
slog.InfoContext(ctx, "scanner: starting", "component", "scanner", "folder_id", id)
|
||||
```
|
||||
|
||||
Rules, enforced by `sloglint` in `make lint` (`.golangci.yml`):
|
||||
|
||||
- **`slog.<Level>Context(ctx, …)`** wherever a `context.Context` is in scope (so records
|
||||
carry the active `trace_id`/`span_id`). The plain `slog.Info(…)` form is only allowed
|
||||
where no `ctx` exists (early boot, top-level goroutines).
|
||||
- **Static message** — the message is a constant; move dynamic parts to attributes.
|
||||
- **snake_case attribute keys** — `component`, `request_id`, `trace_id`, `user_id`, …
|
||||
- **No mixed args** — don't combine key-value pairs and `slog.Attr` in one call.
|
||||
|
||||
### Component classification
|
||||
|
||||
Every direct `slog.*Context` call carries a `component` attribute. `opslog` classifies
|
||||
records by that attribute first, falling back to `opslog.InferComponent` (the `subsystem:`
|
||||
prefix in the message) when no attribute is present — bound loggers that already set
|
||||
`component` via `.With(...)` are left as-is.
|
||||
|
||||
**Limitation:** `sloglint` enforces the *shape* (context variant, snake keys, static
|
||||
message) but **cannot** enforce that a `component` attribute is present. That convention
|
||||
rests on this doc, the canonical list below, and review.
|
||||
|
||||
Canonical component values (first path segment under `internal/`; `app` for `cmd/silo`):
|
||||
|
||||
`activitylog`, `adminjob`, `ai`, `api`, `app`, `audiobooks`, `auth`, `autoscan`,
|
||||
`catalog`, `chapterthumbs`, `downloads`, `ebooks`, `historyimport`, `jellycompat`,
|
||||
`libraryingest`, `manga`, `metadata`, `nodeconfig`, `nodepool`, `noderecipe`,
|
||||
`nodesessions`, `notifications`, `opslog`, `playback`, `plugins`, `policy`, `proxy`, `ratelimit`,
|
||||
`recommendations`, `requests`, `scanner`, `scanqueue`, `sections`, `taskmanager`,
|
||||
`telemetry`, `transcodenode`, `watchlist`, `watchsync`, `webhooksync`, `worker`.
|
||||
|
||||
## Metrics stay on Prometheus
|
||||
|
||||
OpenTelemetry here installs **no MeterProvider**. Metrics continue to flow through the
|
||||
existing Prometheus `client_golang` instrumentation to `/metrics`; Grafana scraping is
|
||||
unaffected.
|
||||
|
||||
This is deliberate and guarded: the trace-instrumentation libraries (`otelhttp`, etc.,
|
||||
added in later phases) also emit metrics through the *global* MeterProvider. Because none
|
||||
is set, that global stays the built-in **no-op**, so those metric calls are silently
|
||||
discarded — no double-counting into Prometheus, no second `/metrics` source. A guard test
|
||||
asserts `otel.GetMeterProvider()` remains the no-op after `Setup`. Migrating metrics to
|
||||
the OTel metrics SDK is out of scope.
|
||||
|
||||
## Local collector (example)
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (dev)
|
||||
otel-collector:
|
||||
image: otel/opentelemetry-collector:latest
|
||||
command: ["--config=/etc/otelcol/config.yaml"]
|
||||
volumes:
|
||||
- ./otelcol.yaml:/etc/otelcol/config.yaml
|
||||
ports:
|
||||
- "4317:4317" # OTLP gRPC
|
||||
- "4318:4318" # OTLP HTTP
|
||||
```
|
||||
|
||||
```yaml
|
||||
# otelcol.yaml — minimal debug pipeline
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc: {}
|
||||
http: {}
|
||||
exporters:
|
||||
debug:
|
||||
verbosity: detailed
|
||||
service:
|
||||
pipelines:
|
||||
traces: { receivers: [otlp], exporters: [debug] }
|
||||
logs: { receivers: [otlp], exporters: [debug] }
|
||||
```
|
||||
|
||||
Run Silo with `SILO_OTEL_ENABLED=1 OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317` and watch
|
||||
logs + traces arrive at the collector. Swap `debug` for Loki/Tempo (or a vendor OTLP
|
||||
endpoint) for real storage; Grafana can then show traces alongside the unchanged
|
||||
Prometheus metrics.
|
||||
|
||||
## Log retention & rotation
|
||||
|
||||
Silo does **not** write or rotate its own log files. Rotation/retention is handled by the
|
||||
layer that owns each sink, which is the intended cloud-native split:
|
||||
|
||||
- **Console (stderr)** is owned by the container runtime. Under Docker, configure the
|
||||
`json-file` driver to cap and rotate on-disk logs, and mount the log directory on a
|
||||
volume so history survives container recreation (the problem that motivated this work):
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
silo:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m" # rotate at 50 MB
|
||||
max-file: "5" # keep 5 rotated files
|
||||
```
|
||||
|
||||
For a non-Docker deployment, run under a supervisor/journald or pipe to `logrotate`.
|
||||
|
||||
- **OTLP** is the durable, queryable path: the collector/backend (Loki, a vendor, …) owns
|
||||
retention and is the recommended place to keep searchable history. Enable it (see above)
|
||||
and set retention on the backend.
|
||||
|
||||
- **opslog (Postgres)** self-prunes: daily partitions with per-component retention and
|
||||
size caps (`internal/opslog/cleanup.go`). No operator action needed.
|
||||
|
||||
An in-process rotating file sink (e.g. lumberjack) was deliberately **not** added — it
|
||||
would re-introduce a custom sink the OTLP + runtime split already covers.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **`log_quiet` also suppresses OTel logs.** The fan-out sits below the quiet filter, so
|
||||
quieting a subsystem empties its OTLP stream too (OTLP mirrors the console stream).
|
||||
- **Redaction is key-based, not value-based.** Secrets under a recognized key are masked
|
||||
on every sink, but a secret embedded in a message string or under an unrecognized key is
|
||||
not caught (see [Redacted](#log-handler-chain)).
|
||||
- **Early-boot logs are not exported.** Records emitted before the handler is installed
|
||||
(DB connect, migrations, tuning) reach stderr only, matching existing `opslog` behavior.
|
||||
- **Per-subsystem trace propagation into plugins** is a follow-up owned by
|
||||
`silo-plugin-sdk`; this repo instruments only the host side.
|
||||
@@ -0,0 +1,252 @@
|
||||
# OpenTelemetry Observability: Logs + Traces Adoption Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Commands assume the repository root is the cwd unless a task explicitly says otherwise.
|
||||
|
||||
**Goal:** Adopt OpenTelemetry for **logs and traces** (metrics explicitly deferred), so Silo emits OTLP-exportable structured logs correlated with distributed traces, without discarding the existing stderr + `opslog` DB/admin-stream pipeline. Standardize all `slog` call sites so every log record can carry `trace_id`/`span_id` when a span is active.
|
||||
|
||||
**Architecture:** Add a single OTel SDK bootstrap module (`internal/telemetry`) that builds a shared `resource.Resource`, a `TracerProvider`, and a `LoggerProvider` from `OTEL_*` env config, with graceful shutdown wired into the existing signal path. Bridge logs by inserting an `otelslog` handler into the existing `slog.Handler` chain via **fan-out** (`slog.MultiHandler`, stdlib in Go 1.26) — the current stderr + `opslog` sinks are untouched; OTel receives the same stream. Standardize the ~1,200 `slog.*` call sites to `slog.*Context(ctx, …)` and to explicit `component` attrs (retiring the `subsystem:` message-prefix convention as the classification signal, while keeping `opslog.InferComponent` as a backward-compatible fallback). Add tracing at the natural seams: chi HTTP middleware, `pgx` pool tracer, `go-redis` hook, outbound `http.Client` transports, host-side plugin gRPC stats handlers, and domain spans for scanner/playback/taskmanager. W3C `traceparent` propagation ties main-server → transcode-node and → plugin-subprocess traces together.
|
||||
|
||||
**Tech Stack:** Go 1.26, `log/slog`, chi/v5, pgx/v5 + pgxpool, go-redis/v9, gRPC, PostgreSQL. New deps: `go.opentelemetry.io/otel` (+ `sdk`, `sdk/trace`, `sdk/log`), OTLP exporters (`otlptracegrpc`/`otlptracehttp`, `otlploggrpc`/`otlploghttp`), `go.opentelemetry.io/contrib/bridges/otelslog`, `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp`, `go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc`, `github.com/redis/go-redis/extra/redisotel/v9`, and a pgx tracer (`github.com/exaring/otelpgx`).
|
||||
|
||||
**Non-goals (explicit):**
|
||||
- **Metrics stay on Prometheus — and must not break.** The existing `client_golang` instrumentation (`/metrics` on the dedicated `metricsMux` at `cmd/silo/main.go:2078`, `internal/api/middleware/metrics.go`, `internal/api/middleware/domain_metrics.go`) is **not** migrated to the OTel metrics SDK in this plan. Prometheus and OTel run on **separate, parallel rails**: Prometheus → `/metrics` → Grafana scrape (unchanged), OTel → OTLP → traces/logs backend. See the "Prometheus / Grafana coexistence" section below for the one footgun that must be guarded.
|
||||
- **Plugin-SDK-side instrumentation is out of repo.** Only host-side gRPC handler options live here; the plugin subprocess side is owned by `silo-plugin-sdk` and is a coordination follow-up.
|
||||
- **Log redaction now covers all sinks.** A shared `internal/logredact` handler masks secret-keyed attrs on console + OTLP (opslog DB path already redacted); `opslog.shouldRedact` delegates to `logredact.SecretKey` so the marker list is single-sourced. (Superseded the earlier "OTel output is raw" risk.)
|
||||
|
||||
---
|
||||
|
||||
## Validated Findings
|
||||
|
||||
Established by codebase research on the `feat/opentelemetry-plan` branch (based off `origin/main` @ `5172155a`).
|
||||
|
||||
### Logging
|
||||
- The `slog.Handler` chain is assembled in two stages. Base handler is `slog.NewJSONHandler`/`NewTextHandler(os.Stderr, …)` in `buildBaseHandler` (`cmd/silo/main.go:132-138`), gated by a shared `*slog.LevelVar` (`cmd/silo/main.go:540-544`).
|
||||
- `internal/logfilter/handler.go` wraps the base handler to drop records whose message has a configured `prefix:` (hot-reloadable `SetQuiet`). Installed as the first default logger at `cmd/silo/main.go:544-545`.
|
||||
- Once Postgres/settings exist, `configureOperationalLogging` (`cmd/silo/main.go:169-211`) re-wraps: `slog.New(opslog.NewHandler(filteredHandler, operationalWriter, opsCaptureLevel, nodeID))` (`cmd/silo/main.go:209`).
|
||||
- `opslog.Handler.Handle` (`internal/opslog/handler.go:33-93`) forwards to `inner` first, then (if level ≥ capture) flattens attrs, redacts secret-ish keys (`internal/opslog/handler.go:188-196`), and writes an `opslog.Entry` to a `Writer` (Redis list or in-memory chan → `Consumer` → partitioned `operational_logs` table → `logstream.Hub` admin stream).
|
||||
- **Component classification**: explicit `component` attr, else `inferComponent(message)` splitting on first `:` (`internal/opslog/handler.go:51-54,166-174`), default `"app"`. Correlation attrs already extracted if present: `request_id`, `session_id`, `playback_session_id`, `client_ip`/`remote_addr`, `node_id`, `user_id`.
|
||||
- **~1,211 `slog.(Info|Warn|Error|Debug)(` call sites** across `internal/`. Heaviest: `internal/api/handlers/libraries.go` (83), `internal/metadata/service.go` (70), `internal/api/handlers/playback.go` (60), `internal/metadata/worker.go` (45), `internal/recommendations/worker.go` (43), `internal/scanner/scanner.go` (40), `internal/adminjob/runner.go` (37), `internal/api/handlers/plugins.go` (35). Dominant idiom is the bare package-level `slog.Info("subsystem: msg", "k", v)` (no context). A few subsystems bind `slog.Default().With("component", …)` on a struct field (`internal/notifications/*`, `internal/playback/ffmpeg_log_sink.go`, `internal/api/middleware/request_logger.go`).
|
||||
- **No `trace_id`/`span_id` concept exists** anywhere yet. No automatic context→attr bridging; each call passes correlation attrs manually.
|
||||
|
||||
### Instrumentation seams
|
||||
- Router is chi/v5. Main middleware stack (`internal/api/router.go:209-231`): `RequestID` (`router.go:213`) → `clientip.Middleware` → `apimw.RequestLogger` (`internal/api/middleware/request_logger.go:19`) → `Recoverer` → `apimw.Metrics` (`internal/api/middleware/metrics.go:35`) → `Compress`. `request_logger.go:53-70` already logs `request_id`/`user_id`/`session_id`/`playback_session_id`. `metrics.go:79-89` has `sanitizePath` (solves `http.route` cardinality).
|
||||
- jellycompat has its own chi router mounting `middleware.RequestID` (`internal/jellycompat/router.go:30`). `internal/transcodenode/server.go` is a standalone chi server in a separate process.
|
||||
- Postgres pool built at `internal/database/postgres.go:15` via `pgxpool.ParseConfig`; no `Tracer` set (pgx v5 `QueryTracer` seam).
|
||||
- Redis clients built at `internal/cache/redis.go:145-254` (two `redis.NewClient` sites); go-redis v9 `AddHook`/`redisotel` seam.
|
||||
- Outbound `http.Client` literals (not a shared factory): `internal/nodepool/health.go:22-26`, metadata providers (`internal/metadata/tmdb`, `.../trakt`, `.../translation`, `internal/mdblist`), subtitle providers (`internal/subtitles/opensubtitles`, `subdl`, `subsource`), plugin HTTP proxy (`internal/plugins.HTTPProxy`, wired `router.go:149`).
|
||||
- Host-side plugin gRPC server: `grpc.NewServer(opts...)` at `internal/pluginhost/host.go:336` — but note this is inside `broker.AcceptAndServe(...)`, a hashicorp/go-plugin **brokered sub-stream** (reverse channel), not the primary host→plugin invocation channel. The load-bearing seam is the client dial (`grpc.NewClient`, pattern in `internal/pluginhost/client_test.go:18`). See Task 7.
|
||||
- Background entrypoints: scanner `NewScanner` (`internal/scanner/scanner.go:192`), scoped scan `applyScopedScan` (`internal/scanner/scanner.go:1287`); playback `NewSessionManager` (`internal/playback/session.go:152`); taskmanager `RunTask` (`internal/taskmanager/manager.go:231`).
|
||||
- `context.Context` is threaded pervasively (`Dependencies.AppContext` at `internal/api/router.go:88`; every repo/service method takes `ctx` first). Span context propagates cleanly for the **request/service call chains** — the work there is adding spans + switching call sites to the `…Context` variants. **Exception:** struct-bound loggers whose methods don't take a `ctx` (`taskmanager`, `notifications/*`, `ffmpeg_log_sink.go`) need signature plumbing or stay uncorrelated; see Task 3 Step 3.
|
||||
- Existing endpoints: `/metrics` on a dedicated `metricsMux` (`cmd/silo/main.go:2078`), `/health` + `/ready` (`internal/api/router.go:247,1436`). No pprof registered.
|
||||
|
||||
### Dependencies
|
||||
- `go 1.26.4` → `slog.MultiHandler` is available in the stdlib (no `samber/slog-multi` needed).
|
||||
- Already present: `github.com/prometheus/client_golang v1.23.2`; `go.opentelemetry.io/otel/sdk/metric v1.41.0` is present only as an **indirect** transitive dep (not wired). No `go.opentelemetry.io/otel` core, no trace SDK, no exporters, no otelslog/otelhttp/otelgrpc.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### New files
|
||||
- `internal/telemetry/telemetry.go` — SDK bootstrap: `Config` (parsed from env), `Setup(ctx, Config) (*Providers, shutdown func(context.Context) error, error)`, shared `resource.Resource`, `TracerProvider`, `LoggerProvider`, W3C propagator, batch processors.
|
||||
- `internal/telemetry/config.go` — `OTEL_*` + `SILO_OTEL_*` env parsing (enable gate, endpoint, protocol, sampler, service name/version).
|
||||
- `internal/telemetry/loghandler.go` — a best-effort wrapper around `otelslog.Handler` whose `Handle` never propagates an OTel export error (mirrors the `logsink` best-effort contract), plus the fan-out assembly helper.
|
||||
- `internal/telemetry/httpclient.go` — shared `otelhttp.NewTransport`-wrapped `http.RoundTripper` factory for outbound clients.
|
||||
- `internal/telemetry/telemetry_test.go`, `internal/telemetry/config_test.go`, `internal/telemetry/loghandler_test.go`.
|
||||
|
||||
### Modified files (by phase)
|
||||
- `go.mod` / `go.sum` — add OTel core/sdk/exporters + contrib instrumentation + pgx/redis tracers.
|
||||
- `cmd/silo/main.go` — call `telemetry.Setup`, insert otelslog fan-out into `buildBaseHandler`, defer `shutdown` in `run()`.
|
||||
- `internal/api/router.go` — `otelhttp` handler/middleware in the chi chain; inject `trace_id`/`span_id` into request-logger attrs.
|
||||
- `internal/api/middleware/request_logger.go` — add trace/span IDs to the emitted attrs.
|
||||
- `internal/jellycompat/router.go`, `internal/transcodenode/server.go` — `otelhttp` server instrumentation + propagation.
|
||||
- `internal/database/postgres.go` — `otelpgx` `QueryTracer` on the pool config.
|
||||
- `internal/cache/redis.go` — `redisotel.InstrumentTracing` on both client constructions.
|
||||
- Outbound-client packages listed above — accept/use the shared traced transport.
|
||||
- `internal/pluginhost/host.go` (+ client dial sites) — `otelgrpc` server/client stats handlers.
|
||||
- `internal/scanner/scanner.go`, `internal/playback/session.go`, `internal/taskmanager/manager.go` — domain spans.
|
||||
- **All packages under `internal/`** — call-site sweep (`slog.* → slog.*Context`, `component` attr). Batched per-package in Phase 3.
|
||||
- `docs/architecture/observability.md` — operator docs (env vars, collector wiring, what is/isn't instrumented).
|
||||
|
||||
---
|
||||
|
||||
## Phasing overview
|
||||
|
||||
- **Phase 0** — Dependencies + `internal/telemetry` SDK bootstrap (no behavior change; disabled unless configured).
|
||||
- **Phase 1** — Logs bridge fan-out (logs export works end-to-end; call sites unchanged).
|
||||
- **Phase 2** — Tracing providers + propagation plumbing (spans exist, exported).
|
||||
- **Phase 3** — Full call-site sweep to `…Context` + `component` attrs (unlocks `trace_id` in logs).
|
||||
- **Phase 4** — HTTP server + client tracing seams.
|
||||
- **Phase 5** — pgx + Redis tracing seams.
|
||||
- **Phase 6** — Plugin gRPC host-side propagation.
|
||||
- **Phase 7** — Domain spans (scanner / playback / taskmanager).
|
||||
- **Phase 8** — Shutdown wiring, docs, verification hardening.
|
||||
|
||||
Each phase is independently shippable and leaves `main` green. Phases 1–2 are prerequisites for Phase 3's payoff; Phases 4–7 can land in any order after Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Prometheus / Grafana coexistence
|
||||
|
||||
**Question this answers:** does adopting OTel break, replace, or entangle the existing Prometheus + Grafana metrics? **Answer: no — they stay separate rails, provided one footgun is guarded. Metrics stay on Prometheus permanently for this plan; migrating them to OTel is not in scope.**
|
||||
|
||||
- **Separate lines, not merged (this plan).** OTel `telemetry.Setup` installs **only** a `TracerProvider` and a `LoggerProvider`. It **must not** call `otel.SetMeterProvider` (Task 1 Step 5 builds no MeterProvider). Prometheus keeps its own independent rail: `client_golang` registry → `/metrics` (`cmd/silo/main.go:2078`) → Grafana scrape. Nothing about the metrics path changes. Grafana metric dashboards are untouched; traces become a *new, additive* Grafana/Tempo capability, not a change to existing panels.
|
||||
- **THE FOOTGUN — global MeterProvider (guard required).** The OTel instrumentation libraries used for traces (`otelhttp`, `otelgrpc`, `otelpgx`, `redisotel`) **also emit metrics by default**, through `otel.GetMeterProvider()`. Because this plan never sets a MeterProvider, that global stays the built-in **no-op** — so those metric calls are silently discarded: no double-counting into Prometheus, no second `/metrics` source, no crash, no measurable overhead. This is safe *by omission*, so make it **explicit and tested**, not incidental:
|
||||
- In each instrumentation call, prefer passing an explicit no-op meter provider option where the library supports it (e.g. `otelhttp.WithMeterProvider(noop.NewMeterProvider())`) so a future accidental `SetMeterProvider` can't silently start double-emitting HTTP/DB metrics.
|
||||
- Add a guard test asserting `otel.GetMeterProvider()` is the no-op after `telemetry.Setup` (i.e. scope B did not wire metrics).
|
||||
- HTTP request metrics continue to come **solely** from the existing `apimw.Metrics` Prometheus middleware (`internal/api/middleware/metrics.go:35`); `otelhttp` contributes **traces only**.
|
||||
- **Metrics remain fully on Prometheus.** Migrating metrics to the OTel metrics SDK is explicitly out of scope and not planned here — the current `client_golang` instrumentation is retained as-is.
|
||||
|
||||
## Blast radius & complexity
|
||||
|
||||
Grounded in actual counts (`grep` over `internal/` + `cmd/`): **173 files** contain `slog.*` calls totalling **1,339 call sites** (39 already on the `…Context` variant → **~1,300 to convert**). Total change spans **~185–190 files** and **~2,500–3,200 diff lines**.
|
||||
|
||||
Complexity is **concentrated, not spread** — line count is a poor proxy for risk here:
|
||||
|
||||
| Phase group | Files | ~LOC | Nature | Risk |
|
||||
|---|---|---|---|---|
|
||||
| **Phase 0–2** — telemetry bootstrap, logs fan-out bridge, tracing providers + propagation | ~10–12 (mostly new `internal/telemetry/*` + `cmd/silo/main.go`) | ~900–1,100 net-new | Genuinely new code | **High** — the level-gated fan-out, provider lifecycle, shutdown flush |
|
||||
| **Phase 3** — call-site sweep | 173 | ~1,300–1,700 modified | Mechanical, codemod-assisted, per-package-green | **Low per file** — a bad conversion fails the build, not runtime; the burden is review volume, not engineering |
|
||||
| **Phase 4–7** — HTTP/pgx/redis/gRPC/client + domain spans | ~12–15 | ~350–500 net-new | Small, localized seams | **Medium** — ~4 seams are subtly easy to get wrong (gRPC broker-vs-real channel, per-session playback span, chi route timing, struct-bound loggers) |
|
||||
| **Phase 8** — shutdown wiring, docs | ~2 | ~170 | Wiring + prose | Low |
|
||||
|
||||
**Takeaway:** ~90% of the *file count* (the 173-file sweep) is the *safe* part; ~90% of the *risk* lives in the few hundred lines of `internal/telemetry` plus ~4 seams. The scary number (1,300 sites) is mechanical churn.
|
||||
|
||||
### Commit strategy
|
||||
|
||||
- **Commit 1 — Phases 0–2** (the observability capability itself): telemetry bootstrap + level-gated logs fan-out + tracing providers/propagation. Small, high-value, reviewable as a unit; delivers OTLP log/trace export end-to-end without touching call sites.
|
||||
- **Commit 2 — Phase 3** (the call-site sweep, in isolation): the ~1,300-site `…Context` + `component` conversion across 173 files, as its own commit so the large mechanical diff never obscures the load-bearing logic in Commit 1. (May itself be split per-package internally, but lands as a distinct changeset from Commit 1.)
|
||||
- **Later commits — Phases 4–8**: seam tracing + domain spans + shutdown/docs, each landing independently after the foundation is in.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add dependencies and the telemetry SDK bootstrap
|
||||
|
||||
**Files:**
|
||||
- Modify: `go.mod`, `go.sum`
|
||||
- Create: `internal/telemetry/config.go`, `internal/telemetry/config_test.go`, `internal/telemetry/telemetry.go`, `internal/telemetry/telemetry_test.go`
|
||||
|
||||
- [ ] **Step 1: Add dependencies.** `go get go.opentelemetry.io/otel go.opentelemetry.io/otel/sdk go.opentelemetry.io/otel/sdk/trace go.opentelemetry.io/otel/sdk/log go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp go.opentelemetry.io/contrib/bridges/otelslog`. Run `go mod tidy`.
|
||||
- [ ] **Step 2: Write failing config tests** (`config_test.go`) covering: disabled by default (no endpoint, no `SILO_OTEL_ENABLED`); enabled when `OTEL_EXPORTER_OTLP_ENDPOINT` set; `service.name` from `OTEL_SERVICE_NAME` overriding a default of `silo-server`; protocol selection (`grpc` default / `http/protobuf`); sampler default (`parentbased_always_on`) and ratio override; invalid numeric sampler arg falls back to default (not a crash).
|
||||
- [ ] **Step 3: Implement `config.go`.** Parse `OTEL_*` (respect the OTel env spec) plus a `SILO_OTEL_ENABLED` convenience gate. Default `service.name=silo-server`, `service.version` from build info, `node.id` from the existing `SILO_NODE_NAME`/nodeID signal. Return a fully-defaulted `Config` with an `Enabled bool`.
|
||||
- [ ] **Step 4: Write failing telemetry test** (`telemetry_test.go`): `Setup` with `Enabled=false` returns a no-op shutdown and installs nothing; `Setup` with a `console`/`none` exporter builds providers and a shutdown that returns nil; shutdown is idempotent.
|
||||
- [ ] **Step 5: Implement `telemetry.go`.** Follow the canonical `setupOTelSDK` shape: build one shared `resource.Resource` (`resource.WithAttributes(semconv.ServiceName(cfg.ServiceName))`, `WithFromEnv`, `WithProcess`, `WithHost`), a composite W3C `TraceContext{}+Baggage{}` propagator, a `TracerProvider` (`trace.WithBatcher`), and a `LoggerProvider` (`log.WithProcessor(log.NewBatchProcessor(...))`). Register `otel.SetTracerProvider`, `otel.SetTextMapPropagator`, `global.SetLoggerProvider`. **Do NOT build or register a MeterProvider** — metrics stay on Prometheus (see "Prometheus / Grafana coexistence"); leaving `otel.GetMeterProvider()` as the built-in no-op is what keeps the trace instrumentation libraries from double-emitting metrics. Accumulate `shutdownFuncs` and join their errors. When `Enabled=false`, return early with a no-op shutdown so the entire feature is dormant unless configured.
|
||||
- [ ] **Step 6: Verify.** `go build ./...`, `go vet ./internal/telemetry/`, `go test ./internal/telemetry/ -race`, `gofmt -l` clean.
|
||||
|
||||
## Task 2: Logs bridge — fan-out otelslog into the handler chain
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/telemetry/loghandler.go`, `internal/telemetry/loghandler_test.go`
|
||||
- Modify: `cmd/silo/main.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests** (`loghandler_test.go`): a fan-out handler forwards a record to both a capturing stderr-substitute handler and an otelslog handler backed by an in-memory `LoggerProvider`; an OTel-branch `Handle` error is swallowed and does **not** prevent the console branch from receiving the record (mirrors the `logsink` best-effort contract that keeps `opslog`'s DB path alive — see `internal/opslog/handler.go` "inner error skips capture" behavior).
|
||||
- [ ] **Step 2: Implement `loghandler.go`.** Provide `NewOTelHandler(lp) slog.Handler` = `otelslog.NewHandler("silo-server", otelslog.WithLoggerProvider(lp))` wrapped so its `Handle`/`WithAttrs`/`WithGroup` never propagate export errors. Provide `FanOut(console, otel slog.Handler) slog.Handler` returning `slog.NewMultiHandler(console, otelBestEffort)` (stdlib, Go 1.26).
|
||||
- [ ] **Step 2a (REQUIRED — level gating): gate the OTel branch by the shared `LevelVar`.** `slog.MultiHandler.Enabled` returns true if **any** child is enabled. The stderr child is gated by the shared `*slog.LevelVar` (`cmd/silo/main.go:542-543`), but otelslog's `Enabled` follows the (unfiltered-by-default) `LoggerProvider`. Left unfixed, the logger would evaluate `Enabled==true` for `Debug` at `LogLevel=info` — constructing + dispatching Debug records it previously skipped across ~1,200 sites (per-call allocation, violates "Performance first") **and** exporting Debug to OTLP while stderr stays silent (a confusing, silent divergence). Wrap `NewOTelHandler` in a level-gate bound to the same `logLevelVar` (or an equivalent `otelslog` level-bridge option) so console and OTLP share exactly one verbosity knob. Add a test asserting a Debug record is dropped by both branches when the shared level is `info`.
|
||||
- [ ] **Step 3: Wire into `buildBaseHandler`** (defined `cmd/silo/main.go:132-138`, **called at `cmd/silo/main.go:543`** inside `run()` after the pool is up). When telemetry is enabled, return `FanOut(stderrHandler, levelGated(NewOTelHandler(lp)))` instead of the bare stderr handler; otherwise return today's stderr handler unchanged. The `logfilter` (`:544`) → `opslog` (`:209`, installed `:567`) wrapping above stays exactly as-is, so quiet-filtering and DB capture are preserved and OTel mirrors the console stream (document this mirror behavior, matching the prior file-sink decision). Ordering is safe: `telemetry.Setup` depends only on `OTEL_*` env (not the DB), so call it earlier in `run()` and thread the `LoggerProvider` into `buildBaseHandler` — the provider is available well before `:543`.
|
||||
- [ ] **Step 3a (coverage caveat): early-boot logs are not exported.** Many `slog` calls fire before the fan-out is installed at `:543` — DB connect (`:389`), migrations (`:423`,`:441`), secret backfill (`:287-305`), auto-tuning (`:223-272`), and everything inside `database.NewPool`. These reach only Go's default stderr handler today (not even opslog), so OTel not capturing them is **not** a regression — but it means "logs export end-to-end" excludes boot. Either document this gap in the observability doc, or (if boot logs matter to operators) move `telemetry.Setup` + a minimal OTel-only tee to the very top of `run()`.
|
||||
- [ ] **Step 4: Verify** logs actually export. Bring up a local collector (`otel/opentelemetry-collector` with a `debug`/`logging` exporter, documented in Task 10) or use the `console` log exporter; run the server, confirm `slog.Info` records appear on both stderr and the OTLP/console sink. Run `go build ./...`, `go test ./cmd/silo/ ./internal/telemetry/`.
|
||||
|
||||
## Task 3: Full call-site sweep — `…Context` variants + `component` attrs
|
||||
|
||||
This is the largest task. Batch per-package; each batch is its own commit and must keep the build green. The goal state: every `slog.Info/Warn/Error/Debug` becomes the `…Context(ctx, …)` variant wherever a `ctx` is in scope, and the `"subsystem: message"` prefix convention is replaced by an explicit `slog.String("component", "<subsystem>")` attr. `opslog.InferComponent` stays as the fallback for any residual prefix-only calls (backward compatible — do **not** remove it).
|
||||
|
||||
- [ ] **Step 1: Establish the pattern + guardrail.** In a heavily-used package (start with `internal/scanner`), convert calls to `slog.InfoContext(ctx, "msg", slog.String("component", "scanner"), …)`. Confirm `opslog` still classifies correctly (component attr wins over inference — `internal/opslog/handler.go:51-54`). Prototype the `sloglint` rule locally to sanity-check the target form, but do **not** enable it repo-wide yet — it is turned on at `error` severity only as the closing step of the sweep (Task 9), since enabling it before all ~1,300 sites convert turns CI red.
|
||||
- [ ] **Step 2: Codemod the mechanical conversions.** Use an AST-based rewrite (`gofmt -r` is insufficient for adding a `ctx` arg) — a small `golang.org/x/tools/go/analysis` or `astutil` script that, for each `slog.Info(...)` call inside a function with a `context.Context` in scope, rewrites to `slog.InfoContext(ctx, ...)`. Calls with no reachable `ctx` are left on the non-context variant and flagged for manual review (background init, `main`, top-level goroutines). Review every rewrite — do not blind-commit the codemod.
|
||||
- [ ] **Step 3: Convert per-package, hottest first**, one commit each, `go build`/`go test` between: `internal/api/handlers/libraries.go` (83), `internal/metadata/service.go` (70), `internal/api/handlers/playback.go` (60), `internal/metadata/worker.go` (45), `internal/recommendations/worker.go` (43), `internal/scanner/scanner.go` (40), `internal/adminjob/runner.go` (37), `internal/api/handlers/plugins.go` (35), then the long tail. For struct-bound `.With("component", …)` loggers (`internal/notifications/*`, `internal/playback/ffmpeg_log_sink.go`, `request_logger.go`, `taskmanager.New(logger *slog.Logger)` at `internal/taskmanager/manager.go:25`), keep the bound `component` and migrate emit calls to the context variants. **Caveat (corrects the "no structural re-plumbing" claim in Validated Findings):** many such structs log from methods that do **not** currently take a `ctx`. For those, either thread a `ctx` parameter through the method signatures (structural change — budget for it) or, where a request/operation ctx genuinely isn't reachable, leave the call on the non-context variant (still captured via fan-out, just without `trace_id`). Span-context propagation is free *only where a `ctx` already flows*; the struct-bound method surfaces are the exception, not the rule.
|
||||
- [ ] **Step 4: Normalize component names.** Produce a canonical component list (jellycompat, playback, api, scanner, metadata, notifications, catalog, auth, recommendations, adminjob, taskmanager, …) and apply it consistently so trace/log/DB classification agree. Document the list in the package doc.
|
||||
- [ ] **Step 5: Verify.** `go build ./...`, `go vet ./...`, full `go test ./...`, `gofmt -l` clean, the new sloglint guard passes repo-wide. Spot-check that `opslog` entries still populate `component` after the sweep.
|
||||
|
||||
## Task 4: Tracing — HTTP server middleware + request-logger correlation
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/router.go`, `internal/api/middleware/request_logger.go`, `internal/jellycompat/router.go`, `internal/transcodenode/server.go`
|
||||
|
||||
- [ ] **Step 1: Add deps.** `go get go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp`; `go mod tidy`.
|
||||
- [ ] **Step 2: Instrument the main router.** Insert `otelhttp` server handling into the chi chain right after `middleware.RequestID` (`internal/api/router.go:213`) and before `apimw.RequestLogger`, so a server span exists for the whole handler and `request_logger` can read it. **Route-tag timing:** chi resolves the route *after* the middleware stack runs, so `http.route` cannot be set at middleware entry — set it post-routing from `chi.RouteContext(r).RoutePattern()`, and commit to `sanitizePath` (`metrics.go:79-89`) on the raw path as the fallback when no pattern is available. Exclude `/health`/`/ready` like the logger already does (`request_logger.go:22`). Note `activitylog.NewMiddleware` (`internal/api/router.go:230`) is also request-scoped and may want the span/`trace_id` — evaluate wiring it in the same pass.
|
||||
- [ ] **Step 3: Correlate logs.** In `request_logger.go:53-70`, pull the active span from `r.Context()` and add `slog.String("trace_id", …)`/`slog.String("span_id", …)` to the emitted attrs. (Once Task 3 lands and handlers log with `ctx`, the otelslog bridge attaches these automatically to every downstream record too — this step guarantees the access-log line itself carries them.)
|
||||
- [ ] **Step 4: Instrument jellycompat + transcode-node servers** the same way (`internal/jellycompat/router.go:30`, `internal/transcodenode/server.go`), ensuring incoming W3C `traceparent` headers continue the trace across processes.
|
||||
- [ ] **Step 5: Verify.** Drive a request through a local collector; confirm one server span per request with `http.route`, `trace_id` present in the access log, and a continued trace when hitting a transcode-node endpoint.
|
||||
|
||||
## Task 5: Tracing — outbound HTTP clients (shared traced transport)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/telemetry/httpclient.go`
|
||||
- Modify: `internal/nodepool/health.go`, metadata/subtitle provider packages, `internal/plugins` HTTP proxy
|
||||
|
||||
- [ ] **Step 1: Shared factory.** Implement `telemetry.NewHTTPTransport(base http.RoundTripper) http.RoundTripper` = `otelhttp.NewTransport(base)` with sane span-name/route options. This satisfies CLAUDE.md's "avoid duplicate logic" — one wrapper, injected everywhere, not per-package patches.
|
||||
- [ ] **Step 2: Inject** the traced transport into the ad-hoc `http.Client` literals: `internal/nodepool/health.go:22-26` and other nodepool clients, `internal/metadata/tmdb`, `internal/metadata/trakt`, `internal/metadata/translation`, `internal/mdblist`, `internal/subtitles/{opensubtitles,subdl,subsource}`, and `internal/plugins.HTTPProxy` (`router.go:149`). Prefer constructing the transport once in `cmd/silo/main.go` and passing it down over editing each package's client construction independently where a seam exists.
|
||||
- [ ] **Step 3: Verify.** A play/scan flow that calls a metadata provider and a transcode node shows child client spans nested under the originating server span, with propagated `traceparent`.
|
||||
|
||||
## Task 6: Tracing — pgx pool + Redis
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/database/postgres.go`, `internal/cache/redis.go`, `go.mod`
|
||||
|
||||
- [ ] **Step 1: pgx tracer.** `go get github.com/exaring/otelpgx`; set `poolCfg.ConnConfig.Tracer = otelpgx.NewTracer(...)` on the config returned by `pgxpool.ParseConfig` (`internal/database/postgres.go:14`), guarded on telemetry enabled. Zero call-site changes — instruments every query. Use `otelpgx` options to avoid logging full SQL args if that is a concern.
|
||||
- [ ] **Step 2: Redis hook.** `go get github.com/redis/go-redis/extra/redisotel/v9`; call `redisotel.InstrumentTracing(client)` after **both** `redis.NewClient` constructions (`internal/cache/redis.go:145-254`).
|
||||
- [ ] **Step 3: Verify.** A request that hits Postgres and Redis shows DB and cache child spans under the server span. `go build ./...`, `go test ./internal/database/ ./internal/cache/`.
|
||||
|
||||
## Task 7: Tracing — host-side plugin gRPC propagation
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/pluginhost/host.go`, plugin gRPC client dial sites, `go.mod`
|
||||
|
||||
- [ ] **Step 1: Add dep.** `go get go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc`.
|
||||
- [ ] **Step 2: Find the real invocation channel FIRST.** `grpc.NewServer(opts...)` at `internal/pluginhost/host.go:336` sits inside `broker.AcceptAndServe(...)` — a hashicorp/go-plugin **brokered sub-stream** (host services exposed back to the plugin), *not* the primary host→plugin RPC channel (go-plugin stands that up internally; silo does not construct it at this line). Instrumenting only `:336` would capture the reverse/broker channel and **miss primary plugin invocations**. Identify where the primary plugin client connection is created/dialed (the load-bearing path) before instrumenting.
|
||||
- [ ] **Step 3: Client side (load-bearing).** Add `grpc.WithStatsHandler(otelgrpc.NewClientHandler())` to the host→plugin dial that carries actual invocations (dial pattern per `internal/pluginhost/client_test.go:18`). This is the half that must cover the real call path.
|
||||
- [ ] **Step 4: Server side.** Add `grpc.StatsHandler(otelgrpc.NewServerHandler())` to `grpc.NewServer(...)` at `:336` for the brokered channel — useful but secondary.
|
||||
- [ ] **Step 5: Note the boundary.** The plugin-subprocess side that receives `traceparent` is owned by `silo-plugin-sdk` — record a coordination follow-up; do **not** attempt SDK-side changes here.
|
||||
- [ ] **Step 6: Verify against a real plugin call.** Confirm an actual plugin invocation (not just a broker sub-stream) produces a gRPC client span; full end-to-end continuation into the plugin process is deferred to the SDK follow-up.
|
||||
|
||||
## Task 8: Domain spans — scanner, playback, taskmanager
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/scanner/scanner.go`, `internal/playback/session.go`, `internal/taskmanager/manager.go`
|
||||
|
||||
- [ ] **Step 1: Scanner.** Start a span per scan run in `applyScopedScan` (`internal/scanner/scanner.go:1287`) and, if not too chatty, a child per file. Attach library/scope attrs.
|
||||
- [ ] **Step 2: Playback.** Do **not** span `NewSessionManager` (`internal/playback/session.go:152`) — that is the singleton manager constructor, called once at startup; a span there covers the whole process lifetime and cannot carry per-session attrs. Instead attach the span at the actual per-session create/start path (where a `*Session` is added to the `sessions` map), spanning start→stop with `play_method`/session-id attrs, so it links to the transcode-node client spans (Task 5) for end-to-end session traces.
|
||||
- [ ] **Step 3: Taskmanager.** Wrap `RunTask` (`internal/taskmanager/manager.go:231`) in a per-run span; these background goroutines start new root traces (no incoming context), so create a fresh root span with baggage identifying the task.
|
||||
- [ ] **Step 4: Verify.** A scan and a playback session each produce a coherent trace tree; background task runs appear as their own traces.
|
||||
|
||||
## Task 9: Durable enforcement — keep future code (human + AI) on the standard
|
||||
|
||||
The sweep is worthless if new code drifts back to context-less `slog.Info(...)`. Lock the standard in machine-enforced form so it holds without relying on reviewer vigilance. **Sequencing: lands together with the Phase-3 sweep in the same commit, after the legacy sites are converted — enabling it earlier turns CI red repo-wide.**
|
||||
|
||||
**Files:**
|
||||
- Modify: `.golangci.yml`
|
||||
- Reference: `docs/architecture/observability.md` (from Task 10)
|
||||
|
||||
- [x] **Step 1: Machine gate (the real guarantee) — add `sloglint` to `.golangci.yml`.** Added to `linters.enable` with all four rules configured: `context: "scope"`, `static-msg: true`, `key-naming-case: snake`, `no-mixed-args: true`. Since `make lint` runs `golangci-lint run` (`Makefile:54`), this blocks any PR — human- or AI-authored — that regresses the call form. Verified via standalone `sloglint`: after the sweep all four rules report **0 violations** repo-wide (production and test), so no `_test.go` exclusion was needed. (`key-naming-case` and `no-mixed-args` were already clean pre-sweep; `static-msg` had 6 sites, fixed in the sweep.)
|
||||
- [x] **Step 2: Document the honest gap.** Recorded in `docs/architecture/observability.md`: `sloglint` enforces the *shape* (`…Context`, snake keys, static msg) but **cannot** enforce that a `component` attr is present. That convention rests on the observability doc, the canonical component list, and review.
|
||||
- **Note (CLAUDE.md subsection dropped):** per maintainer direction, no Logging subsection is added to `CLAUDE.md`. The post-sweep codebase already models the idiom everywhere and the `sloglint` gate enforces it, so the standard holds without a CLAUDE.md entry; `docs/architecture/observability.md` carries the human/LLM-facing rationale and canonical list.
|
||||
|
||||
## Task 10: Shutdown wiring, docs, and final verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `cmd/silo/main.go`
|
||||
- Create: `docs/architecture/observability.md`
|
||||
|
||||
- [ ] **Step 1: Graceful shutdown.** `defer shutdown(ctx)` from `telemetry.Setup` in `run()`, integrated with the existing signal handler, using a **generous** timeout so batch processors flush (buffered spans/logs are lost otherwise). Ensure it runs before process exit on every path.
|
||||
- [ ] **Step 2: Docs.** Write `docs/architecture/observability.md`: the `OTEL_*`/`SILO_OTEL_ENABLED` surface, default-off behavior, a local `docker compose` collector example (OTLP in → debug/Loki/Tempo out), the canonical component list, and the documented non-goals/risks (metrics stay Prometheus; logs unredacted at the OTLP sink; quiet-filter also suppresses OTel logs; plugin-SDK side is a follow-up).
|
||||
- [ ] **Step 3: Full verification.** `go build ./...`; `go vet ./...`; `go test ./... -race` (at least the touched packages under `-race`); `gofmt -l` clean; `cd web && pnpm run lint && pnpm run format:check` (no-op for this backend change but part of the MR gate); `make verify-local-paths`. Manual: default-off start (no OTel env) behaves exactly as today; enabled start exports logs+traces to a local collector; a single request produces correlated log `trace_id` + a server→DB→cache→client span tree. **Metrics coexistence:** with OTel enabled, confirm `/metrics` still serves the full existing Prometheus set (HTTP + domain metrics), Grafana scraping is unaffected, and the guard test confirms `otel.GetMeterProvider()` is the no-op (no OTel metrics rail wired).
|
||||
|
||||
---
|
||||
|
||||
## Risk / follow-ups
|
||||
|
||||
- **Level-gating is load-bearing (see Task 2 Step 2a).** `slog.MultiHandler.Enabled` is an OR across children; without gating the OTel branch by the shared `LevelVar`, Debug records get allocated + OTLP-exported at `level=info`. Treated as a required step, not optional.
|
||||
- **Early-boot logs (pre-`:543`) are not exported** — matches existing opslog behavior, documented not fixed (Task 2 Step 3a).
|
||||
- **Playback span must attach per-session, not at the manager constructor**, and **plugin gRPC instrumentation must target the primary invocation channel, not just the broker sub-stream** — both are easy to get wrong (Tasks 8/7).
|
||||
- **Call-site sweep is a large, review-heavy diff.** Mitigated by per-package batching, an AST codemod with mandatory human review, and a `sloglint` guardrail. Background/init calls with no `ctx` legitimately stay on the non-context variants.
|
||||
- **Quiet-filter also suppresses OTel logs** (the fan-out sits at the base, below the filter). This mirrors stderr exactly — intended, but if durable-capture-despite-quiet is wanted, the OTel branch must tee in above `logfilter`. Deferred, product call.
|
||||
- **Redaction is key-based** (handled via `internal/logredact` on all sinks): secrets under a recognized key are masked, but a secret in a free-text message or under an unrecognized key is not caught.
|
||||
- **Metrics remain on Prometheus** by explicit non-goal; dual observability backends until a future OTel-metrics plan.
|
||||
- **Plugin-subprocess trace continuation** needs a coordinated `silo-plugin-sdk` change; host-side only here.
|
||||
- **Sampler config in multi-node deploys** must use `parentbased_traceidratio` (not bare `traceidratio`) so transcode/proxy nodes respect the parent decision — documented in Task 10.
|
||||
- **Batch-processor flush on shutdown** is critical; verify no lost telemetry on redeploy/signal paths.
|
||||
|
||||
## Verification summary (gate before MR)
|
||||
|
||||
- `go build ./...`, `go vet ./...`, `go test ./... -race` (touched packages), `gofmt -l` clean.
|
||||
- `cd web && pnpm run lint && pnpm run format:check`; `make verify-local-paths`.
|
||||
- Manual: default-off parity; enabled logs+traces to a local collector; correlated `trace_id` in logs; end-to-end span tree across HTTP → pgx → redis → outbound client → gRPC.
|
||||
@@ -47,13 +47,17 @@ require (
|
||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||
github.com/dunglas/httpsfv v1.1.0 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/gookit/color v1.5.4 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||
github.com/hashicorp/yamux v0.1.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
@@ -92,8 +96,14 @@ require (
|
||||
github.com/zishang520/engine.io/v2 v2.5.0 // indirect
|
||||
github.com/zishang520/socket.io-go-parser/v2 v2.5.0 // indirect
|
||||
github.com/zishang520/webtransport-go v0.9.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
@@ -120,6 +130,15 @@ require (
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sony/sonyflake v1.3.0
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
|
||||
go.opentelemetry.io/otel v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0
|
||||
go.opentelemetry.io/otel/log v0.20.0
|
||||
go.opentelemetry.io/otel/sdk v1.44.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/sync v0.21.0
|
||||
|
||||
@@ -50,6 +50,8 @@ github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/
|
||||
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
|
||||
github.com/bytecodealliance/wasmtime-go/v44 v44.0.0 h1:WRZXnLPIer/TWs5aYPaMlmVcOlzmR6Ur6wjLRIQOhTQ=
|
||||
github.com/bytecodealliance/wasmtime-go/v44 v44.0.0/go.mod h1:GP93piU+39CoFVCQ5xfHrPOUtL0APlMnkbblJ2d3YY0=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -82,6 +84,7 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
@@ -111,6 +114,8 @@ github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
|
||||
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||
github.com/h2non/bimg v1.1.9 h1:WH20Nxko9l/HFm4kZCA3Phbgu2cbHvYzxwxn9YROEGg=
|
||||
github.com/h2non/bimg v1.1.9/go.mod h1:R3+UiYwkK4rQl6KVFTOFJHitgLbZXBZNFh2cv3AEbp8=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
@@ -301,16 +306,36 @@ go.n16f.net/thumbhash v1.1.0 h1:aBEvuAd4yiwzeQ7Sm4BZoHJYbrQ1ewjrmrRlCE79snk=
|
||||
go.n16f.net/thumbhash v1.1.0/go.mod h1:mo9pP7WtfdV9ojIamGFR/Vc0PaPA2l0CUtmYQf/SweU=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
|
||||
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
|
||||
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
@@ -413,6 +438,8 @@ golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
|
||||
@@ -71,35 +71,35 @@ func CleanupOnce(ctx context.Context, pool *pgxpool.Pool, store SettingsStore, p
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -days)
|
||||
if pm != nil {
|
||||
if err := pm.EnsureFuturePartitions(ctx); err != nil {
|
||||
slog.Warn("activitylog ensure future partitions error", "error", err)
|
||||
slog.WarnContext(ctx, "activitylog ensure future partitions error", "component", "activitylog", "error", err)
|
||||
}
|
||||
|
||||
partitionCleanupFailed := false
|
||||
totalDeleted := int64(0)
|
||||
if dropped, err := pm.DropExpiredPartitions(ctx, cutoff); err != nil {
|
||||
slog.Warn("activitylog partition cleanup error", "error", err)
|
||||
slog.WarnContext(ctx, "activitylog partition cleanup error", "component", "activitylog", "error", err)
|
||||
partitionCleanupFailed = true
|
||||
} else if len(dropped) > 0 {
|
||||
slog.Info("activitylog dropped expired partitions", "partitions", dropped)
|
||||
slog.InfoContext(ctx, "activitylog dropped expired partitions", "component", "activitylog", "partitions", dropped)
|
||||
}
|
||||
|
||||
if deleted, err := pm.DeleteExpiredRowsFromDefault(ctx, cutoff); err != nil {
|
||||
slog.Warn("activitylog default partition cleanup error", "error", err)
|
||||
slog.WarnContext(ctx, "activitylog default partition cleanup error", "component", "activitylog", "error", err)
|
||||
partitionCleanupFailed = true
|
||||
} else if deleted > 0 {
|
||||
totalDeleted += deleted
|
||||
slog.Info("activitylog default partition cleanup completed", "deleted", deleted, "retention_days", days)
|
||||
slog.InfoContext(ctx, "activitylog default partition cleanup completed", "component", "activitylog", "deleted", deleted, "retention_days", days)
|
||||
}
|
||||
|
||||
if !partitionCleanupFailed {
|
||||
return totalDeleted
|
||||
}
|
||||
slog.Warn("activitylog partition cleanup degraded, falling back to row deletes", "retention_days", days)
|
||||
slog.WarnContext(ctx, "activitylog partition cleanup degraded, falling back to row deletes", "component", "activitylog", "retention_days", days)
|
||||
}
|
||||
|
||||
total := deleteExpiredRowsBefore(ctx, pool, cutoff)
|
||||
if total > 0 {
|
||||
slog.Info("activitylog cleanup completed", "deleted", total, "retention_days", days)
|
||||
slog.InfoContext(ctx, "activitylog cleanup completed", "component", "activitylog", "deleted", total, "retention_days", days)
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func deleteExpiredRowsBefore(ctx context.Context, pool *pgxpool.Pool, cutoff tim
|
||||
)
|
||||
`, cutoff, cleanupBatchSize)
|
||||
if err != nil {
|
||||
slog.Warn("activitylog cleanup error", "error", err)
|
||||
slog.WarnContext(ctx, "activitylog cleanup error", "component", "activitylog", "error", err)
|
||||
return total
|
||||
}
|
||||
deleted := result.RowsAffected()
|
||||
|
||||
@@ -69,14 +69,14 @@ func (c *Consumer) drainRedis(ctx context.Context) {
|
||||
for {
|
||||
entries, err := c.popRedisBatch(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("activitylog: Redis pop error", "error", err)
|
||||
slog.WarnContext(ctx, "activitylog: Redis pop error", "component", "activitylog", "error", err)
|
||||
return
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
if err := c.insertBatchWithRetry(ctx, entries); err != nil {
|
||||
slog.Error("activitylog: batch insert failed after retries, dropping batch",
|
||||
slog.ErrorContext(ctx, "activitylog: batch insert failed after retries, dropping batch", "component", "activitylog",
|
||||
"error", err, "count", len(entries))
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func (c *Consumer) popRedisBatch(ctx context.Context) ([]LogEntry, error) {
|
||||
for _, raw := range result {
|
||||
var entry LogEntry
|
||||
if err := json.Unmarshal([]byte(raw), &entry); err != nil {
|
||||
slog.Warn("activitylog: skipping malformed entry", "error", err)
|
||||
slog.WarnContext(ctx, "activitylog: skipping malformed entry", "component", "activitylog", "error", err)
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
@@ -108,7 +108,7 @@ func (c *Consumer) insertBatchWithRetry(ctx context.Context, entries []LogEntry)
|
||||
for attempt := 0; attempt < c.maxRetries; attempt++ {
|
||||
if err := c.insertBatch(ctx, entries); err != nil {
|
||||
lastErr = err
|
||||
slog.Warn("activitylog: batch insert attempt failed",
|
||||
slog.WarnContext(ctx, "activitylog: batch insert attempt failed", "component", "activitylog",
|
||||
"attempt", attempt+1, "error", err)
|
||||
time.Sleep(time.Duration(attempt+1) * time.Second)
|
||||
continue
|
||||
@@ -131,7 +131,7 @@ func (c *Consumer) RunMemory(ctx context.Context, ch <-chan LogEntry) {
|
||||
case <-ctx.Done():
|
||||
if len(batch) > 0 {
|
||||
if err := c.insertBatch(context.Background(), batch); err != nil {
|
||||
slog.Warn("activity log batch insert failed", "entries", len(batch), "error", err)
|
||||
slog.WarnContext(ctx, "activity log batch insert failed", "component", "activitylog", "entries", len(batch), "error", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -139,7 +139,7 @@ func (c *Consumer) RunMemory(ctx context.Context, ch <-chan LogEntry) {
|
||||
if !ok {
|
||||
if len(batch) > 0 {
|
||||
if err := c.insertBatch(context.Background(), batch); err != nil {
|
||||
slog.Warn("activity log batch insert failed", "entries", len(batch), "error", err)
|
||||
slog.WarnContext(ctx, "activity log batch insert failed", "component", "activitylog", "entries", len(batch), "error", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -147,14 +147,14 @@ func (c *Consumer) RunMemory(ctx context.Context, ch <-chan LogEntry) {
|
||||
batch = append(batch, entry)
|
||||
if len(batch) >= c.batchSize {
|
||||
if err := c.insertBatch(ctx, batch); err != nil {
|
||||
slog.Warn("activity log batch insert failed", "entries", len(batch), "error", err)
|
||||
slog.WarnContext(ctx, "activity log batch insert failed", "component", "activitylog", "entries", len(batch), "error", err)
|
||||
}
|
||||
batch = batch[:0]
|
||||
}
|
||||
case <-ticker.C:
|
||||
if len(batch) > 0 {
|
||||
if err := c.insertBatch(ctx, batch); err != nil {
|
||||
slog.Warn("activity log batch insert failed", "entries", len(batch), "error", err)
|
||||
slog.WarnContext(ctx, "activity log batch insert failed", "component", "activitylog", "entries", len(batch), "error", err)
|
||||
}
|
||||
batch = batch[:0]
|
||||
}
|
||||
@@ -220,7 +220,7 @@ func (c *Consumer) insertBatch(ctx context.Context, entries []LogEntry) error {
|
||||
|
||||
for _, entry := range inserted {
|
||||
if err := c.streamHub.PublishAppend(ctx, logstream.StreamAudit, entry); err != nil {
|
||||
slog.Warn("activitylog: failed to publish log stream append", "error", err, "id", entry.ID)
|
||||
slog.WarnContext(ctx, "activitylog: failed to publish log stream append", "component", "activitylog", "error", err, "id", entry.ID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ func (e *ImageCacheCleanupExecutor) Execute(
|
||||
}
|
||||
n, err := e.s3.DeletePrefix(ctx, bucket, prefix)
|
||||
if err != nil {
|
||||
slog.Warn("image cache cleanup: s3 delete failed", "prefix", prefix, "error", err)
|
||||
slog.WarnContext(ctx, "image cache cleanup: s3 delete failed", "component", "adminjob", "prefix", prefix, "error", err)
|
||||
continue
|
||||
}
|
||||
deletedPrefixes++
|
||||
|
||||
@@ -261,7 +261,7 @@ func (r *Runner) queueImageCacheCleanup(ctx context.Context, createdByUserID int
|
||||
Message: "Queued cached image cleanup",
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("admin jobs: failed to queue image cache cleanup",
|
||||
slog.WarnContext(ctx, "admin jobs: failed to queue image cache cleanup", "component", "adminjob",
|
||||
"library_id", result.LibraryID,
|
||||
"library_name", result.LibraryName,
|
||||
"error", err,
|
||||
@@ -782,7 +782,7 @@ func (r *Runner) heartbeatLoop(ctx context.Context, jobID string, stop <-chan st
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := r.repo.TouchHeartbeat(ctx, jobID); err != nil && !errors.Is(err, ErrJobNotFound) {
|
||||
slog.Warn("admin jobs: failed to touch heartbeat", "job_id", jobID, "error", err)
|
||||
slog.WarnContext(ctx, "admin jobs: failed to touch heartbeat", "component", "adminjob", "job_id", jobID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -823,7 +823,7 @@ func (r *Runner) publishJobByID(ctx context.Context, eventType notifications.Typ
|
||||
job, err := r.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrJobNotFound) {
|
||||
slog.Warn("admin jobs: failed to load job for realtime event", "job_id", id, "error", err)
|
||||
slog.WarnContext(ctx, "admin jobs: failed to load job for realtime event", "component", "adminjob", "job_id", id, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -836,7 +836,7 @@ func (r *Runner) publishJob(ctx context.Context, eventType notifications.Type, j
|
||||
return
|
||||
}
|
||||
if err := r.realtimeHub.PublishJob(ctx, eventType, job); err != nil {
|
||||
slog.Warn("admin jobs: failed to publish realtime job event",
|
||||
slog.WarnContext(ctx, "admin jobs: failed to publish realtime job event", "component", "adminjob",
|
||||
"job_id", job.ID,
|
||||
"type", eventType,
|
||||
"error", err,
|
||||
|
||||
@@ -163,7 +163,7 @@ func (c *Client) Chat(ctx context.Context, messages []Message, jsonObject bool)
|
||||
return fmt.Errorf("chat API error: %s", parsed.Error.Message)
|
||||
}
|
||||
if len(parsed.Choices) == 0 || parsed.Choices[0].Message.Content == "" {
|
||||
slog.Warn("AI chat returned no choices, retrying",
|
||||
slog.WarnContext(ctx, "AI chat returned no choices, retrying", "component", "ai",
|
||||
"model", cfg.ChatModel, "response_bytes", len(respBody))
|
||||
return fmt.Errorf("chat API returned no choices")
|
||||
}
|
||||
@@ -231,7 +231,7 @@ func (c *Client) doWithRetry(ctx context.Context, httpClient *http.Client, label
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusTooManyRequests:
|
||||
wait := rateLimitBackoff(resp, attempt)
|
||||
slog.Warn("rate limited by AI API, waiting", "api", label, "attempt", attempt+1, "wait", wait)
|
||||
slog.WarnContext(ctx, "rate limited by AI API, waiting", "component", "ai", "api", label, "attempt", attempt+1, "wait", wait)
|
||||
lastErr = fmt.Errorf("%s returned 429: %s", label, Truncate(string(respBody), 300))
|
||||
if waitErr := sleepCtx(ctx, wait); waitErr != nil {
|
||||
return waitErr
|
||||
|
||||
@@ -388,7 +388,7 @@ func (h *AdminHandler) HandleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
lastActive, err := h.loadUserLastActiveAt(r.Context(), userIDs)
|
||||
if err != nil {
|
||||
slog.Warn("failed to load admin user last activity", "error", err)
|
||||
slog.WarnContext(r.Context(), "failed to load admin user last activity", "component", "api", "error", err)
|
||||
}
|
||||
for i := range resp {
|
||||
applyLastActiveAt(&resp[i], lastActive)
|
||||
@@ -415,7 +415,7 @@ func (h *AdminHandler) HandleGetUser(w http.ResponseWriter, r *http.Request) {
|
||||
resp := toAdminUserResponse(user)
|
||||
lastActive, err := h.loadUserLastActiveAt(r.Context(), []int{user.ID})
|
||||
if err != nil {
|
||||
slog.Warn("failed to load admin user last activity", "user_id", user.ID, "error", err)
|
||||
slog.WarnContext(r.Context(), "failed to load admin user last activity", "component", "api", "user_id", user.ID, "error", err)
|
||||
}
|
||||
applyLastActiveAt(&resp, lastActive)
|
||||
|
||||
@@ -1018,7 +1018,7 @@ func (h *AdminHandler) publishStatsEvent(ctx context.Context, channel, eventType
|
||||
return
|
||||
}
|
||||
if err := h.EventBus.Publish(ctx, channel, cache.Event{Type: eventType, Payload: payload}); err != nil {
|
||||
slog.Warn("admin: failed to publish stats invalidation event",
|
||||
slog.WarnContext(ctx, "admin: failed to publish stats invalidation event", "component", "api",
|
||||
"channel", channel,
|
||||
"type", eventType,
|
||||
"error", err,
|
||||
@@ -1074,7 +1074,7 @@ func (h *AdminHandler) HandleRefreshItemMetadata(w http.ResponseWriter, r *http.
|
||||
writeError(w, scopeErr.StatusCode, code, scopeErr.Message)
|
||||
return
|
||||
}
|
||||
slog.Error("admin: resolve item refresh scope failed", "content_id", contentID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "admin: resolve item refresh scope failed", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item refresh scope")
|
||||
return
|
||||
}
|
||||
@@ -1086,7 +1086,7 @@ func (h *AdminHandler) HandleRefreshItemMetadata(w http.ResponseWriter, r *http.
|
||||
Message: "Queued item metadata refresh",
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("admin: create item refresh job failed", "content_id", contentID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "admin: create item refresh job failed", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue item metadata refresh")
|
||||
return
|
||||
}
|
||||
@@ -1171,13 +1171,13 @@ func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.R
|
||||
// Try media_items first, then seasons, then episodes.
|
||||
if err := h.DetailSvc.UpdateMediaItemMetadata(r.Context(), contentID, &upd); err != nil {
|
||||
if !errors.Is(err, catalog.ErrItemNotFound) {
|
||||
slog.Error("admin: update item metadata failed", "content_id", contentID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "admin: update item metadata failed", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update metadata")
|
||||
return
|
||||
}
|
||||
if err := h.DetailSvc.UpdateSeasonMetadata(r.Context(), contentID, &upd); err != nil {
|
||||
if !errors.Is(err, catalog.ErrSeasonNotFound) {
|
||||
slog.Error("admin: update season metadata failed", "content_id", contentID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "admin: update season metadata failed", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update metadata")
|
||||
return
|
||||
}
|
||||
@@ -1186,7 +1186,7 @@ func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.R
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
slog.Error("admin: update episode metadata failed", "content_id", contentID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "admin: update episode metadata failed", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update metadata")
|
||||
return
|
||||
}
|
||||
@@ -1203,7 +1203,7 @@ func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.R
|
||||
|
||||
detail, err := h.DetailSvc.GetItemDetail(r.Context(), contentID, catalog.AccessFilter{})
|
||||
if err != nil {
|
||||
slog.Error("admin: fetch updated detail failed", "content_id", contentID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "admin: fetch updated detail failed", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Updated but failed to fetch result")
|
||||
return
|
||||
}
|
||||
@@ -1725,7 +1725,7 @@ func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
profileNames, err := listProfileNamesByID(gctx, store)
|
||||
if err != nil {
|
||||
slog.Warn("admin list devices profile lookup failed",
|
||||
slog.WarnContext(r.Context(), "admin list devices profile lookup failed", "component", "api",
|
||||
"user_id", user.ID,
|
||||
"error", err,
|
||||
)
|
||||
@@ -1743,7 +1743,7 @@ func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
}
|
||||
if err := g.Wait(); err != nil {
|
||||
slog.Error("admin list devices failed", "error", err)
|
||||
slog.ErrorContext(r.Context(), "admin list devices failed", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list devices")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ func (h *AdminImageHandler) HandleGetItemImages(w http.ResponseWriter, r *http.R
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
slog.Error("admin images: resolve content ID failed", "content_id", contentID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "admin images: resolve content ID failed", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item")
|
||||
return
|
||||
}
|
||||
@@ -201,7 +201,7 @@ func (h *AdminImageHandler) HandleGetItemImages(w http.ResponseWriter, r *http.R
|
||||
lookupContentID := resolved.parentItem.ContentID
|
||||
folderID, err := h.resolveImageFolderID(r.Context(), lookupContentID)
|
||||
if err != nil {
|
||||
slog.Error("admin images: resolve folder failed", "content_id", contentID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "admin images: resolve folder failed", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Could not determine library for item")
|
||||
return
|
||||
}
|
||||
@@ -221,7 +221,7 @@ func (h *AdminImageHandler) HandleGetItemImages(w http.ResponseWriter, r *http.R
|
||||
r.Context(), providerIDs, resolved.parentItem.Type, language, folderID,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("admin images: fetch failed", "content_id", contentID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "admin images: fetch failed", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch images")
|
||||
return
|
||||
}
|
||||
@@ -298,7 +298,7 @@ func (h *AdminImageHandler) HandleApplyItemImage(w http.ResponseWriter, r *http.
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
slog.Error("admin images: resolve content ID failed", "content_id", contentID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "admin images: resolve content ID failed", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item")
|
||||
return
|
||||
}
|
||||
@@ -339,7 +339,7 @@ func (h *AdminImageHandler) HandleApplyItemImage(w http.ResponseWriter, r *http.
|
||||
EpisodeNumber: episodeNumber,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("admin images: apply failed", "content_id", contentID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "admin images: apply failed", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to apply image")
|
||||
return
|
||||
}
|
||||
@@ -356,7 +356,7 @@ func (h *AdminImageHandler) HandleApplyItemImage(w http.ResponseWriter, r *http.
|
||||
|
||||
// Persist to the correct table.
|
||||
if err := h.persistImageUpdate(r.Context(), contentID, resolved, &upd); err != nil {
|
||||
slog.Error("admin images: persist failed", "content_id", contentID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "admin images: persist failed", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Image cached but failed to update item")
|
||||
return
|
||||
}
|
||||
@@ -366,7 +366,7 @@ func (h *AdminImageHandler) HandleApplyItemImage(w http.ResponseWriter, r *http.
|
||||
parentLocked := mergeLockedField(resolved.parentItem.LockedFields, int(metadata.FieldImages))
|
||||
parentUpd := catalog.MetadataUpdate{LockedFields: &parentLocked}
|
||||
if lockErr := h.detailSvc.UpdateMediaItemMetadata(r.Context(), resolved.parentItem.ContentID, &parentUpd); lockErr != nil {
|
||||
slog.Error("admin images: failed to lock FieldImages on parent series",
|
||||
slog.ErrorContext(r.Context(), "admin images: failed to lock FieldImages on parent series", "component", "api",
|
||||
"content_id", contentID, "parent_id", resolved.parentItem.ContentID, "error", lockErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func (h *AdminIntroHandler) handleEpisodeMarkers(w http.ResponseWriter, r *http.
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Item must be an episode")
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin intro: resolve episode failed", "episode_id", episodeID, "error", err)
|
||||
h.logger.ErrorContext(r.Context(), "admin intro: resolve episode failed", "episode_id", episodeID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve episode")
|
||||
return
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func (h *AdminIntroHandler) handleEpisodeMarkers(w http.ResponseWriter, r *http.
|
||||
}
|
||||
raw, err := h.Settings.Get(r.Context(), markers.SettingMode)
|
||||
if err != nil {
|
||||
h.logger.Error("admin markers: load mode failed", "episode_id", episodeID, "error", err)
|
||||
h.logger.ErrorContext(r.Context(), "admin markers: load mode failed", "episode_id", episodeID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load marker settings")
|
||||
return
|
||||
}
|
||||
@@ -135,17 +135,17 @@ func (h *AdminIntroHandler) handleEpisodeMarkers(w http.ResponseWriter, r *http.
|
||||
go func() {
|
||||
defer h.inFlight.Delete(episodeID)
|
||||
start := time.Now()
|
||||
h.logger.Info("admin markers: episode refresh started", "episode_id", episodeID, "action", action)
|
||||
h.logger.InfoContext(r.Context(), "admin markers: episode refresh started", "episode_id", episodeID, "action", action)
|
||||
summary, err := h.analyzer.AnalyzeEpisode(h.baseContext, episodeID)
|
||||
if err != nil {
|
||||
h.logger.Error("admin markers: episode refresh failed",
|
||||
h.logger.ErrorContext(r.Context(), "admin markers: episode refresh failed",
|
||||
"episode_id", episodeID,
|
||||
"action", action,
|
||||
"duration", time.Since(start),
|
||||
"error", err)
|
||||
return
|
||||
}
|
||||
h.logger.Info("admin markers: episode refresh finished",
|
||||
h.logger.InfoContext(r.Context(), "admin markers: episode refresh finished",
|
||||
"episode_id", episodeID,
|
||||
"action", action,
|
||||
"duration", time.Since(start),
|
||||
@@ -168,7 +168,7 @@ func (h *AdminIntroHandler) notifyEpisodeMarkerUpdates(ctx context.Context, epis
|
||||
}
|
||||
files, err := h.FileResolver.GetByEpisodeID(ctx, episodeID)
|
||||
if err != nil {
|
||||
h.logger.Warn("admin markers: reload episode files for marker update failed",
|
||||
h.logger.WarnContext(ctx, "admin markers: reload episode files for marker update failed",
|
||||
"episode_id", episodeID,
|
||||
"action", action,
|
||||
"error", err)
|
||||
@@ -179,7 +179,7 @@ func (h *AdminIntroHandler) notifyEpisodeMarkerUpdates(ctx context.Context, epis
|
||||
continue
|
||||
}
|
||||
h.MarkerUpdateNotifier.MarkersUpdated(ctx, file)
|
||||
h.logger.Info("admin markers: emitted marker update",
|
||||
h.logger.InfoContext(ctx, "admin markers: emitted marker update",
|
||||
"episode_id", episodeID,
|
||||
"action", action,
|
||||
"file_id", file.ID)
|
||||
|
||||
@@ -245,7 +245,7 @@ func (h *AdminLogsHandler) HandleLogStreamWebSocket(w http.ResponseWriter, r *ht
|
||||
}
|
||||
result, err := h.opsRepo.List(context.Background(), appOpts)
|
||||
if err != nil {
|
||||
slog.Error("admin log stream operational snapshot failed", "error", err)
|
||||
slog.ErrorContext(r.Context(), "admin log stream operational snapshot failed", "component", "api", "error", err)
|
||||
h.writeStreamError(conn, stream, "internal_error", "Failed to query operational logs")
|
||||
return
|
||||
}
|
||||
@@ -285,7 +285,7 @@ func (h *AdminLogsHandler) HandleLogStreamWebSocket(w http.ResponseWriter, r *ht
|
||||
}
|
||||
result, err := h.auditRepo.List(context.Background(), auditOpts)
|
||||
if err != nil {
|
||||
slog.Error("admin log stream audit snapshot failed", "error", err)
|
||||
slog.ErrorContext(r.Context(), "admin log stream audit snapshot failed", "component", "api", "error", err)
|
||||
h.writeStreamError(conn, stream, "internal_error", "Failed to query audit logs")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ func (h *AdminMarkerProvidersHandler) HandleUpdateProvider(w http.ResponseWriter
|
||||
existing.ContributeMinConfidence = v
|
||||
}
|
||||
if err := h.Config.Update(r.Context(), existing); err != nil {
|
||||
h.logger.Error("admin markers: update provider config failed", "provider", provider, "error", err)
|
||||
h.logger.ErrorContext(r.Context(), "admin markers: update provider config failed", "provider", provider, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update provider")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ func (h *AdminMatchHandler) HandleSearchItemMatchCandidates(w http.ResponseWrite
|
||||
// Load the existing item to infer content type.
|
||||
item, err := h.items.GetByID(r.Context(), contentID)
|
||||
if err != nil {
|
||||
slog.Warn("admin match: item not found", "content_id", contentID, "error", err)
|
||||
slog.WarnContext(r.Context(), "admin match: item not found", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func (h *AdminMatchHandler) HandleSearchItemMatchCandidates(w http.ResponseWrite
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "library_id is required for items in multiple libraries")
|
||||
return
|
||||
}
|
||||
slog.Warn("admin match: resolve folder failed", "content_id", contentID, "error", err)
|
||||
slog.WarnContext(r.Context(), "admin match: resolve folder failed", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library_id")
|
||||
return
|
||||
}
|
||||
@@ -137,7 +137,7 @@ func (h *AdminMatchHandler) HandleSearchItemMatchCandidates(w http.ResponseWrite
|
||||
|
||||
candidates, err := h.metadata.SearchAndNormalize(r.Context(), query, folderID)
|
||||
if err != nil {
|
||||
slog.Error("admin match: search failed", "content_id", contentID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "admin match: search failed", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Metadata search failed")
|
||||
return
|
||||
}
|
||||
@@ -173,7 +173,7 @@ func (h *AdminMatchHandler) HandleApplyItemMatch(w http.ResponseWriter, r *http.
|
||||
// Verify the item exists.
|
||||
_, err := h.items.GetByID(r.Context(), contentID)
|
||||
if err != nil {
|
||||
slog.Warn("admin match: item not found for apply", "content_id", contentID, "error", err)
|
||||
slog.WarnContext(r.Context(), "admin match: item not found for apply", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func (h *AdminMatchHandler) HandleApplyItemMatch(w http.ResponseWriter, r *http.
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "library_id is required for items in multiple libraries")
|
||||
return
|
||||
}
|
||||
slog.Warn("admin match: resolve folder failed for apply", "content_id", contentID, "error", err)
|
||||
slog.WarnContext(r.Context(), "admin match: resolve folder failed for apply", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library_id")
|
||||
return
|
||||
}
|
||||
@@ -200,7 +200,7 @@ func (h *AdminMatchHandler) HandleApplyItemMatch(w http.ResponseWriter, r *http.
|
||||
Mode: metadata.ModeIdentify,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("admin match: apply failed", "content_id", contentID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "admin match: apply failed", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to apply match")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ func (h *AdminSplitHandler) HandleListItemFiles(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
files, err := h.loadItemFiles(r.Context(), contentID)
|
||||
if err != nil {
|
||||
slog.Error("admin split: listing item files", "content_id", contentID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "admin split: listing item files", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files")
|
||||
return
|
||||
}
|
||||
@@ -196,7 +196,7 @@ func (h *AdminSplitHandler) HandleSplitItem(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
files, err := h.loadItemFiles(ctx, sourceID)
|
||||
if err != nil {
|
||||
slog.Error("admin split: loading item files", "content_id", sourceID, "error", err)
|
||||
slog.ErrorContext(ctx, "admin split: loading item files", "component", "api", "content_id", sourceID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files")
|
||||
return
|
||||
}
|
||||
@@ -234,7 +234,7 @@ func (h *AdminSplitHandler) HandleSplitItem(w http.ResponseWriter, r *http.Reque
|
||||
// Everything transactional happens here; a dry run rolls back at the end.
|
||||
tx, err := h.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
slog.Error("admin split: begin transaction", "error", err)
|
||||
slog.ErrorContext(ctx, "admin split: begin transaction", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start split")
|
||||
return
|
||||
}
|
||||
@@ -242,13 +242,13 @@ func (h *AdminSplitHandler) HandleSplitItem(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
if target.created {
|
||||
if err := insertSkeletonItem(ctx, tx, target, sourceItem); err != nil {
|
||||
slog.Error("admin split: creating target item", "target", target.contentID, "error", err)
|
||||
slog.ErrorContext(ctx, "admin split: creating target item", "component", "api", "target", target.contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create target item")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := moveFilesToItem(ctx, tx, target.contentID, moved); err != nil {
|
||||
slog.Error("admin split: moving files", "target", target.contentID, "error", err)
|
||||
slog.ErrorContext(ctx, "admin split: moving files", "component", "api", "target", target.contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to move files")
|
||||
return
|
||||
}
|
||||
@@ -258,7 +258,7 @@ func (h *AdminSplitHandler) HandleSplitItem(w http.ResponseWriter, r *http.Reque
|
||||
if persistOverride && target.hasForcedIdentity() {
|
||||
rootOverrides, fileOverrides, err = h.persistOverrides(ctx, tx, moved, target, middleware.GetUserID(ctx))
|
||||
if err != nil {
|
||||
slog.Error("admin split: persisting overrides", "error", err)
|
||||
slog.ErrorContext(ctx, "admin split: persisting overrides", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to persist identity overrides")
|
||||
return
|
||||
}
|
||||
@@ -272,18 +272,18 @@ func (h *AdminSplitHandler) HandleSplitItem(w http.ResponseWriter, r *http.Reque
|
||||
EpisodePairs: episodePairs,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("admin split: reattributing user state", "error", err)
|
||||
slog.ErrorContext(ctx, "admin split: reattributing user state", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to reattribute user state")
|
||||
return
|
||||
}
|
||||
|
||||
if !req.DryRun {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
slog.Error("admin split: commit", "error", err)
|
||||
slog.ErrorContext(ctx, "admin split: commit", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to commit split")
|
||||
return
|
||||
}
|
||||
slog.Info("admin split: item split",
|
||||
slog.InfoContext(ctx, "admin split: item split", "component", "api",
|
||||
"actor_user_id", middleware.GetUserID(ctx),
|
||||
"source_content_id", sourceID,
|
||||
"target_content_id", target.contentID,
|
||||
@@ -337,18 +337,18 @@ func (h *AdminSplitHandler) HandleMergeItem(w http.ResponseWriter, r *http.Reque
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
slog.Warn("admin merge: failed", "source", sourceID, "target", req.Into, "error", err)
|
||||
slog.WarnContext(r.Context(), "admin merge: failed", "component", "api", "source", sourceID, "target", req.Into, "error", err)
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
slog.Info("admin merge: item merged",
|
||||
slog.InfoContext(r.Context(), "admin merge: item merged", "component", "api",
|
||||
"actor_user_id", middleware.GetUserID(r.Context()),
|
||||
"source_content_id", sourceID,
|
||||
"target_content_id", req.Into,
|
||||
)
|
||||
if h.refresher != nil {
|
||||
if err := h.refresher.RefreshItem(context.WithoutCancel(r.Context()), req.Into); err != nil {
|
||||
slog.Warn("admin merge: target refresh failed", "content_id", req.Into, "error", err)
|
||||
slog.WarnContext(r.Context(), "admin merge: target refresh failed", "component", "api", "content_id", req.Into, "error", err)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"merged_into": req.Into})
|
||||
|
||||
@@ -219,7 +219,7 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
|
||||
activity, err := queryWatchProviderActivity(ctx, pool)
|
||||
if err != nil {
|
||||
slog.Warn("failed to query watch provider admin stats", "error", err)
|
||||
slog.WarnContext(ctx, "failed to query watch provider admin stats", "component", "api", "error", err)
|
||||
activity = WatchProviderActivity{}
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ func (h *AutoscanHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Re
|
||||
if terr := h.triggers.UpdateTriggers(autoscanPollTaskKey, []taskmanager.TriggerConfig{
|
||||
{Type: taskmanager.TriggerTypeInterval, IntervalMs: intervalMs},
|
||||
}); terr != nil {
|
||||
slog.WarnContext(r.Context(), "autoscan: reschedule poll task failed", "err", terr)
|
||||
slog.WarnContext(r.Context(), "autoscan: reschedule poll task failed", "component", "api", "err", terr)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, settingsResponse(updated))
|
||||
@@ -687,7 +687,7 @@ func (h *AutoscanHandler) HandleTrigger(w http.ResponseWriter, r *http.Request)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
if err := h.svc.PollOnce(ctx); err != nil {
|
||||
slog.WarnContext(ctx, "autoscan: manual poll failed", "err", err)
|
||||
slog.WarnContext(ctx, "autoscan: manual poll failed", "component", "api", "err", err)
|
||||
}
|
||||
}()
|
||||
writeJSON(w, http.StatusAccepted, struct {
|
||||
|
||||
@@ -241,7 +241,7 @@ func (h *CalendarHandler) resolveWatched(ctx context.Context, af catalog.AccessF
|
||||
}
|
||||
watched, err := h.personal.ListWatchedItemIDs(ctx, af.UserID, af.ProfileID, ids)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "calendar watched overlay failed", "error", err)
|
||||
slog.WarnContext(ctx, "calendar watched overlay failed", "component", "api", "error", err)
|
||||
return map[string]bool{}
|
||||
}
|
||||
return watched
|
||||
|
||||
@@ -114,7 +114,7 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request
|
||||
writeError(w, http.StatusNotFound, "not_found", "Catalog source not found")
|
||||
return
|
||||
}
|
||||
slog.Error("catalog: resolve grouped by work failed", "err_msg", err.Error())
|
||||
slog.ErrorContext(r.Context(), "catalog: resolve grouped by work failed", "component", "api", "err_msg", err.Error())
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve catalog")
|
||||
return
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request
|
||||
writeError(w, http.StatusNotFound, "not_found", "Catalog source not found")
|
||||
return
|
||||
}
|
||||
slog.Error("catalog: resolve failed", "err_msg", err.Error())
|
||||
slog.ErrorContext(r.Context(), "catalog: resolve failed", "component", "api", "err_msg", err.Error())
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve catalog")
|
||||
return
|
||||
}
|
||||
@@ -731,7 +731,7 @@ func (h *CatalogHandler) HandleLegacySearch(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
items, total, err := h.itemsH.itemRepo.Search(r.Context(), query, parseSearchTypes(r.URL.Query()["type"]), limit, offset, h.itemsH.accessFilter(r))
|
||||
if err != nil {
|
||||
slog.Error("search failed", "query", query, "error", err)
|
||||
slog.ErrorContext(r.Context(), "search failed", "component", "api", "query", query, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Search failed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ func (h *DownloadHandler) HandleCapability(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
capInfo, err := h.svc.Capability(r.Context(), userID)
|
||||
if err != nil {
|
||||
slog.Error("failed to load download capability", "user_id", userID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "failed to load download capability", "component", "api", "user_id", userID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load download capability")
|
||||
return
|
||||
}
|
||||
@@ -297,7 +297,7 @@ func (h *DownloadHandler) HandleListDownloads(w http.ResponseWriter, r *http.Req
|
||||
writeError(w, http.StatusBadRequest, "profile_required", "A profile is required for managed downloads")
|
||||
return
|
||||
}
|
||||
slog.Error("failed to list downloads", "user_id", userID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "failed to list downloads", "component", "api", "user_id", userID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list downloads")
|
||||
return
|
||||
}
|
||||
@@ -336,7 +336,7 @@ func (h *DownloadHandler) HandleDeleteDownload(w http.ResponseWriter, r *http.Re
|
||||
writeError(w, http.StatusBadRequest, "profile_required", "A profile is required for managed downloads")
|
||||
return
|
||||
}
|
||||
slog.Error("failed to delete download", "download_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "failed to delete download", "component", "api", "download_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete download")
|
||||
return
|
||||
}
|
||||
@@ -371,7 +371,7 @@ func (h *DownloadHandler) HandlePatchDownload(w http.ResponseWriter, r *http.Req
|
||||
case errors.Is(err, downloads.ErrProfileRequired):
|
||||
writeError(w, http.StatusBadRequest, "profile_required", "A profile is required for managed downloads")
|
||||
default:
|
||||
slog.Error("failed to patch download", "download_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "failed to patch download", "component", "api", "download_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update download")
|
||||
}
|
||||
return
|
||||
@@ -415,7 +415,7 @@ func (h *DownloadHandler) HandleDownloadFile(w http.ResponseWriter, r *http.Requ
|
||||
writeError(w, http.StatusForbidden, "forbidden", "You are not allowed to download")
|
||||
return
|
||||
}
|
||||
slog.Error("failed to serve download file", "download_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "failed to serve download file", "component", "api", "download_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to serve download")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ func publishEventJob(ctx context.Context, hub *evt.Hub, eventName string, job *m
|
||||
if err := hub.PublishJSON(ctx, evt.ChannelJobs, eventName, job, evt.PublishOptions{
|
||||
AdminOnly: true,
|
||||
}); err != nil {
|
||||
slog.Warn("events: failed to publish job event", "job_id", job.ID, "event", eventName, "error", err)
|
||||
slog.WarnContext(ctx, "events: failed to publish job event", "component", "api", "job_id", job.ID, "event", eventName, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,6 @@ func publishEventMetadataUpdate(ctx context.Context, hub *evt.Hub, libraryID int
|
||||
"content_id": contentID,
|
||||
"change": "metadata_updated",
|
||||
}, evt.PublishOptions{}); err != nil {
|
||||
slog.Warn("events: failed to publish metadata update", "library_id", libraryID, "content_id", contentID, "error", err)
|
||||
slog.WarnContext(ctx, "events: failed to publish metadata update", "component", "api", "library_id", libraryID, "content_id", contentID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
// the client retries it when its notifications subscription is
|
||||
// rejected — whereas a hard 403 would take down every realtime
|
||||
// channel over a notifications-only concern.
|
||||
slog.Warn("events: websocket ticket rejected; connection unbound",
|
||||
slog.WarnContext(r.Context(), "events: websocket ticket rejected; connection unbound", "component", "api",
|
||||
"user_id", claims.UserID)
|
||||
}
|
||||
}
|
||||
@@ -463,8 +463,8 @@ func (h *EventsHandler) writeSnapshotFrame(
|
||||
) error {
|
||||
data, err := h.snapshotForChannel(r, claims, boundProfileID, channel)
|
||||
if err != nil {
|
||||
slog.Error(
|
||||
"events: failed to build initial snapshot",
|
||||
slog.ErrorContext(r.Context(),
|
||||
"events: failed to build initial snapshot", "component", "api",
|
||||
"channel",
|
||||
channel,
|
||||
"user_id",
|
||||
@@ -498,7 +498,7 @@ func (h *EventsHandler) writeEventFrame(
|
||||
// writeSnapshotFrame): durable state covers the gap on the next
|
||||
// event or reconnect, while closing the socket tears down every
|
||||
// channel the client subscribed to.
|
||||
slog.Error("events: failed to build event payload", "channel", env.Channel, "event", env.Event, "error", err)
|
||||
slog.ErrorContext(r.Context(), "events: failed to build event payload", "component", "api", "channel", env.Channel, "event", env.Event, "error", err)
|
||||
return nil
|
||||
}
|
||||
data = snapshot
|
||||
|
||||
@@ -50,7 +50,7 @@ func (h *HistoryImportHandler) HandleLoginConnect(w http.ResponseWriter, r *http
|
||||
|
||||
session, err := h.service.LoginConnect(r.Context(), userID, req)
|
||||
if err != nil {
|
||||
slog.Error("history import emby connect login failed", "user_id", userID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "history import emby connect login failed", "component", "api", "user_id", userID, "error", err)
|
||||
h.writeHistoryImportError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -120,7 +120,7 @@ func (h *HistoryImportHandler) HandleCreatePlexPin(w http.ResponseWriter, r *htt
|
||||
}
|
||||
pin, err := h.service.CreatePlexPin(r.Context(), userID)
|
||||
if err != nil {
|
||||
slog.Error("history import plex pin creation failed", "user_id", userID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "history import plex pin creation failed", "component", "api", "user_id", userID, "error", err)
|
||||
h.writeHistoryImportError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func (h *HistoryImportHandler) HandleAdminDiscoverUsers(w http.ResponseWriter, r
|
||||
}
|
||||
users, err := h.service.DiscoverExternalUsers(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Error("history import: discover external users failed", "source_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "history import: discover external users failed", "component", "api", "source_id", id, "error", err)
|
||||
h.writeDiscoverError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -281,7 +281,7 @@ func (h *HistoryImportHandler) HandleAdminPlexLogin(w http.ResponseWriter, r *ht
|
||||
}
|
||||
token, err := h.service.AuthenticatePlex(r.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
slog.Error("history import: plex login failed", "error", err)
|
||||
slog.ErrorContext(r.Context(), "history import: plex login failed", "component", "api", "error", err)
|
||||
h.writeDiscoverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ func (h *ItemsHandler) requestStaleMetadataRefresh(ctx context.Context, targetTy
|
||||
return
|
||||
}
|
||||
if err := h.metadataRefreshRequester.RequestStaleMetadataRefresh(ctx, targetType, contentID); err != nil {
|
||||
slog.Warn("catalog: failed to request stale metadata refresh",
|
||||
slog.WarnContext(ctx, "catalog: failed to request stale metadata refresh", "component", "api",
|
||||
"target_type", targetType,
|
||||
"content_id", contentID,
|
||||
"error", err)
|
||||
@@ -457,7 +457,7 @@ func (h *ItemsHandler) dispatchLocalWatchEvent(
|
||||
ProfileID: profileID,
|
||||
Plays: plays,
|
||||
}); err != nil {
|
||||
slog.Warn("failed to queue local watch provider event", "kind", kind, "user_id", userID, "profile_id", profileID, "error", err)
|
||||
slog.WarnContext(ctx, "failed to queue local watch provider event", "component", "api", "kind", kind, "user_id", userID, "profile_id", profileID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1830,7 +1830,7 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
|
||||
if userID != 0 {
|
||||
user, userErr := h.UserRepo.GetByID(r.Context(), userID)
|
||||
if userErr != nil {
|
||||
slog.Error("looking up user for library access", "error", userErr)
|
||||
slog.ErrorContext(r.Context(), "looking up user for library access", "component", "api", "error", userErr)
|
||||
} else {
|
||||
if user.LibraryIDs != nil {
|
||||
libraryIDs = user.LibraryIDs
|
||||
@@ -2004,7 +2004,7 @@ func (h *ItemsHandler) requestCanViewFilePaths(r *http.Request) bool {
|
||||
}
|
||||
user, err := h.UserRepo.GetByID(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
slog.WarnContext(r.Context(), "checking file path visibility permissions", "user_id", claims.UserID, "error", err)
|
||||
slog.WarnContext(r.Context(), "checking file path visibility permissions", "component", "api", "user_id", claims.UserID, "error", err)
|
||||
return false
|
||||
}
|
||||
return auth.HasEffectivePermission(user, auth.PermissionMetadataCuration)
|
||||
|
||||
@@ -424,7 +424,7 @@ func (h *LibraryHandler) HandleListUserLibraries(w http.ResponseWriter, r *http.
|
||||
if h.userRepo != nil {
|
||||
user, userErr := h.userRepo.GetByID(r.Context(), userID)
|
||||
if userErr != nil {
|
||||
slog.Error("looking up user for library access", "error", userErr)
|
||||
slog.ErrorContext(r.Context(), "looking up user for library access", "component", "api", "error", userErr)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to look up user")
|
||||
return
|
||||
}
|
||||
@@ -440,7 +440,7 @@ func (h *LibraryHandler) HandleListUserLibraries(w http.ResponseWriter, r *http.
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.Error("listing user libraries", "error", err)
|
||||
slog.ErrorContext(r.Context(), "listing user libraries", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list libraries")
|
||||
return
|
||||
}
|
||||
@@ -472,7 +472,7 @@ func (h *LibraryHandler) HandleListUserLibraries(w http.ResponseWriter, r *http.
|
||||
func (h *LibraryHandler) HandleListLibraries(w http.ResponseWriter, r *http.Request) {
|
||||
folders, err := h.folderRepo.List(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("listing libraries", "error", err)
|
||||
slog.ErrorContext(r.Context(), "listing libraries", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list libraries")
|
||||
return
|
||||
}
|
||||
@@ -498,7 +498,7 @@ func (h *LibraryHandler) HandleReorderLibraries(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
if err := h.folderRepo.Reorder(r.Context(), req.Entries); err != nil {
|
||||
slog.Error("reordering libraries", "error", err)
|
||||
slog.ErrorContext(r.Context(), "reordering libraries", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to reorder libraries")
|
||||
return
|
||||
}
|
||||
@@ -514,7 +514,7 @@ func (h *LibraryHandler) HandleListSkippedRoots(w http.ResponseWriter, r *http.R
|
||||
|
||||
folders, err := h.folderRepo.List(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("listing libraries for skipped roots", "error", err)
|
||||
slog.ErrorContext(r.Context(), "listing libraries for skipped roots", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list libraries")
|
||||
return
|
||||
}
|
||||
@@ -526,7 +526,7 @@ func (h *LibraryHandler) HandleListSkippedRoots(w http.ResponseWriter, r *http.R
|
||||
|
||||
roots, err := h.SkippedRootRepo.ListAll(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("listing skipped roots", "error", err)
|
||||
slog.ErrorContext(r.Context(), "listing skipped roots", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list skipped roots")
|
||||
return
|
||||
}
|
||||
@@ -583,7 +583,7 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
writeError(w, http.StatusConflict, "conflict", "A library with this path already exists")
|
||||
return
|
||||
}
|
||||
slog.Error("creating library", "error", err)
|
||||
slog.ErrorContext(r.Context(), "creating library", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create library")
|
||||
return
|
||||
}
|
||||
@@ -591,15 +591,15 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
// Seed default sections for the new library.
|
||||
if h.SectionRepo != nil {
|
||||
if seedErr := h.SectionRepo.SeedDefaults(r.Context(), "library", &folder.ID, sections.DefaultLibrarySectionsForType(&folder.ID, folder.Type)); seedErr != nil {
|
||||
slog.Warn("seed default sections for new library", "library_id", folder.ID, "error", seedErr)
|
||||
slog.WarnContext(r.Context(), "seed default sections for new library", "component", "api", "library_id", folder.ID, "error", seedErr)
|
||||
}
|
||||
if sections.IsAudiobookLibraryType(folder.Type) {
|
||||
if _, seedErr := h.SectionRepo.EnsureHomeContinueListeningSection(r.Context()); seedErr != nil {
|
||||
slog.Warn("ensure home continue listening section", "library_id", folder.ID, "error", seedErr)
|
||||
slog.WarnContext(r.Context(), "ensure home continue listening section", "component", "api", "library_id", folder.ID, "error", seedErr)
|
||||
}
|
||||
}
|
||||
if _, seedErr := h.SectionRepo.CreateGeneratedHomeLibraryRecentSections(r.Context(), folder.ID, folder.Name, folder.Type); seedErr != nil {
|
||||
slog.Warn("seed generated home sections for new library", "library_id", folder.ID, "error", seedErr)
|
||||
slog.WarnContext(r.Context(), "seed generated home sections for new library", "component", "api", "library_id", folder.ID, "error", seedErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,7 +608,7 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
entries := h.seedDefaultChain(r.Context(), req.Type)
|
||||
if len(entries) > 0 {
|
||||
if seedErr := h.ChainRepo.SetChain(r.Context(), folder.ID, entries); seedErr != nil {
|
||||
slog.Warn("seed default chain failed", "folder_id", folder.ID, "error", seedErr)
|
||||
slog.WarnContext(r.Context(), "seed default chain failed", "component", "api", "folder_id", folder.ID, "error", seedErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -616,7 +616,7 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
// Kick off an initial scan so content appears immediately.
|
||||
if h.ScanQueue != nil {
|
||||
if _, err := h.ScanQueue.EnqueueLibraryScan(r.Context(), folder.ID, "library_created"); err != nil {
|
||||
slog.Warn("queue initial library scan failed", "library_id", folder.ID, "error", err)
|
||||
slog.WarnContext(r.Context(), "queue initial library scan failed", "component", "api", "library_id", folder.ID, "error", err)
|
||||
}
|
||||
} else {
|
||||
initialScanID := ulid.Make().String()
|
||||
@@ -660,7 +660,7 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
||||
return
|
||||
}
|
||||
slog.Error("fetching library for update", "error", err)
|
||||
slog.ErrorContext(r.Context(), "fetching library for update", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library")
|
||||
return
|
||||
}
|
||||
@@ -685,7 +685,7 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
writeError(w, http.StatusConflict, "conflict", "A library with this path already exists")
|
||||
return
|
||||
}
|
||||
slog.Error("updating library", "error", err)
|
||||
slog.ErrorContext(r.Context(), "updating library", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update library")
|
||||
return
|
||||
}
|
||||
@@ -693,14 +693,14 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
// Fetch the updated folder to return it.
|
||||
folder, err := h.folderRepo.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Error("fetching updated library", "error", err)
|
||||
slog.ErrorContext(r.Context(), "fetching updated library", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch updated library")
|
||||
return
|
||||
}
|
||||
|
||||
if h.SectionRepo != nil && oldFolder.Name != folder.Name {
|
||||
if syncErr := h.SectionRepo.SyncGeneratedHomeLibraryRecentTitles(r.Context(), id, oldFolder.Name, folder.Name); syncErr != nil {
|
||||
slog.Warn("sync generated home section titles", "library_id", id, "error", syncErr)
|
||||
slog.WarnContext(r.Context(), "sync generated home section titles", "component", "api", "library_id", id, "error", syncErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -717,7 +717,7 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
if jobErr != nil {
|
||||
var conflict *adminjob.ActiveJobConflictError
|
||||
if !errors.As(jobErr, &conflict) {
|
||||
slog.Warn("queue language-change metadata refresh failed", "library_id", folder.ID, "error", jobErr)
|
||||
slog.WarnContext(r.Context(), "queue language-change metadata refresh failed", "component", "api", "library_id", folder.ID, "error", jobErr)
|
||||
}
|
||||
} else {
|
||||
publishEventJob(r.Context(), h.EventsHub, "job.created", job)
|
||||
@@ -728,7 +728,7 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
if req.Paths != nil && !slices.Equal(oldFolder.Paths, *req.Paths) {
|
||||
if h.ScanQueue != nil {
|
||||
if _, err := h.ScanQueue.EnqueueLibraryScan(r.Context(), folder.ID, "library_paths_changed"); err != nil {
|
||||
slog.Warn("queue library path-change scan failed", "library_id", folder.ID, "error", err)
|
||||
slog.WarnContext(r.Context(), "queue library path-change scan failed", "component", "api", "library_id", folder.ID, "error", err)
|
||||
}
|
||||
} else {
|
||||
updateScanID := ulid.Make().String()
|
||||
@@ -763,7 +763,7 @@ func (h *LibraryHandler) HandleDeleteLibrary(w http.ResponseWriter, r *http.Requ
|
||||
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
||||
return
|
||||
}
|
||||
slog.Error("fetching library before delete", "library_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "fetching library before delete", "component", "api", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load library")
|
||||
return
|
||||
}
|
||||
@@ -772,7 +772,7 @@ func (h *LibraryHandler) HandleDeleteLibrary(w http.ResponseWriter, r *http.Requ
|
||||
if wasEnabled {
|
||||
disabled := false
|
||||
if err := h.folderRepo.Update(r.Context(), folder.ID, catalog.UpdateFolderInput{Enabled: &disabled}); err != nil {
|
||||
slog.Error("disabling library before delete", "library_id", folder.ID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "disabling library before delete", "component", "api", "library_id", folder.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare library deletion")
|
||||
return
|
||||
}
|
||||
@@ -792,7 +792,7 @@ func (h *LibraryHandler) HandleDeleteLibrary(w http.ResponseWriter, r *http.Requ
|
||||
if wasEnabled {
|
||||
enabled := true
|
||||
if revertErr := h.folderRepo.Update(r.Context(), folder.ID, catalog.UpdateFolderInput{Enabled: &enabled}); revertErr != nil {
|
||||
slog.Error("re-enabling library after failed delete queue",
|
||||
slog.ErrorContext(r.Context(), "re-enabling library after failed delete queue", "component", "api",
|
||||
"library_id", folder.ID,
|
||||
"queue_error", err,
|
||||
"revert_error", revertErr,
|
||||
@@ -805,21 +805,21 @@ func (h *LibraryHandler) HandleDeleteLibrary(w http.ResponseWriter, r *http.Requ
|
||||
writeAdminJobConflict(w, "A library deletion is already queued or running", conflict.Job, jobsHandler, r)
|
||||
return
|
||||
}
|
||||
slog.Error("queuing library delete job", "library_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "queuing library delete job", "component", "api", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue library delete")
|
||||
return
|
||||
}
|
||||
|
||||
if h.ingester != nil {
|
||||
cancelled := h.ingester.CancelLibrary(folder.ID)
|
||||
slog.Info("library delete: cancelled running scans", "library_id", folder.ID, "cancelled", cancelled)
|
||||
slog.InfoContext(r.Context(), "library delete: cancelled running scans", "component", "api", "library_id", folder.ID, "cancelled", cancelled)
|
||||
}
|
||||
if h.ScanQueue != nil {
|
||||
queuedCancelled, err := h.ScanQueue.CancelAcceptedByLibrary(r.Context(), folder.ID)
|
||||
if err != nil {
|
||||
slog.Warn("library delete: failed to cancel queued scans", "library_id", folder.ID, "error", err)
|
||||
slog.WarnContext(r.Context(), "library delete: failed to cancel queued scans", "component", "api", "library_id", folder.ID, "error", err)
|
||||
} else if queuedCancelled > 0 {
|
||||
slog.Info("library delete: cancelled queued scans", "library_id", folder.ID, "cancelled", queuedCancelled)
|
||||
slog.InfoContext(r.Context(), "library delete: cancelled queued scans", "component", "api", "library_id", folder.ID, "cancelled", queuedCancelled)
|
||||
}
|
||||
}
|
||||
publishEventJob(r.Context(), h.EventsHub, "job.created", job)
|
||||
@@ -842,7 +842,7 @@ func (h *LibraryHandler) HandleCheckLibraryMount(w http.ResponseWriter, r *http.
|
||||
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
||||
return
|
||||
}
|
||||
slog.Error("fetching library for mount check", "error", err)
|
||||
slog.ErrorContext(r.Context(), "fetching library for mount check", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library")
|
||||
return
|
||||
}
|
||||
@@ -854,7 +854,7 @@ func (h *LibraryHandler) HandleCheckLibraryMount(w http.ResponseWriter, r *http.
|
||||
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
||||
return
|
||||
}
|
||||
slog.Error("clearing empty-root warning after successful mount check", "library_id", folder.ID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "clearing empty-root warning after successful mount check", "component", "api", "library_id", folder.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear library warning")
|
||||
return
|
||||
}
|
||||
@@ -882,14 +882,14 @@ func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, reqErr.Status, reqErr.Code, reqErr.Message)
|
||||
return
|
||||
}
|
||||
slog.Error("resolving scan target", "error", err)
|
||||
slog.ErrorContext(r.Context(), "resolving scan target", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve scan target")
|
||||
return
|
||||
}
|
||||
|
||||
if h.ScanQueue != nil {
|
||||
if _, err := h.ScanQueue.EnqueueScan(r.Context(), target.Folder.ID, target.Mode, target.Path, target.Trigger); err != nil {
|
||||
slog.Error("queueing library scan", "library_id", target.Folder.ID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "queueing library scan", "component", "api", "library_id", target.Folder.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue scan")
|
||||
return
|
||||
}
|
||||
@@ -937,7 +937,7 @@ func (h *LibraryHandler) HandleScanCancel(w http.ResponseWriter, r *http.Request
|
||||
if h.ScanQueue != nil {
|
||||
queuedCancelled, err := h.ScanQueue.CancelByLibrary(r.Context(), req.LibraryID)
|
||||
if err != nil {
|
||||
slog.Error("cancel library scans", "library_id", req.LibraryID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "cancel library scans", "component", "api", "library_id", req.LibraryID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel scans")
|
||||
return
|
||||
}
|
||||
@@ -949,7 +949,7 @@ func (h *LibraryHandler) HandleScanCancel(w http.ResponseWriter, r *http.Request
|
||||
for _, run := range h.cancelActiveScans(req.LibraryID) {
|
||||
h.publishScanEvent(r.Context(), "scan.cancelled", run)
|
||||
}
|
||||
slog.Info("scan: cancelled running scans",
|
||||
slog.InfoContext(r.Context(), "scan: cancelled running scans", "component", "api",
|
||||
"library_id", req.LibraryID,
|
||||
"cancelled", cancelled,
|
||||
)
|
||||
@@ -1422,7 +1422,7 @@ func (h *LibraryHandler) HandleListMetadataMatchQueues(w http.ResponseWriter, r
|
||||
|
||||
folders, err := h.folderRepo.List(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to list libraries", "error", err)
|
||||
slog.ErrorContext(r.Context(), "metadata queue: failed to list libraries", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queues")
|
||||
return
|
||||
}
|
||||
@@ -1434,7 +1434,7 @@ func (h *LibraryHandler) HandleListMetadataMatchQueues(w http.ResponseWriter, r
|
||||
}
|
||||
status, err := h.metadataMatchQueueStatus(r.Context(), folder.ID)
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to load queue status", "library_id", folder.ID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "metadata queue: failed to load queue status", "component", "api", "library_id", folder.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue")
|
||||
return
|
||||
}
|
||||
@@ -1473,7 +1473,7 @@ func (h *LibraryHandler) HandleGetMetadataMatchQueue(w http.ResponseWriter, r *h
|
||||
|
||||
status, err := h.metadataMatchQueueStatus(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to load queue status", "library_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "metadata queue: failed to load queue status", "component", "api", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue")
|
||||
return
|
||||
}
|
||||
@@ -1487,7 +1487,7 @@ func (h *LibraryHandler) HandleGetMetadataMatchQueue(w http.ResponseWriter, r *h
|
||||
if h.MovieMatchQueueRepo != nil {
|
||||
movies, _, err := h.MovieMatchQueueRepo.ListByFolder(r.Context(), id, limit, 0)
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to list movie queue", "library_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "metadata queue: failed to list movie queue", "component", "api", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queue")
|
||||
return
|
||||
}
|
||||
@@ -1508,7 +1508,7 @@ func (h *LibraryHandler) HandleGetMetadataMatchQueue(w http.ResponseWriter, r *h
|
||||
if h.SeriesMatchQueueRepo != nil {
|
||||
series, _, err := h.SeriesMatchQueueRepo.ListByFolder(r.Context(), id, limit, 0)
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to list series queue", "library_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "metadata queue: failed to list series queue", "component", "api", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queue")
|
||||
return
|
||||
}
|
||||
@@ -1528,7 +1528,7 @@ func (h *LibraryHandler) HandleGetMetadataMatchQueue(w http.ResponseWriter, r *h
|
||||
if h.RawMatchBacklogRepo != nil {
|
||||
rawFiles, _, err := h.RawMatchBacklogRepo.ListUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode(), limit, 0)
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to list raw backlog", "library_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "metadata queue: failed to list raw backlog", "component", "api", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher backlog")
|
||||
return
|
||||
}
|
||||
@@ -1575,14 +1575,14 @@ func (h *LibraryHandler) HandleRetryMetadataMatchQueue(w http.ResponseWriter, r
|
||||
|
||||
if h.SeriesMatchQueueRepo != nil {
|
||||
if err := h.SeriesMatchQueueRepo.SyncForFolder(r.Context(), id); err != nil {
|
||||
slog.Error("metadata queue: failed to retry series queue", "library_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "metadata queue: failed to retry series queue", "component", "api", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher")
|
||||
return
|
||||
}
|
||||
}
|
||||
if h.MovieMatchQueueRepo != nil {
|
||||
if err := h.MovieMatchQueueRepo.SyncForFolder(r.Context(), id); err != nil {
|
||||
slog.Error("metadata queue: failed to retry movie queue", "library_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "metadata queue: failed to retry movie queue", "component", "api", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher")
|
||||
return
|
||||
}
|
||||
@@ -1591,7 +1591,7 @@ func (h *LibraryHandler) HandleRetryMetadataMatchQueue(w http.ResponseWriter, r
|
||||
if h.RawMatchBacklogRepo != nil {
|
||||
rawFileRetried, err = h.RawMatchBacklogRepo.RetryUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode())
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to retry raw backlog", "library_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "metadata queue: failed to retry raw backlog", "component", "api", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher")
|
||||
return
|
||||
}
|
||||
@@ -1599,7 +1599,7 @@ func (h *LibraryHandler) HandleRetryMetadataMatchQueue(w http.ResponseWriter, r
|
||||
|
||||
status, err := h.metadataMatchQueueStatus(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to load retried queue status", "library_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "metadata queue: failed to load retried queue status", "component", "api", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue")
|
||||
return
|
||||
}
|
||||
@@ -1635,7 +1635,7 @@ func (h *LibraryHandler) HandleCancelMetadataMatchQueue(w http.ResponseWriter, r
|
||||
if h.SeriesMatchQueueRepo != nil {
|
||||
seriesCancelled, err = h.SeriesMatchQueueRepo.DeleteByFolder(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to cancel series queue", "library_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "metadata queue: failed to cancel series queue", "component", "api", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel metadata matcher")
|
||||
return
|
||||
}
|
||||
@@ -1644,7 +1644,7 @@ func (h *LibraryHandler) HandleCancelMetadataMatchQueue(w http.ResponseWriter, r
|
||||
if h.MovieMatchQueueRepo != nil {
|
||||
movieCancelled, err = h.MovieMatchQueueRepo.DeleteByFolder(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to cancel movie queue", "library_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "metadata queue: failed to cancel movie queue", "component", "api", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel metadata matcher")
|
||||
return
|
||||
}
|
||||
@@ -1653,7 +1653,7 @@ func (h *LibraryHandler) HandleCancelMetadataMatchQueue(w http.ResponseWriter, r
|
||||
if h.RawMatchBacklogRepo != nil {
|
||||
rawFileCancelled, err = h.RawMatchBacklogRepo.SuppressUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode())
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to suppress raw backlog", "library_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "metadata queue: failed to suppress raw backlog", "component", "api", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel metadata matcher")
|
||||
return
|
||||
}
|
||||
@@ -1661,7 +1661,7 @@ func (h *LibraryHandler) HandleCancelMetadataMatchQueue(w http.ResponseWriter, r
|
||||
|
||||
status, err := h.metadataMatchQueueStatus(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to load cancelled queue status", "library_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "metadata queue: failed to load cancelled queue status", "component", "api", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue")
|
||||
return
|
||||
}
|
||||
@@ -1773,7 +1773,7 @@ func (h *LibraryHandler) HandleRefreshLibraryMetadata(w http.ResponseWriter, r *
|
||||
writeAdminJobConflict(w, "A metadata refresh is already queued or running for this library", conflict.Job, jobsHandler, r)
|
||||
return
|
||||
}
|
||||
slog.Error("library: queue library refresh failed", "library_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "library: queue library refresh failed", "component", "api", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue library metadata refresh")
|
||||
return
|
||||
}
|
||||
@@ -1870,13 +1870,13 @@ func (h *LibraryHandler) HandleUploadPoster(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
if err := h.S3Meta.PutObject(r.Context(), h.S3Meta.Bucket(), s3Key, data); err != nil {
|
||||
slog.Error("uploading library poster", "library_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "uploading library poster", "component", "api", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to upload poster")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.folderRepo.SetPosterPath(r.Context(), id, s3Key); err != nil {
|
||||
slog.Error("saving library poster path", "library_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "saving library poster path", "component", "api", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save poster")
|
||||
return
|
||||
}
|
||||
@@ -1911,7 +1911,7 @@ func (h *LibraryHandler) HandleDeletePoster(w http.ResponseWriter, r *http.Reque
|
||||
if folder.PosterPath != "" {
|
||||
_ = h.S3Meta.DeleteObject(r.Context(), h.S3Meta.Bucket(), folder.PosterPath)
|
||||
if err := h.folderRepo.ClearPosterPath(r.Context(), id); err != nil {
|
||||
slog.Error("clearing library poster path", "library_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "clearing library poster path", "component", "api", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear poster")
|
||||
return
|
||||
}
|
||||
@@ -1978,14 +1978,14 @@ func (h *LibraryHandler) HandleGetLibraryProviders(w http.ResponseWriter, r *htt
|
||||
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
||||
return
|
||||
}
|
||||
slog.Error("fetching library for provider chain", "error", err)
|
||||
slog.ErrorContext(r.Context(), "fetching library for provider chain", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library")
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := h.ChainRepo.GetAllChainEntries(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Error("getting provider chain", "error", err)
|
||||
slog.ErrorContext(r.Context(), "getting provider chain", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get provider chain")
|
||||
return
|
||||
}
|
||||
@@ -2063,7 +2063,7 @@ func (h *LibraryHandler) HandleSetLibraryProviders(w http.ResponseWriter, r *htt
|
||||
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
||||
return
|
||||
}
|
||||
slog.Error("fetching library for provider chain update", "error", err)
|
||||
slog.ErrorContext(r.Context(), "fetching library for provider chain update", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library")
|
||||
return
|
||||
}
|
||||
@@ -2089,7 +2089,7 @@ func (h *LibraryHandler) HandleSetLibraryProviders(w http.ResponseWriter, r *htt
|
||||
}
|
||||
|
||||
if err := h.ChainRepo.SetChain(r.Context(), id, entries); err != nil {
|
||||
slog.Error("setting provider chain", "error", err)
|
||||
slog.ErrorContext(r.Context(), "setting provider chain", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set provider chain")
|
||||
return
|
||||
}
|
||||
@@ -2109,7 +2109,7 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
|
||||
|
||||
caps, err := metadata.ListEnabledMetadataCapabilities(ctx, h.ChainRepo.Pool())
|
||||
if err != nil {
|
||||
slog.Warn("seed chain: failed to list metadata capabilities", "error", err)
|
||||
slog.WarnContext(ctx, "seed chain: failed to list metadata capabilities", "component", "api", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2218,7 +2218,7 @@ func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
staleIDs, err := h.StaleIDRepo.ListAll(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("listing stale media IDs", "error", err)
|
||||
slog.ErrorContext(r.Context(), "listing stale media IDs", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list stale IDs")
|
||||
return
|
||||
}
|
||||
@@ -2262,7 +2262,7 @@ func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Reque
|
||||
WHERE mi.content_id = ANY($1)
|
||||
`, contentIDs)
|
||||
if err != nil {
|
||||
slog.Error("loading items for stale IDs", "error", err)
|
||||
slog.ErrorContext(r.Context(), "loading items for stale IDs", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item data")
|
||||
return
|
||||
}
|
||||
@@ -2272,7 +2272,7 @@ func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Reque
|
||||
var cid, title, ctype, libName string
|
||||
var year, libID int
|
||||
if err := rows.Scan(&cid, &title, &year, &ctype, &libID, &libName); err != nil {
|
||||
slog.Error("scanning item for stale IDs", "error", err)
|
||||
slog.ErrorContext(r.Context(), "scanning item for stale IDs", "component", "api", "error", err)
|
||||
continue
|
||||
}
|
||||
items[cid] = itemInfo{Title: title, Year: year, ContentType: ctype, LibraryID: libID, LibraryName: libName}
|
||||
@@ -2316,7 +2316,7 @@ func (h *LibraryHandler) HandleRematchStaleID(w http.ResponseWriter, r *http.Req
|
||||
WHERE content_id = $1
|
||||
`, contentID)
|
||||
if err != nil {
|
||||
slog.Error("clearing stale IDs from media item", "content_id", contentID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "clearing stale IDs from media item", "component", "api", "content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear IDs")
|
||||
return
|
||||
}
|
||||
@@ -2324,7 +2324,7 @@ func (h *LibraryHandler) HandleRematchStaleID(w http.ResponseWriter, r *http.Req
|
||||
// Remove stale_media_ids records.
|
||||
if h.StaleIDRepo != nil {
|
||||
if err := h.StaleIDRepo.DeleteByContentID(r.Context(), contentID); err != nil {
|
||||
slog.Error("deleting stale media ID records", "content_id", contentID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "deleting stale media ID records", "component", "api", "content_id", contentID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2332,7 +2332,7 @@ func (h *LibraryHandler) HandleRematchStaleID(w http.ResponseWriter, r *http.Req
|
||||
if h.refresher != nil {
|
||||
go func() {
|
||||
if err := h.refresher.RefreshItem(h.appCtx, contentID); err != nil {
|
||||
slog.Warn("metadata: rematch refresh failed", "content_id", contentID, "error", err)
|
||||
slog.WarnContext(r.Context(), "metadata: rematch refresh failed", "component", "api", "content_id", contentID, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -2374,7 +2374,7 @@ func (h *LibraryHandler) HandleListRoots(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
groups, total, err := h.ScannedGroupRepo.ListByFolder(r.Context(), libraryID, state, limit, offset)
|
||||
if err != nil {
|
||||
slog.Error("listing scanned groups", "library_id", libraryID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "listing scanned groups", "component", "api", "library_id", libraryID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list roots")
|
||||
return
|
||||
}
|
||||
@@ -2383,7 +2383,7 @@ func (h *LibraryHandler) HandleListRoots(w http.ResponseWriter, r *http.Request)
|
||||
if h.GroupOverrideRepo != nil {
|
||||
overrides, err := h.GroupOverrideRepo.ListByFolder(r.Context(), libraryID)
|
||||
if err != nil {
|
||||
slog.Warn("listing group overrides", "library_id", libraryID, "error", err)
|
||||
slog.WarnContext(r.Context(), "listing group overrides", "component", "api", "library_id", libraryID, "error", err)
|
||||
} else {
|
||||
for _, override := range overrides {
|
||||
overrideByGroup[groupOverrideLookupKey(override.GroupKeyVersion, override.ContentGroupKey)] = override
|
||||
@@ -2399,14 +2399,14 @@ func (h *LibraryHandler) HandleListRoots(w http.ResponseWriter, r *http.Request)
|
||||
WHERE media_folder_id = $1
|
||||
`, libraryID)
|
||||
if err != nil {
|
||||
slog.Warn("listing group claims", "library_id", libraryID, "error", err)
|
||||
slog.WarnContext(r.Context(), "listing group claims", "component", "api", "library_id", libraryID, "error", err)
|
||||
} else {
|
||||
defer claimRows.Close()
|
||||
for claimRows.Next() {
|
||||
var version int
|
||||
var groupKey, contentID string
|
||||
if err := claimRows.Scan(&version, &groupKey, &contentID); err != nil {
|
||||
slog.Warn("scanning group claim", "library_id", libraryID, "error", err)
|
||||
slog.WarnContext(r.Context(), "scanning group claim", "component", "api", "library_id", libraryID, "error", err)
|
||||
break
|
||||
}
|
||||
contentIDByGroup[groupOverrideLookupKey(version, groupKey)] = contentID
|
||||
@@ -2475,7 +2475,7 @@ func (h *LibraryHandler) HandleUpsertRootOverride(w http.ResponseWriter, r *http
|
||||
}
|
||||
location, err := h.ObservedLocationRepo.Get(r.Context(), req.LibraryID, req.RootPath)
|
||||
if err != nil {
|
||||
slog.Error("loading observed media location", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err)
|
||||
slog.ErrorContext(r.Context(), "loading observed media location", "component", "api", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load root")
|
||||
return
|
||||
}
|
||||
@@ -2508,7 +2508,7 @@ func (h *LibraryHandler) HandleUpsertRootOverride(w http.ResponseWriter, r *http
|
||||
override.UpdatedByUserID = &userID
|
||||
}
|
||||
if err := h.GroupOverrideRepo.Upsert(r.Context(), override); err != nil {
|
||||
slog.Error("upserting group override", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err)
|
||||
slog.ErrorContext(r.Context(), "upserting group override", "component", "api", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save override")
|
||||
return
|
||||
}
|
||||
@@ -2533,7 +2533,7 @@ func (h *LibraryHandler) HandleDeleteRootOverride(w http.ResponseWriter, r *http
|
||||
}
|
||||
location, err := h.ObservedLocationRepo.Get(r.Context(), req.LibraryID, req.RootPath)
|
||||
if err != nil {
|
||||
slog.Error("loading observed media location", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err)
|
||||
slog.ErrorContext(r.Context(), "loading observed media location", "component", "api", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load root")
|
||||
return
|
||||
}
|
||||
@@ -2547,7 +2547,7 @@ func (h *LibraryHandler) HandleDeleteRootOverride(w http.ResponseWriter, r *http
|
||||
}
|
||||
|
||||
if err := h.GroupOverrideRepo.Delete(r.Context(), req.LibraryID, location.PrimaryGroupKeyVersion, location.PrimaryContentGroupKey); err != nil {
|
||||
slog.Error("deleting group override", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err)
|
||||
slog.ErrorContext(r.Context(), "deleting group override", "component", "api", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete override")
|
||||
return
|
||||
}
|
||||
@@ -2630,7 +2630,7 @@ func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http
|
||||
|
||||
var total int
|
||||
if err := h.pool.QueryRow(r.Context(), countSQL, filterArgs...).Scan(&total); err != nil {
|
||||
slog.Error("counting unmatched items", "error", err)
|
||||
slog.ErrorContext(r.Context(), "counting unmatched items", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count unmatched items")
|
||||
return
|
||||
}
|
||||
@@ -2655,7 +2655,7 @@ func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http
|
||||
|
||||
rows, err := h.pool.Query(r.Context(), listSQL, listArgs...)
|
||||
if err != nil {
|
||||
slog.Error("listing unmatched items", "error", err)
|
||||
slog.ErrorContext(r.Context(), "listing unmatched items", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list unmatched items")
|
||||
return
|
||||
}
|
||||
@@ -2665,14 +2665,14 @@ func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http
|
||||
for rows.Next() {
|
||||
var item unmatchedItemResponse
|
||||
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &item.ContentType, &item.Status, &item.LibraryID, &item.LibraryName); err != nil {
|
||||
slog.Error("scanning unmatched item", "error", err)
|
||||
slog.ErrorContext(r.Context(), "scanning unmatched item", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to scan item")
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
slog.Error("iterating unmatched items", "error", err)
|
||||
slog.ErrorContext(r.Context(), "iterating unmatched items", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to iterate items")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ func (h *LibraryCollectionHandler) storeBundledTemplatePoster(
|
||||
)
|
||||
if err != nil || !stored {
|
||||
if err != nil {
|
||||
slog.Warn("failed to store bundled template poster",
|
||||
slog.WarnContext(ctx, "failed to store bundled template poster", "component", "api",
|
||||
"collection_id", collectionID,
|
||||
"poster_path", posterPath,
|
||||
"error", err,
|
||||
@@ -151,7 +151,7 @@ func (h *LibraryCollectionHandler) storeBundledTemplatePoster(
|
||||
input.PosterFromTemplate = &fromTemplate
|
||||
}
|
||||
if err := h.repo.Update(ctx, input); err != nil {
|
||||
slog.Warn("failed to persist bundled template poster",
|
||||
slog.WarnContext(ctx, "failed to persist bundled template poster", "component", "api",
|
||||
"collection_id", collectionID,
|
||||
"poster_path", posterPath,
|
||||
"stored_path", storedPath,
|
||||
@@ -175,7 +175,7 @@ func (h *LibraryCollectionHandler) GenerateCollectionPoster(ctx context.Context,
|
||||
return collage.ErrNotEnoughImages
|
||||
}
|
||||
|
||||
slog.Info("collage: generating poster", "collection_id", collectionID, "item_poster_count", len(paths))
|
||||
slog.InfoContext(ctx, "collage: generating poster", "component", "api", "collection_id", collectionID, "item_poster_count", len(paths))
|
||||
|
||||
// Resolve poster paths to fetchable URLs.
|
||||
resolved := h.detailSvc.PresignImageURLs(ctx, paths, "poster", "small")
|
||||
@@ -191,14 +191,14 @@ func (h *LibraryCollectionHandler) GenerateCollectionPoster(ctx context.Context,
|
||||
}
|
||||
data, err := h.fetchImageURL(ctx, url)
|
||||
if err != nil {
|
||||
slog.Debug("collage: failed to fetch poster image", "url", url, "error", err)
|
||||
slog.DebugContext(ctx, "collage: failed to fetch poster image", "component", "api", "url", url, "error", err)
|
||||
fetchFailed++
|
||||
continue
|
||||
}
|
||||
imageData = append(imageData, data)
|
||||
}
|
||||
if fetchFailed > 0 && len(imageData) == 0 {
|
||||
slog.Warn("collage: all poster image fetches failed",
|
||||
slog.WarnContext(ctx, "collage: all poster image fetches failed", "component", "api",
|
||||
"collection_id", collectionID, "total", len(paths), "failed", fetchFailed)
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ func (h *LibraryCollectionHandler) GenerateCollectionPoster(ctx context.Context,
|
||||
|
||||
// Delete any existing auto-generated images before uploading new ones.
|
||||
if err := h.deleteCollectionImages(ctx, collectionID, "poster"); err != nil {
|
||||
slog.Warn("collage: failed to clean up old poster images", "collection_id", collectionID, "error", err)
|
||||
slog.WarnContext(ctx, "collage: failed to clean up old poster images", "component", "api", "collection_id", collectionID, "error", err)
|
||||
}
|
||||
|
||||
// Process through the standard image pipeline (generates WebP variants + thumbhash).
|
||||
@@ -231,7 +231,7 @@ func (h *LibraryCollectionHandler) GenerateCollectionPoster(ctx context.Context,
|
||||
return fmt.Errorf("updating collection poster: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("collage: poster generated successfully", "collection_id", collectionID, "s3_path", s3Path)
|
||||
slog.InfoContext(ctx, "collage: poster generated successfully", "component", "api", "collection_id", collectionID, "s3_path", s3Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1372,9 +1372,9 @@ func (h *LibraryCollectionHandler) HandleDeleteCollectionImage(w http.ResponseWr
|
||||
if imageType == "poster" && h.service.CollageGen != nil {
|
||||
if err := h.GenerateCollectionPoster(r.Context(), collectionID); err != nil {
|
||||
if errors.Is(err, collage.ErrNotEnoughImages) {
|
||||
slog.Debug("collage: not enough images to regenerate after delete", "collection_id", collectionID)
|
||||
slog.DebugContext(r.Context(), "collage: not enough images to regenerate after delete", "component", "api", "collection_id", collectionID)
|
||||
} else {
|
||||
slog.Warn("collage: failed to regenerate poster after delete", "collection_id", collectionID, "error", err)
|
||||
slog.WarnContext(r.Context(), "collage: failed to regenerate poster after delete", "component", "api", "collection_id", collectionID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1420,12 +1420,12 @@ func (h *LibraryCollectionHandler) deleteServerCollection(ctx context.Context, c
|
||||
prefix := fmt.Sprintf("collection-images/%s/", collectionID)
|
||||
keys, err := h.s3GP.ListObjects(ctx, h.s3GP.Bucket(), prefix)
|
||||
if err != nil {
|
||||
slog.Warn("collection delete: listing S3 images failed; image keys may leak",
|
||||
slog.WarnContext(ctx, "collection delete: listing S3 images failed; image keys may leak", "component", "api",
|
||||
"collection_id", collectionID, "prefix", prefix, "error", err)
|
||||
}
|
||||
for _, key := range keys {
|
||||
if err := h.s3GP.DeleteObject(ctx, h.s3GP.Bucket(), key); err != nil {
|
||||
slog.Warn("collection delete: removing S3 image failed; key leaks",
|
||||
slog.WarnContext(ctx, "collection delete: removing S3 image failed; key leaks", "component", "api",
|
||||
"collection_id", collectionID, "key", key, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -1569,7 +1569,7 @@ func (h *LibraryCollectionHandler) HandleSyncAdminCollection(w http.ResponseWrit
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
slog.Error("collection sync failed", "collection_id", collectionID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "collection sync failed", "component", "api", "collection_id", collectionID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sync collection")
|
||||
return
|
||||
}
|
||||
@@ -1817,7 +1817,7 @@ func (h *LibraryCollectionHandler) applyTemplateBundle(
|
||||
Reason: err.Error(),
|
||||
})
|
||||
} else {
|
||||
slog.Warn("listing existing collections before template bundle apply",
|
||||
slog.WarnContext(ctx, "listing existing collections before template bundle apply", "component", "api",
|
||||
"bundle_id", bundle.ID,
|
||||
"library_id", library.ID,
|
||||
"error", err,
|
||||
@@ -1839,7 +1839,7 @@ func (h *LibraryCollectionHandler) applyTemplateBundle(
|
||||
// trips the in-use guard and the admin sees a misleading wall of
|
||||
// "collection_in_use" errors.
|
||||
if err := h.SectionRepo.DeleteGeneratedTemplateBundleFeaturedSections(ctx, bundle.ID, libraryIDs); err != nil {
|
||||
slog.Error("deleting generated template bundle featured sections", "bundle_id", bundle.ID, "error", err)
|
||||
slog.ErrorContext(ctx, "deleting generated template bundle featured sections", "component", "api", "bundle_id", bundle.ID, "error", err)
|
||||
return applyTemplateBundleResponse{}, newTemplateBundleApplyError(http.StatusInternalServerError, "delete_setup_failed", "Failed to clear generated featured sections before delete")
|
||||
}
|
||||
}
|
||||
@@ -2060,7 +2060,7 @@ func (h *LibraryCollectionHandler) ensureTemplatePoster(
|
||||
return
|
||||
}
|
||||
if stored, _, _, err := h.storeBundledTemplatePoster(ctx, collection.ID, posterPath, true); err != nil {
|
||||
slog.Warn("failed to store template poster",
|
||||
slog.WarnContext(ctx, "failed to store template poster", "component", "api",
|
||||
"collection_id", collection.ID,
|
||||
"template_id", tmpl.ID,
|
||||
"poster_path", posterPath,
|
||||
@@ -2081,7 +2081,7 @@ func (h *LibraryCollectionHandler) ensureTemplatePoster(
|
||||
PosterAutoGenerated: ¬AutoGenerated,
|
||||
PosterFromTemplate: &fromTemplate,
|
||||
}); err != nil {
|
||||
slog.Warn("failed to apply template poster",
|
||||
slog.WarnContext(ctx, "failed to apply template poster", "component", "api",
|
||||
"collection_id", collection.ID,
|
||||
"template_id", tmpl.ID,
|
||||
"poster_path", posterPath,
|
||||
@@ -2188,7 +2188,7 @@ func (h *LibraryCollectionHandler) advanceTemplateBundleSyncSchedules(
|
||||
}
|
||||
next := catalog.ComputeNextSyncAtFrom(*item.SyncSchedule, now)
|
||||
if err := h.repo.UpdateNextSyncAt(ctx, item.CollectionID, next); err != nil {
|
||||
slog.Warn("template bundle async sync: failed to advance schedule",
|
||||
slog.WarnContext(ctx, "template bundle async sync: failed to advance schedule", "component", "api",
|
||||
"collection_id", item.CollectionID,
|
||||
"template_id", item.Entry.TemplateID,
|
||||
"error", err,
|
||||
@@ -2213,7 +2213,7 @@ func (h *LibraryCollectionHandler) syncTemplateBundleCollections(
|
||||
eg.Go(func() (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("template bundle sync worker panic",
|
||||
slog.ErrorContext(ctx, "template bundle sync worker panic", "component", "api",
|
||||
"collection_id", item.CollectionID,
|
||||
"template_id", item.Entry.TemplateID,
|
||||
"panic", r,
|
||||
@@ -3081,7 +3081,7 @@ func (h *LibraryCollectionHandler) HandleImportTraktCollection(w http.ResponseWr
|
||||
|
||||
run, err := h.service.SyncCollection(r.Context(), collection.ID)
|
||||
if err != nil {
|
||||
slog.Error("failed to sync imported Trakt collection",
|
||||
slog.ErrorContext(r.Context(), "failed to sync imported Trakt collection", "component", "api",
|
||||
"collection_id", collection.ID,
|
||||
"library_id", req.LibraryID,
|
||||
"title", req.Title,
|
||||
|
||||
@@ -202,7 +202,7 @@ func (h *MarkersHandler) authorizeFile(w http.ResponseWriter, r *http.Request, f
|
||||
case errors.Is(err, catalog.ErrItemNotFound), errors.Is(err, catalog.ErrEpisodeNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
|
||||
default:
|
||||
h.logger.Error("markers: authorize failed", "file_id", fileID, "error", err)
|
||||
h.logger.ErrorContext(r.Context(), "markers: authorize failed", "file_id", fileID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize media file")
|
||||
}
|
||||
return nil, false
|
||||
@@ -230,14 +230,14 @@ func (h *MarkersHandler) loadItemPrimaryFile(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
files, err := h.Files.GetByEpisodeID(r.Context(), itemID)
|
||||
if err != nil {
|
||||
h.logger.Error("markers: episode file lookup failed", "item_id", itemID, "error", err)
|
||||
h.logger.ErrorContext(r.Context(), "markers: episode file lookup failed", "item_id", itemID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files")
|
||||
return nil, false
|
||||
}
|
||||
if len(files) == 0 {
|
||||
files, err = h.Files.GetByContentID(r.Context(), itemID)
|
||||
if err != nil {
|
||||
h.logger.Error("markers: content file lookup failed", "item_id", itemID, "error", err)
|
||||
h.logger.ErrorContext(r.Context(), "markers: content file lookup failed", "item_id", itemID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files")
|
||||
return nil, false
|
||||
}
|
||||
@@ -260,7 +260,7 @@ func (h *MarkersHandler) loadItemPrimaryFile(w http.ResponseWriter, r *http.Requ
|
||||
if errors.Is(err, catalog.ErrItemNotFound) || errors.Is(err, catalog.ErrEpisodeNotFound) {
|
||||
continue
|
||||
}
|
||||
h.logger.Error("markers: authorize item file failed", "item_id", itemID, "file_id", file.ID, "error", err)
|
||||
h.logger.ErrorContext(r.Context(), "markers: authorize item file failed", "item_id", itemID, "file_id", file.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize media file")
|
||||
return nil, false
|
||||
}
|
||||
@@ -377,14 +377,14 @@ func (h *MarkersHandler) setMarkersForFile(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
if _, err := h.Writer.UpsertAndClearMarkers(h.auditContext(r), file.ID, update, clears); err != nil {
|
||||
h.logger.Error("markers: save failed", "file_id", file.ID, "error", err)
|
||||
h.logger.ErrorContext(r.Context(), "markers: save failed", "file_id", file.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save markers")
|
||||
return
|
||||
}
|
||||
|
||||
refreshed, err := h.reloadAndNotify(r.Context(), file.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("markers: reload after save failed", "file_id", file.ID, "error", err)
|
||||
h.logger.ErrorContext(r.Context(), "markers: reload after save failed", "file_id", file.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Markers saved but failed to reload")
|
||||
return
|
||||
}
|
||||
@@ -408,13 +408,13 @@ func (h *MarkersHandler) HandleClearFileSegment(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
if _, err := h.Writer.ClearMarkers(h.auditContext(r), file.ID, []string{segment}); err != nil {
|
||||
h.logger.Error("markers: clear segment failed", "file_id", file.ID, "segment", segment, "error", err)
|
||||
h.logger.ErrorContext(r.Context(), "markers: clear segment failed", "file_id", file.ID, "segment", segment, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear marker")
|
||||
return
|
||||
}
|
||||
refreshed, err := h.reloadAndNotify(r.Context(), file.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("markers: reload after clear failed", "file_id", file.ID, "segment", segment, "error", err)
|
||||
h.logger.ErrorContext(r.Context(), "markers: reload after clear failed", "file_id", file.ID, "segment", segment, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Marker cleared but failed to reload")
|
||||
return
|
||||
}
|
||||
@@ -450,7 +450,7 @@ func (h *MarkersHandler) HandleContributeFile(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
outcomes, err := h.Contributor.ContributeFile(r.Context(), file, markers.ContributeOptions{Provider: body.Provider, Segments: kinds})
|
||||
if err != nil {
|
||||
h.logger.Error("markers: contribute failed", "file_id", file.ID, "error", err)
|
||||
h.logger.ErrorContext(r.Context(), "markers: contribute failed", "file_id", file.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Contribution failed")
|
||||
return
|
||||
}
|
||||
@@ -469,7 +469,7 @@ func (h *MarkersHandler) HandleListFileContributions(w http.ResponseWriter, r *h
|
||||
}
|
||||
rows, err := h.Contributions.ListByFile(r.Context(), file.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("markers: list contributions failed", "file_id", file.ID, "error", err)
|
||||
h.logger.ErrorContext(r.Context(), "markers: list contributions failed", "file_id", file.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load contributions")
|
||||
return
|
||||
}
|
||||
@@ -499,14 +499,14 @@ func (h *MarkersHandler) HandleListItemMarkerHistory(w http.ResponseWriter, r *h
|
||||
|
||||
files, err := h.Files.GetByEpisodeID(r.Context(), itemID)
|
||||
if err != nil {
|
||||
h.logger.Error("markers: episode history file lookup failed", "item_id", itemID, "error", err)
|
||||
h.logger.ErrorContext(r.Context(), "markers: episode history file lookup failed", "item_id", itemID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files")
|
||||
return
|
||||
}
|
||||
if len(files) == 0 {
|
||||
files, err = h.Files.GetByContentID(r.Context(), itemID)
|
||||
if err != nil {
|
||||
h.logger.Error("markers: content history file lookup failed", "item_id", itemID, "error", err)
|
||||
h.logger.ErrorContext(r.Context(), "markers: content history file lookup failed", "item_id", itemID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files")
|
||||
return
|
||||
}
|
||||
@@ -537,7 +537,7 @@ func (h *MarkersHandler) HandleListMarkerHistory(w http.ResponseWriter, r *http.
|
||||
}
|
||||
rows, err := h.AuditHistory.ListAllMarkerEditAudit(r.Context(), limit)
|
||||
if err != nil {
|
||||
h.logger.Error("markers: list all history failed", "error", err)
|
||||
h.logger.ErrorContext(r.Context(), "markers: list all history failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load marker history")
|
||||
return
|
||||
}
|
||||
@@ -555,7 +555,7 @@ func (h *MarkersHandler) listMarkerHistory(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
rows, err := h.AuditHistory.ListMarkerEditAudit(r.Context(), fileIDs, limit)
|
||||
if err != nil {
|
||||
h.logger.Error("markers: list history failed", "file_ids", fileIDs, "error", err)
|
||||
h.logger.ErrorContext(r.Context(), "markers: list history failed", "file_ids", fileIDs, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load marker history")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ func (h *MetadataAIHandler) HandleTranslateOnView(w http.ResponseWriter, r *http
|
||||
case errors.Is(err, translation.ErrInvalidRequest):
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
default:
|
||||
slog.Error("failed to request on-view translation",
|
||||
slog.ErrorContext(r.Context(), "failed to request on-view translation", "component", "api",
|
||||
"content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start translation")
|
||||
}
|
||||
@@ -247,7 +247,7 @@ func (h *MetadataAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
|
||||
case errors.Is(err, translation.ErrInvalidRequest):
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
default:
|
||||
slog.Error("failed to enqueue metadata translation",
|
||||
slog.ErrorContext(r.Context(), "failed to enqueue metadata translation", "component", "api",
|
||||
"content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start translation")
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ type checkNodeResult struct {
|
||||
func (h *NodeHandler) HandleListNodes(w http.ResponseWriter, r *http.Request) {
|
||||
nodes, err := h.repo.List(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("listing nodes", "error", err)
|
||||
slog.ErrorContext(r.Context(), "listing nodes", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list nodes")
|
||||
return
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func (h *NodeHandler) HandleCreateNode(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
slog.Error("creating node", "error", err)
|
||||
slog.ErrorContext(r.Context(), "creating node", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create node")
|
||||
return
|
||||
}
|
||||
@@ -131,7 +131,7 @@ func (h *NodeHandler) HandleUpdateNode(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Node not found")
|
||||
return
|
||||
}
|
||||
slog.Error("updating node", "error", err)
|
||||
slog.ErrorContext(r.Context(), "updating node", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update node")
|
||||
return
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func (h *NodeHandler) HandleDeleteNode(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Node not found")
|
||||
return
|
||||
}
|
||||
slog.Error("deleting node", "error", err)
|
||||
slog.ErrorContext(r.Context(), "deleting node", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete node")
|
||||
return
|
||||
}
|
||||
@@ -177,7 +177,7 @@ func (h *NodeHandler) HandleCheckNode(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Node not found")
|
||||
return
|
||||
}
|
||||
slog.Error("fetching node for check", "error", err)
|
||||
slog.ErrorContext(r.Context(), "fetching node for check", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch node")
|
||||
return
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func (h *NodeHandler) HandleCheckNode(w http.ResponseWriter, r *http.Request) {
|
||||
healthy, activeJobs, egressKbps := nodepool.CheckNode(r.Context(), node)
|
||||
|
||||
if err := h.repo.UpdateHealth(r.Context(), id, healthy, activeJobs, egressKbps); err != nil {
|
||||
slog.Error("persisting health check result", "node_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "persisting health check result", "component", "api", "node_id", id, "error", err)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, checkNodeResult{
|
||||
@@ -362,7 +362,7 @@ func (h *NodeHandler) reloadPools(ctx context.Context) {
|
||||
proxyNodes, proxyErr := h.lister.ListEnabled(ctx, nodepool.NodeTypeProxy)
|
||||
transcodeNodes, tcErr := h.lister.ListEnabled(ctx, nodepool.NodeTypeTranscode)
|
||||
if proxyErr != nil || tcErr != nil {
|
||||
slog.Warn("node pool reload failed", "proxy_err", proxyErr, "transcode_err", tcErr)
|
||||
slog.WarnContext(ctx, "node pool reload failed", "component", "api", "proxy_err", proxyErr, "transcode_err", tcErr)
|
||||
return
|
||||
}
|
||||
if h.proxyPool != nil {
|
||||
|
||||
@@ -128,7 +128,7 @@ func (h *PeopleHandler) HandleGetPerson(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
person, err := h.personRepo.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Warn("people: get person failed", "id", id, "id_str", idStr, "error", err)
|
||||
slog.WarnContext(r.Context(), "people: get person failed", "component", "api", "id", id, "id_str", idStr, "error", err)
|
||||
writeError(w, http.StatusNotFound, "not_found", "person not found")
|
||||
return
|
||||
}
|
||||
@@ -201,7 +201,7 @@ func (h *PeopleHandler) HandleAdminRefreshPerson(w http.ResponseWriter, r *http.
|
||||
case errors.Is(err, metadata.ErrPersonMetadataNotFound):
|
||||
writeError(w, http.StatusBadGateway, "provider_error", "No person metadata found")
|
||||
default:
|
||||
slog.Warn("people: admin refresh failed", "id", id, "error", err)
|
||||
slog.WarnContext(r.Context(), "people: admin refresh failed", "component", "api", "id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to refresh person")
|
||||
}
|
||||
return
|
||||
@@ -281,7 +281,7 @@ func (h *PeopleHandler) HandleAdminUpdatePerson(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
|
||||
if err := h.personRepo.Update(r.Context(), *person); err != nil {
|
||||
slog.Error("people: admin update failed", "id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "people: admin update failed", "component", "api", "id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update person")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ func NewPlaybackHandler(sessionMgr SessionManagerInterface, opts ...FilePathReso
|
||||
return
|
||||
}
|
||||
if err := h.stopPlaybackSessionByID(ctx, sessionID, false); err != nil && !errors.Is(err, playback.ErrSessionNotFound) {
|
||||
slog.Error("failed to stop playback after local transcode exit", "session", sessionID, "error", err, "playback_session_id", sessionID)
|
||||
slog.ErrorContext(ctx, "failed to stop playback after local transcode exit", "component", "api", "session", sessionID, "error", err, "playback_session_id", sessionID)
|
||||
}
|
||||
}
|
||||
if reg, ok := sessionMgr.(interface {
|
||||
@@ -496,7 +496,7 @@ func (h *PlaybackHandler) ensurePlaybackProbe(ctx context.Context, file *models.
|
||||
}
|
||||
repaired, err := h.ProbeEnsurer.Ensure(ctx, file)
|
||||
if err != nil {
|
||||
slog.Warn("playback probe repair failed", "file_id", file.ID, "path", file.FilePath, "error", err)
|
||||
slog.WarnContext(ctx, "playback probe repair failed", "component", "api", "file_id", file.ID, "path", file.FilePath, "error", err)
|
||||
return file
|
||||
}
|
||||
if repaired != nil {
|
||||
@@ -921,13 +921,13 @@ func (h *PlaybackHandler) restoreSessionProgress(
|
||||
|
||||
store, err := h.StoreProvider.ForUser(ctx, session.UserID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get user store", "user_id", session.UserID, "error", err)
|
||||
slog.ErrorContext(ctx, "failed to get user store", "component", "api", "user_id", session.UserID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
progress, err := store.GetProgress(ctx, session.ProfileID, targetID)
|
||||
if err != nil {
|
||||
slog.Error("failed to load progress", "target", targetID, "error", err)
|
||||
slog.ErrorContext(ctx, "failed to load progress", "component", "api", "target", targetID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -936,7 +936,7 @@ func (h *PlaybackHandler) restoreSessionProgress(
|
||||
}
|
||||
|
||||
if err := h.sessionMgr.UpdateProgress(session.ID, progress.PositionSeconds, false); err != nil {
|
||||
slog.Error("failed to restore progress", "session", session.ID, "error", err)
|
||||
slog.ErrorContext(ctx, "failed to restore progress", "component", "api", "session", session.ID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -972,13 +972,13 @@ func (h *PlaybackHandler) persistProgress(ctx context.Context, session *playback
|
||||
|
||||
store, err := h.StoreProvider.ForUser(ctx, session.UserID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get user store", "user_id", session.UserID, "error", err)
|
||||
slog.ErrorContext(ctx, "failed to get user store", "component", "api", "user_id", session.UserID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
duration := float64(file.Duration)
|
||||
if err := store.UpdateProgress(ctx, session.ProfileID, targetID, session.Position, duration, h.playbackThresholds(ctx)); err != nil {
|
||||
slog.Error("failed to persist progress", "session", session.ID, "error", err)
|
||||
slog.ErrorContext(ctx, "failed to persist progress", "component", "api", "session", session.ID, "error", err)
|
||||
} else {
|
||||
triggerProfileRefresh(ctx, h.profileStaler, h.profileRefreshRequester, session.UserID, session.ProfileID)
|
||||
}
|
||||
@@ -990,7 +990,7 @@ func (h *PlaybackHandler) persistProgress(ctx context.Context, session *playback
|
||||
CodecVideo: file.CodecVideo,
|
||||
EditionKey: file.EditionKey,
|
||||
}); err != nil {
|
||||
slog.Error("failed to persist version hints", "session", session.ID, "error", err)
|
||||
slog.ErrorContext(ctx, "failed to persist version hints", "component", "api", "session", session.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1025,7 +1025,7 @@ func (h *PlaybackHandler) persistStopAndHistory(ctx context.Context, session *pl
|
||||
EditionKey: file.EditionKey,
|
||||
}, thresholds)
|
||||
if err != nil {
|
||||
slog.Error("failed to persist playback stop", "session", session.ID, "error", err)
|
||||
slog.ErrorContext(ctx, "failed to persist playback stop", "component", "api", "session", session.ID, "error", err)
|
||||
} else {
|
||||
triggerProfileRefresh(ctx, h.profileStaler, h.profileRefreshRequester, session.UserID, session.ProfileID)
|
||||
}
|
||||
@@ -1091,11 +1091,11 @@ func (h *PlaybackHandler) buildAdminHistoryEntry(
|
||||
if h.StoreProvider != nil {
|
||||
store, storeErr := h.StoreProvider.ForUser(ctx, session.UserID)
|
||||
if storeErr != nil {
|
||||
slog.Error("failed to get user store for admin history", "session", session.ID, "error", storeErr)
|
||||
slog.ErrorContext(ctx, "failed to get user store for admin history", "component", "api", "session", session.ID, "error", storeErr)
|
||||
} else if store != nil {
|
||||
profile, profileErr := store.GetProfile(ctx, session.ProfileID)
|
||||
if profileErr != nil {
|
||||
slog.Error("failed to load profile for admin history", "session", session.ID, "error", profileErr)
|
||||
slog.ErrorContext(ctx, "failed to load profile for admin history", "component", "api", "session", session.ID, "error", profileErr)
|
||||
} else if profile != nil && strings.TrimSpace(profile.Name) != "" {
|
||||
profileName = profile.Name
|
||||
}
|
||||
@@ -1135,7 +1135,7 @@ func (h *PlaybackHandler) syncSessionsNow(ctx context.Context, reason string) {
|
||||
return
|
||||
}
|
||||
if err := h.SessionSyncer.SyncNow(ctx); err != nil {
|
||||
slog.Error("failed to sync sessions", "reason", reason, "error", err)
|
||||
slog.ErrorContext(ctx, "failed to sync sessions", "component", "api", "reason", reason, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1163,25 +1163,25 @@ func (h *PlaybackHandler) finalizeSessionStop(ctx context.Context, session *play
|
||||
event.Completed = stopResult.Completed
|
||||
if stopResult.Completed {
|
||||
if err := h.WatchScrobbler.ScrobbleStop(ctx, event); err != nil {
|
||||
slog.Warn("failed to queue watch provider stop scrobble", "session", session.ID, "error", err)
|
||||
slog.WarnContext(ctx, "failed to queue watch provider stop scrobble", "component", "api", "session", session.ID, "error", err)
|
||||
}
|
||||
} else if !stopResult.SkippedBelowMinResume {
|
||||
if err := h.WatchScrobbler.ScrobblePause(ctx, event); err != nil {
|
||||
slog.Warn("failed to queue watch provider pause scrobble", "session", session.ID, "error", err)
|
||||
slog.WarnContext(ctx, "failed to queue watch provider pause scrobble", "component", "api", "session", session.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if entry, buildErr := h.buildAdminHistoryEntry(ctx, session); buildErr != nil {
|
||||
slog.Error("failed to build admin history", "session", session.ID, "error", buildErr)
|
||||
slog.ErrorContext(ctx, "failed to build admin history", "component", "api", "session", session.ID, "error", buildErr)
|
||||
} else if entry != nil && h.AdminStore != nil {
|
||||
if err := h.AdminStore.RecordHistory(ctx, *entry); err != nil {
|
||||
slog.Error("failed to record admin history", "session", session.ID, "error", err)
|
||||
slog.ErrorContext(ctx, "failed to record admin history", "component", "api", "session", session.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if h.AdminStore != nil {
|
||||
if err := h.AdminStore.DeleteSession(ctx, session.ID); err != nil {
|
||||
slog.Error("failed to delete synced session", "session", session.ID, "error", err)
|
||||
slog.ErrorContext(ctx, "failed to delete synced session", "component", "api", "session", session.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1205,7 +1205,7 @@ func (h *PlaybackHandler) finalizeSessionAbort(ctx context.Context, session *pla
|
||||
if targetID != "" {
|
||||
event := h.scrobbleEventForSession(ctx, session, targetID, float64(file.Duration), session.Position)
|
||||
if err := h.WatchScrobbler.ScrobblePause(ctx, event); err != nil {
|
||||
slog.Warn("failed to queue watch provider abort scrobble", "session", session.ID, "error", err)
|
||||
slog.WarnContext(ctx, "failed to queue watch provider abort scrobble", "component", "api", "session", session.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1213,7 +1213,7 @@ func (h *PlaybackHandler) finalizeSessionAbort(ctx context.Context, session *pla
|
||||
|
||||
if h.AdminStore != nil {
|
||||
if err := h.AdminStore.DeleteSession(ctx, session.ID); err != nil {
|
||||
slog.Error("failed to delete synced session", "session", session.ID, "error", err)
|
||||
slog.ErrorContext(ctx, "failed to delete synced session", "component", "api", "session", session.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1266,7 +1266,7 @@ func (h *PlaybackHandler) persistSeriesPlaybackPreference(
|
||||
|
||||
store, err := h.StoreProvider.ForUser(ctx, userID)
|
||||
if err != nil {
|
||||
slog.Error("failed to access user store for series playback preference", "user_id", userID, "error", err)
|
||||
slog.ErrorContext(ctx, "failed to access user store for series playback preference", "component", "api", "user_id", userID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1277,7 +1277,7 @@ func (h *PlaybackHandler) persistSeriesPlaybackPreference(
|
||||
HDR: file.HDR,
|
||||
CodecVideo: file.CodecVideo,
|
||||
}); err != nil {
|
||||
slog.Error("failed to persist series playback preference", "series_id", seriesID, "profile_id", profileID, "error", err)
|
||||
slog.ErrorContext(ctx, "failed to persist series playback preference", "component", "api", "series_id", seriesID, "profile_id", profileID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1299,7 +1299,7 @@ func (h *PlaybackHandler) persistAudioPreference(
|
||||
|
||||
store, err := h.StoreProvider.ForUser(ctx, userID)
|
||||
if err != nil {
|
||||
slog.Error("failed to access user store for audio preference", "user_id", userID, "error", err)
|
||||
slog.ErrorContext(ctx, "failed to access user store for audio preference", "component", "api", "user_id", userID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1311,7 +1311,7 @@ func (h *PlaybackHandler) persistAudioPreference(
|
||||
AudioLanguage: track.Language,
|
||||
TrackSignature: playback.AudioTrackSignatureFromTrack(track),
|
||||
}); err != nil {
|
||||
slog.Error("failed to persist audio preference", "series_id", seriesID, "profile_id", profileID, "error", err)
|
||||
slog.ErrorContext(ctx, "failed to persist audio preference", "component", "api", "series_id", seriesID, "profile_id", profileID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1444,7 +1444,7 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
)
|
||||
if requestedFile != nil && effectiveFile != nil && requestedFile.ID != effectiveFile.ID {
|
||||
if err := preflightPlaybackFile(r.Context(), requestedFile, h.MissingMarker, h.EventsHub); err != nil && !isPlaybackFileMissing(err) {
|
||||
slog.Warn("requested playback file preflight failed; continuing with alternate file",
|
||||
slog.WarnContext(r.Context(), "requested playback file preflight failed; continuing with alternate file", "component", "api",
|
||||
"requested_file_id", requestedFile.ID,
|
||||
"effective_file_id", effectiveFile.ID,
|
||||
"error", err,
|
||||
@@ -1498,14 +1498,14 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
setPlaybackSessionLogContext(r, session.ID)
|
||||
if req.DisableProgressPersistence {
|
||||
if err := h.sessionMgr.SetProgressPersistenceDisabled(session.ID, true); err != nil {
|
||||
slog.Error("failed to disable progress persistence", "session", session.ID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "failed to disable progress persistence", "component", "api", "session", session.ID, "error", err)
|
||||
} else {
|
||||
session.DisableProgressPersistence = true
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.sessionMgr.UpdateAudioTrack(session.ID, audioTrackIndex, session.PlayMethod); err != nil {
|
||||
slog.Error("failed to set audio track", "session", session.ID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "failed to set audio track", "component", "api", "session", session.ID, "error", err)
|
||||
}
|
||||
targetAudioCodec := ""
|
||||
if session.TranscodeAudio {
|
||||
@@ -1527,7 +1527,7 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
StreamBitrateKbps: streamBitrateKbps,
|
||||
TargetAudioCodec: targetAudioCodec,
|
||||
}); err != nil {
|
||||
slog.Error("failed to set stream state", "session", session.ID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "failed to set stream state", "component", "api", "session", session.ID, "error", err)
|
||||
}
|
||||
session.AudioTrackIndex = audioTrackIndex
|
||||
session.ClientIP = clientip.FromContext(r.Context())
|
||||
@@ -1537,7 +1537,7 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
|
||||
if req.StartPosition != nil {
|
||||
if err := h.sessionMgr.UpdateProgress(session.ID, *req.StartPosition, false); err != nil {
|
||||
slog.Error("failed to set explicit start position", "session", session.ID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "failed to set explicit start position", "component", "api", "session", session.ID, "error", err)
|
||||
} else {
|
||||
session.Position = *req.StartPosition
|
||||
session.IsPaused = false
|
||||
@@ -1550,13 +1550,13 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
if targetID != "" {
|
||||
event := h.scrobbleEventForSession(r.Context(), session, targetID, float64(effectiveFile.Duration), session.Position)
|
||||
if err := h.WatchScrobbler.ScrobbleStart(r.Context(), event); err != nil {
|
||||
slog.Warn("failed to queue watch provider start scrobble", "session", session.ID, "error", err)
|
||||
slog.WarnContext(r.Context(), "failed to queue watch provider start scrobble", "component", "api", "session", session.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if h.ChapterThumbnailQueuer != nil && effectiveFile != nil {
|
||||
slog.Info(
|
||||
"queueing chapter thumbnails",
|
||||
slog.InfoContext(r.Context(),
|
||||
"queueing chapter thumbnails", "component", "api",
|
||||
"source",
|
||||
"playback_start",
|
||||
"content_id",
|
||||
@@ -1808,10 +1808,10 @@ func (h *PlaybackHandler) HandleUpdateProgress(w http.ResponseWriter, r *http.Re
|
||||
event := h.scrobbleEventForSession(r.Context(), sess, targetID, float64(file.Duration), sess.Position)
|
||||
if sess.IsPaused {
|
||||
if err := h.WatchScrobbler.ScrobblePause(r.Context(), event); err != nil {
|
||||
slog.Warn("failed to queue watch provider pause scrobble", "session", sessionID, "error", err)
|
||||
slog.WarnContext(r.Context(), "failed to queue watch provider pause scrobble", "component", "api", "session", sessionID, "error", err)
|
||||
}
|
||||
} else if err := h.WatchScrobbler.ScrobbleStart(r.Context(), event); err != nil {
|
||||
slog.Warn("failed to queue watch provider resume scrobble", "session", sessionID, "error", err)
|
||||
slog.WarnContext(r.Context(), "failed to queue watch provider resume scrobble", "component", "api", "session", sessionID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1946,7 +1946,7 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
|
||||
} else if transcodeAudio {
|
||||
targetAudioCodec = "aac"
|
||||
}
|
||||
slog.Info("audio switch computed playback state",
|
||||
slog.InfoContext(r.Context(), "audio switch computed playback state", "component", "api",
|
||||
"playback_session_id", sessionID,
|
||||
"previous_base_play_method", baseMethod,
|
||||
"new_base_play_method", newMethod,
|
||||
@@ -1989,7 +1989,7 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
|
||||
startSegment := computeStartSegment(seekSeconds, ts.Opts().SegmentDuration)
|
||||
// Throttler + exit monitor re-arm via the session's restart hook.
|
||||
if restartErr := h.tm.RestartSessionLocked(context.WithoutCancel(r.Context()), sessionID, ts, seekSeconds, startSegment); restartErr != nil {
|
||||
slog.Error("failed to restart transcode for audio switch", "session", sessionID, "error", restartErr)
|
||||
slog.ErrorContext(r.Context(), "failed to restart transcode for audio switch", "component", "api", "session", sessionID, "error", restartErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2109,7 +2109,7 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
|
||||
defer cancel()
|
||||
httpReq, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, nodeURL+"/transcode/start", bytes.NewReader(body))
|
||||
if reqErr != nil {
|
||||
slog.Error("failed to build remote transcode restart for audio switch", "session", sessionID, "node", nodeURL, "error", reqErr)
|
||||
slog.ErrorContext(r.Context(), "failed to build remote transcode restart for audio switch", "component", "api", "session", sessionID, "node", nodeURL, "error", reqErr)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build transcode request")
|
||||
return
|
||||
}
|
||||
@@ -2118,20 +2118,20 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
|
||||
|
||||
nodeResp, doErr := http.DefaultClient.Do(httpReq)
|
||||
if doErr != nil {
|
||||
slog.Error("remote transcode restart for audio switch failed", "session", sessionID, "node", nodeURL, "error", doErr)
|
||||
slog.ErrorContext(r.Context(), "remote transcode restart for audio switch failed", "component", "api", "session", sessionID, "node", nodeURL, "error", doErr)
|
||||
writeError(w, http.StatusBadGateway, "transcode_node_unavailable", "Transcode node is unavailable")
|
||||
return
|
||||
}
|
||||
defer nodeResp.Body.Close()
|
||||
if nodeResp.StatusCode != http.StatusAccepted {
|
||||
slog.Error("remote transcode restart for audio switch rejected", "session", sessionID, "node", nodeURL, "status", nodeResp.StatusCode)
|
||||
slog.ErrorContext(r.Context(), "remote transcode restart for audio switch rejected", "component", "api", "session", sessionID, "node", nodeURL, "status", nodeResp.StatusCode)
|
||||
writeError(w, http.StatusBadGateway, "transcode_start_failed", "Transcode node rejected the request")
|
||||
return
|
||||
}
|
||||
|
||||
var startResp transcodenode.TranscodeStartResponse
|
||||
if decErr := json.NewDecoder(nodeResp.Body).Decode(&startResp); decErr != nil {
|
||||
slog.Warn("remote transcode restart response decode failed", "session", sessionID, "node", nodeURL, "error", decErr)
|
||||
slog.WarnContext(r.Context(), "remote transcode restart response decode failed", "component", "api", "session", sessionID, "node", nodeURL, "error", decErr)
|
||||
}
|
||||
effectiveHWAccel := strings.TrimSpace(startResp.HWAccel)
|
||||
if effectiveHWAccel == "" {
|
||||
@@ -2281,7 +2281,7 @@ type transcodeStartState struct {
|
||||
func (h *PlaybackHandler) finalizeTranscodeStart(r *http.Request, st transcodeStartState) {
|
||||
if st.switchedFileID != nil {
|
||||
if err := h.sessionMgr.SetEffectiveMediaFileID(st.req.SessionID, *st.switchedFileID); err != nil {
|
||||
slog.Error("failed to update effective media file", "session", st.req.SessionID, "error", err, "playback_session_id", st.req.SessionID)
|
||||
slog.ErrorContext(r.Context(), "failed to update effective media file", "component", "api", "session", st.req.SessionID, "error", err, "playback_session_id", st.req.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2292,7 +2292,7 @@ func (h *PlaybackHandler) finalizeTranscodeStart(r *http.Request, st transcodeSt
|
||||
transcodeAudio := st.req.TargetCodecAudio != "" && !strings.EqualFold(st.req.TargetCodecAudio, "copy")
|
||||
baseMethod := semanticPlayMethod(st.session)
|
||||
|
||||
slog.Info("transcode start preserved base playback state",
|
||||
slog.InfoContext(r.Context(), "transcode start preserved base playback state", "component", "api",
|
||||
"playback_session_id", st.req.SessionID,
|
||||
"base_play_method", baseMethod,
|
||||
"transport_play_method", playback.PlayTranscode,
|
||||
@@ -2328,7 +2328,7 @@ func (h *PlaybackHandler) finalizeTranscodeStart(r *http.Request, st transcodeSt
|
||||
SubtitleBurnIn: st.req.SubtitleBurnIn,
|
||||
SegmentDuration: segmentDuration,
|
||||
}); err != nil {
|
||||
slog.Error("failed to update transcode stream state", "session", st.req.SessionID, "error", err, "playback_session_id", st.req.SessionID)
|
||||
slog.ErrorContext(r.Context(), "failed to update transcode stream state", "component", "api", "session", st.req.SessionID, "error", err, "playback_session_id", st.req.SessionID)
|
||||
}
|
||||
|
||||
h.syncSessionsNow(r.Context(), "transcode_start")
|
||||
@@ -2377,7 +2377,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
abortCurrentSession := func(reason string, cause error) {
|
||||
if abortErr := h.abortPlaybackSession(r.Context(), session); abortErr != nil && !errors.Is(abortErr, playback.ErrSessionNotFound) {
|
||||
slog.Error("failed to abort playback session",
|
||||
slog.ErrorContext(r.Context(), "failed to abort playback session", "component", "api",
|
||||
"session", req.SessionID,
|
||||
"reason", reason,
|
||||
"cause", cause,
|
||||
@@ -2411,7 +2411,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
// advancing. MPEG-2 compatibility HLS is allowed to keep copy-video so Apple
|
||||
// devices can avoid a full video transcode for those files.
|
||||
if req.SeekSeconds > 0 && strings.EqualFold(req.TargetCodecVideo, "copy") && !playback.IsMPEG2VideoCodec(file.CodecVideo) {
|
||||
slog.Info("forcing video transcode for seeked copy request",
|
||||
slog.InfoContext(r.Context(), "forcing video transcode for seeked copy request", "component", "api",
|
||||
"playback_session_id", req.SessionID,
|
||||
"seek_seconds", req.SeekSeconds,
|
||||
"source_video_codec", file.CodecVideo,
|
||||
@@ -2443,7 +2443,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
if requestedFile != nil && file != nil && requestedFile.ID != file.ID {
|
||||
if err := preflightPlaybackFile(r.Context(), requestedFile, h.MissingMarker, h.EventsHub); err != nil && !isPlaybackFileMissing(err) {
|
||||
slog.Warn("requested transcode file preflight failed; continuing with alternate file",
|
||||
slog.WarnContext(r.Context(), "requested transcode file preflight failed; continuing with alternate file", "component", "api",
|
||||
"requested_file_id", requestedFile.ID,
|
||||
"effective_file_id", file.ID,
|
||||
"error", err,
|
||||
@@ -2472,7 +2472,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
if tcNode != nil {
|
||||
// Remote transcode: forward to the assigned node.
|
||||
if err := h.sessionMgr.SetTranscodeNodeURL(req.SessionID, tcNode.URL); err != nil {
|
||||
slog.Error("set transcode node URL", "error", err, "session", req.SessionID, "playback_session_id", req.SessionID)
|
||||
slog.ErrorContext(r.Context(), "set transcode node URL", "component", "api", "error", err, "session", req.SessionID, "playback_session_id", req.SessionID)
|
||||
}
|
||||
|
||||
nodeReq := transcodenode.TranscodeStartRequest{
|
||||
@@ -2507,20 +2507,20 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
slog.Error("remote transcode start failed", "error", err, "node", tcNode.URL, "session", req.SessionID, "playback_session_id", req.SessionID)
|
||||
slog.ErrorContext(r.Context(), "remote transcode start failed", "component", "api", "error", err, "node", tcNode.URL, "session", req.SessionID, "playback_session_id", req.SessionID)
|
||||
writeError(w, http.StatusBadGateway, "transcode_node_unavailable", "Transcode node is unavailable")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
slog.Error("remote transcode start rejected", "status", resp.StatusCode, "node", tcNode.URL)
|
||||
slog.ErrorContext(r.Context(), "remote transcode start rejected", "component", "api", "status", resp.StatusCode, "node", tcNode.URL)
|
||||
writeError(w, http.StatusBadGateway, "transcode_start_failed", "Transcode node rejected the request")
|
||||
return
|
||||
}
|
||||
var nodeResp transcodenode.TranscodeStartResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&nodeResp); err != nil {
|
||||
slog.Warn("remote transcode start response decode failed",
|
||||
slog.WarnContext(r.Context(), "remote transcode start response decode failed", "component", "api",
|
||||
"error", err,
|
||||
"node", tcNode.URL,
|
||||
"session", req.SessionID,
|
||||
@@ -2702,7 +2702,7 @@ func (h *PlaybackHandler) HandleGetTranscodeManifest(w http.ResponseWriter, r *h
|
||||
|
||||
manifest, err := transcodeSession.BuildPlaybackManifest("segment/", r.URL.RawQuery)
|
||||
if err != nil {
|
||||
slog.Error("build transcode manifest", "error", err, "session", sessionID, "playback_session_id", sessionID)
|
||||
slog.ErrorContext(r.Context(), "build transcode manifest", "component", "api", "error", err, "session", sessionID, "playback_session_id", sessionID)
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Transcode manifest not ready")
|
||||
return
|
||||
}
|
||||
@@ -2770,7 +2770,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
|
||||
if !decision.Progress.LastProducedAt.IsZero() {
|
||||
lastProducedAgeMS = now.Sub(decision.Progress.LastProducedAt).Milliseconds()
|
||||
}
|
||||
slog.Info("transcode segment missing",
|
||||
slog.InfoContext(r.Context(), "transcode segment missing", "component", "api",
|
||||
"segment", segmentName,
|
||||
"requested_segment", segNum,
|
||||
"produced_head", decision.Progress.ProducedHead,
|
||||
@@ -2784,7 +2784,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
|
||||
"playback_session_id", sessionID,
|
||||
)
|
||||
if decision.Wait {
|
||||
slog.Info("transcode segment wait",
|
||||
slog.InfoContext(r.Context(), "transcode segment wait", "component", "api",
|
||||
"segment", segmentName,
|
||||
"requested_segment", segNum,
|
||||
"produced_head", decision.Progress.ProducedHead,
|
||||
@@ -2799,7 +2799,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
|
||||
)
|
||||
segmentPath, err = transcodeSession.WaitForSegment(segmentName, decision.WaitTimeout)
|
||||
if err != nil && errors.Is(err, playback.ErrSegmentNotFound) {
|
||||
slog.Info("transcode segment wait timeout",
|
||||
slog.InfoContext(r.Context(), "transcode segment wait timeout", "component", "api",
|
||||
"segment", segmentName,
|
||||
"requested_segment", segNum,
|
||||
"produced_head", decision.Progress.ProducedHead,
|
||||
@@ -2822,7 +2822,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
|
||||
if err != nil && errors.Is(err, playback.ErrSegmentNotFound) && decision.RestartOnTimeout {
|
||||
seekSeconds, ok, seekErr := transcodeSession.RestartSeekTarget(segNum)
|
||||
if seekErr != nil && !errors.Is(seekErr, playback.ErrManifestNotReady) {
|
||||
slog.Error("resolve transcode seek target", "error", seekErr, "segment", segmentName, "session", sessionID, "playback_session_id", sessionID)
|
||||
slog.ErrorContext(r.Context(), "resolve transcode seek target", "component", "api", "error", seekErr, "segment", segmentName, "session", sessionID, "playback_session_id", sessionID)
|
||||
}
|
||||
|
||||
// Copy-mode with an unresolved seek target (ok=false, no error)
|
||||
@@ -2836,7 +2836,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
|
||||
}
|
||||
|
||||
if ok {
|
||||
slog.Info("transcode seek restart",
|
||||
slog.InfoContext(r.Context(), "transcode seek restart", "component", "api",
|
||||
"segment", segmentName,
|
||||
"requested_segment", segNum,
|
||||
"produced_head", decision.Progress.ProducedHead,
|
||||
@@ -2949,13 +2949,13 @@ func (h *PlaybackHandler) proxyToTranscodeNode(w http.ResponseWriter, r *http.Re
|
||||
req.Header.Set("X-Silo-Stream-Token", stToken)
|
||||
validToken = stToken
|
||||
} else if verifyErr != nil {
|
||||
slog.Warn("stream token not forwarded to transcode node", "error", verifyErr, "playback_session_id", sessionID)
|
||||
slog.WarnContext(r.Context(), "stream token not forwarded to transcode node", "component", "api", "error", verifyErr, "playback_session_id", sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Error("proxy to transcode node", "error", err, "url", targetURL, "playback_session_id", sessionID)
|
||||
slog.ErrorContext(r.Context(), "proxy to transcode node", "component", "api", "error", err, "url", targetURL, "playback_session_id", sessionID)
|
||||
http.Error(w, "transcode node unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
@@ -2970,7 +2970,7 @@ func (h *PlaybackHandler) proxyToTranscodeNode(w http.ResponseWriter, r *http.Re
|
||||
if validToken != "" && resp.StatusCode == http.StatusOK && strings.HasSuffix(path, ".m3u8") {
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
slog.Error("read transcode node manifest", "error", readErr, "url", targetURL, "playback_session_id", sessionID)
|
||||
slog.ErrorContext(r.Context(), "read transcode node manifest", "component", "api", "error", readErr, "url", targetURL, "playback_session_id", sessionID)
|
||||
http.Error(w, "transcode node unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers(
|
||||
|
||||
lazy, err := h.SettingsRepo.Get(ctx, markers.SettingLazyPlayback)
|
||||
if err != nil {
|
||||
slog.Warn("playback lazy markers: load lazy setting failed",
|
||||
slog.WarnContext(ctx, "playback lazy markers: load lazy setting failed", "component", "api",
|
||||
"session_id", session.ID,
|
||||
"file_id", file.ID,
|
||||
"episode_id", file.EpisodeID,
|
||||
@@ -65,7 +65,7 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers(
|
||||
|
||||
rawMode, err := h.SettingsRepo.Get(ctx, markers.SettingMode)
|
||||
if err != nil {
|
||||
slog.Warn("playback lazy markers: load marker mode failed",
|
||||
slog.WarnContext(ctx, "playback lazy markers: load marker mode failed", "component", "api",
|
||||
"session_id", session.ID,
|
||||
"file_id", file.ID,
|
||||
"episode_id", file.EpisodeID,
|
||||
@@ -74,7 +74,7 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers(
|
||||
}
|
||||
mode := markers.NormalizeMode(rawMode)
|
||||
if mode == markers.ModeOff {
|
||||
slog.Debug("playback lazy markers: skipped; marker mode is off",
|
||||
slog.DebugContext(ctx, "playback lazy markers: skipped; marker mode is off", "component", "api",
|
||||
"session_id", session.ID,
|
||||
"file_id", file.ID,
|
||||
"episode_id", file.EpisodeID)
|
||||
@@ -92,7 +92,7 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers(
|
||||
// Online providers work for any enabled library (movies and series alike).
|
||||
ok, err := h.IntroRepository.IsFileInEnabledLibrary(ctx, file.ID)
|
||||
if err != nil {
|
||||
slog.Warn("playback lazy markers: online eligibility check failed",
|
||||
slog.WarnContext(ctx, "playback lazy markers: online eligibility check failed", "component", "api",
|
||||
"session_id", session.ID,
|
||||
"file_id", file.ID,
|
||||
"error", err)
|
||||
@@ -108,7 +108,7 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers(
|
||||
// opted in to expensive fingerprinting and requires an analyzer.
|
||||
ok, err := h.IntroRepository.IntroDetectionEligibleForPlayback(ctx, file.ID)
|
||||
if err != nil {
|
||||
slog.Warn("playback lazy markers: local eligibility check failed",
|
||||
slog.WarnContext(ctx, "playback lazy markers: local eligibility check failed", "component", "api",
|
||||
"session_id", session.ID,
|
||||
"file_id", file.ID,
|
||||
"episode_id", file.EpisodeID,
|
||||
@@ -122,7 +122,7 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers(
|
||||
}
|
||||
|
||||
if !shouldRunOnline && !shouldRunLocal {
|
||||
slog.Debug("playback lazy markers: skipped; no eligible detection path",
|
||||
slog.DebugContext(ctx, "playback lazy markers: skipped; no eligible detection path", "component", "api",
|
||||
"session_id", session.ID,
|
||||
"file_id", file.ID,
|
||||
"episode_id", file.EpisodeID,
|
||||
@@ -136,7 +136,7 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers(
|
||||
|
||||
sessionID := session.ID
|
||||
fileSnapshot := *file
|
||||
slog.Info("playback lazy markers: queued",
|
||||
slog.InfoContext(ctx, "playback lazy markers: queued", "component", "api",
|
||||
"session_id", sessionID,
|
||||
"file_id", file.ID,
|
||||
"episode_id", file.EpisodeID,
|
||||
@@ -248,7 +248,7 @@ func (h *PlaybackHandler) fetchOnlineMarkersForPlayback(ctx context.Context, fil
|
||||
return false, nil
|
||||
}
|
||||
if h.MarkerResolver == nil || h.MarkerUpserter == nil {
|
||||
slog.Debug("playback lazy markers: online fetch skipped; resolver or upserter missing",
|
||||
slog.DebugContext(ctx, "playback lazy markers: online fetch skipped; resolver or upserter missing", "component", "api",
|
||||
"file_id", file.ID)
|
||||
return false, nil
|
||||
}
|
||||
@@ -258,7 +258,7 @@ func (h *PlaybackHandler) fetchOnlineMarkersForPlayback(ctx context.Context, fil
|
||||
return false, err
|
||||
}
|
||||
if !ids.HasAnyID() {
|
||||
slog.Debug("playback lazy markers: online fetch skipped; no external IDs available",
|
||||
slog.DebugContext(ctx, "playback lazy markers: online fetch skipped; no external IDs available", "component", "api",
|
||||
"file_id", file.ID,
|
||||
"episode_id", file.EpisodeID,
|
||||
"content_id", file.ContentID)
|
||||
@@ -297,7 +297,7 @@ func (h *PlaybackHandler) reloadPlaybackMarkerFile(ctx context.Context, fileID i
|
||||
}
|
||||
refreshed, err := h.fileResolver.GetByID(ctx, fileID)
|
||||
if err != nil {
|
||||
slog.Warn("playback lazy markers: reload file failed", "file_id", fileID, "error", err)
|
||||
slog.WarnContext(ctx, "playback lazy markers: reload file failed", "component", "api", "file_id", fileID, "error", err)
|
||||
return nil
|
||||
}
|
||||
return refreshed
|
||||
@@ -313,7 +313,7 @@ func (h *PlaybackHandler) notifyPlaybackMarkers(
|
||||
return
|
||||
}
|
||||
h.MarkerUpdateNotifier.MarkersUpdated(ctx, file)
|
||||
slog.Info("playback lazy markers: emitted marker update",
|
||||
slog.InfoContext(ctx, "playback lazy markers: emitted marker update", "component", "api",
|
||||
"session_id", sessionID,
|
||||
"file_id", file.ID,
|
||||
"episode_id", file.EpisodeID,
|
||||
|
||||
@@ -108,7 +108,7 @@ func markPlaybackFileMissing(
|
||||
|
||||
if marker != nil {
|
||||
if err := marker.MarkMissing(ctx, file.ID, since); err != nil {
|
||||
slog.Warn("failed to mark playback file missing",
|
||||
slog.WarnContext(ctx, "failed to mark playback file missing", "component", "api",
|
||||
"file_id", file.ID,
|
||||
"path", file.FilePath,
|
||||
"error", err,
|
||||
|
||||
@@ -313,7 +313,7 @@ type pluginChunkedUploadSessionResponse struct {
|
||||
func (h *PluginHandler) HandleListRepositories(w http.ResponseWriter, r *http.Request) {
|
||||
repositories, err := h.repositories.List(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("listing plugin repositories", "error", err)
|
||||
slog.ErrorContext(r.Context(), "listing plugin repositories", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list plugin repositories")
|
||||
return
|
||||
}
|
||||
@@ -342,7 +342,7 @@ func (h *PluginHandler) HandleCreateRepository(w http.ResponseWriter, r *http.Re
|
||||
Enabled: req.Enabled,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("creating plugin repository", "error", err)
|
||||
slog.ErrorContext(r.Context(), "creating plugin repository", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create plugin repository")
|
||||
return
|
||||
}
|
||||
@@ -378,14 +378,14 @@ func (h *PluginHandler) HandleUpdateRepository(w http.ResponseWriter, r *http.Re
|
||||
writeError(w, http.StatusNotFound, "not_found", "Plugin repository not found")
|
||||
return
|
||||
}
|
||||
slog.Error("updating plugin repository", "error", err)
|
||||
slog.ErrorContext(r.Context(), "updating plugin repository", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update plugin repository")
|
||||
return
|
||||
}
|
||||
|
||||
repository, err := h.repositories.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Error("loading updated plugin repository", "error", err)
|
||||
slog.ErrorContext(r.Context(), "loading updated plugin repository", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin repository")
|
||||
return
|
||||
}
|
||||
@@ -405,7 +405,7 @@ func (h *PluginHandler) HandleDeleteRepository(w http.ResponseWriter, r *http.Re
|
||||
writeError(w, http.StatusNotFound, "not_found", "Plugin repository not found")
|
||||
return
|
||||
}
|
||||
slog.Error("deleting plugin repository", "error", err)
|
||||
slog.ErrorContext(r.Context(), "deleting plugin repository", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete plugin repository")
|
||||
return
|
||||
}
|
||||
@@ -416,7 +416,7 @@ func (h *PluginHandler) HandleDeleteRepository(w http.ResponseWriter, r *http.Re
|
||||
func (h *PluginHandler) HandleCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := h.service.FetchCatalog(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("fetching plugin catalog", "error", err)
|
||||
slog.ErrorContext(r.Context(), "fetching plugin catalog", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch plugin catalog")
|
||||
return
|
||||
}
|
||||
@@ -443,14 +443,14 @@ func (h *PluginHandler) HandleCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *PluginHandler) HandleListInstallations(w http.ResponseWriter, r *http.Request) {
|
||||
installations, err := h.installations.List(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("listing plugin installations", "error", err)
|
||||
slog.ErrorContext(r.Context(), "listing plugin installations", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list plugin installations")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.buildInstallationResponses(r.Context(), installations)
|
||||
if err != nil {
|
||||
slog.Error("building plugin installations response", "error", err)
|
||||
slog.ErrorContext(r.Context(), "building plugin installations response", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build plugin installation response")
|
||||
return
|
||||
}
|
||||
@@ -497,7 +497,7 @@ func (h *PluginHandler) HandleCreateInstallation(w http.ResponseWriter, r *http.
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("installing plugin archive", "error", err)
|
||||
slog.ErrorContext(r.Context(), "installing plugin archive", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to install plugin")
|
||||
return
|
||||
}
|
||||
@@ -506,7 +506,7 @@ func (h *PluginHandler) HandleCreateInstallation(w http.ResponseWriter, r *http.
|
||||
|
||||
response, err := h.buildInstallationResponse(r.Context(), result.Installation, result.Manifest)
|
||||
if err != nil {
|
||||
slog.Error("building installed plugin response", "error", err)
|
||||
slog.ErrorContext(r.Context(), "building installed plugin response", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build plugin installation response")
|
||||
return
|
||||
}
|
||||
@@ -530,7 +530,7 @@ func (h *PluginHandler) HandleUploadInstallation(w http.ResponseWriter, r *http.
|
||||
|
||||
tempFile, err := os.CreateTemp("", "silo-plugin-*.zip")
|
||||
if err != nil {
|
||||
slog.Error("creating temp plugin upload file", "error", err)
|
||||
slog.ErrorContext(r.Context(), "creating temp plugin upload file", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process plugin upload")
|
||||
return
|
||||
}
|
||||
@@ -541,20 +541,20 @@ func (h *PluginHandler) HandleUploadInstallation(w http.ResponseWriter, r *http.
|
||||
}()
|
||||
|
||||
if _, err := io.Copy(tempFile, file); err != nil {
|
||||
slog.Error("writing temp plugin upload file", "filename", header.Filename, "error", err)
|
||||
slog.ErrorContext(r.Context(), "writing temp plugin upload file", "component", "api", "filename", header.Filename, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process plugin upload")
|
||||
return
|
||||
}
|
||||
|
||||
if err := tempFile.Close(); err != nil {
|
||||
slog.Error("closing temp plugin upload file", "filename", header.Filename, "error", err)
|
||||
slog.ErrorContext(r.Context(), "closing temp plugin upload file", "component", "api", "filename", header.Filename, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process plugin upload")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.installUploadedPlugin(r.Context(), tempPath)
|
||||
if err != nil {
|
||||
slog.Error("installing uploaded plugin", "filename", header.Filename, "error", err)
|
||||
slog.ErrorContext(r.Context(), "installing uploaded plugin", "component", "api", "filename", header.Filename, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to install uploaded plugin")
|
||||
return
|
||||
}
|
||||
@@ -619,7 +619,7 @@ func (h *PluginHandler) HandleCompleteChunkedUpload(w http.ResponseWriter, r *ht
|
||||
|
||||
result, err := h.installUploadedPlugin(r.Context(), upload.Path)
|
||||
if err != nil {
|
||||
slog.Error("installing chunked plugin upload",
|
||||
slog.ErrorContext(r.Context(), "installing chunked plugin upload", "component", "api",
|
||||
"filename", upload.Filename,
|
||||
"upload_id", upload.ID,
|
||||
"error", err,
|
||||
@@ -664,7 +664,7 @@ func (h *PluginHandler) writeUploadedPluginResponse(w http.ResponseWriter, r *ht
|
||||
|
||||
response, err := h.buildInstallationResponse(r.Context(), result.Installation, result.Manifest)
|
||||
if err != nil {
|
||||
slog.Error("building uploaded plugin response", "error", err)
|
||||
slog.ErrorContext(r.Context(), "building uploaded plugin response", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build plugin installation response")
|
||||
return
|
||||
}
|
||||
@@ -681,7 +681,7 @@ func (h *PluginHandler) syncMetadataProviders(ctx context.Context, installation
|
||||
|
||||
caps, err := h.installations.ListCapabilities(ctx, installation.ID)
|
||||
if err != nil {
|
||||
slog.Error("listing capabilities for metadata provider sync",
|
||||
slog.ErrorContext(ctx, "listing capabilities for metadata provider sync", "component", "api",
|
||||
"installation_id", installation.ID, "error", err)
|
||||
return
|
||||
}
|
||||
@@ -693,7 +693,7 @@ func (h *PluginHandler) syncMetadataProviders(ctx context.Context, installation
|
||||
if err := h.chainRepo.AppendProviderToAllChains(ctx, installation.ID, cap.ID, func(level string) metadata.SeedPlacement {
|
||||
return metadata.LookupSeedPlacement(ctx, h.chainRepo.Pool(), installation.ID, cap.ID, level)
|
||||
}); err != nil {
|
||||
slog.Warn("failed to append provider to library chains",
|
||||
slog.WarnContext(ctx, "failed to append provider to library chains", "component", "api",
|
||||
"installation_id", installation.ID,
|
||||
"capability_id", cap.ID,
|
||||
"error", err)
|
||||
@@ -779,14 +779,14 @@ func (h *PluginHandler) HandleUpdateInstallation(w http.ResponseWriter, r *http.
|
||||
writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found")
|
||||
return
|
||||
}
|
||||
slog.Error("loading current plugin installation", "error", err)
|
||||
slog.ErrorContext(r.Context(), "loading current plugin installation", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin installation")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Enabled != nil && !*req.Enabled && currentInstallation.Enabled && h.service != nil {
|
||||
if err := h.service.Stop(id); err != nil && !errors.Is(err, pluginhost.ErrClientNotFound) {
|
||||
slog.Error("stopping plugin before disable", "installation_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "stopping plugin before disable", "component", "api", "installation_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to disable plugin installation")
|
||||
return
|
||||
}
|
||||
@@ -800,7 +800,7 @@ func (h *PluginHandler) HandleUpdateInstallation(w http.ResponseWriter, r *http.
|
||||
writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found")
|
||||
return
|
||||
}
|
||||
slog.Error("updating plugin installation", "error", err)
|
||||
slog.ErrorContext(r.Context(), "updating plugin installation", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update plugin installation")
|
||||
return
|
||||
}
|
||||
@@ -813,14 +813,14 @@ func (h *PluginHandler) HandleUpdateInstallation(w http.ResponseWriter, r *http.
|
||||
|
||||
installation, err := h.installations.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Error("loading updated plugin installation", "error", err)
|
||||
slog.ErrorContext(r.Context(), "loading updated plugin installation", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin installation")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.buildInstallationResponse(r.Context(), installation, nil)
|
||||
if err != nil {
|
||||
slog.Error("building updated plugin installation response", "error", err)
|
||||
slog.ErrorContext(r.Context(), "building updated plugin installation response", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build plugin installation response")
|
||||
return
|
||||
}
|
||||
@@ -841,7 +841,7 @@ func (h *PluginHandler) HandleApplyUpdate(w http.ResponseWriter, r *http.Request
|
||||
writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found")
|
||||
return
|
||||
}
|
||||
slog.Error("apply plugin update", "installation_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "apply plugin update", "component", "api", "installation_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update plugin")
|
||||
return
|
||||
}
|
||||
@@ -850,7 +850,7 @@ func (h *PluginHandler) HandleApplyUpdate(w http.ResponseWriter, r *http.Request
|
||||
|
||||
response, err := h.buildInstallationResponse(r.Context(), installation, nil)
|
||||
if err != nil {
|
||||
slog.Error("building updated plugin installation response", "error", err)
|
||||
slog.ErrorContext(r.Context(), "building updated plugin installation response", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build response")
|
||||
return
|
||||
}
|
||||
@@ -884,7 +884,7 @@ func (h *PluginHandler) HandlePutInstallationConfig(w http.ResponseWriter, r *ht
|
||||
case errors.Is(err, plugins.ErrInstallationNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found")
|
||||
default:
|
||||
slog.Error("setting plugin global config", "installation_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "setting plugin global config", "component", "api", "installation_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save plugin config")
|
||||
}
|
||||
return
|
||||
@@ -929,7 +929,7 @@ func (h *PluginHandler) HandleTestInstallationConfig(w http.ResponseWriter, r *h
|
||||
return
|
||||
}
|
||||
|
||||
slog.Error("testing plugin config", "installation_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "testing plugin config", "component", "api", "installation_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to test plugin config")
|
||||
return
|
||||
}
|
||||
@@ -965,7 +965,7 @@ func (h *PluginHandler) HandlePutAuthBinding(w http.ResponseWriter, r *http.Requ
|
||||
AutoProvision: req.AutoProvision,
|
||||
DefaultLogin: req.DefaultLogin,
|
||||
}); err != nil {
|
||||
slog.Error("saving plugin auth binding", "error", err)
|
||||
slog.ErrorContext(r.Context(), "saving plugin auth binding", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save auth binding")
|
||||
return
|
||||
}
|
||||
@@ -998,7 +998,7 @@ func (h *PluginHandler) HandlePutTaskBinding(w http.ResponseWriter, r *http.Requ
|
||||
Enabled: req.Enabled,
|
||||
Trigger: req.Trigger,
|
||||
}); err != nil {
|
||||
slog.Error("saving plugin task binding", "error", err)
|
||||
slog.ErrorContext(r.Context(), "saving plugin task binding", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save task binding")
|
||||
return
|
||||
}
|
||||
@@ -1019,7 +1019,7 @@ func (h *PluginHandler) HandleDeleteInstallation(w http.ResponseWriter, r *http.
|
||||
writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found")
|
||||
return
|
||||
}
|
||||
slog.Error("deleting plugin installation", "error", err)
|
||||
slog.ErrorContext(r.Context(), "deleting plugin installation", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete plugin installation")
|
||||
return
|
||||
}
|
||||
@@ -1048,7 +1048,7 @@ func manifestHasUserNavigableRoute(manifest *pluginv1.PluginManifest) bool {
|
||||
func (h *PluginHandler) HandleListUserPluginSettings(w http.ResponseWriter, r *http.Request) {
|
||||
installations, err := h.installations.ListEnabled(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("listing enabled plugin installations", "error", err)
|
||||
slog.ErrorContext(r.Context(), "listing enabled plugin installations", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list plugin settings")
|
||||
return
|
||||
}
|
||||
@@ -1059,7 +1059,7 @@ func (h *PluginHandler) HandleListUserPluginSettings(w http.ResponseWriter, r *h
|
||||
for _, installation := range installations {
|
||||
manifest, err := plugins.LoadManifestFile(plugins.InstalledManifestPath(installation.InstallPath))
|
||||
if err != nil {
|
||||
slog.Error("loading plugin manifest", "installation_id", installation.ID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "loading plugin manifest", "component", "api", "installation_id", installation.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin settings")
|
||||
return
|
||||
}
|
||||
@@ -1087,7 +1087,7 @@ func (h *PluginHandler) HandleGetUserPluginSettings(w http.ResponseWriter, r *ht
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
values, err := h.userConfig.Get(r.Context(), userID, id)
|
||||
if err != nil {
|
||||
slog.Error("loading plugin user config", "installation_id", id, "error", err)
|
||||
slog.ErrorContext(r.Context(), "loading plugin user config", "component", "api", "installation_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin settings")
|
||||
return
|
||||
}
|
||||
@@ -1135,7 +1135,7 @@ func (h *PluginHandler) loadUserConfigInstallation(
|
||||
writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found")
|
||||
return nil, nil, err
|
||||
}
|
||||
slog.Error("loading plugin installation", "installation_id", installationID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "loading plugin installation", "component", "api", "installation_id", installationID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin installation")
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -1146,7 +1146,7 @@ func (h *PluginHandler) loadUserConfigInstallation(
|
||||
|
||||
manifest, err := h.loadInstallationManifest(r.Context(), installation)
|
||||
if err != nil {
|
||||
slog.Error("loading plugin manifest", "installation_id", installationID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "loading plugin manifest", "component", "api", "installation_id", installationID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin settings")
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -678,7 +678,7 @@ func (h *PolicyHandler) notifyChanged(r *http.Request) policyApplyStatus {
|
||||
}
|
||||
status := h.system.ApplyChanged(r.Context())
|
||||
if err := status.Err(); err != nil {
|
||||
slog.Error("policy notify changed failed", "error", err, "failed_step", status.FailedStep())
|
||||
slog.ErrorContext(r.Context(), "policy notify changed failed", "component", "api", "error", err, "failed_step", status.FailedStep())
|
||||
}
|
||||
return policyApplyStatus{
|
||||
Applied: status.Applied(),
|
||||
|
||||
@@ -458,7 +458,7 @@ func newStartedPolicyHandlerSystem(t *testing.T, ctx context.Context, store *pol
|
||||
system := policy.NewSystem(
|
||||
store,
|
||||
bus,
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
slog.New(slog.DiscardHandler),
|
||||
policy.WithSystemPollInterval(time.Hour),
|
||||
)
|
||||
if err := system.Start(ctx); err != nil {
|
||||
@@ -611,7 +611,7 @@ func TestPolicyActivateReportsLocalReloadFailureDB(t *testing.T) {
|
||||
system := policy.NewSystem(
|
||||
policy.NewPolicyStore(badPool),
|
||||
&cache.NoopEventBus{},
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
slog.New(slog.DiscardHandler),
|
||||
policy.WithSystemPollInterval(time.Hour),
|
||||
)
|
||||
if err := system.Start(ctx); err != nil {
|
||||
|
||||
@@ -18,7 +18,7 @@ type ProfileRefreshRequester interface {
|
||||
func triggerProfileRefresh(ctx context.Context, staler ProfileStaler, requester ProfileRefreshRequester, userID int, profileID string) {
|
||||
if staler != nil {
|
||||
if err := staler.MarkProfileStale(ctx, userID, profileID); err != nil {
|
||||
slog.Warn("failed to mark profile stale", "user_id", userID, "profile_id", profileID, "error", err)
|
||||
slog.WarnContext(ctx, "failed to mark profile stale", "component", "api", "user_id", userID, "profile_id", profileID, "error", err)
|
||||
}
|
||||
}
|
||||
if requester != nil {
|
||||
|
||||
@@ -593,7 +593,7 @@ func (h *ProfileHandler) HandleUpdateProfile(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
if currentProfile.Avatar != "" && avatarRef != nil && avatarRefReplacesUpload(currentProfile.Avatar, *avatarRef) {
|
||||
if cleanupErr := deleteUploadedAvatarObjects(r.Context(), h.AvatarStore, userID, profileID); cleanupErr != nil {
|
||||
slog.Warn("profile avatar cleanup failed after update", "user_id", userID, "profile_id", profileID, "error", cleanupErr)
|
||||
slog.WarnContext(r.Context(), "profile avatar cleanup failed after update", "component", "api", "user_id", userID, "profile_id", profileID, "error", cleanupErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,14 +656,14 @@ func (h *ProfileHandler) HandleDeleteProfile(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
if isUploadedAvatarRef(profile.Avatar) {
|
||||
if cleanupErr := deleteUploadedAvatarObjects(r.Context(), h.AvatarStore, userID, profileID); cleanupErr != nil {
|
||||
slog.Warn("profile avatar cleanup failed after delete", "user_id", userID, "profile_id", profileID, "error", cleanupErr)
|
||||
slog.WarnContext(r.Context(), "profile avatar cleanup failed after delete", "component", "api", "user_id", userID, "profile_id", profileID, "error", cleanupErr)
|
||||
}
|
||||
}
|
||||
if h.DeviceLibraryPurger != nil {
|
||||
purgeCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 30*time.Second)
|
||||
defer cancel()
|
||||
if purgeErr := h.DeviceLibraryPurger.PurgeProfileDevices(purgeCtx, userID, profileID); purgeErr != nil {
|
||||
slog.Warn("profile device-library purge failed after delete", "user_id", userID, "profile_id", profileID, "error", purgeErr)
|
||||
slog.WarnContext(r.Context(), "profile device-library purge failed after delete", "component", "api", "user_id", userID, "profile_id", profileID, "error", purgeErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -806,7 +806,7 @@ func (h *ProfileHandler) HandleListHouseholdSessions(w http.ResponseWriter, r *h
|
||||
|
||||
sessions, err := h.SessionsReader.Load(r.Context(), r, PlaybackSessionsQuery{UserID: userID})
|
||||
if err != nil {
|
||||
slog.Error("failed to list household playback sessions", "user_id", userID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "failed to list household playback sessions", "component", "api", "user_id", userID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list playback sessions")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -342,7 +342,7 @@ func (h *ProgressHandler) HandleSyncProgress(w http.ResponseWriter, r *http.Requ
|
||||
now := time.Now()
|
||||
eventAt := clampEventAt(client, now)
|
||||
if !client.IsZero() && client.After(now.Add(progressClockSkew)) {
|
||||
slog.Warn("clamped future-dated progress event time",
|
||||
slog.WarnContext(r.Context(), "clamped future-dated progress event time", "component", "api",
|
||||
"profile_id", profileID, "media_item_id", item.MediaItemID)
|
||||
}
|
||||
pos, completed, skip := userstore.ResolveProgressState(item.Position, item.Duration, thresholds)
|
||||
|
||||
@@ -140,7 +140,7 @@ func (h *RecommendationsHandler) HandleForYouMain(w http.ResponseWriter, r *http
|
||||
|
||||
row, err := h.reader.GetForYouMain(r.Context(), userID, profileID, limit, filter)
|
||||
if err != nil {
|
||||
slog.Error("ForYouMain failed", "user_id", userID, "profile_id", profileID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "ForYouMain failed", "component", "api", "user_id", userID, "profile_id", profileID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendations")
|
||||
return
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func (h *RecommendationsHandler) HandleForYouRows(w http.ResponseWriter, r *http
|
||||
|
||||
rows, err := h.reader.GetForYouRows(r.Context(), userID, profileID, limit, filter)
|
||||
if err != nil {
|
||||
slog.Error("ForYouRows failed", "user_id", userID, "profile_id", profileID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "ForYouRows failed", "component", "api", "user_id", userID, "profile_id", profileID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendations")
|
||||
return
|
||||
}
|
||||
@@ -457,7 +457,7 @@ func (h *RecommendationsHandler) HandleDiscover(w http.ResponseWriter, r *http.R
|
||||
|
||||
rows, err := h.reader.GetDiscoverRows(r.Context(), userID, profileID, 20, filter)
|
||||
if err != nil {
|
||||
slog.Error("Discover failed", "user_id", userID, "profile_id", profileID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "Discover failed", "component", "api", "user_id", userID, "profile_id", profileID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendations")
|
||||
return
|
||||
}
|
||||
@@ -473,8 +473,8 @@ func (h *RecommendationsHandler) HandleDiscover(w http.ResponseWriter, r *http.R
|
||||
rows,
|
||||
)
|
||||
if upcomingErr != nil {
|
||||
slog.Warn(
|
||||
"Discover: schedule-aware blending unavailable; falling back to legacy rows",
|
||||
slog.WarnContext(r.Context(),
|
||||
"Discover: schedule-aware blending unavailable; falling back to legacy rows", "component", "api",
|
||||
"user_id",
|
||||
userID,
|
||||
"profile_id",
|
||||
@@ -558,8 +558,8 @@ func (h *RecommendationsHandler) HandleSection(w http.ResponseWriter, r *http.Re
|
||||
|
||||
row, err := h.reader.GetSection(r.Context(), userID, profileID, kind, key, limit, filter)
|
||||
if err != nil {
|
||||
slog.Error(
|
||||
"Section failed",
|
||||
slog.ErrorContext(r.Context(),
|
||||
"Section failed", "component", "api",
|
||||
"user_id", userID,
|
||||
"profile_id", profileID,
|
||||
"kind", kind,
|
||||
@@ -627,7 +627,7 @@ func (h *RecommendationsHandler) loadItemEnrichment(
|
||||
|
||||
mediaItems, err := h.Fetcher.FetchItemsByContentIDs(ctx, ids, filter)
|
||||
if err != nil {
|
||||
slog.Error("Recommendations: fetch items failed", "error", err)
|
||||
slog.ErrorContext(ctx, "Recommendations: fetch items failed", "component", "api", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
for _, mi := range mediaItems {
|
||||
@@ -636,7 +636,7 @@ func (h *RecommendationsHandler) loadItemEnrichment(
|
||||
|
||||
overlays, overlayErr := h.Fetcher.ListOverlaySummaries(ctx, ids, filter)
|
||||
if overlayErr != nil {
|
||||
slog.Error("Recommendations: overlay summaries failed", "error", overlayErr)
|
||||
slog.ErrorContext(ctx, "Recommendations: overlay summaries failed", "component", "api", "error", overlayErr)
|
||||
} else {
|
||||
out.overlays = overlays
|
||||
}
|
||||
|
||||
@@ -388,7 +388,7 @@ func (h *SectionHandler) deleteUnreferencedSectionManagedCollection(ctx context.
|
||||
collection, err := h.CollectionRepo.GetByID(ctx, collectionID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, catalog.ErrLibraryCollectionNotFound) {
|
||||
slog.Warn("failed to load section-managed collection during section delete", "collection_id", collectionID, "error", err)
|
||||
slog.WarnContext(ctx, "failed to load section-managed collection during section delete", "component", "api", "collection_id", collectionID, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -397,14 +397,14 @@ func (h *SectionHandler) deleteUnreferencedSectionManagedCollection(ctx context.
|
||||
}
|
||||
refs, err := h.repo.CountLibraryCollectionReferences(ctx, collectionID, "")
|
||||
if err != nil {
|
||||
slog.Warn("failed to count section-managed collection references", "collection_id", collectionID, "error", err)
|
||||
slog.WarnContext(ctx, "failed to count section-managed collection references", "component", "api", "collection_id", collectionID, "error", err)
|
||||
return
|
||||
}
|
||||
if refs > 0 {
|
||||
return
|
||||
}
|
||||
if err := h.CollectionRepo.Delete(ctx, collectionID); err != nil && !errors.Is(err, catalog.ErrLibraryCollectionNotFound) {
|
||||
slog.Warn("failed to delete unreferenced section-managed collection", "collection_id", collectionID, "error", err)
|
||||
slog.WarnContext(ctx, "failed to delete unreferenced section-managed collection", "component", "api", "collection_id", collectionID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,7 +608,7 @@ func (h *SectionHandler) HandleHomeSectionItems(w http.ResponseWriter, r *http.R
|
||||
|
||||
withItems, fetchErr := h.fetcher.FetchOne(r.Context(), s, nil, libraryIDs, userID, profileID, accessFilter)
|
||||
if fetchErr != nil {
|
||||
slog.Error("fetching section items", "section_id", s.ID, "type", s.SectionType, "error", fetchErr)
|
||||
slog.ErrorContext(r.Context(), "fetching section items", "component", "api", "section_id", s.ID, "type", s.SectionType, "error", fetchErr)
|
||||
withItems = sections.SectionWithItems{
|
||||
ResolvedSection: s,
|
||||
Items: []*models.MediaItem{},
|
||||
@@ -690,7 +690,7 @@ func (h *SectionHandler) HandleLibrarySectionItems(w http.ResponseWriter, r *htt
|
||||
|
||||
withItems, fetchErr := h.fetcher.FetchOne(r.Context(), s, &libraryID, nil, userID, profileID, accessFilter)
|
||||
if fetchErr != nil {
|
||||
slog.Error("fetching section items", "section_id", s.ID, "type", s.SectionType, "error", fetchErr)
|
||||
slog.ErrorContext(r.Context(), "fetching section items", "component", "api", "section_id", s.ID, "type", s.SectionType, "error", fetchErr)
|
||||
withItems = sections.SectionWithItems{
|
||||
ResolvedSection: s,
|
||||
Items: []*models.MediaItem{},
|
||||
@@ -782,7 +782,7 @@ func (h *SectionHandler) loadResolvedLibrarySections(r *http.Request, libraryID
|
||||
if len(adminSections) == 0 {
|
||||
defaults, defaultsErr := h.defaultLibrarySections(r.Context(), libraryID)
|
||||
if defaultsErr != nil {
|
||||
slog.Warn("loading typed library section defaults", "library_id", libraryID, "error", defaultsErr)
|
||||
slog.WarnContext(r.Context(), "loading typed library section defaults", "component", "api", "library_id", libraryID, "error", defaultsErr)
|
||||
adminSections = sections.DefaultLibrarySections(&libraryID)
|
||||
} else {
|
||||
adminSections = defaults
|
||||
@@ -1224,7 +1224,7 @@ func (h *SectionHandler) buildSectionsResponse(r *http.Request, withItems []sect
|
||||
if len(contentIDs) > 0 && h.fetcher != nil {
|
||||
summaries, err := h.fetcher.ListOverlaySummaries(r.Context(), contentIDs, requestAccessFilter(r))
|
||||
if err != nil {
|
||||
slog.Error("loading overlay summaries", "error", err)
|
||||
slog.ErrorContext(r.Context(), "loading overlay summaries", "component", "api", "error", err)
|
||||
} else {
|
||||
overlaySummaries = summaries
|
||||
}
|
||||
@@ -1305,7 +1305,7 @@ func (h *SectionHandler) listSectionMangaChapterItemMeta(ctx context.Context, it
|
||||
}
|
||||
meta, err := h.fetcher.FetchMangaChapterSeriesMeta(ctx, ids)
|
||||
if err != nil {
|
||||
slog.Warn("loading section manga chapter metadata", "error", err)
|
||||
slog.WarnContext(ctx, "loading section manga chapter metadata", "component", "api", "error", err)
|
||||
return map[string]sections.SectionItemMeta{}
|
||||
}
|
||||
return meta
|
||||
@@ -1341,7 +1341,7 @@ func (h *SectionHandler) listSectionEpisodeItemMeta(ctx context.Context, withIte
|
||||
|
||||
_, meta, err := h.episodeFetcher.FetchEpisodesByContentIDs(ctx, ids, filter)
|
||||
if err != nil {
|
||||
slog.Warn("loading section episode metadata", "error", err)
|
||||
slog.WarnContext(ctx, "loading section episode metadata", "component", "api", "error", err)
|
||||
return map[string]sections.SectionItemMeta{}
|
||||
}
|
||||
return meta
|
||||
@@ -1622,7 +1622,7 @@ func (h *SectionHandler) HandleRestoreDefaults(w http.ResponseWriter, r *http.Re
|
||||
if req.Scope == "home" {
|
||||
defaults, err = h.defaultHomeSections(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("loading default home sections", "error", err)
|
||||
slog.ErrorContext(r.Context(), "loading default home sections", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load libraries")
|
||||
return
|
||||
}
|
||||
@@ -1633,7 +1633,7 @@ func (h *SectionHandler) HandleRestoreDefaults(w http.ResponseWriter, r *http.Re
|
||||
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
||||
return
|
||||
}
|
||||
slog.Error("loading default library sections", "library_id", *req.LibraryID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "loading default library sections", "component", "api", "library_id", *req.LibraryID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load library")
|
||||
return
|
||||
}
|
||||
@@ -1641,7 +1641,7 @@ func (h *SectionHandler) HandleRestoreDefaults(w http.ResponseWriter, r *http.Re
|
||||
|
||||
created, err := h.repo.RestoreDefaults(r.Context(), req.Scope, req.LibraryID, defaults)
|
||||
if err != nil {
|
||||
slog.Error("restoring default sections", "scope", req.Scope, "error", err)
|
||||
slog.ErrorContext(r.Context(), "restoring default sections", "component", "api", "scope", req.Scope, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to restore defaults")
|
||||
return
|
||||
}
|
||||
@@ -1653,7 +1653,7 @@ func (h *SectionHandler) HandleRestoreDefaults(w http.ResponseWriter, r *http.Re
|
||||
libraryIDStr = strconv.Itoa(*req.LibraryID)
|
||||
}
|
||||
if err := h.repo.ClearAllProfileOverrides(r.Context(), req.Scope, libraryIDStr); err != nil {
|
||||
slog.Error("clearing profile overrides", "scope", req.Scope, "error", err)
|
||||
slog.ErrorContext(r.Context(), "clearing profile overrides", "component", "api", "scope", req.Scope, "error", err)
|
||||
// Don't fail the whole request — sections were already restored.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func (h *ServerControlHandler) HandleRestart(w http.ResponseWriter, r *http.Requ
|
||||
})
|
||||
return
|
||||
}
|
||||
slog.Error("server restart request failed", "error", err)
|
||||
slog.ErrorContext(r.Context(), "server restart request failed", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to request server restart")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ func (h *PlaybackHandler) HandleSessionWebSocket(w http.ResponseWriter, r *http.
|
||||
|
||||
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
slog.Error("websocket upgrade failed", "error", err, "session", sessionID, "playback_session_id", sessionID)
|
||||
slog.ErrorContext(r.Context(), "websocket upgrade failed", "component", "api", "error", err, "session", sessionID, "playback_session_id", sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ func (h *PlaybackHandler) HandleSessionWebSocket(w http.ResponseWriter, r *http.
|
||||
registration := h.RealtimeHub.Register(sessionID, realtimeConn)
|
||||
if registration == nil {
|
||||
conn.Close()
|
||||
slog.Warn("failed to register realtime websocket", "session", sessionID, "playback_session_id", sessionID)
|
||||
slog.WarnContext(r.Context(), "failed to register realtime websocket", "component", "api", "session", sessionID, "playback_session_id", sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ func (h *PlaybackHandler) HandleSessionWebSocket(w http.ResponseWriter, r *http.
|
||||
break
|
||||
}
|
||||
if err := h.handleRealtimeClientMessage(sessionID, data); err != nil {
|
||||
slog.Warn("invalid realtime client message", "session", sessionID, "playback_session_id", sessionID, "error", err)
|
||||
slog.WarnContext(r.Context(), "invalid realtime client message", "component", "api", "session", sessionID, "playback_session_id", sessionID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,7 +685,7 @@ func (h *SettingsHandler) registerRequestDevice(
|
||||
DeviceName: device.DeviceName,
|
||||
DevicePlatform: device.DevicePlatform,
|
||||
}); err != nil {
|
||||
slog.Warn("failed to register request device",
|
||||
slog.WarnContext(ctx, "failed to register request device", "component", "api",
|
||||
"profile_id", profileID,
|
||||
"device_id", device.DeviceID,
|
||||
"error", err,
|
||||
|
||||
@@ -266,7 +266,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
// surface it as an internal error (with a server-side signal)
|
||||
// so the real failure is diagnosable instead of looking like an
|
||||
// intermittent 404 to the client.
|
||||
slog.ErrorContext(r.Context(), "list downloaded subtitles failed",
|
||||
slog.ErrorContext(r.Context(), "list downloaded subtitles failed", "component", "api",
|
||||
"file_id", file.ID,
|
||||
"track", trackIndex,
|
||||
"error", err,
|
||||
@@ -373,7 +373,7 @@ func (h *StreamHandler) HandleSubtitleFonts(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
fonts, err := playback.ExtractAttachedSubtitleFonts(r.Context(), file.FilePath, h.ffmpegPath())
|
||||
if err != nil {
|
||||
slog.WarnContext(r.Context(), "subtitle font extraction failed",
|
||||
slog.WarnContext(r.Context(), "subtitle font extraction failed", "component", "api",
|
||||
"file_id", file.ID,
|
||||
"track", trackIndex,
|
||||
"error", err,
|
||||
@@ -386,7 +386,7 @@ func (h *StreamHandler) HandleSubtitleFonts(w http.ResponseWriter, r *http.Reque
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err := json.NewEncoder(w).Encode(playback.EncodeSubtitleFontBundle(fonts)); err != nil {
|
||||
slog.WarnContext(r.Context(), "subtitle font response encode failed", "error", err)
|
||||
slog.WarnContext(r.Context(), "subtitle font response encode failed", "component", "api", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,7 +395,7 @@ func (h *StreamHandler) syncSessionsNow(ctx context.Context, reason string) {
|
||||
return
|
||||
}
|
||||
if err := h.SessionSyncer.SyncNow(ctx); err != nil {
|
||||
slog.Error("failed to sync sessions", "reason", reason, "error", err)
|
||||
slog.ErrorContext(ctx, "failed to sync sessions", "component", "api", "reason", reason, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,7 +409,7 @@ func (h *StreamHandler) finalizeSessionAbort(ctx context.Context, session *playb
|
||||
|
||||
if h.AdminStore != nil {
|
||||
if err := h.AdminStore.DeleteSession(ctx, session.ID); err != nil {
|
||||
slog.Error("failed to delete synced session", "session", session.ID, "error", err)
|
||||
slog.ErrorContext(ctx, "failed to delete synced session", "component", "api", "session", session.ID, "error", err)
|
||||
}
|
||||
}
|
||||
if syncNow {
|
||||
@@ -438,7 +438,7 @@ func (h *StreamHandler) handleTransportStartFailure(ctx context.Context, session
|
||||
h.abortPlaybackSession(ctx, session)
|
||||
return
|
||||
}
|
||||
slog.Warn("stream transport startup failed",
|
||||
slog.WarnContext(ctx, "stream transport startup failed", "component", "api",
|
||||
"session", session.ID,
|
||||
"file_id", session.MediaFileID,
|
||||
"error", err,
|
||||
@@ -471,7 +471,7 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
|
||||
seek = subtitleSeekPosition(r, session)
|
||||
duration = subtitleWindowDuration(r)
|
||||
}
|
||||
slog.InfoContext(r.Context(), "subtitle stream requested",
|
||||
slog.InfoContext(r.Context(), "subtitle stream requested", "component", "api",
|
||||
"file_id", file.ID,
|
||||
"embedded_index", embeddedIndex,
|
||||
"track_language", track.Language,
|
||||
|
||||
@@ -136,7 +136,7 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
|
||||
case errors.Is(err, ai.ErrInvalidRequest):
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
default:
|
||||
slog.Error("failed to enqueue subtitle translation",
|
||||
slog.ErrorContext(r.Context(), "failed to enqueue subtitle translation", "component", "api",
|
||||
"media_file_id", req.MediaFileID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start translation")
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func quotaExceededMessage(err error) string {
|
||||
func (h *SubtitleAIHandler) HandleQuota(w http.ResponseWriter, r *http.Request) {
|
||||
quota, err := h.service.TranscribeQuota(r.Context(), apimw.GetUserID(r.Context()), h.quotaExempt(r))
|
||||
if err != nil {
|
||||
slog.Error("failed to load transcription quota", "error", err)
|
||||
slog.ErrorContext(r.Context(), "failed to load transcription quota", "component", "api", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load transcription quota")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ func (h *SubtitleSearchHandler) HandleDownload(w http.ResponseWriter, r *http.Re
|
||||
HearingImpaired: req.HearingImpaired,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("subtitle download failed", "provider", req.Provider, "subtitle_id", req.SubtitleID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "subtitle download failed", "component", "api", "provider", req.Provider, "subtitle_id", req.SubtitleID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "download_error", "Failed to download subtitle")
|
||||
return
|
||||
}
|
||||
@@ -242,7 +242,7 @@ func (h *SubtitleSearchHandler) HandleUpload(w http.ResponseWriter, r *http.Requ
|
||||
case strings.Contains(err.Error(), "exceeds maximum size"):
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Subtitle file must be under 5 MB")
|
||||
default:
|
||||
slog.Error("subtitle upload failed", "media_file_id", mediaFileID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "subtitle upload failed", "component", "api", "media_file_id", mediaFileID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "upload_error", "Failed to upload subtitle")
|
||||
}
|
||||
return
|
||||
|
||||
@@ -42,7 +42,7 @@ func (h *SystemHandler) HandleHWAccel(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
return
|
||||
}
|
||||
slog.Warn("hw-accel: remote node probe failed, falling back to local",
|
||||
slog.WarnContext(r.Context(), "hw-accel: remote node probe failed, falling back to local", "component", "api",
|
||||
"node", node.URL, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func (h *RecommendationsHandler) HandleTasteSeedItems(w http.ResponseWriter, r *
|
||||
// Fetch a page-sized window of candidate IDs ordered by engagement + rating.
|
||||
candidateIDs, err := h.recsRepo.GetTasteSeedCandidates(r.Context(), limit, offset)
|
||||
if err != nil {
|
||||
slog.Error("TasteSeedItems: candidate query failed", "user_id", userID, "profile_id", profileID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "TasteSeedItems: candidate query failed", "component", "api", "user_id", userID, "profile_id", profileID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch taste seed candidates")
|
||||
return
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func (h *RecommendationsHandler) HandleTasteSeedItems(w http.ResponseWriter, r *
|
||||
// Hydrate items, applying the user's library/content-rating access filter.
|
||||
mediaItems, err := h.Fetcher.FetchItemsByContentIDs(r.Context(), candidateIDs, filter)
|
||||
if err != nil {
|
||||
slog.Error("TasteSeedItems: hydrate failed", "user_id", userID, "profile_id", profileID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "TasteSeedItems: hydrate failed", "component", "api", "user_id", userID, "profile_id", profileID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to hydrate taste seed items")
|
||||
return
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func (h *RecommendationsHandler) HandleTasteSeed(w http.ResponseWriter, r *http.
|
||||
|
||||
store, err := h.storeProvider.ForUser(r.Context(), userID)
|
||||
if err != nil || store == nil {
|
||||
slog.Error("TasteSeed: failed to load user store", "user_id", userID, "error", err)
|
||||
slog.ErrorContext(r.Context(), "TasteSeed: failed to load user store", "component", "api", "user_id", userID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load user store")
|
||||
return
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func (h *RecommendationsHandler) HandleTasteSeed(w http.ResponseWriter, r *http.
|
||||
if err := store.AddFavorite(r.Context(), profileID, id); err != nil {
|
||||
// Don't fail the whole request on a single item error — log and
|
||||
// continue so the user's other picks still seed their profile.
|
||||
slog.Warn("TasteSeed: failed to add favorite", "user_id", userID, "profile_id", profileID, "item_id", id, "error", err)
|
||||
slog.WarnContext(r.Context(), "TasteSeed: failed to add favorite", "component", "api", "user_id", userID, "profile_id", profileID, "item_id", id, "error", err)
|
||||
continue
|
||||
}
|
||||
added++
|
||||
|
||||
@@ -284,7 +284,7 @@ func (h *UserCollectionImportHandler) storeBundledTemplatePoster(
|
||||
)
|
||||
if err != nil || !stored {
|
||||
if err != nil {
|
||||
slog.Warn("failed to store bundled user collection poster",
|
||||
slog.WarnContext(r.Context(), "failed to store bundled user collection poster", "component", "api",
|
||||
"collection_id", collection.ID,
|
||||
"poster_path", posterPath,
|
||||
"error", err,
|
||||
@@ -299,7 +299,7 @@ func (h *UserCollectionImportHandler) storeBundledTemplatePoster(
|
||||
PosterURL: &storedPath,
|
||||
PosterThumbhash: &thumbhash,
|
||||
}); err != nil {
|
||||
slog.Warn("failed to persist bundled user collection poster",
|
||||
slog.WarnContext(r.Context(), "failed to persist bundled user collection poster", "component", "api",
|
||||
"collection_id", collection.ID,
|
||||
"poster_path", posterPath,
|
||||
"stored_path", storedPath,
|
||||
|
||||
@@ -83,12 +83,12 @@ func (h *RecommendationsHandler) HandleWatchTonight(w http.ResponseWriter, r *ht
|
||||
wg.Wait()
|
||||
|
||||
if recErr != nil {
|
||||
slog.Error("WatchTonight: recommendations fetch failed", "user_id", userID, "error", recErr)
|
||||
slog.ErrorContext(r.Context(), "WatchTonight: recommendations fetch failed", "component", "api", "user_id", userID, "error", recErr)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendations")
|
||||
return
|
||||
}
|
||||
if liveErr != nil {
|
||||
slog.Warn("WatchTonight: live CW/NextUp fetch failed, continuing with recs only", "user_id", userID, "error", liveErr)
|
||||
slog.WarnContext(r.Context(), "WatchTonight: live CW/NextUp fetch failed, continuing with recs only", "component", "api", "user_id", userID, "error", liveErr)
|
||||
}
|
||||
|
||||
// Filter out already-watched and low-rated items from rec candidates.
|
||||
@@ -295,7 +295,7 @@ func (h *RecommendationsHandler) fetchLiveCWAndNextUp(r *http.Request, userID in
|
||||
if h.WatchTonightFetcher != nil {
|
||||
nuItems, nuMeta, nuErr := h.WatchTonightFetcher.FetchNextUpItems(ctx, userID, profileID, nil, nil, filter, limit)
|
||||
if nuErr != nil {
|
||||
slog.Warn("WatchTonight: next-up fetch failed", "user_id", userID, "error", nuErr)
|
||||
slog.WarnContext(r.Context(), "WatchTonight: next-up fetch failed", "component", "api", "user_id", userID, "error", nuErr)
|
||||
} else {
|
||||
for _, item := range nuItems {
|
||||
meta, ok := nuMeta[item.ContentID]
|
||||
@@ -331,7 +331,7 @@ func (h *RecommendationsHandler) enrichItems(r *http.Request, userID int, profil
|
||||
// Fetch movies/series from media_items table.
|
||||
mediaItems, err := h.Fetcher.FetchItemsByContentIDs(ctx, contentIDs, filter)
|
||||
if err != nil {
|
||||
slog.Error("WatchTonight: fetch items failed", "error", err)
|
||||
slog.ErrorContext(r.Context(), "WatchTonight: fetch items failed", "component", "api", "error", err)
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
|
||||
@@ -343,7 +343,7 @@ func (h *RecommendationsHandler) enrichItems(r *http.Request, userID int, profil
|
||||
// Also fetch episodes — CW/Next-Up content IDs are typically episode IDs.
|
||||
episodeItems, epMeta, epErr := h.Fetcher.FetchEpisodesByContentIDs(ctx, contentIDs, filter)
|
||||
if epErr != nil {
|
||||
slog.Error("WatchTonight: fetch episodes failed", "error", epErr)
|
||||
slog.ErrorContext(r.Context(), "WatchTonight: fetch episodes failed", "component", "api", "error", epErr)
|
||||
} else {
|
||||
for _, item := range episodeItems {
|
||||
itemMap[item.ContentID] = item
|
||||
@@ -354,7 +354,7 @@ func (h *RecommendationsHandler) enrichItems(r *http.Request, userID int, profil
|
||||
|
||||
overlays, err := h.Fetcher.ListOverlaySummaries(ctx, contentIDs, filter)
|
||||
if err != nil {
|
||||
slog.Error("WatchTonight: overlay summaries failed", "error", err)
|
||||
slog.ErrorContext(r.Context(), "WatchTonight: overlay summaries failed", "component", "api", "error", err)
|
||||
} else {
|
||||
overlayMap = overlays
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ func (h *RecommendationsHandler) HandleWatchTonightCards(w http.ResponseWriter,
|
||||
func (h *RecommendationsHandler) buildContinueCards(r *http.Request, userID int, profileID string, filter catalog.AccessFilter, excludeIDs map[string]struct{}, limit int) ([]mergedItem, bool) {
|
||||
cwItems, nextUpItems, err := h.fetchLiveCWAndNextUp(r, userID, profileID, filter, limit+len(excludeIDs))
|
||||
if err != nil {
|
||||
slog.Warn("WatchTonightCards: CW/NextUp fetch failed", "user_id", userID, "error", err)
|
||||
slog.WarnContext(r.Context(), "WatchTonightCards: CW/NextUp fetch failed", "component", "api", "user_id", userID, "error", err)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ func (h *RecommendationsHandler) buildDiscoverCards(r *http.Request, userID int,
|
||||
poolSize := discoverCandidatePoolSize(limit, len(genres))
|
||||
embedding, err := h.recsRepo.GetTasteProfile(ctx, userID, profileID)
|
||||
if err != nil {
|
||||
slog.Warn("WatchTonightCards: taste profile fetch failed", "user_id", userID, "profile_id", profileID, "error", err)
|
||||
slog.WarnContext(r.Context(), "WatchTonightCards: taste profile fetch failed", "component", "api", "user_id", userID, "profile_id", profileID, "error", err)
|
||||
}
|
||||
|
||||
candidates := []recommendations.ScoredItem{}
|
||||
@@ -294,7 +294,7 @@ func (h *RecommendationsHandler) buildDiscoverCards(r *http.Request, userID int,
|
||||
filter,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("WatchTonightCards: taste candidate query failed", "user_id", userID, "profile_id", profileID, "error", err)
|
||||
slog.WarnContext(r.Context(), "WatchTonightCards: taste candidate query failed", "component", "api", "user_id", userID, "profile_id", profileID, "error", err)
|
||||
} else {
|
||||
candidates = candidateItems
|
||||
genreMap = candidateGenres
|
||||
@@ -344,7 +344,7 @@ func (h *RecommendationsHandler) buildColdStartDiscoverCandidates(ctx context.Co
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("WatchTonightCards: popular cache unavailable", "error", err)
|
||||
slog.WarnContext(ctx, "WatchTonightCards: popular cache unavailable", "component", "api", "error", err)
|
||||
}
|
||||
for _, item := range popular {
|
||||
byID[item.MediaItemID] = item
|
||||
@@ -358,7 +358,7 @@ func (h *RecommendationsHandler) buildColdStartDiscoverCandidates(ctx context.Co
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("WatchTonightCards: recently added cache unavailable", "error", err)
|
||||
slog.WarnContext(ctx, "WatchTonightCards: recently added cache unavailable", "component", "api", "error", err)
|
||||
}
|
||||
for _, item := range recentlyAdded {
|
||||
if existing, exists := byID[item.MediaItemID]; !exists || item.Score > existing.Score {
|
||||
@@ -387,7 +387,7 @@ func (h *RecommendationsHandler) fetchCastByIDs(ctx context.Context, lookupIDs [
|
||||
}
|
||||
castMap, err := h.CastFetcher.ListForItems(ctx, lookupIDs)
|
||||
if err != nil {
|
||||
slog.Error("WatchTonightCards: cast fetch failed", "error", err)
|
||||
slog.ErrorContext(ctx, "WatchTonightCards: cast fetch failed", "component", "api", "error", err)
|
||||
return nil
|
||||
}
|
||||
return castMap
|
||||
|
||||
@@ -381,7 +381,7 @@ func (h *WebhookSyncHandler) HandleWebhook(w http.ResponseWriter, r *http.Reques
|
||||
BodyExcerpt: logContext.BodyExcerpt,
|
||||
Attrs: webhookEventAttrs(logContext, result),
|
||||
}); logErr != nil {
|
||||
slog.Warn("webhook sync: failed to persist webhook event log", "connection_id", result.ConnectionID, "error", logErr)
|
||||
slog.WarnContext(r.Context(), "webhook sync: failed to persist webhook event log", "component", "api", "connection_id", result.ConnectionID, "error", logErr)
|
||||
}
|
||||
}
|
||||
logWebhookDelivery(result, logContext, statusCode, err)
|
||||
|
||||
@@ -69,7 +69,7 @@ func RequestLogger(nodeID string) func(http.Handler) http.Handler {
|
||||
if playbackLC.PlaybackSessionID != "" {
|
||||
attrs = append(attrs, "playback_session_id", playbackLC.PlaybackSessionID)
|
||||
}
|
||||
slog.Info("api request", attrs...)
|
||||
slog.InfoContext(r.Context(), "api request", append([]any{"component", "api"}, attrs...)...)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,10 +72,10 @@ func (h *Handler) accessLog(next http.Handler) http.Handler {
|
||||
args = append(args, "ua", ua)
|
||||
}
|
||||
if sw.status >= 400 {
|
||||
slog.Warn("abs req failed", args...)
|
||||
slog.WarnContext(r.Context(), "abs req failed", append([]any{"component", "audiobooks"}, args...)...)
|
||||
return
|
||||
}
|
||||
slog.Debug("abs req", append(args, "bytes", sw.bytes)...)
|
||||
slog.DebugContext(r.Context(), "abs req", append([]any{"component", "audiobooks"}, append(args, "bytes", sw.bytes)...)...)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ func (h *Handler) handleAuthorDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
access, err := h.accessFilterForAuth(r.Context(), a)
|
||||
if err != nil {
|
||||
slog.Warn("abs author access resolution failed", "err", err, "id", id)
|
||||
slog.WarnContext(r.Context(), "abs author access resolution failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func (h *Handler) handleAuthorDetail(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs author detail failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs author detail failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "author get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -49,7 +49,7 @@ func (h *Handler) handleSeriesDetail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
access, err := h.accessFilterForAuth(r.Context(), a)
|
||||
if err != nil {
|
||||
slog.Warn("abs series access resolution failed", "err", err, "id", id)
|
||||
slog.WarnContext(r.Context(), "abs series access resolution failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func (h *Handler) handleSeriesDetail(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs series detail failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs series detail failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "series get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func (h *Handler) handleUpsertBookmark(reason string) http.HandlerFunc {
|
||||
}
|
||||
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID, access)
|
||||
if err != nil {
|
||||
slog.Error("abs bookmark item lookup failed", "err", err, "user", a.UserID, "item", itemID)
|
||||
slog.ErrorContext(r.Context(), "abs bookmark item lookup failed", "component", "audiobooks", "err", err, "user", a.UserID, "item", itemID)
|
||||
http.Error(w, "item lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func (h *Handler) handleUpsertBookmark(reason string) http.HandlerFunc {
|
||||
|
||||
bm, err := h.deps.BookmarkStore.Upsert(r.Context(), a.UserID, a.ProfileID, itemID, *body.Time, body.Title)
|
||||
if err != nil {
|
||||
slog.Error("abs bookmark upsert failed", "err", err, "user", a.UserID, "item", itemID)
|
||||
slog.ErrorContext(r.Context(), "abs bookmark upsert failed", "component", "audiobooks", "err", err, "user", a.UserID, "item", itemID)
|
||||
http.Error(w, "bookmark persist failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func (h *Handler) handleDeleteBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.deps.BookmarkStore.Delete(r.Context(), a.UserID, a.ProfileID, itemID, t); err != nil {
|
||||
slog.Error("abs bookmark delete failed", "err", err, "user", a.UserID, "item", itemID)
|
||||
slog.ErrorContext(r.Context(), "abs bookmark delete failed", "component", "audiobooks", "err", err, "user", a.UserID, "item", itemID)
|
||||
http.Error(w, "bookmark delete failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -170,7 +170,7 @@ func parseBookmarkTime(s string) (float64, bool) {
|
||||
func writeBookmarkList(w http.ResponseWriter, r *http.Request, h *Handler, userID, profileID, itemID string) {
|
||||
rows, err := h.deps.BookmarkStore.List(r.Context(), userID, profileID, itemID)
|
||||
if err != nil {
|
||||
slog.Warn("abs bookmark list after mutation failed", "err", err, "user", userID, "item", itemID)
|
||||
slog.WarnContext(r.Context(), "abs bookmark list after mutation failed", "component", "audiobooks", "err", err, "user", userID, "item", itemID)
|
||||
writeJSON(w, http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func (h *Handler) handleCreateCollection(w http.ResponseWriter, r *http.Request)
|
||||
c.IsPublic = *body.IsPublic
|
||||
}
|
||||
if err := h.deps.CollectionStore.CreateCollection(r.Context(), c); err != nil {
|
||||
slog.Error("abs collection create failed", "err", err, "user", a.UserID)
|
||||
slog.ErrorContext(r.Context(), "abs collection create failed", "component", "audiobooks", "err", err, "user", a.UserID)
|
||||
http.Error(w, "collection persist failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func (h *Handler) handleCreateCollection(w http.ResponseWriter, r *http.Request)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
persisted = c
|
||||
} else if err != nil {
|
||||
slog.Warn("abs collection get-after-create failed", "err", err, "id", c.ID)
|
||||
slog.WarnContext(r.Context(), "abs collection get-after-create failed", "component", "audiobooks", "err", err, "id", c.ID)
|
||||
persisted = c
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h.collectionFullShape(r, persisted))
|
||||
@@ -99,7 +99,7 @@ func (h *Handler) collectionBooks(r *http.Request, collectionID string) []map[st
|
||||
}
|
||||
rows, err := h.deps.CollectionStore.ListCollectionItems(r.Context(), collectionID)
|
||||
if err != nil {
|
||||
slog.Warn("abs collection list-items failed", "err", err, "collection", collectionID)
|
||||
slog.WarnContext(r.Context(), "abs collection list-items failed", "component", "audiobooks", "err", err, "collection", collectionID)
|
||||
return []map[string]any{}
|
||||
}
|
||||
lib := h.resolveDefaultLibrary(r.Context())
|
||||
@@ -159,7 +159,7 @@ func (h *Handler) handleListLibraryCollections(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
rows, err := h.deps.CollectionStore.ListUserCollections(r.Context(), a.UserID, a.ProfileID)
|
||||
if err != nil {
|
||||
slog.Error("abs library collection list failed", "err", err, "user", a.UserID)
|
||||
slog.ErrorContext(r.Context(), "abs library collection list failed", "component", "audiobooks", "err", err, "user", a.UserID)
|
||||
http.Error(w, "collection list failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -200,7 +200,7 @@ func (h *Handler) handleListCollections(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
rows, err := h.deps.CollectionStore.ListUserCollections(r.Context(), a.UserID, a.ProfileID)
|
||||
if err != nil {
|
||||
slog.Error("abs collection list failed", "err", err, "user", a.UserID)
|
||||
slog.ErrorContext(r.Context(), "abs collection list failed", "component", "audiobooks", "err", err, "user", a.UserID)
|
||||
http.Error(w, "collection list failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -235,7 +235,7 @@ func (h *Handler) handleGetCollection(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs collection get failed", "err", err)
|
||||
slog.ErrorContext(r.Context(), "abs collection get failed", "component", "audiobooks", "err", err)
|
||||
http.Error(w, "collection get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -262,7 +262,7 @@ func (h *Handler) handleUpdateCollection(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs collection get-for-update failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs collection get-for-update failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "collection get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -287,14 +287,14 @@ func (h *Handler) handleUpdateCollection(w http.ResponseWriter, r *http.Request)
|
||||
c.IsPublic = *body.IsPublic
|
||||
}
|
||||
if err := h.deps.CollectionStore.UpdateCollection(r.Context(), c); err != nil {
|
||||
slog.Error("abs collection update failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs collection update failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "collection persist failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
persisted, err := h.deps.CollectionStore.GetCollection(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Warn("abs collection get-after-update failed", "err", err, "id", id)
|
||||
slog.WarnContext(r.Context(), "abs collection get-after-update failed", "component", "audiobooks", "err", err, "id", id)
|
||||
persisted = c
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h.collectionFullShape(r, persisted))
|
||||
@@ -327,7 +327,7 @@ func (h *Handler) handleAddCollectionBook(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs collection get-for-add failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs collection get-for-add failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "collection get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -345,7 +345,7 @@ func (h *Handler) handleAddCollectionBook(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
if err := h.deps.CollectionStore.AddCollectionItem(r.Context(), id, bookID); err != nil {
|
||||
slog.Error("abs collection add-item failed", "err", err, "id", id, "book", bookID)
|
||||
slog.ErrorContext(r.Context(), "abs collection add-item failed", "component", "audiobooks", "err", err, "id", id, "book", bookID)
|
||||
http.Error(w, "collection persist failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -384,13 +384,13 @@ func (h *Handler) handleRemoveCollectionBook(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs collection get-for-remove failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs collection get-for-remove failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "collection get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.deps.CollectionStore.RemoveCollectionItem(r.Context(), id, bookID); err != nil {
|
||||
slog.Error("abs collection remove-item failed", "err", err, "id", id, "book", bookID)
|
||||
slog.ErrorContext(r.Context(), "abs collection remove-item failed", "component", "audiobooks", "err", err, "id", id, "book", bookID)
|
||||
http.Error(w, "collection delete failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -422,12 +422,12 @@ func (h *Handler) handleDeleteCollection(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs collection get-for-delete failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs collection get-for-delete failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "collection get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := h.deps.CollectionStore.DeleteCollection(r.Context(), id); err != nil {
|
||||
slog.Error("abs collection delete failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs collection delete failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "collection delete failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func (h *Handler) setHideFromContinue(w http.ResponseWriter, r *http.Request, hi
|
||||
}
|
||||
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID, access)
|
||||
if err != nil {
|
||||
slog.Error("abs continue item lookup failed", "err", err, "user", a.UserID, "item", itemID)
|
||||
slog.ErrorContext(r.Context(), "abs continue item lookup failed", "component", "audiobooks", "err", err, "user", a.UserID, "item", itemID)
|
||||
http.Error(w, "item lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func (h *Handler) setHideFromContinue(w http.ResponseWriter, r *http.Request, hi
|
||||
return
|
||||
}
|
||||
if err := h.deps.ProgressStore.SetHideFromContinue(r.Context(), a.UserID, a.ProfileID, itemID, hide); err != nil {
|
||||
slog.Error("abs continue toggle failed", "err", err, "user", a.UserID, "item", itemID, "hide", hide)
|
||||
slog.ErrorContext(r.Context(), "abs continue toggle failed", "component", "audiobooks", "err", err, "user", a.UserID, "item", itemID, "hide", hide)
|
||||
http.Error(w, "continue toggle failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ func (h *Handler) handleDeleteItemProgress(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
contentID := chi.URLParam(r, "libraryItemId")
|
||||
if err := h.deps.ProgressStore.DeleteProgress(r.Context(), a.UserID, a.ProfileID, contentID); err != nil {
|
||||
slog.Warn("abs delete progress failed", "err", err, "content", contentID)
|
||||
slog.WarnContext(r.Context(), "abs delete progress failed", "component", "audiobooks", "err", err, "content", contentID)
|
||||
http.Error(w, "delete progress failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -620,12 +620,12 @@ func (h *Handler) bearerAuth(next http.Handler) http.Handler {
|
||||
raw = r.URL.Query().Get("token")
|
||||
}
|
||||
if raw == "" {
|
||||
slog.Debug("abs bearerAuth: no token", "path", r.URL.Path, "remote", r.RemoteAddr)
|
||||
slog.DebugContext(r.Context(), "abs bearerAuth: no token", "component", "audiobooks", "path", r.URL.Path, "remote", r.RemoteAddr)
|
||||
http.Error(w, "unauthenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if h.deps.Config == nil || h.deps.TokenStore == nil {
|
||||
slog.Warn("abs bearerAuth: deps not wired",
|
||||
slog.WarnContext(r.Context(), "abs bearerAuth: deps not wired", "component", "audiobooks",
|
||||
"have_config", h.deps.Config != nil,
|
||||
"have_token_store", h.deps.TokenStore != nil,
|
||||
"path", r.URL.Path)
|
||||
@@ -634,50 +634,50 @@ func (h *Handler) bearerAuth(next http.Handler) http.Handler {
|
||||
}
|
||||
secret, err := h.deps.Config.JWTSecret(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("abs bearerAuth: jwt secret fetch failed", "err", err, "path", r.URL.Path)
|
||||
slog.ErrorContext(r.Context(), "abs bearerAuth: jwt secret fetch failed", "component", "audiobooks", "err", err, "path", r.URL.Path)
|
||||
http.Error(w, "config unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
claims, err := ParseToken(secret, raw)
|
||||
if err != nil {
|
||||
slog.Debug("abs bearerAuth: parse failed", "err", err, "path", r.URL.Path)
|
||||
slog.DebugContext(r.Context(), "abs bearerAuth: parse failed", "component", "audiobooks", "err", err, "path", r.URL.Path)
|
||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if claims.Type != "access" {
|
||||
slog.Debug("abs bearerAuth: wrong token type", "type", claims.Type, "path", r.URL.Path)
|
||||
slog.DebugContext(r.Context(), "abs bearerAuth: wrong token type", "component", "audiobooks", "type", claims.Type, "path", r.URL.Path)
|
||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
row, err := h.deps.TokenStore.GetTokenByJTI(r.Context(), claims.JTI)
|
||||
if err != nil {
|
||||
slog.Debug("abs bearerAuth: jti lookup failed",
|
||||
slog.DebugContext(r.Context(), "abs bearerAuth: jti lookup failed", "component", "audiobooks",
|
||||
"jti", claims.JTI, "err", err, "path", r.URL.Path)
|
||||
http.Error(w, "token revoked", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if row.RevokedAt != nil {
|
||||
slog.Debug("abs bearerAuth: jti revoked", "jti", claims.JTI, "path", r.URL.Path)
|
||||
slog.DebugContext(r.Context(), "abs bearerAuth: jti revoked", "component", "audiobooks", "jti", claims.JTI, "path", r.URL.Path)
|
||||
http.Error(w, "token revoked", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if row.UserID != "" && row.UserID != claims.UserID {
|
||||
slog.Debug("abs bearerAuth: token user mismatch", "jti", claims.JTI, "path", r.URL.Path)
|
||||
slog.DebugContext(r.Context(), "abs bearerAuth: token user mismatch", "component", "audiobooks", "jti", claims.JTI, "path", r.URL.Path)
|
||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if row.ProfileID != "" && row.ProfileID != claims.ProfileID {
|
||||
slog.Debug("abs bearerAuth: token profile mismatch", "jti", claims.JTI, "path", r.URL.Path)
|
||||
slog.DebugContext(r.Context(), "abs bearerAuth: token profile mismatch", "component", "audiobooks", "jti", claims.JTI, "path", r.URL.Path)
|
||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if row.Type != "" && row.Type != "access" {
|
||||
slog.Debug("abs bearerAuth: persisted token type mismatch", "type", row.Type, "path", r.URL.Path)
|
||||
slog.DebugContext(r.Context(), "abs bearerAuth: persisted token type mismatch", "component", "audiobooks", "type", row.Type, "path", r.URL.Path)
|
||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !row.ExpiresAt.IsZero() && time.Now().After(row.ExpiresAt) {
|
||||
slog.Debug("abs bearerAuth: persisted token expired", "jti", claims.JTI, "path", r.URL.Path)
|
||||
slog.DebugContext(r.Context(), "abs bearerAuth: persisted token expired", "component", "audiobooks", "jti", claims.JTI, "path", r.URL.Path)
|
||||
http.Error(w, "token expired", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ func (h *Handler) handleLibraryAuthors(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if total > len(pageAuthors) && r.URL.Query().Get("page") == "" {
|
||||
slog.Warn("abs authors truncated for non-paginated request",
|
||||
slog.WarnContext(r.Context(), "abs authors truncated for non-paginated request", "component", "audiobooks",
|
||||
"library", lib.ID, "total", total, "returned", len(pageAuthors))
|
||||
}
|
||||
libID := audiobookLibraryID(lib)
|
||||
@@ -432,7 +432,7 @@ func (h *Handler) handleLibrarySeries(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if total > len(pageSeries) && r.URL.Query().Get("page") == "" {
|
||||
slog.Warn("abs series truncated for non-paginated request",
|
||||
slog.WarnContext(r.Context(), "abs series truncated for non-paginated request", "component", "audiobooks",
|
||||
"library", lib.ID, "total", total, "returned", len(pageSeries))
|
||||
}
|
||||
libID := audiobookLibraryID(lib)
|
||||
|
||||
@@ -22,7 +22,7 @@ func (h *Handler) handleListeningStats(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
stats, err := h.deps.PlaybackSessionStore.AggregateStats(r.Context(), a.UserID, a.ProfileID)
|
||||
if err != nil {
|
||||
slog.Error("abs listening stats failed", "err", err, "user", a.UserID)
|
||||
slog.ErrorContext(r.Context(), "abs listening stats failed", "component", "audiobooks", "err", err, "user", a.UserID)
|
||||
http.Error(w, "stats unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func (h *Handler) handleListeningSessions(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
sessions, total, err := h.deps.PlaybackSessionStore.ListClosedSessions(r.Context(), a.UserID, a.ProfileID, itemsPerPage, page*itemsPerPage)
|
||||
if err != nil {
|
||||
slog.Error("abs listening sessions failed", "err", err, "user", a.UserID)
|
||||
slog.ErrorContext(r.Context(), "abs listening sessions failed", "component", "audiobooks", "err", err, "user", a.UserID)
|
||||
http.Error(w, "sessions unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func (h *Handler) handleListeningSessions(w http.ResponseWriter, r *http.Request
|
||||
if h.deps.MediaStore != nil && len(sessions) > 0 {
|
||||
access, aerr := h.accessFilterForAuth(r.Context(), a)
|
||||
if aerr != nil {
|
||||
slog.Debug("abs listening sessions: resolve access failed", "user", a.UserID, "err", aerr)
|
||||
slog.DebugContext(r.Context(), "abs listening sessions: resolve access failed", "component", "audiobooks", "user", a.UserID, "err", aerr)
|
||||
} else {
|
||||
contentIDs := make([]string, 0, len(sessions))
|
||||
for _, s := range sessions {
|
||||
@@ -72,7 +72,7 @@ func (h *Handler) handleListeningSessions(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
hydrated, herr := h.deps.MediaStore.GetAudiobooksByIDs(r.Context(), contentIDs, access)
|
||||
if herr != nil {
|
||||
slog.Debug("abs listening sessions: hydrate media items failed", "user", a.UserID, "err", herr)
|
||||
slog.DebugContext(r.Context(), "abs listening sessions: hydrate media items failed", "component", "audiobooks", "user", a.UserID, "err", herr)
|
||||
} else {
|
||||
items = hydrated
|
||||
}
|
||||
@@ -111,9 +111,9 @@ func (h *Handler) handleListeningSessionDetail(w http.ResponseWriter, r *http.Re
|
||||
if h.deps.MediaStore != nil {
|
||||
access, aerr := h.accessFilterForAuth(r.Context(), a)
|
||||
if aerr != nil {
|
||||
slog.Debug("abs listening session detail: resolve access failed", "user", a.UserID, "err", aerr)
|
||||
slog.DebugContext(r.Context(), "abs listening session detail: resolve access failed", "component", "audiobooks", "user", a.UserID, "err", aerr)
|
||||
} else if fetched, ferr := h.deps.MediaStore.GetAudiobookByID(r.Context(), sess.ContentID, access); ferr != nil {
|
||||
slog.Debug("abs listening session detail: hydrate media item failed", "user", a.UserID, "content_id", sess.ContentID, "err", ferr)
|
||||
slog.DebugContext(r.Context(), "abs listening session detail: hydrate media item failed", "component", "audiobooks", "user", a.UserID, "content_id", sess.ContentID, "err", ferr)
|
||||
} else {
|
||||
item = fetched
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ func (h *Handler) handleStandaloneLogin(w http.ResponseWriter, r *http.Request)
|
||||
if errors.Is(err, auth.ErrInvalidCredentials) || errors.Is(err, auth.ErrUserDisabled) {
|
||||
http.Error(w, "invalid username or password", http.StatusUnauthorized)
|
||||
} else {
|
||||
slog.Error("abs login: cred validator failed", "username", body.Username, "err", err)
|
||||
slog.ErrorContext(r.Context(), "abs login: cred validator failed", "component", "audiobooks", "username", body.Username, "err", err)
|
||||
http.Error(w, "login service unavailable", http.StatusServiceUnavailable)
|
||||
}
|
||||
return
|
||||
@@ -102,7 +102,7 @@ func (h *Handler) handleStandaloneLogin(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("abs standalone login: validator OK",
|
||||
slog.DebugContext(r.Context(), "abs standalone login: validator OK", "component", "audiobooks",
|
||||
"username", body.Username, "user_id", userID, "profile_id", profileID)
|
||||
h.completeLogin(w, r, userID, profileID, displayName)
|
||||
}
|
||||
@@ -175,7 +175,7 @@ func (h *Handler) completeLogin(w http.ResponseWriter, r *http.Request, userID,
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("abs completeLogin: tokens persisted",
|
||||
slog.DebugContext(r.Context(), "abs completeLogin: tokens persisted", "component", "audiobooks",
|
||||
"user_id", userID, "access_jti", accessJTI, "refresh_jti", refreshJTI)
|
||||
|
||||
// Real ABS delivers the refresh token in the body when the client opts in
|
||||
@@ -512,13 +512,13 @@ func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
if claims != nil {
|
||||
claimsType = claims.Type
|
||||
}
|
||||
slog.Debug("abs refresh: parse/type failed", "err", err, "type", claimsType)
|
||||
slog.DebugContext(r.Context(), "abs refresh: parse/type failed", "component", "audiobooks", "err", err, "type", claimsType)
|
||||
http.Error(w, "invalid refresh token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
row, err := h.deps.TokenStore.RevokeTokenIfActive(r.Context(), claims.JTI)
|
||||
if err != nil {
|
||||
slog.Debug("abs refresh: jti revoke failed", "jti", claims.JTI, "err", err)
|
||||
slog.DebugContext(r.Context(), "abs refresh: jti revoke failed", "component", "audiobooks", "jti", claims.JTI, "err", err)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
http.Error(w, "refresh token revoked", http.StatusUnauthorized)
|
||||
} else {
|
||||
@@ -556,13 +556,13 @@ func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
newRefreshJTI := ulid.Make().String()
|
||||
access, err := IssueAccessToken(secret, claims.UserID, claims.ProfileID, newAccessJTI, accessTTL)
|
||||
if err != nil {
|
||||
slog.Error("abs refresh: mint access failed", "user", claims.UserID, "err", err)
|
||||
slog.ErrorContext(r.Context(), "abs refresh: mint access failed", "component", "audiobooks", "user", claims.UserID, "err", err)
|
||||
http.Error(w, "token mint failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
refresh, err := IssueRefreshToken(secret, claims.UserID, claims.ProfileID, newRefreshJTI, refreshTTL)
|
||||
if err != nil {
|
||||
slog.Error("abs refresh: mint refresh failed", "user", claims.UserID, "err", err)
|
||||
slog.ErrorContext(r.Context(), "abs refresh: mint refresh failed", "component", "audiobooks", "user", claims.UserID, "err", err)
|
||||
http.Error(w, "token mint failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -571,7 +571,7 @@ func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
ID: newAccessJTI, UserID: claims.UserID, ProfileID: claims.ProfileID,
|
||||
Type: "access", JTI: newAccessJTI, ExpiresAt: now.Add(accessTTL),
|
||||
}); err != nil {
|
||||
slog.Error("abs refresh: persist access failed", "user", claims.UserID, "jti", newAccessJTI, "err", err)
|
||||
slog.ErrorContext(r.Context(), "abs refresh: persist access failed", "component", "audiobooks", "user", claims.UserID, "jti", newAccessJTI, "err", err)
|
||||
http.Error(w, "token persist failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -579,11 +579,11 @@ func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
ID: newRefreshJTI, UserID: claims.UserID, ProfileID: claims.ProfileID,
|
||||
Type: "refresh", JTI: newRefreshJTI, ExpiresAt: now.Add(refreshTTL),
|
||||
}); err != nil {
|
||||
slog.Error("abs refresh: persist refresh failed", "user", claims.UserID, "jti", newRefreshJTI, "err", err)
|
||||
slog.ErrorContext(r.Context(), "abs refresh: persist refresh failed", "component", "audiobooks", "user", claims.UserID, "jti", newRefreshJTI, "err", err)
|
||||
http.Error(w, "token persist failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
slog.Debug("abs refresh: rotated", "user", claims.UserID,
|
||||
slog.DebugContext(r.Context(), "abs refresh: rotated", "component", "audiobooks", "user", claims.UserID,
|
||||
"old_jti", claims.JTI, "new_access_jti", newAccessJTI, "new_refresh_jti", newRefreshJTI)
|
||||
|
||||
// Real ABS /auth/refresh returns the SAME payload as /login. Return the
|
||||
@@ -639,22 +639,22 @@ func (h *Handler) revokeLogoutPrincipal(r *http.Request) {
|
||||
}
|
||||
secret, err := h.deps.Config.JWTSecret(r.Context())
|
||||
if err != nil {
|
||||
slog.Debug("abs logout: jwt secret fetch failed", "err", err)
|
||||
slog.DebugContext(r.Context(), "abs logout: jwt secret fetch failed", "component", "audiobooks", "err", err)
|
||||
return
|
||||
}
|
||||
claims, err := ParseToken(secret, raw)
|
||||
if err != nil || claims.JTI == "" {
|
||||
slog.Debug("abs logout: parse failed", "err", err)
|
||||
slog.DebugContext(r.Context(), "abs logout: parse failed", "component", "audiobooks", "err", err)
|
||||
return
|
||||
}
|
||||
if err := h.deps.TokenStore.RevokeTokensForPrincipal(r.Context(), claims.UserID, claims.ProfileID); err != nil {
|
||||
slog.Warn("abs logout: revoke principal tokens failed", "jti", claims.JTI, "user", claims.UserID, "err", err)
|
||||
slog.WarnContext(r.Context(), "abs logout: revoke principal tokens failed", "component", "audiobooks", "jti", claims.JTI, "user", claims.UserID, "err", err)
|
||||
return
|
||||
}
|
||||
if h.deps.PlaybackSessionStore != nil {
|
||||
if err := h.deps.PlaybackSessionStore.CloseOpenSessionsForPrincipal(r.Context(), claims.UserID, claims.ProfileID); err != nil {
|
||||
slog.Warn("abs logout: close sessions failed", "user", claims.UserID, "profile", claims.ProfileID, "err", err)
|
||||
slog.WarnContext(r.Context(), "abs logout: close sessions failed", "component", "audiobooks", "user", claims.UserID, "profile", claims.ProfileID, "err", err)
|
||||
}
|
||||
}
|
||||
slog.Debug("abs logout: revoked principal tokens", "jti", claims.JTI, "user", claims.UserID)
|
||||
slog.DebugContext(r.Context(), "abs logout: revoked principal tokens", "component", "audiobooks", "jti", claims.JTI, "user", claims.UserID)
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func (h *Handler) startNativePlaybackSession(
|
||||
}
|
||||
|
||||
if err := h.deps.NativeSessions.SetProgressPersistenceDisabled(session.ID, true); err != nil {
|
||||
slog.Warn("abs play: disable native progress persistence failed",
|
||||
slog.WarnContext(r.Context(), "abs play: disable native progress persistence failed", "component", "audiobooks",
|
||||
"session_id", session.ID, "error", err)
|
||||
} else {
|
||||
session.DisableProgressPersistence = true
|
||||
@@ -71,7 +71,7 @@ func (h *Handler) startNativePlaybackSession(
|
||||
|
||||
if startPosition > 0 {
|
||||
if err := h.deps.NativeSessions.UpdateProgress(session.ID, startPosition, false); err != nil {
|
||||
slog.Warn("abs play: seed native session progress failed",
|
||||
slog.WarnContext(r.Context(), "abs play: seed native session progress failed", "component", "audiobooks",
|
||||
"session_id", session.ID, "position", startPosition, "error", err)
|
||||
} else {
|
||||
session.Position = startPosition
|
||||
@@ -92,7 +92,7 @@ func (h *Handler) startNativePlaybackSession(
|
||||
ClientUserAgent: session.ClientUserAgent,
|
||||
StreamBitrateKbps: streamBitrateKbps,
|
||||
}); err != nil {
|
||||
slog.Warn("abs play: update native session stream state failed",
|
||||
slog.WarnContext(r.Context(), "abs play: update native session stream state failed", "component", "audiobooks",
|
||||
"session_id", session.ID, "error", err)
|
||||
} else {
|
||||
session.PlayMethod = playback.PlayDirect
|
||||
@@ -111,7 +111,7 @@ func (h *Handler) updateNativePlaybackProgress(ctx context.Context, sessionID st
|
||||
}
|
||||
if err := h.deps.NativeSessions.UpdateProgress(sessionID, position, false); err != nil {
|
||||
if !errors.Is(err, playback.ErrSessionNotFound) {
|
||||
slog.Warn("abs session sync: update native session progress failed",
|
||||
slog.WarnContext(ctx, "abs session sync: update native session progress failed", "component", "audiobooks",
|
||||
"session_id", sessionID, "position", position, "error", err)
|
||||
}
|
||||
return
|
||||
@@ -125,7 +125,7 @@ func (h *Handler) stopNativePlaybackSession(ctx context.Context, sessionID strin
|
||||
}
|
||||
if err := h.deps.NativeSessions.StopSession(sessionID); err != nil {
|
||||
if !errors.Is(err, playback.ErrSessionNotFound) {
|
||||
slog.Warn("abs session close: stop native session failed",
|
||||
slog.WarnContext(ctx, "abs session close: stop native session failed", "component", "audiobooks",
|
||||
"session_id", sessionID, "error", err)
|
||||
}
|
||||
return
|
||||
@@ -162,7 +162,7 @@ func (h *Handler) syncNativeSessionsNow(ctx context.Context, reason string) {
|
||||
return
|
||||
}
|
||||
if err := h.deps.NativeSessionSyncer.SyncNow(ctx); err != nil {
|
||||
slog.Error("abs: failed to sync native playback sessions", "reason", reason, "error", err)
|
||||
slog.ErrorContext(ctx, "abs: failed to sync native playback sessions", "component", "audiobooks", "reason", reason, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ func (h *Handler) handlePlayStart(w http.ResponseWriter, r *http.Request) {
|
||||
var currentTime float64
|
||||
currentTime, err = resolveResumeTime(r.Context(), h.deps.ProgressStore, a.UserID, a.ProfileID, contentID)
|
||||
if err != nil {
|
||||
slog.Debug("play: progress lookup failed", "user", a.UserID, "item", contentID, "err", err)
|
||||
slog.DebugContext(r.Context(), "play: progress lookup failed", "component", "audiobooks", "user", a.UserID, "item", contentID, "err", err)
|
||||
// currentTime is already 0 on error path; safe to continue.
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ func (h *Handler) handlePlayStart(w http.ResponseWriter, r *http.Request) {
|
||||
// Non-fatal: log but still return the manifest so the client
|
||||
// can play. Heartbeat/close calls will fail with 404 until the
|
||||
// next play_start lands cleanly.
|
||||
slog.Warn("abs play: persist session failed",
|
||||
slog.WarnContext(r.Context(), "abs play: persist session failed", "component", "audiobooks",
|
||||
"session_id", sessionID, "content_id", contentID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ func (h *Handler) handleCreatePlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
p.IsPublic = *body.IsPublic
|
||||
}
|
||||
if err := h.deps.PlaylistStore.CreatePlaylist(r.Context(), p); err != nil {
|
||||
slog.Error("abs playlist create failed", "err", err, "user", a.UserID)
|
||||
slog.ErrorContext(r.Context(), "abs playlist create failed", "component", "audiobooks", "err", err, "user", a.UserID)
|
||||
http.Error(w, "playlist persist failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -98,12 +98,12 @@ func (h *Handler) handleCreatePlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
// MediaStore lookup so typos don't create orphan rows.
|
||||
if it.EpisodeID == "" {
|
||||
if mi, mErr := h.deps.MediaStore.GetAudiobookByID(r.Context(), it.LibraryItemID, access); mErr != nil || mi == nil {
|
||||
slog.Debug("abs playlist create-items: skipping unknown audiobook", "id", it.LibraryItemID)
|
||||
slog.DebugContext(r.Context(), "abs playlist create-items: skipping unknown audiobook", "component", "audiobooks", "id", it.LibraryItemID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if addErr := h.deps.PlaylistStore.AddPlaylistItem(r.Context(), p.ID, it.LibraryItemID, it.EpisodeID); addErr != nil {
|
||||
slog.Debug("abs playlist create-items: store error", "err", addErr, "id", it.LibraryItemID)
|
||||
slog.DebugContext(r.Context(), "abs playlist create-items: store error", "component", "audiobooks", "err", addErr, "id", it.LibraryItemID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ func (h *Handler) playlistItems(r *http.Request, playlistID string) []map[string
|
||||
}
|
||||
rows, err := h.deps.PlaylistStore.ListPlaylistItems(r.Context(), playlistID)
|
||||
if err != nil {
|
||||
slog.Warn("abs playlist list-items failed", "err", err, "playlist", playlistID)
|
||||
slog.WarnContext(r.Context(), "abs playlist list-items failed", "component", "audiobooks", "err", err, "playlist", playlistID)
|
||||
return []map[string]any{}
|
||||
}
|
||||
access, _, _ := h.accessFilterFromRequest(r)
|
||||
@@ -197,7 +197,7 @@ func (h *Handler) handleListLibraryPlaylists(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
rows, err := h.deps.PlaylistStore.ListUserPlaylists(r.Context(), a.UserID, a.ProfileID)
|
||||
if err != nil {
|
||||
slog.Error("abs library playlist list failed", "err", err, "user", a.UserID)
|
||||
slog.ErrorContext(r.Context(), "abs library playlist list failed", "component", "audiobooks", "err", err, "user", a.UserID)
|
||||
http.Error(w, "playlist list failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -227,7 +227,7 @@ func (h *Handler) handleListPlaylists(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rows, err := h.deps.PlaylistStore.ListUserPlaylists(r.Context(), a.UserID, a.ProfileID)
|
||||
if err != nil {
|
||||
slog.Error("abs playlist list failed", "err", err, "user", a.UserID)
|
||||
slog.ErrorContext(r.Context(), "abs playlist list failed", "component", "audiobooks", "err", err, "user", a.UserID)
|
||||
http.Error(w, "playlist list failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -257,7 +257,7 @@ func (h *Handler) handleGetPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs playlist get failed", "err", err)
|
||||
slog.ErrorContext(r.Context(), "abs playlist get failed", "component", "audiobooks", "err", err)
|
||||
http.Error(w, "playlist get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -283,7 +283,7 @@ func (h *Handler) handleUpdatePlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs playlist get-for-update failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs playlist get-for-update failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "playlist get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -306,7 +306,7 @@ func (h *Handler) handleUpdatePlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
p.IsPublic = *body.IsPublic
|
||||
}
|
||||
if err := h.deps.PlaylistStore.UpdatePlaylist(r.Context(), p); err != nil {
|
||||
slog.Error("abs playlist update failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs playlist update failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "playlist persist failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -342,7 +342,7 @@ func (h *Handler) handleAddPlaylistItem(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs playlist get-for-add failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs playlist get-for-add failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "playlist get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -372,7 +372,7 @@ func (h *Handler) handleAddPlaylistItem(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
if err := h.deps.PlaylistStore.AddPlaylistItem(r.Context(), id, body.LibraryItemID, body.EpisodeID); err != nil {
|
||||
slog.Error("abs playlist add-item failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs playlist add-item failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "playlist persist failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -405,12 +405,12 @@ func (h *Handler) handleDeletePlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs playlist get-for-delete failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs playlist get-for-delete failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "playlist get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := h.deps.PlaylistStore.DeletePlaylist(r.Context(), id); err != nil {
|
||||
slog.Error("abs playlist delete failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs playlist delete failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "playlist delete failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -446,7 +446,7 @@ func (h *Handler) handleBatchAddPlaylistItems(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs playlist get-for-batch-add failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs playlist get-for-batch-add failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "playlist get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -459,24 +459,24 @@ func (h *Handler) handleBatchAddPlaylistItems(w http.ResponseWriter, r *http.Req
|
||||
|
||||
for _, it := range body.Items {
|
||||
if it.LibraryItemID == "" {
|
||||
slog.Debug("abs playlist batch-add: skipping empty libraryItemId")
|
||||
slog.DebugContext(r.Context(), "abs playlist batch-add: skipping empty libraryItemId", slog.String("component", "audiobooks"))
|
||||
continue
|
||||
}
|
||||
// Audiobook validation; episode items skip.
|
||||
if it.EpisodeID == "" {
|
||||
access, accessErr := h.accessFilterForAuth(r.Context(), a)
|
||||
if accessErr != nil {
|
||||
slog.Debug("abs playlist batch-add: skipping access-denied audiobook", "id", it.LibraryItemID, "err", accessErr)
|
||||
slog.DebugContext(r.Context(), "abs playlist batch-add: skipping access-denied audiobook", "component", "audiobooks", "id", it.LibraryItemID, "err", accessErr)
|
||||
continue
|
||||
}
|
||||
item, lookupErr := h.deps.MediaStore.GetAudiobookByID(r.Context(), it.LibraryItemID, access)
|
||||
if lookupErr != nil || item == nil {
|
||||
slog.Debug("abs playlist batch-add: skipping unknown audiobook", "id", it.LibraryItemID)
|
||||
slog.DebugContext(r.Context(), "abs playlist batch-add: skipping unknown audiobook", "component", "audiobooks", "id", it.LibraryItemID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if addErr := h.deps.PlaylistStore.AddPlaylistItem(r.Context(), id, it.LibraryItemID, it.EpisodeID); addErr != nil {
|
||||
slog.Debug("abs playlist batch-add: store error", "err", addErr, "id", it.LibraryItemID)
|
||||
slog.DebugContext(r.Context(), "abs playlist batch-add: store error", "component", "audiobooks", "err", addErr, "id", it.LibraryItemID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,7 +508,7 @@ func (h *Handler) handleBatchRemovePlaylistItems(w http.ResponseWriter, r *http.
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs playlist get-for-batch-remove failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs playlist get-for-batch-remove failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "playlist get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -521,7 +521,7 @@ func (h *Handler) handleBatchRemovePlaylistItems(w http.ResponseWriter, r *http.
|
||||
|
||||
for _, it := range body.Items {
|
||||
if rmErr := h.deps.PlaylistStore.RemovePlaylistItem(r.Context(), id, it.LibraryItemID, it.EpisodeID); rmErr != nil {
|
||||
slog.Debug("abs playlist batch-remove: store error", "err", rmErr, "id", it.LibraryItemID)
|
||||
slog.DebugContext(r.Context(), "abs playlist batch-remove: store error", "component", "audiobooks", "err", rmErr, "id", it.LibraryItemID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,13 +577,13 @@ func (h *Handler) removePlaylistItemImpl(w http.ResponseWriter, r *http.Request,
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs playlist get-for-remove failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs playlist get-for-remove failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "playlist get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.deps.PlaylistStore.RemovePlaylistItem(r.Context(), id, libItem, episodeIDFromURL); err != nil {
|
||||
slog.Error("abs playlist remove-item failed", "err", err, "id", id, "item", libItem, "episode", episodeIDFromURL)
|
||||
slog.ErrorContext(r.Context(), "abs playlist remove-item failed", "component", "audiobooks", "err", err, "id", id, "item", libItem, "episode", episodeIDFromURL)
|
||||
http.Error(w, "playlist delete failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -615,14 +615,14 @@ func (h *Handler) removePlaylistItemImpl(w http.ResponseWriter, r *http.Request,
|
||||
func (h *Handler) autoDeleteIfEmpty(w http.ResponseWriter, r *http.Request, userID, playlistID string, original Playlist) bool {
|
||||
items, err := h.deps.PlaylistStore.ListPlaylistItems(r.Context(), playlistID)
|
||||
if err != nil {
|
||||
slog.Warn("abs playlist auto-delete: count failed", "err", err, "id", playlistID)
|
||||
slog.WarnContext(r.Context(), "abs playlist auto-delete: count failed", "component", "audiobooks", "err", err, "id", playlistID)
|
||||
return false
|
||||
}
|
||||
if len(items) > 0 {
|
||||
return false
|
||||
}
|
||||
if err := h.deps.PlaylistStore.DeletePlaylist(r.Context(), playlistID); err != nil {
|
||||
slog.Warn("abs playlist auto-delete: delete failed", "err", err, "id", playlistID)
|
||||
slog.WarnContext(r.Context(), "abs playlist auto-delete: delete failed", "component", "audiobooks", "err", err, "id", playlistID)
|
||||
return false
|
||||
}
|
||||
h.publish(userID, "playlist_removed", map[string]any{"id": playlistID})
|
||||
|
||||
@@ -372,7 +372,7 @@ func (h *Handler) handleSessionSync(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.deps.ProgressStore.UpdateProgressPosition(
|
||||
r.Context(), a.UserID, a.ProfileID, sess.ContentID, p.CurrentTime,
|
||||
); err != nil {
|
||||
slog.Warn("abs session sync: update progress position failed",
|
||||
slog.WarnContext(r.Context(), "abs session sync: update progress position failed", "component", "audiobooks",
|
||||
"session_id", sid, "content_id", sess.ContentID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func (h *Handler) handleListRSSFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rows, err := h.deps.RSSFeedStore.ListUserFeeds(r.Context(), a.UserID, a.ProfileID)
|
||||
if err != nil {
|
||||
slog.Error("abs feed list failed", "err", err, "user", a.UserID)
|
||||
slog.ErrorContext(r.Context(), "abs feed list failed", "component", "audiobooks", "err", err, "user", a.UserID)
|
||||
http.Error(w, "feed list failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func (h *Handler) handleOpenItemFeed(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "slug taken", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
slog.Error("abs feed create failed", "err", err, "user", a.UserID)
|
||||
slog.ErrorContext(r.Context(), "abs feed create failed", "component", "audiobooks", "err", err, "user", a.UserID)
|
||||
http.Error(w, "feed persist failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -125,12 +125,12 @@ func (h *Handler) handleCloseFeed(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs feed get-for-close failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs feed get-for-close failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "feed get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := h.deps.RSSFeedStore.CloseFeed(r.Context(), id); err != nil {
|
||||
slog.Error("abs feed close failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs feed close failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "feed close failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -161,7 +161,7 @@ func (h *Handler) handlePublicFeed(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs public feed get failed", "err", err, "slug", slug)
|
||||
slog.ErrorContext(r.Context(), "abs public feed get failed", "component", "audiobooks", "err", err, "slug", slug)
|
||||
http.Error(w, "feed get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ func (h *Handler) handleSyncLocalSessions(w http.ResponseWriter, r *http.Request
|
||||
for _, raw := range body.Sessions {
|
||||
var sess localPlaybackSession
|
||||
if err := json.Unmarshal(raw, &sess); err != nil {
|
||||
slog.Warn("abs local session sync: skipping malformed session", "error", err)
|
||||
slog.WarnContext(r.Context(), "abs local session sync: skipping malformed session", "component", "audiobooks", "error", err)
|
||||
results = append(results, localSyncResult{Success: false, Error: "invalid session"})
|
||||
continue
|
||||
}
|
||||
@@ -155,7 +155,7 @@ func (h *Handler) syncOneLocalSession(ctx context.Context, a ctxAuth, access cat
|
||||
item, err := h.deps.MediaStore.GetAudiobookByID(ctx, sess.LibraryItemID, access)
|
||||
if err != nil || item == nil {
|
||||
if err != nil {
|
||||
slog.Warn("abs local session sync: media lookup failed",
|
||||
slog.WarnContext(ctx, "abs local session sync: media lookup failed", "component", "audiobooks",
|
||||
"library_item_id", sess.LibraryItemID, "error", err)
|
||||
}
|
||||
res.Error = "Media item not found"
|
||||
@@ -195,7 +195,7 @@ func (h *Handler) syncOneLocalSession(ctx context.Context, a ctxAuth, access cat
|
||||
)
|
||||
}
|
||||
if syncErr != nil {
|
||||
slog.Warn("abs local session sync: persist progress position failed",
|
||||
slog.WarnContext(ctx, "abs local session sync: persist progress position failed", "component", "audiobooks",
|
||||
"library_item_id", sess.LibraryItemID, "error", syncErr)
|
||||
} else {
|
||||
res.ProgressSynced = true
|
||||
|
||||
@@ -76,14 +76,14 @@ func (h *Handler) handleCreateSmartCollection(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
qdBytes, err := json.Marshal(qd)
|
||||
if err != nil {
|
||||
slog.Error("abs smart collection marshal query_def failed", "err", err)
|
||||
slog.ErrorContext(r.Context(), "abs smart collection marshal query_def failed", "component", "audiobooks", "err", err)
|
||||
http.Error(w, "smart collection persist failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c.QueryDef = qdBytes
|
||||
|
||||
if err := h.deps.SmartCollectionStore.CreateSmartCollection(r.Context(), c); err != nil {
|
||||
slog.Error("abs smart collection create failed", "err", err, "user", a.UserID)
|
||||
slog.ErrorContext(r.Context(), "abs smart collection create failed", "component", "audiobooks", "err", err, "user", a.UserID)
|
||||
http.Error(w, "smart collection persist failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func (h *Handler) handleListSmartCollections(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
rows, err := h.deps.SmartCollectionStore.ListUserSmartCollections(r.Context(), a.UserID, a.ProfileID)
|
||||
if err != nil {
|
||||
slog.Error("abs smart collection list failed", "err", err, "user", a.UserID)
|
||||
slog.ErrorContext(r.Context(), "abs smart collection list failed", "component", "audiobooks", "err", err, "user", a.UserID)
|
||||
http.Error(w, "smart collection list failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func (h *Handler) handleGetSmartCollection(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs smart collection get failed", "err", err)
|
||||
slog.ErrorContext(r.Context(), "abs smart collection get failed", "component", "audiobooks", "err", err)
|
||||
http.Error(w, "smart collection get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -158,7 +158,7 @@ func (h *Handler) handleUpdateSmartCollection(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs smart collection get-for-update failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs smart collection get-for-update failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "smart collection get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -191,14 +191,14 @@ func (h *Handler) handleUpdateSmartCollection(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
qdBytes, mErr := json.Marshal(qd)
|
||||
if mErr != nil {
|
||||
slog.Error("abs smart collection marshal query_def failed", "err", mErr)
|
||||
slog.ErrorContext(r.Context(), "abs smart collection marshal query_def failed", "component", "audiobooks", "err", mErr)
|
||||
http.Error(w, "smart collection persist failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c.QueryDef = qdBytes
|
||||
}
|
||||
if err := h.deps.SmartCollectionStore.UpdateSmartCollection(r.Context(), c); err != nil {
|
||||
slog.Error("abs smart collection update failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs smart collection update failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "smart collection persist failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -226,12 +226,12 @@ func (h *Handler) handleDeleteSmartCollection(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs smart collection get-for-delete failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs smart collection get-for-delete failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "smart collection get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := h.deps.SmartCollectionStore.DeleteSmartCollection(r.Context(), id); err != nil {
|
||||
slog.Error("abs smart collection delete failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs smart collection delete failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "smart collection delete failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -260,7 +260,7 @@ func (h *Handler) handleSmartCollectionItems(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs smart collection items get failed", "err", err, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs smart collection items get failed", "component", "audiobooks", "err", err, "id", id)
|
||||
http.Error(w, "smart collection get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -268,7 +268,7 @@ func (h *Handler) handleSmartCollectionItems(w http.ResponseWriter, r *http.Requ
|
||||
var qd smartcoll.QueryDefinition
|
||||
if len(c.QueryDef) > 0 {
|
||||
if uErr := json.Unmarshal(c.QueryDef, &qd); uErr != nil {
|
||||
slog.Error("abs smart collection invalid stored query_def", "err", uErr, "id", id)
|
||||
slog.ErrorContext(r.Context(), "abs smart collection invalid stored query_def", "component", "audiobooks", "err", uErr, "id", id)
|
||||
http.Error(w, "smart collection get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -287,7 +287,7 @@ func (h *Handler) handleSmartCollectionItems(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
allLibs, err := h.deps.MediaStore.ListAudiobookLibraries(r.Context(), access)
|
||||
if err != nil {
|
||||
slog.Warn("abs smart collection libraries fetch failed", "err", err, "id", id)
|
||||
slog.WarnContext(r.Context(), "abs smart collection libraries fetch failed", "component", "audiobooks", "err", err, "id", id)
|
||||
allLibs = nil
|
||||
}
|
||||
libByID := make(map[int64]AudiobookLibrary, len(allLibs))
|
||||
@@ -327,7 +327,7 @@ func (h *Handler) handleSmartCollectionItems(w http.ResponseWriter, r *http.Requ
|
||||
for _, lib := range targetLibs {
|
||||
items, _, lerr := h.deps.MediaStore.ListAudiobooks(r.Context(), lib.ID, 0, 0, access, Filter{})
|
||||
if lerr != nil {
|
||||
slog.Warn("abs smart collection list-audiobooks failed", "err", lerr, "library", lib.ID)
|
||||
slog.WarnContext(r.Context(), "abs smart collection list-audiobooks failed", "component", "audiobooks", "err", lerr, "library", lib.ID)
|
||||
continue
|
||||
}
|
||||
for _, mi := range items {
|
||||
|
||||
@@ -185,14 +185,14 @@ func (e *Enricher) Run(ctx context.Context) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
slog.Info("audiobook enrichment: sweep started",
|
||||
slog.InfoContext(ctx, "audiobook enrichment: sweep started", "component", "audiobooks",
|
||||
"count", len(items),
|
||||
"workers", e.workers,
|
||||
)
|
||||
|
||||
enriched := e.runBatch(ctx, items, e.enrichItem)
|
||||
|
||||
slog.Info("audiobook enrichment: sweep complete",
|
||||
slog.InfoContext(ctx, "audiobook enrichment: sweep complete", "component", "audiobooks",
|
||||
"attempted", len(items),
|
||||
"enriched", enriched,
|
||||
)
|
||||
@@ -251,7 +251,7 @@ func (e *Enricher) runBatch(ctx context.Context, items []enrichmentItemRow, enri
|
||||
continue // drain
|
||||
}
|
||||
if err := enrichFn(ctx, item); err != nil {
|
||||
slog.Warn("audiobook enrichment: item failed",
|
||||
slog.WarnContext(ctx, "audiobook enrichment: item failed", "component", "audiobooks",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
"error", err,
|
||||
@@ -354,7 +354,7 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error
|
||||
defer e.maybeApplyCoverFallback(ctx, item.ContentID)
|
||||
|
||||
if item.FolderID == 0 {
|
||||
slog.Debug("audiobook enrichment: item has no library folder, skipping",
|
||||
slog.DebugContext(ctx, "audiobook enrichment: item has no library folder, skipping", "component", "audiobooks",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
)
|
||||
@@ -367,7 +367,7 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error
|
||||
return fmt.Errorf("resolving audiobook chain for folder %d: %w", item.FolderID, err)
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
slog.Debug("audiobook enrichment: no providers in chain",
|
||||
slog.DebugContext(ctx, "audiobook enrichment: no providers in chain", "component", "audiobooks",
|
||||
"content_id", item.ContentID,
|
||||
"folder_id", item.FolderID,
|
||||
)
|
||||
@@ -403,7 +403,7 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error
|
||||
}
|
||||
results, searchErr := sp.Search(ctx, searchQuery)
|
||||
if searchErr != nil {
|
||||
slog.Warn("audiobook enrichment: search error",
|
||||
slog.WarnContext(ctx, "audiobook enrichment: search error", "component", "audiobooks",
|
||||
"provider", p.Slug(),
|
||||
"content_id", item.ContentID,
|
||||
"error", searchErr,
|
||||
@@ -422,7 +422,7 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error
|
||||
}
|
||||
}
|
||||
}
|
||||
slog.Debug("audiobook enrichment: search result",
|
||||
slog.DebugContext(ctx, "audiobook enrichment: search result", "component", "audiobooks",
|
||||
"provider", p.Slug(),
|
||||
"content_id", item.ContentID,
|
||||
"matched_ids", accumulatedIDs,
|
||||
@@ -445,7 +445,7 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error
|
||||
Language: item.Language,
|
||||
})
|
||||
if getErr != nil {
|
||||
slog.Warn("audiobook enrichment: GetMetadata error",
|
||||
slog.WarnContext(ctx, "audiobook enrichment: GetMetadata error", "component", "audiobooks",
|
||||
"provider", p.Slug(),
|
||||
"content_id", item.ContentID,
|
||||
"error", getErr,
|
||||
@@ -461,7 +461,7 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error
|
||||
accumulatedIDs = accumulator.ProviderIDs
|
||||
metadata.MergeMetadata(result, accumulator, nil, metadata.MergeFillEmpty)
|
||||
|
||||
slog.Debug("audiobook enrichment: metadata received",
|
||||
slog.DebugContext(ctx, "audiobook enrichment: metadata received", "component", "audiobooks",
|
||||
"provider", p.Slug(),
|
||||
"content_id", item.ContentID,
|
||||
"has_poster", result.PosterPath != "",
|
||||
@@ -483,7 +483,7 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error
|
||||
}
|
||||
// Providers ran cleanly but nothing matched — stamp last_refreshed so we
|
||||
// skip on the next sweep.
|
||||
slog.Info("audiobook enrichment: no metadata found",
|
||||
slog.InfoContext(ctx, "audiobook enrichment: no metadata found", "component", "audiobooks",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
)
|
||||
@@ -497,7 +497,7 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error
|
||||
e.enqueueRemoteArtwork(ctx, item.ContentID, accumulator)
|
||||
e.autoLinkLiteraryWork(ctx, item.ContentID)
|
||||
|
||||
slog.Info("audiobook enrichment: enriched",
|
||||
slog.InfoContext(ctx, "audiobook enrichment: enriched", "component", "audiobooks",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
"poster", accumulator.PosterPath != "",
|
||||
@@ -514,11 +514,11 @@ func (e *Enricher) autoLinkLiteraryWork(ctx context.Context, contentID string) {
|
||||
}
|
||||
workID, linked, err := e.workLinker.AutoLinkContent(ctx, contentID)
|
||||
if err != nil {
|
||||
slog.Warn("audiobook enrichment: literary work auto-link failed", "content_id", contentID, "error", err)
|
||||
slog.WarnContext(ctx, "audiobook enrichment: literary work auto-link failed", "component", "audiobooks", "content_id", contentID, "error", err)
|
||||
return
|
||||
}
|
||||
if linked {
|
||||
slog.Info("audiobook enrichment: literary work auto-linked", "content_id", contentID, "work_id", workID)
|
||||
slog.InfoContext(ctx, "audiobook enrichment: literary work auto-linked", "component", "audiobooks", "content_id", contentID, "work_id", workID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,7 +531,7 @@ func (e *Enricher) maybeApplyCoverFallback(ctx context.Context, contentID string
|
||||
return
|
||||
}
|
||||
if err := e.applyLocalCoverFallback(ctx, contentID); err != nil {
|
||||
slog.Warn("audiobook enrichment: local cover fallback failed",
|
||||
slog.WarnContext(ctx, "audiobook enrichment: local cover fallback failed", "component", "audiobooks",
|
||||
"content_id", contentID,
|
||||
"error", err,
|
||||
)
|
||||
@@ -562,7 +562,7 @@ func (e *Enricher) cacheRemotePoster(ctx context.Context, contentID string, resu
|
||||
ImageType: metadata.ImagePoster,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("audiobook enrichment: poster cache failed, keeping provider URL",
|
||||
slog.WarnContext(ctx, "audiobook enrichment: poster cache failed, keeping provider URL", "component", "audiobooks",
|
||||
"content_id", contentID,
|
||||
"url", result.PosterPath,
|
||||
"error", err,
|
||||
@@ -651,7 +651,7 @@ func (e *Enricher) persist(ctx context.Context, contentID string, providerIDs ma
|
||||
if e.providerIDs != nil && len(providerIDs) > 0 {
|
||||
if err := e.providerIDs.ReplaceByContentID(ctx, contentID, providerIDs); err != nil {
|
||||
// Non-fatal: log and continue.
|
||||
slog.Warn("audiobook enrichment: failed to persist provider IDs",
|
||||
slog.WarnContext(ctx, "audiobook enrichment: failed to persist provider IDs", "component", "audiobooks",
|
||||
"content_id", contentID,
|
||||
"error", err,
|
||||
)
|
||||
@@ -667,7 +667,7 @@ func (e *Enricher) persist(ctx context.Context, contentID string, providerIDs ma
|
||||
if len(result.People) > 0 && e.personRepo != nil && e.itemRepo != nil {
|
||||
if err := e.persistPeople(ctx, contentID, result.People); err != nil {
|
||||
// Non-fatal: log and continue so at least scalar metadata is saved.
|
||||
slog.Warn("audiobook enrichment: failed to persist people",
|
||||
slog.WarnContext(ctx, "audiobook enrichment: failed to persist people", "component", "audiobooks",
|
||||
"content_id", contentID,
|
||||
"error", err,
|
||||
)
|
||||
@@ -706,7 +706,7 @@ func (e *Enricher) enqueueRemoteArtwork(ctx context.Context, contentID string, r
|
||||
enqueueCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
if _, err := e.imageCacheJobs.EnqueueBatch(enqueueCtx, inputs); err != nil {
|
||||
slog.Warn("audiobook enrichment: failed to enqueue image cache jobs",
|
||||
slog.WarnContext(ctx, "audiobook enrichment: failed to enqueue image cache jobs", "component", "audiobooks",
|
||||
"content_id", contentID,
|
||||
"count", len(inputs),
|
||||
"error", err,
|
||||
|
||||
@@ -140,7 +140,7 @@ func (r *Refresher) RefreshDue(ctx context.Context, s Store) (int, error) {
|
||||
}
|
||||
attempted++
|
||||
if err := r.RefreshOne(ctx, s, f); err != nil {
|
||||
slog.Warn("podcast feed refresh failed",
|
||||
slog.WarnContext(ctx, "podcast feed refresh failed", "component", "audiobooks",
|
||||
"media_item_id", f.MediaItemID,
|
||||
"feed_url", f.FeedURL,
|
||||
"err", err.Error(),
|
||||
@@ -173,7 +173,7 @@ func (r *Refresher) RefreshOne(ctx context.Context, s Store, f PodcastFeed) erro
|
||||
return err
|
||||
}
|
||||
_ = s.MarkFeedRefreshed(ctx, f.MediaItemID, "")
|
||||
slog.Debug("podcast feed refreshed",
|
||||
slog.DebugContext(ctx, "podcast feed refreshed", "component", "audiobooks",
|
||||
"media_item_id", f.MediaItemID,
|
||||
"episodes", count,
|
||||
)
|
||||
@@ -258,7 +258,7 @@ func (r *Refresher) upsertItems(ctx context.Context, s Store, f PodcastFeed, fee
|
||||
StillPath: coverFromItem(item),
|
||||
}
|
||||
if err := s.UpsertPodcastEpisode(ctx, ep); err != nil {
|
||||
slog.Warn("podcast episode upsert failed",
|
||||
slog.WarnContext(ctx, "podcast episode upsert failed", "component", "audiobooks",
|
||||
"media_item_id", f.MediaItemID,
|
||||
"guid", guid,
|
||||
"err", err.Error(),
|
||||
|
||||
@@ -122,7 +122,7 @@ func (h *OAuthHandler) HandleInit(w http.ResponseWriter, r *http.Request) {
|
||||
// Linking is wired in a follow-up — see TODO below.
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("oauth init_authorize failed", "installation_id", installID, "error", err)
|
||||
slog.WarnContext(r.Context(), "oauth init_authorize failed", "component", "auth", "installation_id", installID, "error", err)
|
||||
http.Error(w, "plugin init_authorize failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
@@ -143,7 +143,7 @@ func (h *OAuthHandler) HandleInit(w http.ResponseWriter, r *http.Request) {
|
||||
// and set LinkingUserID here.
|
||||
}
|
||||
if err := h.deps.Store.Insert(r.Context(), sess); err != nil {
|
||||
slog.Warn("oauth session insert failed", "installation_id", installID, "error", err)
|
||||
slog.WarnContext(r.Context(), "oauth session insert failed", "component", "auth", "installation_id", installID, "error", err)
|
||||
http.Error(w, "store insert failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -198,7 +198,7 @@ func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
ProviderState: psStruct,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("oauth exchange_code failed", "installation_id", installID, "error", err)
|
||||
slog.WarnContext(r.Context(), "oauth exchange_code failed", "component", "auth", "installation_id", installID, "error", err)
|
||||
http.Redirect(w, r, "/login?error=oauth_failed&reason=exchange_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
@@ -223,19 +223,19 @@ func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
IP: clientIP(r),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("oauth login completion failed", "installation_id", installID, "error", err)
|
||||
slog.WarnContext(r.Context(), "oauth login completion failed", "component", "auth", "installation_id", installID, "error", err)
|
||||
http.Redirect(w, r, "/login?error=oauth_failed&reason=login_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if h.deps.CompletionStore == nil {
|
||||
slog.Warn("oauth completion store is unavailable", "installation_id", installID)
|
||||
slog.WarnContext(r.Context(), "oauth completion store is unavailable", "component", "auth", "installation_id", installID)
|
||||
http.Redirect(w, r, "/login?error=oauth_failed&reason=completion_unavailable", http.StatusFound)
|
||||
return
|
||||
}
|
||||
completionCode, err := randomHex(32)
|
||||
if err != nil {
|
||||
slog.Warn("oauth completion code generation failed", "installation_id", installID, "error", err)
|
||||
slog.WarnContext(r.Context(), "oauth completion code generation failed", "component", "auth", "installation_id", installID, "error", err)
|
||||
http.Redirect(w, r, "/login?error=oauth_failed&reason=completion_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
@@ -248,7 +248,7 @@ func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
NextURL: sess.NextURL,
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}); err != nil {
|
||||
slog.Warn("oauth completion insert failed", "installation_id", installID, "error", err)
|
||||
slog.WarnContext(r.Context(), "oauth completion insert failed", "component", "auth", "installation_id", installID, "error", err)
|
||||
http.Redirect(w, r, "/login?error=oauth_failed&reason=completion_failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -178,9 +178,9 @@ func (s *Service) PollOnce(ctx context.Context) error {
|
||||
if src.ConnectionID != nil {
|
||||
resolved, cerr := s.resolveConnection(ctx, *src.ConnectionID)
|
||||
if cerr != nil {
|
||||
slog.WarnContext(ctx, "autoscan: resolve connection failed", "source_id", src.ID, "err", cerr)
|
||||
slog.WarnContext(ctx, "autoscan: resolve connection failed", "component", "autoscan", "source_id", src.ID, "err", cerr)
|
||||
if rerr := s.store.RecordError(ctx, src.ID, cerr.Error()); rerr != nil {
|
||||
slog.WarnContext(ctx, "autoscan: record error failed", "source_id", src.ID, "err", rerr)
|
||||
slog.WarnContext(ctx, "autoscan: record error failed", "component", "autoscan", "source_id", src.ID, "err", rerr)
|
||||
}
|
||||
s.finishEvent(ctx, eventID, EventFinish{
|
||||
Status: EventStatusError,
|
||||
@@ -193,9 +193,9 @@ func (s *Service) PollOnce(ctx context.Context) error {
|
||||
}
|
||||
changes, next, perr := s.provider.PollChanges(ctx, src.PluginID, src.CapabilityID, marker, conn, src.SourceConfig)
|
||||
if perr != nil {
|
||||
slog.WarnContext(ctx, "autoscan: poll changes failed", "source_id", src.ID, "err", perr)
|
||||
slog.WarnContext(ctx, "autoscan: poll changes failed", "component", "autoscan", "source_id", src.ID, "err", perr)
|
||||
if rerr := s.store.RecordError(ctx, src.ID, perr.Error()); rerr != nil {
|
||||
slog.WarnContext(ctx, "autoscan: record error failed", "source_id", src.ID, "err", rerr)
|
||||
slog.WarnContext(ctx, "autoscan: record error failed", "component", "autoscan", "source_id", src.ID, "err", rerr)
|
||||
}
|
||||
s.finishEvent(ctx, eventID, EventFinish{
|
||||
Status: EventStatusError,
|
||||
@@ -209,7 +209,7 @@ func (s *Service) PollOnce(ctx context.Context) error {
|
||||
targets, claimed, resolvedAny, stats := s.resolveAndClaim(ctx, rewritten, ttl)
|
||||
if len(targets) > maxAutoscanTargetsPerPoll {
|
||||
collapsed := collapseTargetsToLibraryScans(targets)
|
||||
slog.WarnContext(ctx, "autoscan: collapsed large scan target batch to library scans",
|
||||
slog.WarnContext(ctx, "autoscan: collapsed large scan target batch to library scans", "component", "autoscan",
|
||||
"source_id", src.ID,
|
||||
"targets", len(targets),
|
||||
"collapsed_targets", len(collapsed),
|
||||
@@ -223,7 +223,7 @@ func (s *Service) PollOnce(ctx context.Context) error {
|
||||
enqueue, eerr = s.enqueueScanTargets(ctx, targets, eventID)
|
||||
if eerr != nil {
|
||||
s.releaseClaims(ctx, claimed)
|
||||
slog.WarnContext(ctx, "autoscan: enqueue failed", "source_id", src.ID, "err", eerr)
|
||||
slog.WarnContext(ctx, "autoscan: enqueue failed", "component", "autoscan", "source_id", src.ID, "err", eerr)
|
||||
s.finishEvent(ctx, eventID, EventFinish{
|
||||
Status: EventStatusError,
|
||||
ChangesReturned: len(changes),
|
||||
@@ -265,7 +265,7 @@ func (s *Service) PollOnce(ctx context.Context) error {
|
||||
if stats.TransientErrors > 0 {
|
||||
msg := fmt.Sprintf("%d resolve attempt(s) failed internally (%d of %d path(s) resolved) — holding marker to retry", stats.TransientErrors, stats.ChangesResolved, len(changes))
|
||||
if rerr := s.store.RecordError(ctx, src.ID, msg); rerr != nil {
|
||||
slog.WarnContext(ctx, "autoscan: record error failed", "source_id", src.ID, "err", rerr)
|
||||
slog.WarnContext(ctx, "autoscan: record error failed", "component", "autoscan", "source_id", src.ID, "err", rerr)
|
||||
}
|
||||
s.finishEvent(ctx, eventID, EventFinish{
|
||||
Status: EventStatusError,
|
||||
@@ -289,7 +289,7 @@ func (s *Service) PollOnce(ctx context.Context) error {
|
||||
"source_id", src.ID, "changes", len(changes))
|
||||
}
|
||||
if aerr := s.store.AdvanceMarker(ctx, src.ID, next); aerr != nil {
|
||||
slog.WarnContext(ctx, "autoscan: advance marker failed", "source_id", src.ID, "err", aerr)
|
||||
slog.WarnContext(ctx, "autoscan: advance marker failed", "component", "autoscan", "source_id", src.ID, "err", aerr)
|
||||
s.finishEvent(ctx, eventID, EventFinish{
|
||||
Status: EventStatusError,
|
||||
ChangesReturned: len(changes),
|
||||
@@ -359,10 +359,10 @@ func (s *Service) createEvent(ctx context.Context, src Source, marker string, st
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrPollAlreadyRunning) {
|
||||
slog.DebugContext(ctx, "autoscan: source poll already running", "source_id", src.ID)
|
||||
slog.DebugContext(ctx, "autoscan: source poll already running", "component", "autoscan", "source_id", src.ID)
|
||||
return 0, false
|
||||
}
|
||||
slog.WarnContext(ctx, "autoscan: create event failed", "source_id", src.ID, "err", err)
|
||||
slog.WarnContext(ctx, "autoscan: create event failed", "component", "autoscan", "source_id", src.ID, "err", err)
|
||||
return 0, true
|
||||
}
|
||||
return id, true
|
||||
@@ -378,7 +378,7 @@ func (s *Service) finishEvent(ctx context.Context, eventID int64, finish EventFi
|
||||
finish.Status = EventStatusSuccess
|
||||
}
|
||||
if err := s.store.FinishEvent(ctx, finish); err != nil {
|
||||
slog.WarnContext(ctx, "autoscan: finish event failed", "event_id", eventID, "err", err)
|
||||
slog.WarnContext(ctx, "autoscan: finish event failed", "component", "autoscan", "event_id", eventID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,7 +442,7 @@ func (s *Service) resolveAndClaim(ctx context.Context, changes []Change, ttl tim
|
||||
continue
|
||||
}
|
||||
stats.TransientErrors++
|
||||
slog.WarnContext(ctx, "autoscan: resolve failed", "path", dir, "err", rerr)
|
||||
slog.WarnContext(ctx, "autoscan: resolve failed", "component", "autoscan", "path", dir, "err", rerr)
|
||||
continue
|
||||
}
|
||||
if target == nil || target.Folder == nil {
|
||||
@@ -520,7 +520,7 @@ func (s *Service) resolveChange(ctx context.Context, change Change, stats *resol
|
||||
return nil, false
|
||||
}
|
||||
stats.TransientErrors++
|
||||
slog.WarnContext(ctx, "autoscan: resolve failed", "path", change.SourcePath, "scope", change.Scope, "err", err)
|
||||
slog.WarnContext(ctx, "autoscan: resolve failed", "component", "autoscan", "path", change.SourcePath, "scope", change.Scope, "err", err)
|
||||
return nil, false
|
||||
}
|
||||
if target == nil || target.Folder == nil {
|
||||
@@ -559,7 +559,7 @@ func (s *Service) claimTarget(
|
||||
func (s *Service) releaseClaims(ctx context.Context, claimed []string) {
|
||||
for _, k := range claimed {
|
||||
if rerr := s.suppress.Release(ctx, k); rerr != nil {
|
||||
slog.WarnContext(ctx, "autoscan: release claim failed", "key", k, "err", rerr)
|
||||
slog.WarnContext(ctx, "autoscan: release claim failed", "component", "autoscan", "key", k, "err", rerr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func (s *CollectionSyncScheduler) RunOnce(ctx context.Context) (json.RawMessage,
|
||||
return marshalResult(CollectionSyncResult{}), nil
|
||||
}
|
||||
|
||||
s.logger.Info("collection sync scheduler: starting",
|
||||
s.logger.InfoContext(ctx, "collection sync scheduler: starting",
|
||||
"due", len(due),
|
||||
)
|
||||
|
||||
@@ -81,7 +81,7 @@ func (s *CollectionSyncScheduler) RunOnce(ctx context.Context) (json.RawMessage,
|
||||
|
||||
_ = g.Wait()
|
||||
|
||||
s.logger.Info("collection sync scheduler: complete",
|
||||
s.logger.InfoContext(ctx, "collection sync scheduler: complete",
|
||||
"due", result.Due,
|
||||
"synced", result.Synced,
|
||||
"failed", result.Failed,
|
||||
@@ -95,7 +95,7 @@ func (s *CollectionSyncScheduler) RunOnce(ctx context.Context) (json.RawMessage,
|
||||
func (s *CollectionSyncScheduler) syncOne(ctx context.Context, collection *models.LibraryCollection, mu *sync.Mutex, result *CollectionSyncResult) {
|
||||
// Guard against concurrent sync of the same collection (e.g., manual trigger).
|
||||
if _, loaded := s.inFlight.LoadOrStore(collection.ID, struct{}{}); loaded {
|
||||
s.logger.Info("collection sync scheduler: skipping (already in flight)",
|
||||
s.logger.InfoContext(ctx, "collection sync scheduler: skipping (already in flight)",
|
||||
"collection_id", collection.ID,
|
||||
"title", collection.Title,
|
||||
)
|
||||
@@ -117,7 +117,7 @@ func (s *CollectionSyncScheduler) syncOne(ctx context.Context, collection *model
|
||||
if collection.SyncSchedule != nil {
|
||||
next := ComputeNextSyncAtFrom(*collection.SyncSchedule, completedAt)
|
||||
if err := s.repo.UpdateNextSyncAt(ctx, collection.ID, next); err != nil {
|
||||
s.logger.Error("collection sync scheduler: failed to advance schedule",
|
||||
s.logger.ErrorContext(ctx, "collection sync scheduler: failed to advance schedule",
|
||||
"collection_id", collection.ID,
|
||||
"error", err,
|
||||
)
|
||||
@@ -129,7 +129,7 @@ func (s *CollectionSyncScheduler) syncOne(ctx context.Context, collection *model
|
||||
|
||||
if syncErr != nil {
|
||||
result.Failed++
|
||||
s.logger.Error("collection sync scheduler: sync failed",
|
||||
s.logger.ErrorContext(ctx, "collection sync scheduler: sync failed",
|
||||
"collection_id", collection.ID,
|
||||
"title", collection.Title,
|
||||
"duration", completedAt.Sub(startedAt).Round(time.Millisecond),
|
||||
@@ -137,7 +137,7 @@ func (s *CollectionSyncScheduler) syncOne(ctx context.Context, collection *model
|
||||
)
|
||||
} else {
|
||||
result.Synced++
|
||||
s.logger.Info("collection sync scheduler: synced",
|
||||
s.logger.InfoContext(ctx, "collection sync scheduler: synced",
|
||||
"collection_id", collection.ID,
|
||||
"title", collection.Title,
|
||||
"duration", completedAt.Sub(startedAt).Round(time.Millisecond),
|
||||
|
||||
@@ -1560,7 +1560,7 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M
|
||||
if s.groupClaimRepo != nil {
|
||||
paths, err := s.groupClaimRepo.ListObservedRootsByContentID(ctx, contentID)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "failed to fetch series group locations", "content_id", contentID, "error", err)
|
||||
slog.WarnContext(ctx, "failed to fetch series group locations", "component", "catalog", "content_id", contentID, "error", err)
|
||||
} else if len(paths) > 0 {
|
||||
detail.FolderPaths = paths
|
||||
}
|
||||
@@ -1568,7 +1568,7 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M
|
||||
if len(detail.FolderPaths) == 0 && s.rootClaimRepo != nil {
|
||||
roots, err := s.rootClaimRepo.ListByContentID(ctx, contentID)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "failed to fetch series root claims", "content_id", contentID, "error", err)
|
||||
slog.WarnContext(ctx, "failed to fetch series root claims", "component", "catalog", "content_id", contentID, "error", err)
|
||||
} else if len(roots) > 0 {
|
||||
paths := make([]string, len(roots))
|
||||
for i, root := range roots {
|
||||
@@ -1581,7 +1581,7 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M
|
||||
if len(detail.FolderPaths) == 0 && s.fileFetcher != nil {
|
||||
files, err := s.fileFetcher.GetByContentID(ctx, contentID)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "failed to fetch series files for folder paths", "content_id", contentID, "error", err)
|
||||
slog.WarnContext(ctx, "failed to fetch series files for folder paths", "component", "catalog", "content_id", contentID, "error", err)
|
||||
} else {
|
||||
detail.FolderPaths = seriesFolderPathsFromFiles(files)
|
||||
}
|
||||
@@ -1844,7 +1844,7 @@ func (s *DetailService) fetchMangaChapters(ctx context.Context, seriesContentID
|
||||
chapters = append(chapters, ch)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
slog.Warn("manga chapters: row iteration error", "series", seriesContentID, "error", err)
|
||||
slog.WarnContext(ctx, "manga chapters: row iteration error", "component", "catalog", "series", seriesContentID, "error", err)
|
||||
}
|
||||
// Presign every chapter poster in one batch rather than per chapter — a
|
||||
// long-running series has hundreds of chapters.
|
||||
@@ -3350,8 +3350,8 @@ func (s *DetailService) queueWatchPlaybackFiles(
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info(
|
||||
"queueing chapter thumbnails",
|
||||
slog.InfoContext(ctx,
|
||||
"queueing chapter thumbnails", "component", "catalog",
|
||||
"source",
|
||||
"watch_detail",
|
||||
"content_id",
|
||||
@@ -3459,7 +3459,7 @@ func (s *DetailService) PresignURLWithExpiry(ctx context.Context, path string, v
|
||||
return ResolvedImageURL{URL: s.imageResolver.ResolveImageURL(ctx, path, variant)}
|
||||
}
|
||||
|
||||
slog.Warn("image path could not be resolved", "path", path)
|
||||
slog.WarnContext(ctx, "image path could not be resolved", "component", "catalog", "path", path)
|
||||
return ResolvedImageURL{}
|
||||
}
|
||||
|
||||
|
||||
@@ -463,7 +463,7 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context,
|
||||
return nil, fmt.Errorf("fetching TMDB preset: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("TMDB preset sync: fetched results",
|
||||
slog.InfoContext(ctx, "TMDB preset sync: fetched results", "component", "catalog",
|
||||
"collection_id", collection.ID,
|
||||
"preset", preset,
|
||||
"media_type", mediaType,
|
||||
@@ -486,7 +486,7 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context,
|
||||
return nil, err
|
||||
}
|
||||
if item == nil {
|
||||
slog.Debug("TMDB preset sync: no match",
|
||||
slog.DebugContext(ctx, "TMDB preset sync: no match", "component", "catalog",
|
||||
"rank", i+1,
|
||||
"title", entry.Title,
|
||||
"type", entry.MediaType,
|
||||
@@ -499,7 +499,7 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context,
|
||||
continue
|
||||
}
|
||||
if firstRank, exists := seenContentIDs[item.ContentID]; exists {
|
||||
slog.Debug("TMDB preset sync: duplicate match skipped",
|
||||
slog.DebugContext(ctx, "TMDB preset sync: duplicate match skipped", "component", "catalog",
|
||||
"rank", i+1,
|
||||
"title", entry.Title,
|
||||
"type", entry.MediaType,
|
||||
@@ -515,7 +515,7 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context,
|
||||
}
|
||||
seenContentIDs[item.ContentID] = i + 1
|
||||
|
||||
slog.Debug("TMDB preset sync: matched",
|
||||
slog.DebugContext(ctx, "TMDB preset sync: matched", "component", "catalog",
|
||||
"rank", i+1,
|
||||
"title", entry.Title,
|
||||
"type", entry.MediaType,
|
||||
@@ -535,7 +535,7 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context,
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("TMDB preset sync: complete",
|
||||
slog.InfoContext(ctx, "TMDB preset sync: complete", "component", "catalog",
|
||||
"collection_id", collection.ID,
|
||||
"preset", preset,
|
||||
"matched", len(matchedItems),
|
||||
@@ -629,7 +629,7 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte
|
||||
return nil, fmt.Errorf("fetching TMDB collection: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("TMDB franchise sync: fetched results",
|
||||
slog.InfoContext(ctx, "TMDB franchise sync: fetched results", "component", "catalog",
|
||||
"collection_id", collection.ID,
|
||||
"tmdb_collection_id", cfg.CollectionID,
|
||||
"count", len(results),
|
||||
@@ -650,7 +650,7 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte
|
||||
return nil, err
|
||||
}
|
||||
if item == nil {
|
||||
slog.Debug("TMDB franchise sync: no match",
|
||||
slog.DebugContext(ctx, "TMDB franchise sync: no match", "component", "catalog",
|
||||
"rank", i+1,
|
||||
"title", entry.Title,
|
||||
"tmdb_id", entry.ID,
|
||||
@@ -661,7 +661,7 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte
|
||||
continue
|
||||
}
|
||||
if firstRank, exists := seenContentIDs[item.ContentID]; exists {
|
||||
slog.Debug("TMDB franchise sync: duplicate match skipped",
|
||||
slog.DebugContext(ctx, "TMDB franchise sync: duplicate match skipped", "component", "catalog",
|
||||
"rank", i+1,
|
||||
"title", entry.Title,
|
||||
"content_id", item.ContentID,
|
||||
@@ -673,7 +673,7 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte
|
||||
}
|
||||
seenContentIDs[item.ContentID] = i + 1
|
||||
|
||||
slog.Debug("TMDB franchise sync: matched",
|
||||
slog.DebugContext(ctx, "TMDB franchise sync: matched", "component", "catalog",
|
||||
"rank", i+1,
|
||||
"title", entry.Title,
|
||||
"tmdb_id", entry.ID,
|
||||
@@ -690,7 +690,7 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("TMDB franchise sync: complete",
|
||||
slog.InfoContext(ctx, "TMDB franchise sync: complete", "component", "catalog",
|
||||
"collection_id", collection.ID,
|
||||
"tmdb_collection_id", cfg.CollectionID,
|
||||
"matched", len(matchedItems),
|
||||
@@ -803,7 +803,7 @@ func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Contex
|
||||
return nil, fmt.Errorf("fetching TMDB discover: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("TMDB discover sync: fetched results",
|
||||
slog.InfoContext(ctx, "TMDB discover sync: fetched results", "component", "catalog",
|
||||
"collection_id", collection.ID,
|
||||
"media_type", mediaType,
|
||||
"sort_by", params.SortBy,
|
||||
@@ -825,7 +825,7 @@ func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Contex
|
||||
return nil, err
|
||||
}
|
||||
if item == nil {
|
||||
slog.Debug("TMDB discover sync: no match",
|
||||
slog.DebugContext(ctx, "TMDB discover sync: no match", "component", "catalog",
|
||||
"rank", i+1,
|
||||
"title", entry.Title,
|
||||
"type", entry.MediaType,
|
||||
@@ -838,7 +838,7 @@ func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Contex
|
||||
continue
|
||||
}
|
||||
if firstRank, exists := seenContentIDs[item.ContentID]; exists {
|
||||
slog.Debug("TMDB discover sync: duplicate match skipped",
|
||||
slog.DebugContext(ctx, "TMDB discover sync: duplicate match skipped", "component", "catalog",
|
||||
"rank", i+1,
|
||||
"title", entry.Title,
|
||||
"content_id", item.ContentID,
|
||||
@@ -861,7 +861,7 @@ func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Contex
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("TMDB discover sync: complete",
|
||||
slog.InfoContext(ctx, "TMDB discover sync: complete", "component", "catalog",
|
||||
"collection_id", collection.ID,
|
||||
"media_type", mediaType,
|
||||
"matched", len(matchedItems),
|
||||
@@ -941,7 +941,7 @@ func (s *LibraryCollectionService) syncTraktPresetCollection(ctx context.Context
|
||||
return s.recordFailedCollectionSync(ctx, collection.ID, startedAt, "Trakt recommendations require a profile")
|
||||
}
|
||||
if s.TraktTokenResolver == nil {
|
||||
slog.Error("Trakt recommendations: token resolver not configured",
|
||||
slog.ErrorContext(ctx, "Trakt recommendations: token resolver not configured", "component", "catalog",
|
||||
"collection_id", collection.ID,
|
||||
"profile_id", profileID,
|
||||
)
|
||||
@@ -949,7 +949,7 @@ func (s *LibraryCollectionService) syncTraktPresetCollection(ctx context.Context
|
||||
}
|
||||
token, err := s.TraktTokenResolver.ResolveTraktAccessToken(ctx, profileID)
|
||||
if err != nil {
|
||||
slog.Error("Trakt recommendations: failed to resolve access token",
|
||||
slog.ErrorContext(ctx, "Trakt recommendations: failed to resolve access token", "component", "catalog",
|
||||
"collection_id", collection.ID,
|
||||
"profile_id", profileID,
|
||||
"error", err,
|
||||
@@ -965,7 +965,7 @@ func (s *LibraryCollectionService) syncTraktPresetCollection(ctx context.Context
|
||||
return nil, fmt.Errorf("fetching Trakt preset: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Trakt preset sync: fetched results",
|
||||
slog.InfoContext(ctx, "Trakt preset sync: fetched results", "component", "catalog",
|
||||
"collection_id", collection.ID,
|
||||
"preset", preset,
|
||||
"media_type", mediaType,
|
||||
@@ -1003,7 +1003,7 @@ func (s *LibraryCollectionService) syncTraktListCollection(ctx context.Context,
|
||||
return nil, fmt.Errorf("fetching Trakt list %s/%s: %w", user, list, err)
|
||||
}
|
||||
|
||||
slog.Info("Trakt list sync: fetched results",
|
||||
slog.InfoContext(ctx, "Trakt list sync: fetched results", "component", "catalog",
|
||||
"collection_id", collection.ID,
|
||||
"user", user,
|
||||
"list", list,
|
||||
@@ -1083,7 +1083,7 @@ func (s *LibraryCollectionService) completeTraktEntrySync(ctx context.Context, c
|
||||
if firstRank, exists := seenContentIDs[item.ContentID]; exists {
|
||||
duplicateCount++
|
||||
warnings = append(warnings, fmt.Sprintf("Duplicate Trakt entry for %s matched existing item %s", entry.Title, item.ContentID))
|
||||
slog.Info("Trakt preset sync: duplicate match skipped",
|
||||
slog.InfoContext(ctx, "Trakt preset sync: duplicate match skipped", "component", "catalog",
|
||||
"rank", i+1,
|
||||
"title", entry.Title,
|
||||
"content_id", item.ContentID,
|
||||
@@ -1368,7 +1368,7 @@ func (s *LibraryCollectionService) maybeGenerateCollage(ctx context.Context, col
|
||||
|
||||
collection, err := s.collections.GetByID(ctx, collectionID)
|
||||
if err != nil {
|
||||
slog.Warn("collage: failed to load collection", "collection_id", collectionID, "error", err)
|
||||
slog.WarnContext(ctx, "collage: failed to load collection", "component", "catalog", "collection_id", collectionID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1379,9 +1379,9 @@ func (s *LibraryCollectionService) maybeGenerateCollage(ctx context.Context, col
|
||||
|
||||
if err := s.CollageGen.GenerateCollectionPoster(ctx, collectionID); err != nil {
|
||||
if errors.Is(err, collage.ErrNotEnoughImages) {
|
||||
slog.Debug("collage: not enough images", "collection_id", collectionID)
|
||||
slog.DebugContext(ctx, "collage: not enough images", "component", "catalog", "collection_id", collectionID)
|
||||
} else {
|
||||
slog.Warn("collage: poster generation failed", "collection_id", collectionID, "error", err)
|
||||
slog.WarnContext(ctx, "collage: poster generation failed", "component", "catalog", "collection_id", collectionID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,7 +754,7 @@ func (r *PersonRepository) resolveExternalIDConflict(
|
||||
// Writing the row's own current value back can never violate the unique
|
||||
// index, so this field will not re-trigger the conflict.
|
||||
setExternalIDField(p, field, externalIDValue(people[p.ID], field))
|
||||
slog.Warn("person merge: declining to merge, preserving survivor's existing id",
|
||||
slog.WarnContext(ctx, "person merge: declining to merge, preserving survivor's existing id", "component", "catalog",
|
||||
"person_id", p.ID, "partner_id", partnerID, "field", field, "value", value,
|
||||
"person_name", p.Name, "partner_name", partner.Name)
|
||||
return true, nil
|
||||
@@ -784,7 +784,7 @@ func (r *PersonRepository) resolveExternalIDConflict(
|
||||
return false, fmt.Errorf("delete merged person %d: %w", partnerID, err)
|
||||
}
|
||||
|
||||
slog.Info("person merge: folded duplicate person",
|
||||
slog.InfoContext(ctx, "person merge: folded duplicate person", "component", "catalog",
|
||||
"survivor_id", p.ID, "merged_id", partnerID, "field", field, "value", value)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -351,7 +351,7 @@ func (i *CatalogSearchIndexer) Rebuild(ctx context.Context, progress SearchIndex
|
||||
if err != nil {
|
||||
// The new index is already active; a failed cleanup costs disk on the
|
||||
// Meilisearch instance, not correctness, and the next rebuild retries.
|
||||
slog.Warn("catalog search: failed to remove superseded meilisearch indexes", "err", err, "removed", removed)
|
||||
slog.WarnContext(ctx, "catalog search: failed to remove superseded meilisearch indexes", "component", "catalog", "err", err, "removed", removed)
|
||||
}
|
||||
setSearchIndexTaskResult(progress, stats)
|
||||
reportSearchIndexProgress(progress, 100, fmt.Sprintf("Rebuilt catalog search index with %d documents", docCount))
|
||||
|
||||
@@ -24,7 +24,7 @@ func NewCatalogSearchService(
|
||||
) *CatalogSearchService {
|
||||
settings, err := LoadCatalogSearchSettings(ctx, settingsStore)
|
||||
if err != nil {
|
||||
slog.Warn("catalog search: failed to load settings; using postgres", "err", err)
|
||||
slog.WarnContext(ctx, "catalog search: failed to load settings; using postgres", "component", "catalog", "err", err)
|
||||
settings = DefaultCatalogSearchSettings()
|
||||
}
|
||||
return NewCatalogSearchServiceFromSettings(settings, itemRepo, stateRepo, vectorizer...)
|
||||
|
||||
@@ -275,7 +275,7 @@ func (t *semanticCoverageTracker) Refresh(ctx context.Context) error {
|
||||
m, err := t.models.ActiveEmbeddingModel(ctx)
|
||||
if err != nil {
|
||||
// Retain last-good: do not publish on a provider error.
|
||||
slog.Warn("catalog semantic coverage: active model lookup failed; retaining last snapshot", "err", err)
|
||||
slog.WarnContext(ctx, "catalog semantic coverage: active model lookup failed; retaining last snapshot", "component", "catalog", "err", err)
|
||||
return err
|
||||
}
|
||||
model = m
|
||||
@@ -307,7 +307,7 @@ func (t *semanticCoverageTracker) Refresh(ctx context.Context) error {
|
||||
types, err := t.fetch(ctx, model)
|
||||
if err != nil {
|
||||
// Retain last-good: do not overwrite a healthy snapshot with zeros.
|
||||
slog.Warn("catalog semantic coverage: count query failed; retaining last snapshot", "err", err)
|
||||
slog.WarnContext(ctx, "catalog semantic coverage: count query failed; retaining last snapshot", "component", "catalog", "err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -364,7 +364,7 @@ func (t *semanticCoverageTracker) Snapshot() *semanticCoverageSnapshot {
|
||||
// the loop (the last-good snapshot is retained by Refresh).
|
||||
func (t *semanticCoverageTracker) Run(ctx context.Context) {
|
||||
if err := t.Refresh(ctx); err != nil {
|
||||
slog.Warn("catalog semantic coverage: initial refresh failed", "err", err)
|
||||
slog.WarnContext(ctx, "catalog semantic coverage: initial refresh failed", "component", "catalog", "err", err)
|
||||
}
|
||||
ticker := time.NewTicker(semanticCoverageRefreshInterval)
|
||||
defer ticker.Stop()
|
||||
@@ -374,7 +374,7 @@ func (t *semanticCoverageTracker) Run(ctx context.Context) {
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := t.Refresh(ctx); err != nil {
|
||||
slog.Warn("catalog semantic coverage: refresh failed", "err", err)
|
||||
slog.WarnContext(ctx, "catalog semantic coverage: refresh failed", "component", "catalog", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func (r *SmartCountRefresher) RefreshAll(ctx context.Context) (refreshed int, er
|
||||
WHERE collection_type = 'smart'
|
||||
`)
|
||||
if err != nil {
|
||||
slog.Warn("smart-count refresher: list query failed", "error", err)
|
||||
slog.WarnContext(ctx, "smart-count refresher: list query failed", "component", "catalog", "error", err)
|
||||
return 0, 1
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -38,19 +38,19 @@ func (r *SmartCountRefresher) RefreshAll(ctx context.Context) (refreshed int, er
|
||||
for rows.Next() {
|
||||
var e entry
|
||||
if err := rows.Scan(&e.id, &e.def); err != nil {
|
||||
slog.Warn("smart-count refresher: scan failed", "error", err)
|
||||
slog.WarnContext(ctx, "smart-count refresher: scan failed", "component", "catalog", "error", err)
|
||||
return refreshed, errs + 1
|
||||
}
|
||||
batch = append(batch, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
slog.Warn("smart-count refresher: row iter failed", "error", err)
|
||||
slog.WarnContext(ctx, "smart-count refresher: row iter failed", "component", "catalog", "error", err)
|
||||
return refreshed, errs + 1
|
||||
}
|
||||
|
||||
for _, e := range batch {
|
||||
if err := r.refreshOne(ctx, e.id, e.def); err != nil {
|
||||
slog.Warn("smart-count refresher: collection failed", "collection_id", e.id, "error", err)
|
||||
slog.WarnContext(ctx, "smart-count refresher: collection failed", "component", "catalog", "collection_id", e.id, "error", err)
|
||||
errs++
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -204,8 +204,8 @@ func (s *Service) Start(ctx context.Context) {
|
||||
}
|
||||
|
||||
resolvedAccel, resolvedDevice := s.resolveHWConfig()
|
||||
slog.Info(
|
||||
"chapter thumbnail service started",
|
||||
slog.InfoContext(ctx,
|
||||
"chapter thumbnail service started", "component", "chapterthumbs",
|
||||
"workers",
|
||||
s.workerCount,
|
||||
"priority_workers",
|
||||
@@ -280,7 +280,7 @@ func (s *Service) BackfillMissing(ctx context.Context, limit int) (int, error) {
|
||||
continue
|
||||
}
|
||||
if _, err := s.processRequest(ctx, ChapterThumbnailRequest{FileID: file.ID}, false); err != nil {
|
||||
slog.Warn("chapter thumbnail backfill failed", "file_id", file.ID, "error", err)
|
||||
slog.WarnContext(ctx, "chapter thumbnail backfill failed", "component", "chapterthumbs", "file_id", file.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
processed++
|
||||
@@ -296,7 +296,7 @@ func (s *Service) worker(ctx context.Context, priorityOnly bool) {
|
||||
}
|
||||
requeueNormal, err := s.processRequest(ctx, req, priorityOnly)
|
||||
if err != nil {
|
||||
slog.Warn("chapter thumbnail generation failed", "file_id", req.FileID, "error", err)
|
||||
slog.WarnContext(ctx, "chapter thumbnail generation failed", "component", "chapterthumbs", "file_id", req.FileID, "error", err)
|
||||
}
|
||||
notifyPriority, notifyNormal := s.finishProcessing(req.FileID)
|
||||
if notifyPriority {
|
||||
@@ -315,7 +315,7 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques
|
||||
file, err := s.fileRepo.GetByID(ctx, req.FileID)
|
||||
if err != nil || file == nil {
|
||||
if err == nil {
|
||||
slog.Info("chapter thumbnail request skipped", "file_id", req.FileID, "priority", priority, "reason", "file_not_found")
|
||||
slog.InfoContext(ctx, "chapter thumbnail request skipped", "component", "chapterthumbs", "file_id", req.FileID, "priority", priority, "reason", "file_not_found")
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
@@ -323,13 +323,13 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques
|
||||
folder, err := s.folderRepo.GetByID(ctx, file.MediaFolderID)
|
||||
if err != nil || folder == nil {
|
||||
if err == nil {
|
||||
slog.Info("chapter thumbnail request skipped", "file_id", req.FileID, "priority", priority, "reason", "folder_not_found")
|
||||
slog.InfoContext(ctx, "chapter thumbnail request skipped", "component", "chapterthumbs", "file_id", req.FileID, "priority", priority, "reason", "folder_not_found")
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if !folder.Enabled || !folder.ChapterThumbnailsEnabled {
|
||||
slog.Info(
|
||||
"chapter thumbnail request skipped",
|
||||
slog.InfoContext(ctx,
|
||||
"chapter thumbnail request skipped", "component", "chapterthumbs",
|
||||
"file_id",
|
||||
req.FileID,
|
||||
"priority",
|
||||
@@ -344,8 +344,8 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques
|
||||
|
||||
now := s.now()
|
||||
if file.ChapterThumbnailRetryAfter != nil && file.ChapterThumbnailRetryAfter.After(now) {
|
||||
slog.Info(
|
||||
"chapter thumbnail request skipped",
|
||||
slog.InfoContext(ctx,
|
||||
"chapter thumbnail request skipped", "component", "chapterthumbs",
|
||||
"file_id",
|
||||
req.FileID,
|
||||
"priority",
|
||||
@@ -363,14 +363,14 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques
|
||||
return false, err
|
||||
}
|
||||
if len(file.Chapters) == 0 {
|
||||
slog.Info("chapter thumbnail request skipped", "file_id", req.FileID, "priority", priority, "reason", "no_chapters")
|
||||
slog.InfoContext(ctx, "chapter thumbnail request skipped", "component", "chapterthumbs", "file_id", req.FileID, "priority", priority, "reason", "no_chapters")
|
||||
return false, nil
|
||||
}
|
||||
|
||||
hdrPolicy := s.chapterThumbnailHDRPolicy(ctx)
|
||||
if needsTonemap(file) && hdrPolicy == chapterThumbnailHDRPolicyDisabled {
|
||||
slog.Info(
|
||||
"chapter thumbnail request skipped",
|
||||
slog.InfoContext(ctx,
|
||||
"chapter thumbnail request skipped", "component", "chapterthumbs",
|
||||
"file_id",
|
||||
req.FileID,
|
||||
"priority",
|
||||
@@ -383,8 +383,8 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques
|
||||
|
||||
selected := selectChapterCandidates(file.Chapters, req.TargetSeconds, priority, s.batchSize(priority), now)
|
||||
if len(selected) == 0 {
|
||||
slog.Info(
|
||||
"chapter thumbnail request skipped",
|
||||
slog.InfoContext(ctx,
|
||||
"chapter thumbnail request skipped", "component", "chapterthumbs",
|
||||
"file_id",
|
||||
req.FileID,
|
||||
"priority",
|
||||
@@ -395,8 +395,8 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques
|
||||
return false, nil
|
||||
}
|
||||
|
||||
slog.Info(
|
||||
"chapter thumbnail processing started",
|
||||
slog.InfoContext(ctx,
|
||||
"chapter thumbnail processing started", "component", "chapterthumbs",
|
||||
"file_id",
|
||||
req.FileID,
|
||||
"priority",
|
||||
@@ -422,8 +422,8 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques
|
||||
chapter := updated.Chapters[candidate.offset]
|
||||
frame, reason, err := s.extractFrame(ctx, &updated, chapterCaptureTime(chapter), hdrPolicy)
|
||||
if err != nil {
|
||||
slog.Warn(
|
||||
"chapter thumbnail extract failed",
|
||||
slog.WarnContext(ctx,
|
||||
"chapter thumbnail extract failed", "component", "chapterthumbs",
|
||||
"file_id",
|
||||
updated.ID,
|
||||
"chapter_index",
|
||||
@@ -438,8 +438,8 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques
|
||||
failed++
|
||||
if shouldApplyFileFailure(reason) {
|
||||
hardFileFailure = buildPersistentFileFailure(&updated, now, reason, err)
|
||||
slog.Warn(
|
||||
"chapter thumbnail file marked failed",
|
||||
slog.WarnContext(ctx,
|
||||
"chapter thumbnail file marked failed", "component", "chapterthumbs",
|
||||
"file_id",
|
||||
updated.ID,
|
||||
"reason",
|
||||
@@ -454,8 +454,8 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques
|
||||
|
||||
path, thumbhash, err := s.uploadChapterThumbnail(ctx, updated.ID, chapter.Index, frame)
|
||||
if err != nil {
|
||||
slog.Warn(
|
||||
"chapter thumbnail upload failed",
|
||||
slog.WarnContext(ctx,
|
||||
"chapter thumbnail upload failed", "component", "chapterthumbs",
|
||||
"file_id",
|
||||
updated.ID,
|
||||
"chapter_index",
|
||||
@@ -519,8 +519,8 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques
|
||||
}
|
||||
|
||||
requeue := hardFileFailure == nil && hasEligibleMissingChapter(updated.Chapters, now)
|
||||
slog.Info(
|
||||
"chapter thumbnail processing finished",
|
||||
slog.InfoContext(ctx,
|
||||
"chapter thumbnail processing finished", "component", "chapterthumbs",
|
||||
"file_id",
|
||||
req.FileID,
|
||||
"priority",
|
||||
@@ -547,7 +547,7 @@ func (s *Service) ensureChapters(ctx context.Context, file *models.MediaFile, no
|
||||
}
|
||||
|
||||
reason := classifyProbeError(err)
|
||||
slog.Warn("chapter thumbnail probe failed", "file_id", file.ID, "reason", reason, "error", err)
|
||||
slog.WarnContext(ctx, "chapter thumbnail probe failed", "component", "chapterthumbs", "file_id", file.ID, "reason", reason, "error", err)
|
||||
if applyErr := s.applyFileFailure(ctx, file, now, reason, err); applyErr != nil {
|
||||
return file, applyErr
|
||||
}
|
||||
@@ -596,8 +596,8 @@ func (s *Service) extractFrame(
|
||||
node, release, nodeReason := s.reserveRemoteNode(ctx)
|
||||
if node == nil {
|
||||
if mode == chapterThumbnailExecutionPreferTranscode {
|
||||
slog.Info(
|
||||
"chapter thumbnail remote execution unavailable; falling back to local",
|
||||
slog.InfoContext(ctx,
|
||||
"chapter thumbnail remote execution unavailable; falling back to local", "component", "chapterthumbs",
|
||||
"file_path",
|
||||
file.FilePath,
|
||||
"reason",
|
||||
@@ -620,8 +620,8 @@ func (s *Service) extractFrame(
|
||||
}
|
||||
|
||||
if mode == chapterThumbnailExecutionPreferTranscode && isInfrastructureRemoteFailure(reason) {
|
||||
slog.Warn(
|
||||
"chapter thumbnail remote extraction failed; falling back to local",
|
||||
slog.WarnContext(ctx,
|
||||
"chapter thumbnail remote extraction failed; falling back to local", "component", "chapterthumbs",
|
||||
"file_path",
|
||||
file.FilePath,
|
||||
"node",
|
||||
@@ -888,8 +888,8 @@ func (s *Service) nextRequest(ctx context.Context, priorityOnly bool) (ChapterTh
|
||||
normalDepth := len(s.queuedNormal)
|
||||
inProgress := len(s.inProgress)
|
||||
s.mu.Unlock()
|
||||
slog.Info(
|
||||
"chapter thumbnail dequeued",
|
||||
slog.InfoContext(ctx,
|
||||
"chapter thumbnail dequeued", "component", "chapterthumbs",
|
||||
"file_id",
|
||||
req.FileID,
|
||||
"priority",
|
||||
@@ -913,8 +913,8 @@ func (s *Service) nextRequest(ctx context.Context, priorityOnly bool) (ChapterTh
|
||||
normalDepth := len(s.queuedNormal)
|
||||
inProgress := len(s.inProgress)
|
||||
s.mu.Unlock()
|
||||
slog.Info(
|
||||
"chapter thumbnail dequeued",
|
||||
slog.InfoContext(ctx,
|
||||
"chapter thumbnail dequeued", "component", "chapterthumbs",
|
||||
"file_id",
|
||||
req.FileID,
|
||||
"priority",
|
||||
|
||||
@@ -229,7 +229,7 @@ func (m *ArtifactManager) RunOnce(ctx context.Context) error {
|
||||
// repeatedly (each step is idempotent).
|
||||
func (m *ArtifactManager) recover(ctx context.Context) {
|
||||
if _, err := m.repo.ReclaimExpiredLeases(ctx); err != nil {
|
||||
slog.Warn("download artifact lease reclaim failed", "error", err)
|
||||
slog.WarnContext(ctx, "download artifact lease reclaim failed", "component", "downloads", "error", err)
|
||||
}
|
||||
|
||||
// Reconcile downloads stranded in 'preparing' against their artifact's
|
||||
@@ -239,7 +239,7 @@ func (m *ArtifactManager) recover(ctx context.Context) {
|
||||
// failed above) so a download can never sit 'preparing' forever.
|
||||
readyFlipped, failedFlipped, err := m.downloads.ReconcileLinkedDownloads(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("reconciling linked downloads failed", "error", err)
|
||||
slog.WarnContext(ctx, "reconciling linked downloads failed", "component", "downloads", "error", err)
|
||||
} else {
|
||||
for _, d := range readyFlipped {
|
||||
m.publish(ctx, d)
|
||||
@@ -256,7 +256,7 @@ func (m *ArtifactManager) recover(ctx context.Context) {
|
||||
}
|
||||
ready, err := m.repo.ListReady(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("download artifact ready scan failed", "error", err)
|
||||
slog.WarnContext(ctx, "download artifact ready scan failed", "component", "downloads", "error", err)
|
||||
return
|
||||
}
|
||||
for _, a := range ready {
|
||||
@@ -264,9 +264,9 @@ func (m *ArtifactManager) recover(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
if _, statErr := os.Stat(a.OutputPath); statErr != nil {
|
||||
slog.Warn("download artifact output missing, re-queuing", "artifact_id", a.ID, "path", a.OutputPath)
|
||||
slog.WarnContext(ctx, "download artifact output missing, re-queuing", "component", "downloads", "artifact_id", a.ID, "path", a.OutputPath)
|
||||
if err := m.repo.Requeue(ctx, a.ID); err != nil {
|
||||
slog.Warn("re-queue artifact failed", "artifact_id", a.ID, "error", err)
|
||||
slog.WarnContext(ctx, "re-queue artifact failed", "component", "downloads", "artifact_id", a.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
m.triggerDrain()
|
||||
@@ -340,10 +340,10 @@ func (m *ArtifactManager) encodeOne(ctx context.Context, a *Artifact) {
|
||||
return
|
||||
case hbCtx.Err() != nil:
|
||||
// We lost the lease mid-encode; another worker now owns the job.
|
||||
slog.Warn("download artifact encode aborted; lease lost", "artifact_id", a.ID)
|
||||
slog.WarnContext(ctx, "download artifact encode aborted; lease lost", "component", "downloads", "artifact_id", a.ID)
|
||||
return
|
||||
default:
|
||||
slog.Warn("download artifact encode failed", "artifact_id", a.ID, "error", err)
|
||||
slog.WarnContext(ctx, "download artifact encode failed", "component", "downloads", "artifact_id", a.ID, "error", err)
|
||||
m.failJob(ctx, a, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -358,16 +358,16 @@ func (m *ArtifactManager) encodeOne(ctx context.Context, a *Artifact) {
|
||||
// do not flip them here or we would race/duplicate that owner's work.
|
||||
applied, err := m.repo.MarkReady(ctx, a.ID, m.owner, a.OutputPath, size)
|
||||
if err != nil {
|
||||
slog.Error("marking artifact ready failed", "artifact_id", a.ID, "error", err)
|
||||
slog.ErrorContext(ctx, "marking artifact ready failed", "component", "downloads", "artifact_id", a.ID, "error", err)
|
||||
return
|
||||
}
|
||||
if !applied {
|
||||
slog.Warn("download artifact ready skipped; lease lost", "artifact_id", a.ID)
|
||||
slog.WarnContext(ctx, "download artifact ready skipped; lease lost", "component", "downloads", "artifact_id", a.ID)
|
||||
return
|
||||
}
|
||||
flipped, err := m.downloads.MarkLinkedDownloadsReady(ctx, a.ID, size)
|
||||
if err != nil {
|
||||
slog.Error("flipping linked downloads ready failed", "artifact_id", a.ID, "error", err)
|
||||
slog.ErrorContext(ctx, "flipping linked downloads ready failed", "component", "downloads", "artifact_id", a.ID, "error", err)
|
||||
return
|
||||
}
|
||||
for _, d := range flipped {
|
||||
@@ -378,7 +378,7 @@ func (m *ArtifactManager) encodeOne(ctx context.Context, a *Artifact) {
|
||||
func (m *ArtifactManager) failJob(ctx context.Context, a *Artifact, msg string) {
|
||||
terminal, applied, err := m.repo.MarkFailedOrRetry(ctx, a.ID, m.owner, msg, backoffFor(a.Attempts))
|
||||
if err != nil {
|
||||
slog.Error("marking artifact failed/retry errored", "artifact_id", a.ID, "error", err)
|
||||
slog.ErrorContext(ctx, "marking artifact failed/retry errored", "component", "downloads", "artifact_id", a.ID, "error", err)
|
||||
return
|
||||
}
|
||||
if !applied {
|
||||
@@ -395,7 +395,7 @@ func (m *ArtifactManager) failJob(ctx context.Context, a *Artifact, msg string)
|
||||
func (m *ArtifactManager) failLinkedDownloads(ctx context.Context, artifactID, msg string) {
|
||||
flipped, err := m.downloads.MarkLinkedDownloadsFailed(ctx, artifactID, msg)
|
||||
if err != nil {
|
||||
slog.Error("flipping linked downloads failed errored", "artifact_id", artifactID, "error", err)
|
||||
slog.ErrorContext(ctx, "flipping linked downloads failed errored", "component", "downloads", "artifact_id", artifactID, "error", err)
|
||||
return
|
||||
}
|
||||
for _, d := range flipped {
|
||||
@@ -426,9 +426,9 @@ func (m *ArtifactManager) heartbeatLoop(ctx context.Context, cancel context.Canc
|
||||
case err != nil && ctx.Err() != nil:
|
||||
return // encode finished or shutting down
|
||||
case err != nil:
|
||||
slog.Warn("download artifact heartbeat errored", "artifact_id", id, "error", err)
|
||||
slog.WarnContext(ctx, "download artifact heartbeat errored", "component", "downloads", "artifact_id", id, "error", err)
|
||||
case !ok:
|
||||
slog.Warn("download artifact lease lost; aborting encode", "artifact_id", id)
|
||||
slog.WarnContext(ctx, "download artifact lease lost; aborting encode", "component", "downloads", "artifact_id", id)
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
@@ -498,7 +498,7 @@ func (m *ArtifactManager) Cleanup(ctx context.Context) error {
|
||||
}
|
||||
active, err := m.repo.HasActiveLink(ctx, a.ID)
|
||||
if err != nil {
|
||||
slog.Warn("artifact link check failed", "artifact_id", a.ID, "error", err)
|
||||
slog.WarnContext(ctx, "artifact link check failed", "component", "downloads", "artifact_id", a.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
if active {
|
||||
@@ -506,14 +506,14 @@ func (m *ArtifactManager) Cleanup(ctx context.Context) error {
|
||||
}
|
||||
if a.OutputPath != "" {
|
||||
if err := os.Remove(a.OutputPath); err != nil && !os.IsNotExist(err) {
|
||||
slog.Warn("removing evicted artifact file failed", "artifact_id", a.ID, "error", err)
|
||||
slog.WarnContext(ctx, "removing evicted artifact file failed", "component", "downloads", "artifact_id", a.ID, "error", err)
|
||||
}
|
||||
}
|
||||
if err := m.repo.DeleteArtifact(ctx, a.ID); err != nil {
|
||||
slog.Warn("deleting evicted artifact row failed", "artifact_id", a.ID, "error", err)
|
||||
slog.WarnContext(ctx, "deleting evicted artifact row failed", "component", "downloads", "artifact_id", a.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
slog.Info("evicted download artifact (LRU)", "artifact_id", a.ID, "bytes", a.FileSize)
|
||||
slog.InfoContext(ctx, "evicted download artifact (LRU)", "component", "downloads", "artifact_id", a.ID, "bytes", a.FileSize)
|
||||
total -= a.FileSize
|
||||
}
|
||||
return nil
|
||||
@@ -529,14 +529,14 @@ func (m *ArtifactManager) sweepStale(ctx context.Context) {
|
||||
}
|
||||
now := time.Now()
|
||||
if failed, err := m.repo.ListFailedBefore(ctx, now.Add(-failedArtifactRetention)); err != nil {
|
||||
slog.Warn("failed-artifact sweep list failed", "error", err)
|
||||
slog.WarnContext(ctx, "failed-artifact sweep list failed", "component", "downloads", "error", err)
|
||||
} else {
|
||||
for _, a := range failed {
|
||||
m.removeArtifact(ctx, a, "failed")
|
||||
}
|
||||
}
|
||||
if orphans, err := m.repo.ListUnlinkedReadyBefore(ctx, now.Add(-unlinkedArtifactRetention)); err != nil {
|
||||
slog.Warn("unlinked-artifact sweep list failed", "error", err)
|
||||
slog.WarnContext(ctx, "unlinked-artifact sweep list failed", "component", "downloads", "error", err)
|
||||
} else {
|
||||
for _, a := range orphans {
|
||||
m.removeArtifact(ctx, a, "unlinked")
|
||||
@@ -544,9 +544,9 @@ func (m *ArtifactManager) sweepStale(ctx context.Context) {
|
||||
}
|
||||
if m.downloads != nil {
|
||||
if n, err := m.downloads.PruneEphemeralOlderThan(ctx, now.Add(-ephemeralDownloadRetention)); err != nil {
|
||||
slog.Warn("ephemeral download prune failed", "error", err)
|
||||
slog.WarnContext(ctx, "ephemeral download prune failed", "component", "downloads", "error", err)
|
||||
} else if n > 0 {
|
||||
slog.Info("pruned expired ephemeral downloads", "rows", n)
|
||||
slog.InfoContext(ctx, "pruned expired ephemeral downloads", "component", "downloads", "rows", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -556,17 +556,17 @@ func (m *ArtifactManager) sweepStale(ctx context.Context) {
|
||||
func (m *ArtifactManager) removeArtifact(ctx context.Context, a *Artifact, reason string) {
|
||||
if a.OutputPath != "" {
|
||||
if err := os.Remove(a.OutputPath); err != nil && !os.IsNotExist(err) {
|
||||
slog.Warn("removing swept artifact file failed", "artifact_id", a.ID, "error", err)
|
||||
slog.WarnContext(ctx, "removing swept artifact file failed", "component", "downloads", "artifact_id", a.ID, "error", err)
|
||||
}
|
||||
if err := os.Remove(a.OutputPath + ".part"); err != nil && !os.IsNotExist(err) {
|
||||
slog.Warn("removing swept artifact partial failed", "artifact_id", a.ID, "error", err)
|
||||
slog.WarnContext(ctx, "removing swept artifact partial failed", "component", "downloads", "artifact_id", a.ID, "error", err)
|
||||
}
|
||||
}
|
||||
if err := m.repo.DeleteArtifact(ctx, a.ID); err != nil {
|
||||
slog.Warn("deleting swept artifact row failed", "artifact_id", a.ID, "error", err)
|
||||
slog.WarnContext(ctx, "deleting swept artifact row failed", "component", "downloads", "artifact_id", a.ID, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("swept stale download artifact", "artifact_id", a.ID, "reason", reason, "bytes", a.FileSize)
|
||||
slog.InfoContext(ctx, "swept stale download artifact", "component", "downloads", "artifact_id", a.ID, "reason", reason, "bytes", a.FileSize)
|
||||
}
|
||||
|
||||
// backoffFor returns the retry delay for the next attempt after a failure.
|
||||
|
||||
@@ -92,7 +92,7 @@ func (s *Service) BuildBatchManifests(ctx context.Context, userID int, profileID
|
||||
if errors.Is(err, catalog.ErrItemNotFound) {
|
||||
reason = "not_found"
|
||||
} else {
|
||||
slog.Warn("batch manifest build failed", "download_id", dl.ID, "batch_id", batchID, "error", err)
|
||||
slog.WarnContext(ctx, "batch manifest build failed", "component", "downloads", "download_id", dl.ID, "batch_id", batchID, "error", err)
|
||||
}
|
||||
skipped = append(skipped, SkippedManifest{DownloadID: dl.ID, Reason: reason})
|
||||
continue
|
||||
|
||||
@@ -209,13 +209,13 @@ func (s *Service) loadConfig(ctx context.Context) config.DownloadConfig {
|
||||
|
||||
allSettings, err := s.settings.GetAll(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("failed to reload download config from DB, using cached", "error", err)
|
||||
slog.WarnContext(ctx, "failed to reload download config from DB, using cached", "component", "downloads", "error", err)
|
||||
return s.cfg
|
||||
}
|
||||
|
||||
newFullCfg, err := config.LoadFromDB(allSettings)
|
||||
if err != nil {
|
||||
slog.Warn("failed to parse download config from DB, using cached", "error", err)
|
||||
slog.WarnContext(ctx, "failed to parse download config from DB, using cached", "component", "downloads", "error", err)
|
||||
return s.cfg
|
||||
}
|
||||
|
||||
@@ -226,13 +226,13 @@ func (s *Service) loadConfig(ctx context.Context) config.DownloadConfig {
|
||||
// Update bandwidth manager if limits changed.
|
||||
if s.bandwidth != nil && (oldCfg.ServerBandwidthBPS != s.cfg.ServerBandwidthBPS || oldCfg.UserBandwidthBPS != s.cfg.UserBandwidthBPS) {
|
||||
s.bandwidth.Reload(s.cfg.ServerBandwidthBPS, s.cfg.UserBandwidthBPS)
|
||||
slog.Info("download bandwidth config reloaded", "server_bps", s.cfg.ServerBandwidthBPS, "user_bps", s.cfg.UserBandwidthBPS)
|
||||
slog.InfoContext(ctx, "download bandwidth config reloaded", "component", "downloads", "server_bps", s.cfg.ServerBandwidthBPS, "user_bps", s.cfg.UserBandwidthBPS)
|
||||
}
|
||||
|
||||
// Update quantity limiter if limits changed.
|
||||
if s.limiter != nil && (oldCfg.MaxConcurrentPerUser != s.cfg.MaxConcurrentPerUser || oldCfg.MaxPerPeriod != s.cfg.MaxPerPeriod || oldCfg.PeriodDuration != s.cfg.PeriodDuration) {
|
||||
s.limiter.Reload(s.cfg.MaxConcurrentPerUser, s.cfg.MaxPerPeriod, s.cfg.PeriodDuration)
|
||||
slog.Info("download quantity limits reloaded", "max_concurrent", s.cfg.MaxConcurrentPerUser, "max_per_period", s.cfg.MaxPerPeriod, "period", s.cfg.PeriodDuration)
|
||||
slog.InfoContext(ctx, "download quantity limits reloaded", "component", "downloads", "max_concurrent", s.cfg.MaxConcurrentPerUser, "max_per_period", s.cfg.MaxPerPeriod, "period", s.cfg.PeriodDuration)
|
||||
}
|
||||
|
||||
return s.cfg
|
||||
@@ -905,14 +905,14 @@ func (s *Service) ServeFile(ctx context.Context, w http.ResponseWriter, r *http.
|
||||
if errors.Is(err, ErrStatusConflict) {
|
||||
return fmt.Errorf("download already in progress: %w", ErrDownloadNotActive)
|
||||
}
|
||||
slog.Warn("failed to transition download to downloading", "download_id", dl.ID, "error", err)
|
||||
slog.WarnContext(ctx, "failed to transition download to downloading", "component", "downloads", "download_id", dl.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.serveDownloadBytes(ctx, w, r, dl, userID, filter); err != nil {
|
||||
if dl.Format == FormatOriginal {
|
||||
if updateErr := s.repo.UpdateStatus(ctx, dl.ID, StatusFailed, 0, nil); updateErr != nil {
|
||||
slog.Error("failed to mark download as failed", "download_id", dl.ID, "error", updateErr)
|
||||
slog.ErrorContext(ctx, "failed to mark download as failed", "component", "downloads", "download_id", dl.ID, "error", updateErr)
|
||||
}
|
||||
}
|
||||
return err
|
||||
@@ -920,7 +920,7 @@ func (s *Service) ServeFile(ctx context.Context, w http.ResponseWriter, r *http.
|
||||
|
||||
now := time.Now()
|
||||
if err := s.repo.UpdateStatus(ctx, dl.ID, StatusCompleted, dl.FileSize, &now); err != nil {
|
||||
slog.Error("failed to mark download as completed", "download_id", dl.ID, "error", err)
|
||||
slog.ErrorContext(ctx, "failed to mark download as completed", "component", "downloads", "download_id", dl.ID, "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ func (s *Service) CreateSubscription(ctx context.Context, userID int, req Subscr
|
||||
// be retried by syncing or re-monitoring. So log it rather than failing.
|
||||
registered, err := s.syncSubscription(ctx, stored)
|
||||
if err != nil {
|
||||
slog.Warn("download subscription initial sync failed", "subscription_id", stored.ID, "error", err)
|
||||
slog.WarnContext(ctx, "download subscription initial sync failed", "component", "downloads", "subscription_id", stored.ID, "error", err)
|
||||
}
|
||||
return &SubscriptionResult{Subscription: stored, Registered: registered}, nil
|
||||
}
|
||||
@@ -200,7 +200,7 @@ func (s *Service) UpdateSubscription(ctx context.Context, userID int, profileID,
|
||||
}
|
||||
registered, err := s.syncSubscription(ctx, sub)
|
||||
if err != nil {
|
||||
slog.Warn("download subscription update sync failed", "subscription_id", sub.ID, "error", err)
|
||||
slog.WarnContext(ctx, "download subscription update sync failed", "component", "downloads", "subscription_id", sub.ID, "error", err)
|
||||
}
|
||||
return &SubscriptionResult{Subscription: sub, Registered: registered}, nil
|
||||
}
|
||||
@@ -250,7 +250,7 @@ func (s *Service) SyncSubscriptions(ctx context.Context, userID int, profileID,
|
||||
}
|
||||
n, err := s.syncSubscription(ctx, sub)
|
||||
if err != nil {
|
||||
slog.Warn("download subscription sync failed", "subscription_id", sub.ID, "error", err)
|
||||
slog.WarnContext(ctx, "download subscription sync failed", "component", "downloads", "subscription_id", sub.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
total += n
|
||||
@@ -294,7 +294,7 @@ func (s *Service) capItemsToStorage(ctx context.Context, sub *Subscription, item
|
||||
}
|
||||
used, err := s.repo.SumManagedFileSize(ctx, sub.UserID, sub.ProfileID, sub.DeviceID)
|
||||
if err != nil {
|
||||
slog.Warn("download subscription storage gate: sum failed; skipping cap", "subscription_id", sub.ID, "error", err)
|
||||
slog.WarnContext(ctx, "download subscription storage gate: sum failed; skipping cap", "component", "downloads", "subscription_id", sub.ID, "error", err)
|
||||
return items
|
||||
}
|
||||
kept := make([]managedItem, 0, len(items))
|
||||
|
||||
@@ -145,14 +145,14 @@ func (e *Enricher) Run(ctx context.Context) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
slog.Info("ebook enrichment: sweep started",
|
||||
slog.InfoContext(ctx, "ebook enrichment: sweep started", "component", "ebooks",
|
||||
"count", len(items),
|
||||
"workers", e.workers,
|
||||
)
|
||||
|
||||
enriched := e.runBatch(ctx, items, e.enrichItem, e.recordEnrichFailure)
|
||||
|
||||
slog.Info("ebook enrichment: sweep complete",
|
||||
slog.InfoContext(ctx, "ebook enrichment: sweep complete", "component", "ebooks",
|
||||
"attempted", len(items),
|
||||
"enriched", enriched,
|
||||
)
|
||||
@@ -188,14 +188,14 @@ func (e *Enricher) runBatch(
|
||||
}
|
||||
if err := enrichFn(ctx, item); err != nil {
|
||||
if errors.Is(err, errEnrichmentSkipped) {
|
||||
slog.Debug("ebook enrichment: item skipped",
|
||||
slog.DebugContext(ctx, "ebook enrichment: item skipped", "component", "ebooks",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
"reason", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
slog.Warn("ebook enrichment: item failed",
|
||||
slog.WarnContext(ctx, "ebook enrichment: item failed", "component", "ebooks",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
"error", err,
|
||||
@@ -348,7 +348,7 @@ func (e *Enricher) enrichWithProviders(ctx context.Context, item enrichmentItemR
|
||||
return fmt.Errorf("no metadata obtained, %d provider error(s): %w",
|
||||
len(providerErrs), errors.Join(providerErrs...))
|
||||
}
|
||||
slog.Info("ebook enrichment: no metadata found",
|
||||
slog.InfoContext(ctx, "ebook enrichment: no metadata found", "component", "ebooks",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
)
|
||||
@@ -361,7 +361,7 @@ func (e *Enricher) enrichWithProviders(ctx context.Context, item enrichmentItemR
|
||||
e.enqueueRemoteArtwork(ctx, item.ContentID, accumulator)
|
||||
e.autoLinkLiteraryWork(ctx, item.ContentID)
|
||||
|
||||
slog.Info("ebook enrichment: enriched",
|
||||
slog.InfoContext(ctx, "ebook enrichment: enriched", "component", "ebooks",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
"poster", accumulator.PosterPath != "",
|
||||
@@ -397,7 +397,7 @@ func collectEbookMetadata(ctx context.Context, item enrichmentItemRow, providers
|
||||
}
|
||||
results, searchErr := sp.Search(ctx, searchQuery)
|
||||
if searchErr != nil {
|
||||
slog.Warn("ebook enrichment: search error",
|
||||
slog.WarnContext(ctx, "ebook enrichment: search error", "component", "ebooks",
|
||||
"provider", p.Slug(),
|
||||
"content_id", item.ContentID,
|
||||
"error", searchErr,
|
||||
@@ -425,7 +425,7 @@ func collectEbookMetadata(ctx context.Context, item enrichmentItemRow, providers
|
||||
continue
|
||||
}
|
||||
if ownerID != "" {
|
||||
slog.Info("ebook enrichment: provider id already owned by another item; skipping match",
|
||||
slog.InfoContext(ctx, "ebook enrichment: provider id already owned by another item; skipping match", "component", "ebooks",
|
||||
"provider", k,
|
||||
"provider_id", v,
|
||||
"content_id", item.ContentID,
|
||||
@@ -436,7 +436,7 @@ func collectEbookMetadata(ctx context.Context, item enrichmentItemRow, providers
|
||||
}
|
||||
accumulatedIDs[k] = v
|
||||
}
|
||||
slog.Debug("ebook enrichment: search result",
|
||||
slog.DebugContext(ctx, "ebook enrichment: search result", "component", "ebooks",
|
||||
"provider", p.Slug(),
|
||||
"content_id", item.ContentID,
|
||||
"matched_ids", accumulatedIDs,
|
||||
@@ -454,7 +454,7 @@ func collectEbookMetadata(ctx context.Context, item enrichmentItemRow, providers
|
||||
}
|
||||
result, getErr := mp.GetMetadata(ctx, buildEbookMetadataRequest(accumulator.ProviderIDs, item.Language))
|
||||
if getErr != nil {
|
||||
slog.Warn("ebook enrichment: GetMetadata error",
|
||||
slog.WarnContext(ctx, "ebook enrichment: GetMetadata error", "component", "ebooks",
|
||||
"provider", p.Slug(),
|
||||
"content_id", item.ContentID,
|
||||
"error", getErr,
|
||||
@@ -468,7 +468,7 @@ func collectEbookMetadata(ctx context.Context, item enrichmentItemRow, providers
|
||||
mergeEnrichmentProviderIDs(accumulator, result)
|
||||
metadata.MergeMetadata(result, accumulator, nil, metadata.MergeFillEmpty)
|
||||
|
||||
slog.Debug("ebook enrichment: metadata received",
|
||||
slog.DebugContext(ctx, "ebook enrichment: metadata received", "component", "ebooks",
|
||||
"provider", p.Slug(),
|
||||
"content_id", item.ContentID,
|
||||
"has_poster", result.PosterPath != "",
|
||||
@@ -485,11 +485,11 @@ func (e *Enricher) autoLinkLiteraryWork(ctx context.Context, contentID string) {
|
||||
}
|
||||
workID, linked, err := e.workLinker.AutoLinkContent(ctx, contentID)
|
||||
if err != nil {
|
||||
slog.Warn("ebook enrichment: literary work auto-link failed", "content_id", contentID, "error", err)
|
||||
slog.WarnContext(ctx, "ebook enrichment: literary work auto-link failed", "component", "ebooks", "content_id", contentID, "error", err)
|
||||
return
|
||||
}
|
||||
if linked {
|
||||
slog.Info("ebook enrichment: literary work auto-linked", "content_id", contentID, "work_id", workID)
|
||||
slog.InfoContext(ctx, "ebook enrichment: literary work auto-linked", "component", "ebooks", "content_id", contentID, "work_id", workID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,7 +512,7 @@ func (e *Enricher) cacheRemotePoster(ctx context.Context, contentID string, resu
|
||||
ImageType: metadata.ImagePoster,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("ebook enrichment: poster cache failed, keeping provider URL",
|
||||
slog.WarnContext(ctx, "ebook enrichment: poster cache failed, keeping provider URL", "component", "ebooks",
|
||||
"content_id", contentID,
|
||||
"url", result.PosterPath,
|
||||
"error", err,
|
||||
@@ -520,7 +520,7 @@ func (e *Enricher) cacheRemotePoster(ctx context.Context, contentID string, resu
|
||||
return
|
||||
}
|
||||
if cached == nil {
|
||||
slog.Warn("ebook enrichment: poster cache returned no result, keeping provider URL",
|
||||
slog.WarnContext(ctx, "ebook enrichment: poster cache returned no result, keeping provider URL", "component", "ebooks",
|
||||
"content_id", contentID,
|
||||
"url", result.PosterPath,
|
||||
)
|
||||
@@ -564,7 +564,7 @@ func (e *Enricher) enqueueRemoteArtwork(ctx context.Context, contentID string, r
|
||||
enqueueCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
if _, err := e.imageCacheJobs.EnqueueBatch(enqueueCtx, inputs); err != nil {
|
||||
slog.Warn("ebook enrichment: failed to enqueue image cache jobs",
|
||||
slog.WarnContext(ctx, "ebook enrichment: failed to enqueue image cache jobs", "component", "ebooks",
|
||||
"content_id", contentID,
|
||||
"count", len(inputs),
|
||||
"error", err,
|
||||
@@ -659,7 +659,7 @@ func (e *Enricher) persist(ctx context.Context, contentID string, providerIDs ma
|
||||
providerIDs = filterEbookProviderIDs(providerIDs)
|
||||
if e.providerIDs != nil && len(providerIDs) > 0 {
|
||||
if err := e.providerIDs.ReplaceByContentID(ctx, contentID, providerIDs); err != nil {
|
||||
slog.Warn("ebook enrichment: failed to persist provider IDs",
|
||||
slog.WarnContext(ctx, "ebook enrichment: failed to persist provider IDs", "component", "ebooks",
|
||||
"content_id", contentID,
|
||||
"error", err,
|
||||
)
|
||||
@@ -673,7 +673,7 @@ func (e *Enricher) persist(ctx context.Context, contentID string, providerIDs ma
|
||||
authors := filterEbookPeople(result.People)
|
||||
if len(authors) > 0 && e.personRepo != nil && e.itemRepo != nil {
|
||||
if err := e.persistPeople(ctx, contentID, authors); err != nil {
|
||||
slog.Warn("ebook enrichment: failed to persist people",
|
||||
slog.WarnContext(ctx, "ebook enrichment: failed to persist people", "component", "ebooks",
|
||||
"content_id", contentID,
|
||||
"error", err,
|
||||
)
|
||||
@@ -731,7 +731,7 @@ func (e *Enricher) recordEnrichFailure(ctx context.Context, item enrichmentItemR
|
||||
failures = ebook_enrichment_state.failures + 1,
|
||||
updated_at = NOW()
|
||||
`, item.ContentID); err != nil {
|
||||
slog.Warn("ebook enrichment: failed to record enrichment failure",
|
||||
slog.WarnContext(ctx, "ebook enrichment: failed to record enrichment failure", "component", "ebooks",
|
||||
"content_id", item.ContentID,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
@@ -81,7 +81,7 @@ func (p *PlexAdminProvider) fetchSeriesMetadata(ctx context.Context, items []Ple
|
||||
for _, key := range seriesKeys {
|
||||
meta, err := p.client.FetchMetadata(ctx, p.baseURL, p.token, key)
|
||||
if err != nil {
|
||||
slog.Warn("plex admin history import: failed to fetch series metadata", "rating_key", key, "error", err)
|
||||
slog.WarnContext(ctx, "plex admin history import: failed to fetch series metadata", "component", "historyimport", "rating_key", key, "error", err)
|
||||
*warnings = append(*warnings, fmt.Sprintf("failed to fetch series metadata for %s: %v", key, err))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ func (p *PlexServerProvider) fetchSeriesMetadata(ctx context.Context, items []Pl
|
||||
for _, key := range seriesKeys {
|
||||
meta, err := p.client.FetchMetadata(ctx, p.baseURL, p.token, key)
|
||||
if err != nil {
|
||||
slog.Warn("plex history import: failed to fetch series metadata", "rating_key", key, "error", err)
|
||||
slog.WarnContext(ctx, "plex history import: failed to fetch series metadata", "component", "historyimport", "rating_key", key, "error", err)
|
||||
*warnings = append(*warnings, fmt.Sprintf("failed to fetch series metadata for %s: %v", key, err))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -600,12 +600,12 @@ func (s *Service) executeRun(run *Run, provider Provider) {
|
||||
func (s *Service) failRun(ctx context.Context, runID string, summary ExecutionSummary, err error) {
|
||||
message := userFacingRunError(summary, err)
|
||||
if updateErr := s.repo.FailRun(ctx, runID, summary, message); updateErr != nil {
|
||||
slog.Error("history import: failed to mark run failed", "run_id", runID, "error", updateErr, "root_error", err)
|
||||
slog.ErrorContext(ctx, "history import: failed to mark run failed", "component", "historyimport", "run_id", runID, "error", updateErr, "root_error", err)
|
||||
return
|
||||
}
|
||||
s.notifyRunByID(ctx, runID)
|
||||
slog.Error(
|
||||
"history import: failed",
|
||||
slog.ErrorContext(ctx,
|
||||
"history import: failed", "component", "historyimport",
|
||||
"run_id", runID,
|
||||
"error", message,
|
||||
"root_error", err,
|
||||
@@ -756,7 +756,7 @@ func toConnectServerResponses(servers []ConnectServer) []ConnectServerResponse {
|
||||
|
||||
func (s *Service) persistProgress(ctx context.Context, runID string, summary ExecutionSummary) {
|
||||
if err := s.repo.UpdateRunProgress(ctx, runID, summary); err != nil && !errors.Is(err, ErrRunNotFound) {
|
||||
slog.Warn("history import: failed to persist progress", "run_id", runID, "error", err)
|
||||
slog.WarnContext(ctx, "history import: failed to persist progress", "component", "historyimport", "run_id", runID, "error", err)
|
||||
return
|
||||
}
|
||||
s.notifyRunByID(ctx, runID)
|
||||
@@ -765,8 +765,8 @@ func (s *Service) persistProgress(ctx context.Context, runID string, summary Exe
|
||||
func (s *Service) persistProgressMaybe(ctx context.Context, runID string, summary ExecutionSummary, processed, total int) {
|
||||
if processed == total || processed%25 == 0 {
|
||||
s.persistProgress(ctx, runID, summary)
|
||||
slog.Info(
|
||||
"history import: progress",
|
||||
slog.InfoContext(ctx,
|
||||
"history import: progress", "component", "historyimport",
|
||||
"run_id", runID,
|
||||
"processed", processed,
|
||||
"total", total,
|
||||
|
||||
@@ -182,7 +182,7 @@ func (s *Service) BulkCreateAdminRuns(ctx context.Context, sourceID int) (*BulkR
|
||||
mappingID := mapping.ID
|
||||
provider, err := s.buildAdminProvider(source, token, mapping.ExternalUserID)
|
||||
if err != nil {
|
||||
slog.Error("history import bulk: failed to build provider", "mapping_id", mappingID, "error", err)
|
||||
slog.ErrorContext(ctx, "history import bulk: failed to build provider", "component", "historyimport", "mapping_id", mappingID, "error", err)
|
||||
result.Errors++
|
||||
continue
|
||||
}
|
||||
@@ -200,7 +200,7 @@ func (s *Service) BulkCreateAdminRuns(ctx context.Context, sourceID int) (*BulkR
|
||||
}
|
||||
created, err := s.repo.CreateRun(ctx, run)
|
||||
if err != nil {
|
||||
slog.Error("history import bulk: failed to create run", "mapping_id", mappingID, "error", err)
|
||||
slog.ErrorContext(ctx, "history import bulk: failed to create run", "component", "historyimport", "mapping_id", mappingID, "error", err)
|
||||
result.Errors++
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func (s *Service) reconcileStaleRuns(ctx context.Context, now time.Time) error {
|
||||
return sweepStaleRunsOnce(ctx, now, historyImportStaleRunThreshold, func(ctx context.Context, staleBefore time.Time, message string) (int64, error) {
|
||||
count, err := s.repo.FailStaleRuns(ctx, staleBefore, message)
|
||||
if err == nil && count > 0 {
|
||||
slog.Warn("history import: reconciled stale running runs", "count", count, "stale_before", staleBefore)
|
||||
slog.WarnContext(ctx, "history import: reconciled stale running runs", "component", "historyimport", "count", count, "stale_before", staleBefore)
|
||||
}
|
||||
return count, err
|
||||
})
|
||||
@@ -51,7 +51,7 @@ func (s *Service) startRunHeartbeat(ctx context.Context, runID string) context.C
|
||||
go heartbeatLoop(hbCtx, historyImportHeartbeatInterval, func(ctx context.Context) error {
|
||||
err := s.repo.TouchRunHeartbeat(ctx, runID)
|
||||
if err != nil && !errors.Is(err, ErrRunNotFound) {
|
||||
slog.Warn("history import: failed to persist heartbeat", "run_id", runID, "error", err)
|
||||
slog.WarnContext(ctx, "history import: failed to persist heartbeat", "component", "historyimport", "run_id", runID, "error", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -153,7 +153,7 @@ func (a *Analyzer) Run(ctx context.Context, progress ProgressFunc) (RunSummary,
|
||||
summary.GroupsSkipped += groupSummary.GroupsSkipped
|
||||
summary.Errors = append(summary.Errors, groupSummary.Errors...)
|
||||
if err != nil {
|
||||
a.logger.Warn("intro marker group analysis failed",
|
||||
a.logger.WarnContext(ctx, "intro marker group analysis failed",
|
||||
"season_id", group.SeasonID,
|
||||
"media_folder_id", group.MediaFolderID,
|
||||
"group_key", group.AnalysisGroupKey,
|
||||
@@ -261,7 +261,7 @@ func (a *Analyzer) AnalyzeEpisode(ctx context.Context, episodeID string) (RunSum
|
||||
summary.GroupsSkipped += groupSummary.GroupsSkipped
|
||||
summary.Errors = append(summary.Errors, groupSummary.Errors...)
|
||||
if err != nil {
|
||||
a.logger.Warn("intro marker episode group analysis failed",
|
||||
a.logger.WarnContext(ctx, "intro marker episode group analysis failed",
|
||||
"episode_id", episodeID,
|
||||
"season_id", group.SeasonID,
|
||||
"media_folder_id", group.MediaFolderID,
|
||||
@@ -335,7 +335,7 @@ func (a *Analyzer) processChapterCandidates(ctx context.Context, candidates []Ca
|
||||
})
|
||||
if patchErr != nil {
|
||||
summary.Errors = append(summary.Errors, patchErr.Error())
|
||||
a.logger.Warn("intro marker chapter patch failed", "file_id", candidate.FileID, "error", patchErr)
|
||||
a.logger.WarnContext(ctx, "intro marker chapter patch failed", "file_id", candidate.FileID, "error", patchErr)
|
||||
remaining = append(remaining, candidate)
|
||||
continue
|
||||
}
|
||||
@@ -393,7 +393,7 @@ func (a *Analyzer) processChapterCandidates(ctx context.Context, candidates []Ca
|
||||
if patchErr != nil {
|
||||
msg := fmt.Sprintf("file %d: %v", candidate.FileID, patchErr)
|
||||
summary.Errors = append(summary.Errors, msg)
|
||||
a.logger.Warn("intro marker episode version copy failed", "file_id", candidate.FileID, "source_file_id", source.candidate.FileID, "error", patchErr)
|
||||
a.logger.WarnContext(ctx, "intro marker episode version copy failed", "file_id", candidate.FileID, "source_file_id", source.candidate.FileID, "error", patchErr)
|
||||
unresolved = append(unresolved, candidate)
|
||||
continue
|
||||
}
|
||||
@@ -413,7 +413,7 @@ func (a *Analyzer) refineChapterSegment(ctx context.Context, candidate Candidate
|
||||
refined, ok, err := a.refiner.RefineChapterEnd(ctx, candidate, segment)
|
||||
if err != nil {
|
||||
summary.SilenceRefinementErrors++
|
||||
a.logger.Warn("intro marker silence refinement failed", "file_id", candidate.FileID, "path", candidate.FilePath, "error", err)
|
||||
a.logger.WarnContext(ctx, "intro marker silence refinement failed", "file_id", candidate.FileID, "path", candidate.FilePath, "error", err)
|
||||
return segment
|
||||
}
|
||||
if ok {
|
||||
@@ -617,7 +617,7 @@ func (a *Analyzer) analyzeGroup(ctx context.Context, group candidateGroup, opts
|
||||
if patchErr != nil {
|
||||
msg := fmt.Sprintf("file %d: %v", fileID, patchErr)
|
||||
summary.Errors = append(summary.Errors, msg)
|
||||
a.logger.Warn("intro marker chromaprint patch failed", "file_id", fileID, "path", candidate.FilePath, "error", patchErr)
|
||||
a.logger.WarnContext(ctx, "intro marker chromaprint patch failed", "file_id", fileID, "path", candidate.FilePath, "error", patchErr)
|
||||
continue
|
||||
}
|
||||
if applied {
|
||||
@@ -643,7 +643,7 @@ func (a *Analyzer) refineChromaprintSegment(ctx context.Context, candidate Candi
|
||||
refined, ok, err := a.chromaprintRefiner.RefineChromaprintStart(ctx, candidate, segment)
|
||||
if err != nil {
|
||||
summary.DialogueRefinementErrors++
|
||||
a.logger.Warn("intro marker dialogue refinement failed", "file_id", candidate.FileID, "path", candidate.FilePath, "error", err)
|
||||
a.logger.WarnContext(ctx, "intro marker dialogue refinement failed", "file_id", candidate.FileID, "path", candidate.FilePath, "error", err)
|
||||
return segment
|
||||
}
|
||||
if ok {
|
||||
@@ -722,7 +722,7 @@ func (a *Analyzer) ensureFingerprints(ctx context.Context, candidates []Candidat
|
||||
fp, ok, err := a.extractor.Extract(ctx, candidate)
|
||||
<-sem
|
||||
if err != nil {
|
||||
a.logger.Warn("intro marker fingerprint extraction failed", "file_id", candidate.FileID, "path", candidate.FilePath, "error", err)
|
||||
a.logger.WarnContext(ctx, "intro marker fingerprint extraction failed", "file_id", candidate.FileID, "path", candidate.FilePath, "error", err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
|
||||
@@ -31,7 +31,7 @@ func NewScopeAccessFilter(resolver ScopeResolver) AccessFilterResolver {
|
||||
SkipPINVerification: true,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("jellycompat: access scope resolution failed; denying library access",
|
||||
slog.WarnContext(ctx, "jellycompat: access scope resolution failed; denying library access", "component", "jellycompat",
|
||||
"user_id", userID,
|
||||
"profile_id", profileID,
|
||||
"error", err,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user