Merge remote-tracking branch 'origin/main' into pr-397-devmerge

# Conflicts:
#	internal/catalog/item_repo.go
This commit is contained in:
Quick104
2026-07-23 11:01:21 -04:00
372 changed files with 51412 additions and 2770 deletions
+84 -10
View File
@@ -19,7 +19,15 @@ env:
jobs:
build:
runs-on: [self-hosted, linux]
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
@@ -28,6 +36,13 @@ jobs:
- name: Checkout
uses: actions/checkout@v5
- name: Prepare platform slug and lowercase image name
run: |
echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
echo "IMAGE_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
env:
PLATFORM: ${{ matrix.platform }}
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
@@ -76,9 +91,6 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
name: silo-builder
keep-state: true
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
@@ -92,19 +104,81 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=,format=short
- name: Build and push
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
build-contexts: |
frontend_dist=./web/dist
build-args: |
BUILD_REVISION=${{ github.sha }}
BUILD_DIRTY=false
cache-from: type=gha,scope=docker-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,scope=docker-${{ env.PLATFORM_PAIR }},mode=max
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_LC }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p "${RUNNER_TEMP}/digests"
digest="${{ steps.build.outputs.digest }}"
touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: build
permissions:
packages: write
steps:
- name: Prepare lowercase image name
run: echo "IMAGE_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_LC }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=,format=short
- name: Create and push manifest list
working-directory: ${{ runner.temp }}/digests
run: |
# shellcheck disable=SC2046
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${REGISTRY}/${IMAGE_LC}@sha256:%s " *)
- name: Inspect manifest list
run: |
docker buildx imagetools inspect "${REGISTRY}/${IMAGE_LC}:${GITHUB_SHA::7}"
+73 -1
View File
@@ -50,6 +50,7 @@ import (
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/database"
"github.com/Silo-Server/silo-server/internal/diagnostics"
"github.com/Silo-Server/silo-server/internal/downloads"
"github.com/Silo-Server/silo-server/internal/ebooks"
evt "github.com/Silo-Server/silo-server/internal/events"
@@ -67,6 +68,11 @@ import (
"github.com/Silo-Server/silo-server/internal/markers"
"github.com/Silo-Server/silo-server/internal/mdblist"
"github.com/Silo-Server/silo-server/internal/metadata"
// Built-in metadata providers self-register into the metadata package's
// builtin registry on import; buildProviders resolves their seeded chain
// entries in-process (no gRPC).
_ "github.com/Silo-Server/silo-server/internal/metadata/nfo"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/nodeconfig"
"github.com/Silo-Server/silo-server/internal/nodepool"
@@ -189,6 +195,9 @@ func configureOperationalLogging(
if err := opslog.SeedDefaults(ctx, settingsRepo); err != nil {
log.Fatalf("seed opslog defaults: %v", err)
}
if err := diagnostics.SeedDefaults(ctx, settingsRepo); err != nil {
log.Fatalf("seed diagnostics defaults: %v", err)
}
opsPM := partman.NewManager(pool, "operational_logs", partman.Daily, 3)
if err := opsPM.EnsureFuturePartitions(ctx); err != nil {
// Non-fatal: a partition hiccup must not crash-loop the server (see the
@@ -665,6 +674,9 @@ func main() {
// start, so this node can rebuild a Jellyfin transcode after its own
// restart (the node hop token is recipe-less). Shares the offload Redis.
srv.SetRecipeStore(noderecipe.NewStore(redisClient, 0))
// Reclaim orphaned transcode dirs at boot and hourly thereafter, bound
// to appCtx so it stops on shutdown.
srv.StartOrphanSweeper(appCtx)
handler = srv.Handler()
}
@@ -996,6 +1008,11 @@ func main() {
}
out := make([]pluginhost.InstalledPluginRecord, 0, len(installations))
for _, installation := range installations {
// The reserved builtin row is not a plugin; keep it out
// of the host's installed-plugin listing.
if installation.IsBuiltin() {
continue
}
capabilities, err := installationStore.ListCapabilities(ctx, installation.ID)
if err != nil {
return nil, err
@@ -1145,6 +1162,17 @@ func main() {
var mangaEnricher *manga.Enricher
if needsWorkers && deps.DB != nil && deps.FileRepo != nil {
chainRepo := metadata.NewChainRepository(deps.DB)
// Make every existing library chain aware of the built-in providers
// before serving: materialize legacy content_level='' chains per level,
// then append registered builtins disabled (idempotent; also the repair
// path after a stale chain-editor save drops a builtin row). Runs before
// the metadata service exists, so no chain cache to invalidate here.
syncCtx, syncCancel := context.WithTimeout(appCtx, 30*time.Second)
syncErr := metadata.SyncBuiltinProviderChains(syncCtx, chainRepo)
syncCancel()
if syncErr != nil {
log.Fatalf("sync builtin provider chains: %v", syncErr)
}
skippedRootRepo = metadata.NewSkippedRootRepository(deps.DB)
itemRepo = catalog.NewItemRepository(deps.DB).WithActiveSearchProvider(activeCatalogSearchProvider)
episodeRepo = catalog.NewEpisodeRepository(deps.DB)
@@ -1252,6 +1280,7 @@ func main() {
// admin image applies can succeed even if automatic metadata caching is off.
if deps.S3Public != nil {
imageCacher := imagecache.New(deps.S3Public)
imageCacher.SetArtworkRevisionTracker(catalog.NewArtworkRevisionTracker(deps.DB))
metadataService.SetImageCacher(imageCacher)
imageCacheJobs := metadata.NewImageCacheJobRepository(deps.DB)
metadataService.SetImageCacheJobEnqueuer(imageCacheJobs)
@@ -1268,6 +1297,11 @@ func main() {
People: personRepo,
},
)
// Local file:// artwork (NFO sidecars): confine reads to the owning
// library's roots and sweep stale hashed local/ prefixes on re-cache.
// The processor host must mount the libraries, like the metadata worker.
metadataImageCacheProcessor.SetLibraryRootResolver(deps.FolderRepo)
metadataImageCacheProcessor.SetImagePrefixDeleter(deps.S3Public)
metadataService.SetAutoCacheImages(cfg.Metadata.CacheImages)
metadataImageCacheProcessor.SetEnabled(cfg.Metadata.CacheImages)
configWatcher.OnChange(func(_, updated *config.Config) {
@@ -1550,6 +1584,7 @@ func main() {
// Step 6: Create playback session manager and wire into dependencies.
sessionMgr := playback.NewSessionManager(6, 2) // defaults from plan: max_streams=6, max_transcodes=2
var compatTerminalRecoveryReady <-chan struct{}
if userStoreProvider != nil {
deps.UserStoreProvider = userStoreProvider
}
@@ -1561,6 +1596,13 @@ func main() {
WithWatchState(watchstate.NewService(userStoreProvider).WithStableIdentityResolver(historyIdentity)).
WithUserStoreProvider(userStoreProvider)
backgroundInit = append(backgroundInit, func(ctx context.Context) {
if compatTerminalRecoveryReady != nil {
select {
case <-compatTerminalRecoveryReady:
case <-ctx.Done():
return
}
}
if err := watchProviderService.SweepOpenScrobbles(ctx); err != nil {
slog.WarnContext(ctx, "failed to sweep open watch provider scrobbles", "component", "app", "error", err)
}
@@ -1879,6 +1921,11 @@ func main() {
taskMgr.Register(tasks.NewScanLibrariesTask(deps.FolderRepo, deps.LibraryScanQueue, deps.EventBus))
}
taskMgr.Register(tasks.NewCleanupOrphanedMediaItemsTask(catalog.NewOrphanedProvisionalCleaner(deps.DB)))
if deps.S3Public != nil {
taskMgr.Register(tasks.NewCleanupArtworkRevisionsTask(
metadata.NewArtworkRevisionGarbageCollector(deps.DB, deps.S3Public),
))
}
catalogSearchIndexer := catalog.NewCatalogSearchIndexer(deps.DB, settingsRepo)
taskMgr.Register(tasks.NewSyncCatalogSearchIndexTask(catalogSearchIndexer))
taskMgr.Register(tasks.NewRebuildCatalogSearchIndexTask(catalogSearchIndexer))
@@ -1895,6 +1942,15 @@ func main() {
}
taskMgr.Register(tasks.NewActivityLogCleanupTask(deps.DB, settingsRepo, activityPM))
taskMgr.Register(tasks.NewOperationalLogCleanupTask(deps.DB, settingsRepo, opsPM))
var diagnosticsStore diagnostics.ObjectStore
if deps.S3Private != nil {
diagnosticsStore = diagnostics.NewS3ObjectStore(deps.S3Private)
}
taskMgr.Register(tasks.NewClientDiagnosticsCleanupTask(
diagnostics.NewPostgresRepository(deps.DB),
settingsRepo,
diagnosticsStore,
))
taskMgr.Register(tasks.NewPolicyDecisionLogCleanupTask(deps.DB, settingsRepo, policyPM))
if deps.FileRepo != nil {
// Download prepare-to-file pipeline (Phase 3): a durable, leased encode
@@ -2359,6 +2415,7 @@ func main() {
if (mode == "integrated" || mode == "api") && cfg.JellyfinCompat.Enabled && cfg.JellyfinCompat.Listen != "" {
compatDeps := jellycompat.Dependencies{
Config: cfg,
AppContext: appCtx,
LiveConfig: configWatcher.Config,
DB: deps.DB,
SecretCipher: dataCipher,
@@ -2402,6 +2459,7 @@ func main() {
compatDeps.SeasonRepo = seasonRepo
compatDeps.EpisodeRepo = episodeRepo
compatDeps.ProviderIDRepo = providerIDRepo
compatDeps.StableIdentityResolver = watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, providerIDRepo)
compatDeps.DetailSvc = detailSvc
compatDeps.FolderRepo = folderRepo
compatDeps.SessionMgr = sessionMgr
@@ -2409,6 +2467,9 @@ func main() {
compatDeps.WatchCompletionObserver = deps.WatchCompletionObserver
compatDeps.SettingsRepo = settingsRepo
compatDeps.PersonRepo = personRepo
if watchProviderService != nil {
compatDeps.WatchScrobbler = watchProviderService
}
compatSearchService := catalog.NewCatalogSearchService(
appCtx,
settingsRepo,
@@ -2488,7 +2549,7 @@ func main() {
compat := jellycompat.NewServerWithDependencies(compatDeps)
compatServer = compat
compat.StartBackgroundTasks(context.Background())
compatTerminalRecoveryReady = compat.StartBackgroundTasks(context.Background())
compatSrv = compat.HTTPServer()
compatSrv.ReadTimeout = 30 * time.Second
compatSrv.WriteTimeout = 0
@@ -2784,6 +2845,12 @@ func reloadPluginImageResolvers(
if installation == nil {
continue
}
// Builtin installations resolve in-process metadata providers only;
// registering them here would claim their capability id as a gRPC
// image-resolver scheme with no binary behind it.
if installation.IsBuiltin() {
continue
}
capabilities, err := store.ListCapabilities(ctx, installation.ID)
if err != nil {
return fmt.Errorf("list image resolver capabilities for installation %d: %w", installation.ID, err)
@@ -2970,6 +3037,11 @@ func reloadMarkerPluginProviders(
if installation == nil {
continue
}
// Builtin installations expose no marker providers; defense in depth
// alongside the capability-type filter below.
if installation.IsBuiltin() {
continue
}
capabilities, err := store.ListCapabilities(ctx, installation.ID)
if err != nil {
return fmt.Errorf("list marker provider capabilities for installation %d: %w", installation.ID, err)
+126
View File
@@ -0,0 +1,126 @@
# Local NFO metadata and sidecar artwork (as built)
Implements #216: Kodi/Jellyfin-style `.nfo` files and sidecar images
(`poster.jpg`, `fanart.jpg`, …) next to movies and shows are read by a builtin
metadata provider, so curated local libraries work with partial or zero
TMDB/TVDB coverage. Admin-facing usage lives in
`docs/wiki/admin/nfo-local-metadata.md`; this page records the architecture for
maintainers.
## Registration: a builtin provider in the plugin chain
Provider chains are keyed on `plugin_installations` rows, so builtins are
registered **as data**: a reserved installation (`plugin_id='silo.builtin'`,
`kind='builtin'`, sentinel `install_path`, `update_policy='manual'`) plus a
`metadata_provider.v1` capability `nfo` with `default_priority` 1 for
movie/series/season/episode and `default_enabled=false`
(`migrations/sql/20260712100125_builtin_host_providers.sql`,
`...124741_nfo_capability_season_episode_levels.sql`).
- `internal/metadata/builtin.go` is the in-process registry; providers
self-register (`internal/metadata/nfo/register.go`) and `buildProviders`
(`internal/metadata/chain.go`) returns the in-process provider for
`kind='builtin'` rows instead of constructing a gRPC plugin.
- Guard rails keep the reserved row out of every plugin surface (user plugin
settings, installations list, image resolvers, preload, auto-update, store
delete, mutation handlers); `silo.builtin` is a reserved manifest id. Generic
installation reads must **not** filter builtins — the chain enabled-check
depends on them.
- On startup, `SyncBuiltinProviderChains` (`internal/metadata/builtin_sync.go`)
first materializes legacy `content_level=''` chains per level, then appends
builtin capabilities to all existing chains, disabled. New libraries pick the
capability up through normal chain seeding. The chain-less fallback respects
`default_enabled=false`.
- **Everything is inert until an admin enables "NFO Files" on a library.**
## Identity: hint-first, never candidate-first
NFO `<uniqueid>` values (tmdb/imdb/tvdb) are **trusted identity hints** seeded
before remote search (`IdentityHintProvider`, applied in
`internal/metadata/service.go`), unlocking full remote enrichment even when
search-by-title fails. Conflict policy by mode:
- initial match / scheduled refresh: NFO hints beat folder-name hints; stored
durable IDs beat NFO hints (no background identity flips);
- manual refresh: NFO hints beat stored IDs — the recovery path for a corrected
NFO. The item's `content_id` re-anchors too (not just its stored provider
IDs): a provider-anchored item whose fixed `<uniqueid>` now derives a
different anchor is renamed to it, or merged onto the existing item already
holding that id;
- manual Identify: the user's choice wins; the NFO provider is skipped for the
whole operation.
The NFO search candidate exists only for the title-only case and is defanged:
ID-less candidates lose provider-priority tie-breaks to ID-bearing group
members, `nfo` never counts as corroboration, and Phase-2 NFO results cannot
inject provider-id keys. A title-only NFO yields a `matched` item under a
path-deterministic `local:` content id; adding a `<uniqueid>` later promotes it
to that provider anchor on the next refresh (this promotion from an unanchored
id runs on scheduled refresh too — only an *already-anchored* identity is held
stable in the background). NFO field edits propagate on **manual refresh only**
(scheduled refresh is fill-empty by design).
## Sidecar artwork: copy into the S3 image cache
Sidecar images (`poster`/`folder`/`cover`, `fanart`/`backdrop`/`background`,
`logo`/`clearlogo`, and `<basename>-poster/-fanart/-logo` variants; extensions
`.jpg/.jpeg/.png/.webp`, 8 MiB cap, symlinked leaves rejected) are discovered
by the provider's `GetImages` (`internal/metadata/nfo/images.go`). Clients —
including jellycompat — always receive the normal presigned
`poster_url`/`backdrop_url`/`logo_url`; library files are never served
directly, and API nodes never need filesystem access to libraries.
- Sources are recorded as `file://<absolute-logical-path>` in `*_source_path`
columns and cached by the metadata image-cache processor under
`local/{contentType}/{contentID}/{hash8}/{imageType}/...` (`hash8` = content
hash; the discriminator sits before the imageType segment because variant
clamping reads the parent directory). Editing the file and refreshing rotates
the key; the stale hashed prefix is deleted after a successful re-cache, and
item deletion trims local paths to the content prefix.
- The processor confines each read to the owning library's `media_folders`
roots: a lexical check on the logical path, then a symlink-resolving re-check
(both path and roots are resolved, so a legitimately symlinked root stays
valid while an intermediate directory symlink escaping a root is rejected).
Missing/unreadable/out-of-root and structurally-unusable (non-regular,
over-cap) paths are stable failures with a long retry deferral; recovery is
refresh-driven — the provider-artwork backfill sweep deliberately skips
`file://` sources.
- Generic filenames attach only when the directory holds a single content
group; a shared `folder.jpg` in a flat multi-movie folder applies to none.
`<basename>-poster.jpg` style names always attach to their file.
- `applyBestImages`' final write has a local exemption so rating-0 local art
can fill already-matched items and cannot be stickily displaced by remote
art; the localization pass skips language-neutral local candidates.
**Deployment constraint:** the host running the metadata image-cache processor
must mount the media libraries at the same paths as the scanner/metadata
worker, otherwise local artwork jobs fail until the mount is present.
## Series depth and mixed libraries
`SeasonsRequest`/`EpisodesRequest` carry local path context; `season.nfo`
supplies season name/plot, `<basename>.nfo` + `<basename>-thumb.ext` supply
episode metadata/thumbs (`internal/metadata/nfo/series_depth.go`). **Naming
supplies structure; NFOs supply metadata**: NFO season/episode numbers are
advisory — directory/filename-derived numbers win with a warning. Episode NFOs
work without a `season.nfo`; `SynthesizeFallbackEpisodes` still covers
NFO-less episodes.
In `mixed` libraries, movie-vs-series is decided per file by naming before any
provider runs (`internal/naming/filename.go`); the NFO provider is seeded into
all levels and behaves as in dedicated libraries. The provider's root-element
type guard means a `tvshow.nfo` next to a movie-classified file is ignored,
never applied; the per-root Type override is the correction path. This is the
contract for sports/mixed libraries (events as movies, weekly shows as series).
## Known limitations
- The admin image picker does not surface local art (automatic chain path
only).
- Multi-part movies: a basename-mismatched NFO in a folder holding multiple
content groups is not found (directory candidates are suppressed there).
- No NFO writing, no music/audiobook/ebook NFO (those ecosystems use
OPF/metadata.json — future builtin providers via the same registry).
- Feature detection: the `nfo` capability's presence in
`GET /api/v1/libraries/provider-defaults` (see
`docs/architecture/v1-scope.md`).
+5
View File
@@ -17,3 +17,8 @@ When the scope locks, this file becomes the source of truth and will contain:
Until lock: treat any capability not tracked as `Proposed`/`Locked` on the project as out of scope
for feature PRs (see the scope gate in `CLAUDE.md`).
Feature-detection precedent: clients discover which metadata providers (including the
built-in NFO provider, #216) apply to a library type via
`GET /api/v1/libraries/provider-defaults` rather than version sniffing. New capabilities
should follow the same capability-endpoint pattern.
@@ -0,0 +1,771 @@
# Client Diagnostics: crash reports and debug log upload
Status: Draft (spec + cross-repo plan)
Date: 2026-07-19
Repos affected: `silo-server`, `silo-android`, `silo-apple`
## Summary
Add an opt-in, self-hosted diagnostics pipeline: Silo clients detect crashes
and abnormal exits, collect structured debug logs (playback, TV focus,
network, lifecycle) plus a hardware snapshot (device model, display/HDMI,
audio path, codec capabilities), and — only with explicit user consent —
upload a compressed diagnostics bundle to the user's own Silo server. Admins
review bundles through new acting-admin API endpoints alongside the existing
server logs tooling.
Everything ships **disabled by default**:
- Server: a `diagnostics.uploads_enabled` server setting gates the whole
feature (default off), and it cannot be enabled unless the server's private
object storage is configured and validated.
- Client: verbose "debug logging" is a per-device setting (default off).
Crash reporting defaults to **Ask** — after an abnormal exit the app shows
an incident-specific prompt (e.g. *"Silo closed unexpectedly during
playback. Send a diagnostic report to your server?"*) with
**Send / Don't send / Always send**. Nothing uploads without one of: an
explicit prompt confirmation, a prior "Always send" election bound to this
server and account, or the user explicitly sending a manual capture.
## Motivation
Today we are blind to client-side failures:
- **No crash reporting anywhere.** Neither the Android repo nor the Apple
repo installs an uncaught-exception handler, uses MetricKit, or bundles a
crash SDK (verified: no handler/SDK references in either repo; the only
Firebase dependency is messaging). A user saying "the app crashed" is a
dead end.
- **Diagnostics don't survive to the report.** Android logs go to logcat (raw
`android.util.Log` in ~40 files, no buffer or file sink); the Apple
player's rich `[CMP]`/`[CMP-DIAG]` diagnostics go through `print()`
(`PlayerLog.swift` / `PlayerCore.swift`) with no app-owned persistent sink.
By the time a user reports a problem, the evidence is gone.
- **The hardest bugs are device-specific.** Shield HLG quirks, Fire TV
passthrough suppression, HDMI mode switches, TV focus traps — reproducing
them requires knowing the exact device, panel, audio chain, and codec path.
Users can't reliably report any of that.
- **The server already has half the story.** `operational_logs` records
`playback_session_id` (migration `028_log_partitioning.sql`), the admin
logs API filters by it, and the web admin has a logs viewer. What's missing
is the client half, and a way to join the two.
## Goals
- Capture crashes and abnormal exits (JVM/native/ANR on Android; crash and
hang diagnostics on iOS; abnormal-exit detection on tvOS) with enough
context to act on: stack or exit trace where the platform provides one,
recent structured logs, and a device snapshot.
- Capture deep opt-in debug logs for playback, TV focus behavior, networking,
and navigation/lifecycle — everything needed to troubleshoot user issues.
- Always include a hardware snapshot: device (Shield / Fire TV / onn /
phone), OS, app build, display and HDMI mode with HDR capabilities, audio
output path and passthrough capabilities, decoder inventory — with honest
`unknown` / `not_collected` states where a platform can't provide a field.
- One payload contract across Android and Apple; one server endpoint; one
admin surface. The contract is a checked-in versioned JSON Schema with
golden fixtures consumed by all three repos' CI.
- Explicit, server-bound consent for every byte uploaded; feature fully off
by default on both server and client.
- Bounded cost: size caps, per-account quotas, concurrency limits, and
server-side retention.
## Non-goals (v1)
- Live/continuous log streaming from clients to the server.
- Screenshots or screen recordings in bundles.
- Native/tvOS crash *stack* capture via an in-process crash reporter
(PLCrashReporter/KSCrash-style). tvOS v1 detects abnormal exits and
attaches breadcrumbs, not stacks (see Apple design). Adopting an in-process
crash reporter is a separate decision with its own signal-safety and review
implications.
- Symbolication. Bundles store what the OS gives us; a server-side
symbolication job can come later.
- Native-app admin viewer screens (per current product policy, admin surfaces
beyond STATS on the clients are a separate product decision; v1 admin
consumption is via API + web admin).
- macOS. The Apple repo has a macOS target, but v1 scopes to
`android`, `android-tv`, `ios`, `tvos`; the platform enum reserves `macos`
without accepting it.
- Third-party telemetry, metrics aggregation, or fleet analytics. This is a
support/debugging tool, not analytics.
## Why this route
**Upload to the user's own Silo server, not a third-party crash service.**
Crashlytics/Sentry SaaS would send user data to a third party, require vendor
accounts/keys we'd have to ship, and put the reports in front of *us* rather
than the person who can actually act — the self-hosting admin. Requiring
every admin to also run self-hosted Sentry is far too heavy. The Silo server
is the natural, privacy-aligned destination, and it already has auth, admin
tooling, object storage, and retention patterns to build on.
**Bundle-on-event, not streaming.** A bundle captured at the moment of
failure (crash, or a user-triggered capture) contains exactly the context
needed: recent structured logs, the playback stats timeline, and the hardware
snapshot. Continuous streaming would cost bandwidth and privacy while mostly
capturing noise, and would still miss pre-crash buffers.
**Hybrid storage: Postgres row + object-store blob.** Report *metadata* (a
small manifest: device summary, truncated crash summary, type, timestamps)
goes in a queryable table; the payload itself (potentially megabytes of logs
and crash artifacts) goes to the S3-compatible private bucket as a single
archive. Ingesting client log lines into the partitioned `operational_logs`
table was considered and rejected: bundles are consumed as whole documents by
a human, not queried row-wise; client lines are untrusted input with
different retention needs; and multi-MB uploads would bloat a table tuned for
the server's own high-churn logs. The server *does* emit normalized
operational events about diagnostics activity (report accepted / rejected /
downloaded / deleted, with report id, account, sizes, result) so ingest and
admin actions are auditable and correlate with existing logs — raw client
lines never enter `operational_logs`.
**A versioned archive, not a bare gzipped log stream.** Crash evidence is
heterogeneous: JSONL logs, ANR trace text, Android native tombstones
(protobuf on API 31+), MetricKit diagnostic JSON. A tar.gz archive with named
entries carries each artifact losslessly with a declared media type, keeps
large traces out of Postgres, and can evolve by adding entries.
**Build small in-house rather than adopt ACRA/other SDKs.** The surface we
need on Android (one exception handler writing a bounded marker, a ring
buffer, one upload call) is a few hundred lines; ACRA would add a dependency
and its own report format without covering Apple. On Apple, the OS provides
MetricKit natively (iOS; not tvOS — see Apple design).
## Architecture overview
```text
Android / Apple client silo-server
┌────────────────────────────────┐ ┌──────────────────────────────────────┐
│ Safe-logging facade → ring │ │ GET /api/v1/diagnostics/status │
│ └ debug mode: rotating │ │ └ available|disabled| │
│ segment files │ │ storage_unavailable + limits │
│ Crash/abnormal-exit capture │ │ POST /api/v1/diagnostics/reports │
│ ├ UEH marker (Android) │──▶│ ├ gate + storage check │
│ ├ ApplicationExitInfo (30+) │ │ ├ streaming, bounded multipart │
│ ├ MetricKit (iOS) │ │ ├ account quotas + concurrency │
│ └ exit sentinel (tvOS) │ │ ├ manifest → client_diagnostic_ │
│ Pending reports (server-bound) │ │ │ reports (state machine) │
│ DeviceSnapshot (probes+new │ │ └ archive → S3 private bucket │
│ accessors) │ │ Admin (acting admin): list/detail/ │
│ Consent UI (ask/always/never, │ │ download/delete + audit events │
│ per server+account) │ │ Retention + orphan reconciler task │
└────────────────────────────────┘ └──────────────────────────────────────┘
```
## Consent model
Consent is **scoped, versioned, and destination-bound** — not a global
boolean. Both clients support multiple servers and accounts, so a device-wide
"Always send" would let a bundle full of server A's titles and URLs upload to
server B after a switch. Rules:
- Consent (including "Always send") is **account-scoped**: every consent
record and every pending report is bound to
`(server_instance_id, account_user_id, consent_notice_version)`, where
`server_instance_id` is a stable opaque ID returned by the status
endpoint. The capturing `profile_id` is recorded on reports for
*attribution*, not as a consent scope — quotas and elections follow the
account.
- A pending report is only ever uploaded to the server+account it was
captured under. Reports are never retargeted. Switching server or account
neither uploads nor migrates pending reports; signing out of an account or
removing a server deletes its pending reports.
- "Always send" is an election for one server+account. Bumping
`consent_notice_version` (when we materially change what bundles contain)
invalidates prior elections and reverts to Ask.
- Setting crash reports to **Never** deletes pending reports for that
server+account and stops persistent capture (breadcrumbs/debug files); the
in-memory ring keeps running (memory-only, never uploaded without a later
explicit action).
- **Child/managed profiles**: the Diagnostics section is not shown to
profiles with `is_child`, and every diagnostics action — changing
settings, electing Always, the crash prompt, and manual capture/send —
requires a non-child profile. A crash captured while a child profile was
active is held pending and prompted for the next time a non-child profile
is active. Reports are attributed to account + capturing profile.
- The crash prompt and the manual flow both offer **View report** — a
human-readable summary (incident, categories and line counts, device
identity, destination server) before anything is sent.
- Uploads carry `consent: {mode, notice_version}` in the manifest so the
server can reject stale-consent uploads outright.
## Bundle format (shared contract)
An upload is `multipart/form-data` with exactly two parts, in order:
1. `manifest` — `application/json`, ≤ 64 KiB. Queryable summary; stored in
Postgres.
2. `bundle` — `application/gzip`: a tar.gz archive, `manifest.json` first.
Entry names come from a **fixed allowlist**, and each name implies its
media type — there is no per-entry type metadata to drift:
```jsonc
manifest.json (manifest minus the `archive` object; see hashing note)
device.json (full device snapshot)
logs.jsonl (structured log lines, newest-last)
crash/summary.json (normalized crash info incl. provenance)
crash/stack.txt (JVM stack / ANR trace, when present)
crash/tombstone.pb (Android native tombstone, opaque bytes, API 31+)
crash/metrickit.json (raw MetricKit diagnostic JSON, iOS)
breadcrumbs.jsonl (persisted pre-crash context journal, when present)
```
Hashing note: `archive.sha256` and the size fields in part 1 describe the
finalized `bundle` part bytes as transmitted. Since the archive is hashed
after it is built, the embedded `manifest.json` is the manifest **without**
the `archive` object; the server verifies part 1's `archive` fields against
the received blob. `archive.bytes` is the transmitted (gzip) size;
`archive.uncompressed_bytes` is the total decompressed tar stream size,
including tar headers, the end-of-archive marker, and record padding —
i.e. the byte count between the client's tar writer and gzip writer, and
what `gzip -l` reports. Standard end-of-archive zero padding (up to 64 KiB)
is accepted; any other trailing data is rejected.
The contract lives in this repo as a versioned JSON Schema
(`docs/design/schemas/client-diagnostics/v1/`) with canonical valid and
invalid fixtures; Go, Kotlin, and Swift CI all validate against the same
fixtures. Servers accept all schema versions they support and ignore unknown
additive fields.
### Manifest (part 1)
```json
{
"schema_version": 1,
"report": {
"type": "crash", // crash | anr | native_crash | hang | abnormal_exit | manual
"captured_at": "2026-07-19T18:22:31Z",
"capture_session_id": "run_c7d…", // UUID per app run, also stamped on log lines
"app_version": "1.4.2",
"app_build": "20841",
"platform": "android-tv", // android | android-tv | ios | tvos (macos reserved)
"os_version": "11 (API 30)",
"profile_id": "prof_…" // capturing profile, attribution only
},
"destination": { "server_instance_id": "srv_…" }, // consent binding; server rejects mismatches
"consent": { "mode": "prompt", "notice_version": 1 }, // prompt | always | manual
"crash": { // absent for manual
"summary": "NullPointerException in PlaybackSessionManager.start",
"stack_excerpt": "…truncated ≤ 8 KiB; full artifact in archive…",
"thread": "main",
"foreground": true,
"source": "ueh", // ueh | exit_info | metrickit | exit_sentinel
"provenance": "pre_failure", // pre_failure | post_restart | metric_reporting_period
"occurred_at": "2026-07-19T18:22:31Z" // best known; MetricKit reports carry period bounds
},
"device_summary": { "manufacturer":"NVIDIA", "model":"SHIELD Android TV", "os":"11", "form_factor":"tv" },
"playback_session_ids": ["ps_9f2…"],
"log_summary": { "lines": 3841, "bytes_gz": 412034, "dropped_lines": 12, "categories": ["playback","focus","network"], "debug_logging": true },
"archive": { "entries": ["manifest.json","device.json","logs.jsonl","crash/summary.json","crash/stack.txt"], "bytes": 498112, "uncompressed_bytes": 3110400, "sha256": "…" }
}
```
### Log line schema (`logs.jsonl`)
```json
{"ts":"2026-07-19T18:22:29.412Z","run":"run_c7d…","lvl":"W","cat":"playback","tag":"AudioCapabilityMgr","msg":"passthrough suppressed: HDMI sink lost TrueHD","attrs":{"sink":"HDMI","fmt":"truehd"}}
```
- `lvl`: `V|D|I|W|E`. `cat`: `playback`, `focus`, `network`, `lifecycle`,
`browse`, `cast`, `download`, `crash`, `other`.
- `run` (capture_session_id) distinguishes lines from prior app runs when
breadcrumbs or debug files span sessions.
- Playback additionally emits periodic (~5s) player-stats snapshot lines
(`"cat":"playback","tag":"StatsSnapshot"`) built from the existing stats
models — a timeline of decoder, resolution, HDR mode, bitrate, dropped
frames, and audio underruns around the failure.
### Safe logging contract (redaction happens at collection time)
Best-effort scrubbing at package time is not enough: today's client logs
already emit full URLs with query strings, token suffixes, and raw exception
strings (e.g. the Apple `HTTPClient` response logging and player URL prints;
Android exception messages containing backend URLs). Therefore:
- The facade accepts **typed attributes**, not preformatted strings, for
anything risky: URL values are normalized (userinfo, query, and fragment
always stripped; path preserved), exceptions pass through a sanitizing
formatter, and free-text `msg` is authored, not interpolated from network
data.
- Attribute keys are **normatively registered**: a per-category key registry
(checked in next to the JSON Schema) declares each key and its value type
(scalar types plus a few enumerated units). Unregistered keys fail debug
builds/CI and are dropped in production builds.
- Existing call sites are migrated **curated, not mechanically** — each
migrated line is reviewed against the contract. Unmigrated `Log.*`/`print`
call sites simply never enter bundles.
- Defense in depth at bundle time: values matching the account's current
access/profile tokens are replaced before packaging.
- Golden leak-fixture tests (JWTs, query tokens, signed URLs, cookies, token
fragments) run in all three repos' CI against the facade and the bundle
builder.
- The network category logs `method, templated path, status, duration`;
never headers, bodies, query strings, or full URLs.
### Device snapshot (`device.json`)
Fields are tri-state where a platform can't provide them: a value,
`"unknown"` (probe exists, couldn't determine), or `"not_collected"`
(no probe on this platform). Every snapshot carries `captured_at` and
`provenance` (`pre_failure` for snapshots persisted with the incident,
`post_restart` for snapshots taken at bundle-build time).
```json
{
"captured_at": "2026-07-19T18:22:31Z",
"provenance": "pre_failure",
"identity": { "manufacturer":"NVIDIA", "model":"SHIELD Android TV", "device":"mdarcy", "form_factor":"tv", "device_id":"…" },
"display": { "mode":"3840x2160@59.94", "modes_supported":[…], "hdr_types":["HDR10","HLG","DV"], "wide_gamut":true },
"audio": { "outputs":[{"type":"HDMI","encodings":["AC3","EAC3","TRUEHD"],"channels":8}], "passthrough":["AC3","EAC3","TRUEHD"], "suppressions":[…] },
"video_codecs": [ { "mime":"video/hevc", "hw":true, "profiles":["Main10","DV P8"], "max":"4096x2160@60" } ],
"network": { "transport":"ethernet" }
}
```
Android sources: `AndroidDeviceMetadataProvider` (id/model/platform/app
version), `MediaCodecCapabilitiesProbe` (codec + HDR decode caps),
`AudioCapabilityManager` (aggregate passthrough). **New accessors required**
(the probes exist but don't currently export everything): current + supported
`Display.Mode` list (DisplayHdrProbe exposes HDR support only), exact
identity fields (`Build.DEVICE`, form factor), `AudioManager.getDevices()`
output enumeration, and a snapshot/export API on
`PassthroughSuppressionRegistry` (its set is currently private).
Apple sources: `AppleDeviceIdentity.current` (persisted id, device name,
platform) and `ApplePlaybackV3Capabilities.snapshot()` (codec/HDR/device
context). **Honesty constraints:** the current `outputSnapshot()` helper is
private, reports route UIDs/types rather than full output capabilities, and
its passthrough payload is a placeholder — so Apple v1 reports audio as
route metadata with `passthrough: "unknown"` until a real probe exists, and
the private helper is refactored into an internal shared accessor so
diagnostics can reuse it without duplicating route logic. Audio route UIDs
are persistent peripheral identifiers and are one-way hashed before
inclusion.
### Playback session correlation
Clients include recent server playback session IDs so admins can pivot into
the existing server-side logs (`operational_logs.playback_session_id` +
the admin logs filter). Both clients currently retain only the *active*
session ID (Android `PlaybackSessionManager`, Apple `PlaybackSessionBridge`),
so each client adds a small **bounded, persisted recent-session tracker**
(last ~10 session IDs with timestamps) that survives restarts — required for
next-launch crash reports.
## Server design (`silo-server`)
### Feature gate
Borrows the *behavior* of the requests gate (status endpoint + per-call
guard that rereads settings) but stores keys in the general `server_settings`
store — requests uses its own dedicated table, which diagnostics doesn't
need. Key naming follows existing namespaces (`opslog.*`,
`audiobooks.enabled`, `playback.protocol_v3_enabled`):
- `diagnostics.uploads_enabled` (bool, default `false`)
- `diagnostics.max_bundle_bytes` (default 10 MiB compressed)
- `diagnostics.max_uncompressed_bytes` (default 64 MiB)
- `diagnostics.max_reports_per_user_per_day` (default 20)
- `diagnostics.retention_days` (default 30)
- `diagnostics.max_bytes_per_user` (default 200 MiB)
Enabling `diagnostics.uploads_enabled` **requires a storage validation**
(private object store configured and writable); the admin settings API
refuses to enable it otherwise. `S3Private` is a nullable dependency in the
router and is only populated when configured, so the feature must degrade
predictably, not 500.
### Status endpoint
`GET /api/v1/diagnostics/status` — authenticated (`RequireAuth`), **no
profile required**: crashes during login/profile selection must still be
reportable, so capability discovery and upload are account-scoped with
optional profile attribution.
```json
{
"status": "available", // available | disabled | storage_unavailable
"server_instance_id": "srv_…", // stable; binds consent + pending reports
"accepted_schema_versions": [1],
"max_bundle_bytes": 10485760,
"max_manifest_bytes": 65536,
"retention_days": 30,
"consent_notice_version": 1
}
```
Clients cache this alongside existing server-driven config refresh.
### Ingest endpoint
`POST /api/v1/diagnostics/reports` — `RequireAuth`; access-token
`Authorization` header only (no query credentials; `sa_` API keys rejected
for diagnostics). Optional validated profile attribution. Hardened parsing —
the avatar handler is the *precedent* (multipart + `PutObject`), but its
buffer-everything approach (`ParseMultipartForm` + `io.ReadAll`) is not
reused:
1. Guard: `diagnostics.uploads_enabled` + storage present, else stable error
codes (`disabled`, `storage_unavailable`).
2. `http.MaxBytesReader` with `max_bundle_bytes` + manifest overhead before
any parsing; request deadline; per-account concurrency cap (1 in-flight)
and a server-global concurrency ceiling, on top of the existing rate-limit
middleware.
3. Streaming `multipart.Reader`: exactly the two named parts in order;
manifest capped at 64 KiB and schema-validated; unknown parts rejected.
4. Quota reservation: in one transaction holding a per-account advisory
lock (`pg_advisory_xact_lock(user_id)` — serializing concurrent uploads
from multiple devices on one account), check the per-day count and
per-user byte quota and insert the report row in state `receiving`
(server-generated UUID + `short_id`). Failures return `quota_exceeded`
(429, with `retry_after`) or `too_large` (413) — machine-readable so
clients back off and keep the report pending locally.
5. Stream the `bundle` part directly to its final key
(`diagnostics/{user_id}/{report_id}.tar.gz`) while hashing and counting
bytes; enforce the compressed cap during streaming. Validate the
gzip/tar structure and per-entry limits (entry count, name allowlist,
uncompressed total ≤ `max_uncompressed_bytes`, compression ratio bound)
in the same bounded streaming pass. Reject on violation
(`invalid_bundle`, `unsupported_schema`). There is no staging/rename
step — S3-style stores have no cheap rename, and the row's `state` is
the source of truth: a blob is not a report until its row is `ready`.
6. On success, update the row to `ready` with sizes + sha256. On any
failure: delete the object, mark the row `failed` (compensation); the
reconciler (below) cleans up stragglers from process death mid-request.
7. Respond `201 {"report_id":"…","short_id":"SILO-…"}` and emit a normalized
operational event (accepted/rejected, account, sizes, result).
`short_id` is 12 Crockford-Base32 characters, server-generated with
collision retry, case-insensitively unique, shown to the user for support
conversations, filterable in the admin API — and explicitly not an
authorization secret.
New code: `internal/diagnostics/` (service + store interface), handler in
`internal/api/handlers/diagnostics.go`, wired through `Dependencies` in
`internal/api/router.go`. The store interface is diagnostics-owned (the
avatar handler's is package-private and presign-only) and covers everything
the design uses: a **streaming** put (`io.Reader` — the existing s3client
`PutObject` takes `[]byte`, so this is a small s3client addition),
`GetObject` (streamed admin download fallback), `DeleteObject`,
`ListObjects(prefix)` (reconciler), and `PresignGetURL`.
### Storage
```sql
CREATE TABLE client_diagnostic_reports (
id UUID PRIMARY KEY,
short_id TEXT NOT NULL, -- unique, case-insensitive (expression index)
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
profile_id TEXT, -- capturing profile, nullable
state TEXT NOT NULL, -- receiving | ready | failed
captured_at TIMESTAMPTZ NOT NULL, -- client claim
received_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- server truth; retention keys off this
report_type TEXT NOT NULL, -- crash|anr|native_crash|hang|abnormal_exit|manual
platform TEXT NOT NULL,
app_version TEXT NOT NULL,
crash_summary TEXT, -- truncated ≤ 8 KiB
manifest JSONB NOT NULL, -- part 1 verbatim (≤ 64 KiB by construction)
playback_session_ids TEXT[] NOT NULL DEFAULT '{}',
blob_bucket TEXT,
blob_key TEXT,
blob_bytes BIGINT,
uncompressed_bytes BIGINT,
blob_sha256 TEXT
);
CREATE UNIQUE INDEX ON client_diagnostic_reports (lower(short_id));
CREATE INDEX ON client_diagnostic_reports (user_id, received_at DESC);
CREATE INDEX ON client_diagnostic_reports (received_at);
```
Plain table, not partitioned — volume is orders of magnitude below
`operational_logs`; the byte-heavy payload lives in object storage. Full
stacks/traces live only in the archive; the row holds a truncated summary.
### Admin API
Under the acting-admin subtree (same placement as the `admin_logs.go`
routes):
- `GET /api/v1/admin/diagnostics/reports` — list; filters: user, platform,
type, date range, `short_id` (exact, case-insensitive); paginated.
- `GET /api/v1/admin/diagnostics/reports/{id}` — row incl. manifest.
- `GET /api/v1/admin/diagnostics/reports/{id}/download` — presigned GET URL
(primary, matching avatar behavior) with a streamed `GetObject` proxy
fallback for deployments where presigned URLs aren't reachable — the
fallback is new behavior, not something the avatar path provides.
- `DELETE /api/v1/admin/diagnostics/reports/{id}` — row + blob.
Admin download and delete emit audit/operational events. Web admin gets a
list/detail/download page next to the logs viewer, with a
playback-session-id link into the existing filtered logs view. Native apps
add no admin screens in v1 (product policy).
### Retention + reconciliation
A taskmanager task `internal/taskmanager/tasks/cleanup_client_diagnostics.go`
mirroring `cleanup_operational_log.go`, registered in `cmd/silo/main.go`
with the other cleanup tasks:
- Delete reports older than `diagnostics.retention_days` (by `received_at`),
then enforce `diagnostics.max_bytes_per_user` oldest-first. Blob deleted
first (tolerating already-missing objects), then the row; deletes are
idempotent.
- Reconcile: remove `receiving`/`failed` rows older than a grace window,
and (via `ListObjects`) any `diagnostics/*` objects whose row is absent,
`failed`, or stale-`receiving` — so partial failures and process deaths
mid-request never leak storage.
- Account deletion cascades rows (FK) ; the reconciler then removes orphaned
blobs.
## Android design (`silo-android`)
New package
`android-shared/src/androidMain/kotlin/org/siloserver/silo/common/diagnostics/`.
### Logging
- `SiloLog.kt` — safe-logging facade per the contract above:
`SiloLog.d(cat, tag, msg, attrs)` (+`i/w/e`, sanitized `Throwable`).
Always forwards to `android.util.Log` (today's behavior unchanged) and
offers the entry to the ring buffer.
- `LogRing.kt` — always-on in-memory ring: fixed-size array of pre-rendered
compact entries, **non-blocking** writes (atomic index; a torn final entry
during crash snapshot is acceptable), ~4000 entries / ~1.5 MB cap, lazy
attribute formatting off the hot path, dropped-entry counter. Must add no
measurable contention to playback; a playback benchmark gate accompanies
the PR that wires player events in.
- `DiagnosticsFileLogger.kt` — only while debug logging is on: a
single-writer coroutine draining a bounded channel (drop + count under
backpressure) into **append-only framed segment files** (5 × 2 MB,
app-private `files/diagnostics/logs/`, `noBackup`), compressed only at
bundle-build time — a process death never corrupts more than the tail of
the active segment (unlike a single long-lived gzip stream).
- Playback wiring: each `PlaybackAnalyticsListener` event plus a ~5s periodic
`PlayerStatsSnapshot` line while debug logging is on (reuses the existing
reducer). TV focus logging hooks the TV app's focus observers under
`cat=focus`. Network: a Ktor hook logging `method, templated path, status,
duration_ms` only.
### Crash and abnormal-exit capture
- `CrashCapture.kt` — installed as early as possible in both `Application`
classes; idempotent; captures the previous
`Thread.getDefaultUncaughtExceptionHandler` once. On crash it does the
minimum on the dying thread: render the stack (bounded), copy what the
non-blocking ring exposes within a hard time budget, write **one bounded
marker file** (≤ ~512 KiB) via temp-write + atomic rename, and invoke the
prior handler in a `finally`. No locks shared with the ring's write path,
no coroutines, no allocation-heavy JSON building (preformatted line
buffer). Bundle assembly happens on next launch, never in the handler.
At most 3 pending reports are kept (oldest dropped) as a crash-loop guard.
- `ExitInfoCollector.kt` — API 30+ enhancement (minSdk is 24; many Fire TV
devices ship below API 30, where the handler is the only source): reads
`ActivityManager.getHistoricalProcessExitReasons()` on launch.
`REASON_CRASH`, `REASON_CRASH_NATIVE`, `REASON_ANR` become pending
reports. Dedupe uses a persisted bounded **fingerprint set** (process
name, pid, timestamp, reason, status, trace hash) — not a raw timestamp
watermark, which loses same-stamp events and breaks on clock changes.
`getTraceInputStream()` is treated as optional opaque bytes with a bounded
read: ANR text → `crash/stack.txt`; API 31+ native tombstone protobuf →
`crash/tombstone.pb` (never decoded into the manifest). UEH-captured
events are correlated by fingerprint so the same death isn't reported
twice.
- Next foreground launch, per the consent model: Ask → aggregated,
incident-specific prompt (one prompt covering N pending reports; default
focus **Don't send** on TV; "Always send" requires a second confirmation;
full-screen review flow on TV rather than a small dialog); Always →
silent upload, at most one bundle per crash fingerprint per day;
Never → purge. Per-fingerprint prompt suppression with backoff prevents
crash-loop prompt fatigue. Pending reports expire after 7 days —
visibly (the Diagnostics screen shows pending reports with expiry), not
silently.
### Device snapshot
`DeviceSnapshotCollector.kt` assembles `device.json` from the existing
probes plus the new accessors listed in the contract section (display mode
list, `Build.DEVICE`/form factor, `AudioManager.getDevices()` enumeration,
suppression-registry export). A compact snapshot is persisted with each
crash marker (`provenance: pre_failure`) since post-restart audio/display
state can differ from crash-time state.
### Settings + consent UI
- `DiagnosticsSettingsStore.kt` — DataStore. Deliberately **not** the
per-profile pattern of `AndroidPlayerSettingsStore`: consent and pending
reports are keyed by server+account (per the consent model), and debug
logging is per-device. Holds consent records, crash-report mode,
debug-logging flag, fingerprint set, session tracker, and status cache.
- Phone: "Diagnostics" section in `SettingsScreen.kt`; TV: rail category in
`TvSettingsScreen.kt`. For non-child profiles the section is **always
visible** and state-aware rather than hidden when the server gate is off — it distinguishes
`available` / `disabled by server` / `storage unavailable` / `offline`,
and always shows local pending reports (count, size, expiry) with
review/delete controls. Uploads are simply unavailable in non-available
states; a report captured while the gate was off is not auto-uploaded
later unless consent for that server+account exists.
- Manual capture is the primary support flow and is stronger than a bare
"send now": **Start diagnostic capture** (turns on verbose capture,
shows an indicator) → user reproduces the issue → **Stop & review** →
summary (categories, time range, sizes, destination) → send. A one-shot
"Send diagnostics now" remains for quick cases but warns when only the
in-memory ring is available ("this bundle contains only the last few
minutes of basic logs").
- After upload, the `short_id` is displayed and kept in a small sent
history so a user can read it to their admin later.
### Upload
- `shared/commonMain/…/network/api/DiagnosticsApi.kt` — `getStatus()` and
`uploadReport(manifestJson, bundleBytes)` via Ktor
`MultiPartFormDataContent` (first multipart use in the client — verified
none exists — written as a small reusable helper), using the existing
`safeApiCall`/`ApiResult` pattern, wired through Koin like other `*Api`
classes.
- `DiagnosticsUploader.kt` — builds the tar.gz (marker + ring/segment files
overlapping the incident window + breadcrumbs + snapshot), streams it to
the endpoint, deletes the pending report only on 201, records `short_id`.
On `quota_exceeded`/`too_large`/`disabled`/`storage_unavailable` it keeps
the pending report and backs off per `retry_after`; on
`unsupported_schema` it surfaces "server needs an update" rather than
silently retrying until expiry.
## Apple design (`silo-apple`)
Mirrors Android with platform-native mechanisms, and with tvOS scoped
honestly — **MetricKit diagnostic payloads (crash/hang) are iOS-only; the
SDK marks them unavailable on tvOS**, and delivery is delayed (typically
daily, while the app runs), not "next launch".
- **Ring + facade**: `DiagLog` implementing the same safe-logging contract.
The player's existing `cmpLog` helper (`PlayerLog.swift`) is **extended**
into a tee — it keeps `print()`ing (preserving `devicectl --console`
capture on tvOS) and also feeds the ring; remaining direct `print()` call
sites in `Screens/Player/` migrate to it curated, per the redaction
contract. OSLog-based subsystems are harvested at bundle-build time via
`OSLogStore(scope: .currentProcessIdentifier)` (deployment targets are
iOS/tvOS 26, far above the OSLogStore 15+ floor), merged into
`logs.jsonl` with provenance.
- **Breadcrumb journal**: because MetricKit reports arrive in a *later*
process, the in-memory ring is gone by delivery time. While crash
reporting is enabled, a small append-only breadcrumb journal (bounded,
rotating, crash-safe appends: lifecycle transitions, playback start/stop,
route changes, screen changes) persists across runs and becomes
`breadcrumbs.jsonl`, matched to a diagnostic by `capture_session_id` and
time window. Snapshots taken at bundle-build time are labeled
`provenance: post_restart`; the design never presents post-restart state
as crash-time state.
- **iOS crash/hang detection**: an `MXMetricManager` subscriber. Each
received `MXCrashDiagnostic`/`MXHangDiagnostic` becomes its **own**
pending report: raw diagnostic JSON preserved as `crash/metrickit.json`,
deduped by a canonical hash of the diagnostic (not timestamps), carrying
its own app version and reporting-period bounds. Prompt copy is
type-specific ("crashed" vs "was not responding") and honest about timing
("yesterday during playback"). Delivery is verified on physical devices;
parsing is tested via injected fixtures.
- **tvOS abnormal-exit detection**: no crash stacks in v1. A dirty-exit
sentinel (marker written at launch, cleared on clean
background/termination) plus the breadcrumb journal yields
`type: abnormal_exit` reports — "Silo did not shut down cleanly last
time" — with breadcrumbs and device snapshot but no stack. An in-process
crash reporter for tvOS is explicitly deferred (see Non-goals).
- **Device snapshot**: `AppleDeviceIdentity.current` +
`ApplePlaybackV3Capabilities.snapshot()` for identity/codec/HDR context;
audio reported as route metadata with hashed route UIDs and
`passthrough: "unknown"` until a real output-capability probe exists (the
current one is a private placeholder).
- **Settings/consent**: Diagnostics section in `SettingsView` (iOS) and
`TVSettingsView` (tvOS) with the same state-aware presentation, manual
capture flow, and consent rules.
- **Upload**: a small multipart body writer added to `HTTPClient` (verified:
JSON-only today, no multipart/uploadTask), same status/error handling as
Android.
- Housekeeping note: product bundle IDs are already `org.siloserver.silo`;
only legacy keychain/logging identifiers (`com.continuum.*`) remain, so
nothing here blocks on rebranding.
## Privacy summary
- **Consent boundaries**: no network transmission of diagnostic data without
prompt confirmation, a server+account-bound Always election, or an
explicit manual send. The always-on ring is memory-only; persistent
capture (segments, breadcrumbs, markers) is app-private, size-capped,
backup-excluded, and purged by Never/sign-out/server removal.
- **Never collected**: credentials, `Authorization`/profile-token values,
request bodies, query strings, full URLs (normalized at collection),
unsanitized exception dumps. Bearer-token matching at bundle time as
defense in depth. Golden leak fixtures in CI.
- **Deliberately collected** (disclosed in View report): device identity
incl. device name, media titles appearing in playback logs, templated
server paths. The recipient is the user's own server admin, who already
has server-side access to this information; that is the trust model this
feature is scoped to — and why third-party destinations are out.
- **Persistent identifiers**: the existing device IDs (already sent to the
server today) plus hashed audio-route UIDs; each is enumerated in the
schema docs with its purpose and retention.
- **User control**: state-aware Diagnostics screen with pending list,
review, delete, sent history; admin-side deletion; account deletion
cascades reports; admin access is acting-admin-only and audited.
- **Bounded retention**: defaults 30 days / 200 MiB per user, admin-tunable,
enforced server-side.
## Rollout plan (vertical slices, each independently shippable)
1. **Server foundation** — settings + storage-validated gate, status
endpoint, hardened ingest with state machine, schema + fixtures, admin
API, retention/reconciler task, operational audit events. Feature stays
off; nothing user-visible.
2. **Contract fixtures in clients** — JSON Schema + golden fixtures wired
into Android/Apple CI; safe-logging facades + rings land **dark** (no
UI, no capture persistence, no prompts).
3. **Android end-to-end slice** — consent model + Diagnostics settings UI +
crash marker/ExitInfo capture + device snapshot + uploader + manual
capture flow, with the initial curated logging migration (player tags +
TV focus + network hook + stats snapshots). First release where a user
can be asked for, and send, a useful report.
4. **Android coverage expansion** — remaining curated call-site migration,
breadcrumbs-on-Android if wanted, prompt-fatigue tuning from the field.
5. **Apple slice** — cmpLog tee + OSLogStore harvest + breadcrumbs +
MetricKit (iOS) + exit sentinel (tvOS) + settings/consent + multipart
upload.
6. **Web admin viewer + correlation UX** — list/detail/download page,
playback-session pivot into the logs viewer.
7. **Later / separate decisions** — symbolication job, tvOS in-process
crash reporter, native-app admin viewer, macOS.
Slices 3 and 5 ship complete consent→capture→upload flows; intermediate
releases never prompt for reports they can't send.
## Open questions
1. Should users be able to delete their **uploaded** reports from the client
(self-service deletion), or is pending-report control + admin deletion
enough for v1?
2. tvOS: is abnormal-exit detection (no stacks) acceptable for v1, or does
the Shield/Fire TV debugging need justify evaluating an in-process crash
reporter (PLCrashReporter/KSCrash) with its signal-safety and review
trade-offs?
3. Do we want breadcrumbs on Android in v1 (cheap once the facade exists),
or is the UEH marker + ExitInfo enough there?
4. Localization of prompt/consent strings at launch vs English-first.
5. Exact quota defaults (20/day, 200 MiB/user, 10 MiB/bundle) — tune before
enabling anywhere real.
6. Whether the status payload should also fold into an existing server-driven
config refresh response later (clients poll both today); the dedicated
endpoint ships first either way.
## Review notes
This spec was adversarially reviewed before the draft PR: two independent
review passes (a fact-check of every codebase claim against the three repos,
and a design attack across consent, crash-capture correctness, ingest
robustness, storage consistency, UX, and phasing). Material corrections
adopted include: tvOS MetricKit unavailability and honest MetricKit timing;
server+account-bound consent; storage-validated gating (private object store
is optional in real deployments); streaming bounded ingest with a report
state machine and orphan reconciliation; the archive container replacing a
bare log gzip; collection-time typed redaction replacing scrub-at-package;
fingerprint-based ExitInfo dedupe; non-blocking ring + bounded crash marker;
recent-session trackers (only the active playback session ID exists today);
and honest Apple snapshot capabilities (passthrough currently unknown).
@@ -0,0 +1,5 @@
The canonical client-diagnostics v1 contract lives in `v1/`.
Clients vendor the schema files, attribute registry, and fixtures from this directory.
Servers accept supported schema versions and ignore unknown additive fields.
Within v1, changes are additive only: new optional fields, enum values after coordinated client support, and new allowlisted archive entries.
Breaking changes require a new versioned contract directory and explicit server support.
@@ -0,0 +1,89 @@
{
"$schema": "https://silo-server.dev/schemas/client-diagnostics/v1/attr-registry.schema.json",
"schema_version": 1,
"policy": "Attribute keys are registered per category. Debug builds and CI should fail unregistered keys; production clients drop unregistered keys before upload.",
"categories": {
"playback": {
"sink": {
"type": "string",
"description": "Current audio output sink, such as HDMI or speaker."
},
"fmt": {
"type": "string",
"description": "Media or passthrough format identifier, such as truehd."
},
"decoder": {
"type": "string",
"description": "Selected decoder name."
},
"width": {
"type": "integer",
"description": "Video width in pixels."
},
"height": {
"type": "integer",
"description": "Video height in pixels."
},
"hdr_mode": {
"type": "string",
"description": "Active HDR mode or selected tone-mapping mode."
},
"bitrate_kbps": {
"type": "integer",
"description": "Observed or selected bitrate in kilobits per second."
},
"dropped_frames": {
"type": "integer",
"description": "Dropped video frame count."
},
"audio_underruns": {
"type": "integer",
"description": "Audio underrun count."
}
},
"focus": {
"target": {
"type": "string",
"description": "Focusable element or logical destination."
},
"action": {
"type": "string",
"description": "Focus action or navigation direction."
}
},
"network": {
"method": {
"type": "string",
"description": "HTTP method."
},
"path": {
"type": "string",
"description": "Templated path without query string."
},
"status": {
"type": "integer",
"description": "HTTP status code."
},
"duration_ms": {
"type": "integer",
"description": "Request duration in milliseconds."
}
},
"lifecycle": {
"state": {
"type": "string",
"description": "App lifecycle state."
}
},
"crash": {
"fingerprint": {
"type": "string",
"description": "Client-side dedupe fingerprint."
},
"source": {
"type": "string",
"description": "Crash source matching manifest crash.source."
}
}
}
}
@@ -0,0 +1,113 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/schemas/client-diagnostics/v1/device.schema.json",
"title": "Silo Client Diagnostics Device Snapshot v1",
"type": "object",
"required": [
"captured_at",
"provenance",
"identity",
"display",
"audio",
"video_codecs",
"network"
],
"properties": {
"captured_at": {
"type": "string",
"format": "date-time",
"maxLength": 64
},
"provenance": {
"type": "string",
"enum": [
"pre_failure",
"post_restart"
],
"maxLength": 32
},
"identity": {
"$ref": "#/$defs/tri_state_object"
},
"display": {
"$ref": "#/$defs/tri_state_object"
},
"audio": {
"$ref": "#/$defs/tri_state_object"
},
"video_codecs": {
"anyOf": [
{
"type": "array",
"maxItems": 128,
"items": {
"$ref": "#/$defs/open_object"
}
},
{
"$ref": "#/$defs/sentinel"
}
]
},
"network": {
"$ref": "#/$defs/tri_state_object"
}
},
"$defs": {
"sentinel": {
"type": "string",
"enum": [
"unknown",
"not_collected"
]
},
"tri_state_object": {
"anyOf": [
{
"$ref": "#/$defs/open_object"
},
{
"$ref": "#/$defs/sentinel"
}
]
},
"open_object": {
"type": "object",
"maxProperties": 64,
"additionalProperties": {
"$ref": "#/$defs/tri_state_value"
}
},
"tri_state_value": {
"anyOf": [
{
"type": "string",
"maxLength": 512
},
{
"type": "number"
},
{
"type": "integer"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"type": "array",
"maxItems": 128,
"items": {
"$ref": "#/$defs/tri_state_value"
}
},
{
"$ref": "#/$defs/open_object"
}
]
}
},
"additionalProperties": true
}
@@ -0,0 +1,45 @@
{
"schema_version": 1,
"report": {
"type": "manual",
"captured_at": "2026-07-19T18:22:31Z",
"capture_session_id": "run_bad_archive_entry",
"app_version": "1.4.2",
"app_build": "20841",
"platform": "android",
"os_version": "15 (API 35)",
"profile_id": "prof_tablet"
},
"destination": {
"server_instance_id": "srv_home_01"
},
"consent": {
"mode": "manual",
"notice_version": 1
},
"device_summary": {
"manufacturer": "Google",
"model": "Pixel Tablet",
"os": "15",
"form_factor": "tablet"
},
"playback_session_ids": [],
"log_summary": {
"lines": 1,
"bytes_gz": 100,
"dropped_lines": 0,
"categories": [
"other"
],
"debug_logging": false
},
"archive": {
"entries": [
"manifest.json",
"../secrets.txt"
],
"bytes": 100,
"uncompressed_bytes": 200,
"sha256": "3333333333333333333333333333333333333333333333333333333333333333"
}
}
@@ -0,0 +1,44 @@
{
"schema_version": 2,
"report": {
"type": "manual",
"captured_at": "2026-07-19T18:22:31Z",
"capture_session_id": "run_bad_schema",
"app_version": "1.4.2",
"app_build": "20841",
"platform": "android",
"os_version": "15 (API 35)",
"profile_id": "prof_tablet"
},
"destination": {
"server_instance_id": "srv_home_01"
},
"consent": {
"mode": "manual",
"notice_version": 1
},
"device_summary": {
"manufacturer": "Google",
"model": "Pixel Tablet",
"os": "15",
"form_factor": "tablet"
},
"playback_session_ids": [],
"log_summary": {
"lines": 1,
"bytes_gz": 100,
"dropped_lines": 0,
"categories": [
"other"
],
"debug_logging": false
},
"archive": {
"entries": [
"manifest.json"
],
"bytes": 100,
"uncompressed_bytes": 200,
"sha256": "2222222222222222222222222222222222222222222222222222222222222222"
}
}
@@ -0,0 +1,40 @@
{
"schema_version": 1,
"report": {
"type": "manual",
"captured_at": "2026-07-19T18:22:31Z",
"capture_session_id": "run_missing_consent",
"app_version": "1.4.2",
"app_build": "20841",
"platform": "android",
"os_version": "15 (API 35)",
"profile_id": "prof_tablet"
},
"destination": {
"server_instance_id": "srv_home_01"
},
"device_summary": {
"manufacturer": "Google",
"model": "Pixel Tablet",
"os": "15",
"form_factor": "tablet"
},
"playback_session_ids": [],
"log_summary": {
"lines": 1,
"bytes_gz": 100,
"dropped_lines": 0,
"categories": [
"other"
],
"debug_logging": false
},
"archive": {
"entries": [
"manifest.json"
],
"bytes": 100,
"uncompressed_bytes": 200,
"sha256": "1111111111111111111111111111111111111111111111111111111111111111"
}
}
@@ -0,0 +1,44 @@
{
"schema_version": 1,
"report": {
"type": "manual",
"captured_at": "2026-07-19T18:22:31Z",
"capture_session_id": "run_macos_reserved",
"app_version": "1.4.2",
"app_build": "20841",
"platform": "macos",
"os_version": "26.0",
"profile_id": "prof_mac"
},
"destination": {
"server_instance_id": "srv_home_01"
},
"consent": {
"mode": "manual",
"notice_version": 1
},
"device_summary": {
"manufacturer": "Apple",
"model": "Mac",
"os": "26.0",
"form_factor": "desktop"
},
"playback_session_ids": [],
"log_summary": {
"lines": 1,
"bytes_gz": 100,
"dropped_lines": 0,
"categories": [
"other"
],
"debug_logging": false
},
"archive": {
"entries": [
"manifest.json"
],
"bytes": 100,
"uncompressed_bytes": 200,
"sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,44 @@
{
"schema_version": 1,
"report": {
"type": "kernel_panic",
"captured_at": "2026-07-19T18:22:31Z",
"capture_session_id": "run_bad_type",
"app_version": "1.4.2",
"app_build": "20841",
"platform": "android-tv",
"os_version": "11 (API 30)",
"profile_id": "prof_living_room"
},
"destination": {
"server_instance_id": "srv_home_01"
},
"consent": {
"mode": "prompt",
"notice_version": 1
},
"device_summary": {
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"os": "11",
"form_factor": "tv"
},
"playback_session_ids": [],
"log_summary": {
"lines": 1,
"bytes_gz": 100,
"dropped_lines": 0,
"categories": [
"crash"
],
"debug_logging": false
},
"archive": {
"entries": [
"manifest.json"
],
"bytes": 100,
"uncompressed_bytes": 200,
"sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
}
}
@@ -0,0 +1,46 @@
{
"schema_version": 1,
"report": {
"type": "manual",
"captured_at": "2026-07-19T23:45:00Z",
"capture_session_id": "run_android_manual_preprofile_006",
"app_version": "1.4.2",
"app_build": "20841",
"platform": "android",
"os_version": "15 (API 35)"
},
"destination": {
"server_instance_id": "srv_home_01"
},
"consent": {
"mode": "manual",
"notice_version": 1
},
"device_summary": {
"manufacturer": "Google",
"model": "Pixel Tablet",
"os": "15",
"form_factor": "tablet"
},
"playback_session_ids": [],
"log_summary": {
"lines": 12,
"bytes_gz": 2048,
"dropped_lines": 0,
"categories": [
"lifecycle",
"network"
],
"debug_logging": false
},
"archive": {
"entries": [
"manifest.json",
"device.json",
"logs.jsonl"
],
"bytes": 4096,
"uncompressed_bytes": 16384,
"sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
}
}
@@ -0,0 +1,47 @@
{
"schema_version": 1,
"report": {
"type": "manual",
"captured_at": "2026-07-19T23:40:00Z",
"capture_session_id": "run_android_manual_005",
"app_version": "1.4.2",
"app_build": "20841",
"platform": "android",
"os_version": "15 (API 35)",
"profile_id": "prof_tablet"
},
"destination": {
"server_instance_id": "srv_home_01"
},
"consent": {
"mode": "manual",
"notice_version": 1
},
"device_summary": {
"manufacturer": "Google",
"model": "Pixel Tablet",
"os": "15",
"form_factor": "tablet"
},
"playback_session_ids": [],
"log_summary": {
"lines": 60,
"bytes_gz": 5400,
"dropped_lines": 0,
"categories": [
"browse",
"network"
],
"debug_logging": false
},
"archive": {
"entries": [
"manifest.json",
"device.json",
"logs.jsonl"
],
"bytes": 8000,
"uncompressed_bytes": 43000,
"sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
}
}
@@ -0,0 +1,61 @@
{
"schema_version": 1,
"report": {
"type": "crash",
"captured_at": "2026-07-19T18:22:31Z",
"capture_session_id": "run_android_tv_ueh_001",
"app_version": "1.4.2",
"app_build": "20841",
"platform": "android-tv",
"os_version": "11 (API 30)",
"profile_id": "prof_living_room"
},
"destination": {
"server_instance_id": "srv_home_01"
},
"consent": {
"mode": "prompt",
"notice_version": 1
},
"crash": {
"summary": "NullPointerException in PlaybackSessionManager.start",
"stack_excerpt": "java.lang.NullPointerException: player was null\n\tat org.siloserver.silo.PlaybackSessionManager.start(PlaybackSessionManager.kt:42)",
"thread": "main",
"foreground": true,
"source": "ueh",
"provenance": "pre_failure",
"occurred_at": "2026-07-19T18:22:31Z"
},
"device_summary": {
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"os": "11",
"form_factor": "tv"
},
"playback_session_ids": [
"ps_9f2a"
],
"log_summary": {
"lines": 3841,
"bytes_gz": 412034,
"dropped_lines": 12,
"categories": [
"playback",
"focus",
"network"
],
"debug_logging": true
},
"archive": {
"entries": [
"manifest.json",
"device.json",
"logs.jsonl",
"crash/summary.json",
"crash/stack.txt"
],
"bytes": 498112,
"uncompressed_bytes": 3110400,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
}
@@ -0,0 +1,57 @@
{
"captured_at": "2026-07-19T18:22:31Z",
"provenance": "pre_failure",
"identity": {
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"device": "mdarcy",
"form_factor": "tv",
"device_id": "dev_8f4a"
},
"display": {
"mode": "3840x2160@59.94",
"modes_supported": [
"3840x2160@59.94",
"1920x1080@60"
],
"hdr_types": [
"HDR10",
"HLG",
"DV"
],
"wide_gamut": true
},
"audio": {
"outputs": [
{
"type": "HDMI",
"encodings": [
"AC3",
"EAC3",
"TRUEHD"
],
"channels": 8
}
],
"passthrough": [
"AC3",
"EAC3",
"TRUEHD"
],
"suppressions": []
},
"video_codecs": [
{
"mime": "video/hevc",
"hw": true,
"profiles": [
"Main10",
"DV P8"
],
"max": "4096x2160@60"
}
],
"network": {
"transport": "ethernet"
}
}
@@ -0,0 +1,62 @@
{
"schema_version": 1,
"report": {
"type": "hang",
"captured_at": "2026-07-19T21:15:00Z",
"capture_session_id": "run_ios_metric_042",
"app_version": "1.4.2",
"app_build": "20841",
"platform": "ios",
"os_version": "26.0",
"profile_id": "prof_phone"
},
"destination": {
"server_instance_id": "srv_home_01"
},
"consent": {
"mode": "always",
"notice_version": 1
},
"crash": {
"summary": "Main thread hang reported by MetricKit",
"stack_excerpt": "Thread 0 name: main\n0 Silo PlayerCore.waitForRouteChange",
"thread": "main",
"foreground": true,
"source": "metrickit",
"provenance": "metric_reporting_period",
"occurred_at": "2026-07-19T21:15:00Z"
},
"device_summary": {
"manufacturer": "Apple",
"model": "iPhone17,2",
"os": "26.0",
"form_factor": "phone"
},
"playback_session_ids": [
"ps_ios_103"
],
"log_summary": {
"lines": 902,
"bytes_gz": 84511,
"dropped_lines": 0,
"categories": [
"playback",
"lifecycle",
"network"
],
"debug_logging": true
},
"archive": {
"entries": [
"manifest.json",
"device.json",
"logs.jsonl",
"crash/summary.json",
"crash/metrickit.json",
"breadcrumbs.jsonl"
],
"bytes": 141120,
"uncompressed_bytes": 812400,
"sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
}
}
@@ -0,0 +1,3 @@
{"ts":"2026-07-19T18:22:29.412Z","run":"run_android_tv_ueh_001","lvl":"W","cat":"playback","tag":"AudioCapabilityMgr","msg":"passthrough suppressed: HDMI sink lost TrueHD","attrs":{"sink":"HDMI","fmt":"truehd"}}
{"ts":"2026-07-19T18:22:30Z","run":"run_android_tv_ueh_001","lvl":"I","cat":"network","tag":"HTTPClient","msg":"request completed","attrs":{"method":"GET","path":"/api/v1/playback/{id}","status":200,"duration_ms":84}}
{"ts":"2026-07-19T18:22:31Z","run":"run_android_tv_ueh_001","lvl":"D","cat":"focus","tag":"TvFocus","msg":"focus moved","attrs":{"target":"play-button","action":"right"}}
@@ -0,0 +1,60 @@
{
"schema_version": 1,
"report": {
"type": "abnormal_exit",
"captured_at": "2026-07-19T22:05:11Z",
"capture_session_id": "run_tvos_exit_007",
"app_version": "1.4.2",
"app_build": "20841",
"platform": "tvos",
"os_version": "26.0",
"profile_id": "prof_den"
},
"destination": {
"server_instance_id": "srv_home_01"
},
"consent": {
"mode": "prompt",
"notice_version": 1
},
"crash": {
"summary": "Silo did not shut down cleanly last time",
"thread": "unknown",
"foreground": true,
"source": "exit_sentinel",
"provenance": "post_restart",
"occurred_at": "2026-07-19T22:05:11Z"
},
"device_summary": {
"manufacturer": "Apple",
"model": "Apple TV",
"os": "26.0",
"form_factor": "tv"
},
"playback_session_ids": [
"ps_tvos_11",
"ps_tvos_10"
],
"log_summary": {
"lines": 120,
"bytes_gz": 12044,
"dropped_lines": 3,
"categories": [
"lifecycle",
"playback"
],
"debug_logging": false
},
"archive": {
"entries": [
"manifest.json",
"device.json",
"logs.jsonl",
"crash/summary.json",
"breadcrumbs.jsonl"
],
"bytes": 30001,
"uncompressed_bytes": 104000,
"sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
}
}
@@ -0,0 +1,95 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/schemas/client-diagnostics/v1/logline.schema.json",
"title": "Silo Client Diagnostics Log Line v1",
"type": "object",
"required": [
"ts",
"run",
"lvl",
"cat",
"tag",
"msg"
],
"properties": {
"ts": {
"type": "string",
"format": "date-time",
"maxLength": 64
},
"run": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"lvl": {
"type": "string",
"enum": [
"V",
"D",
"I",
"W",
"E"
],
"maxLength": 1
},
"cat": {
"type": "string",
"enum": [
"playback",
"focus",
"network",
"lifecycle",
"browse",
"cast",
"download",
"crash",
"other"
],
"maxLength": 32
},
"tag": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"msg": {
"type": "string",
"minLength": 1,
"maxLength": 2048
},
"attrs": {
"type": "object",
"description": "Attribute keys are registered in attr-registry.json. Production clients drop unregistered keys before upload.",
"additionalProperties": {
"anyOf": [
{
"type": "string",
"maxLength": 512
},
{
"type": "number"
},
{
"type": "integer"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"type": "array",
"maxItems": 32,
"items": {
"type": "string",
"maxLength": 256
}
}
]
}
}
},
"additionalProperties": true
}
@@ -0,0 +1,392 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/schemas/client-diagnostics/v1/manifest.schema.json",
"title": "Silo Client Diagnostics Manifest v1",
"description": "Canonical manifest contract for a client diagnostics bundle. STRING LENGTHS: every `maxLength` in this schema is a character-count sanity bound. The server enforces the matching limit as a UTF-8 *byte* length (Go `len(string)`), so a value near a cap that contains multi-byte characters (CJK, emoji, accented text) can satisfy `maxLength` here yet be rejected at ingest. Treat each numeric cap as a byte budget for non-ASCII text. JSON Schema cannot express byte length, so this divergence is intentional and documented rather than encoded.",
"type": "object",
"required": [
"schema_version",
"report",
"destination",
"consent",
"device_summary",
"playback_session_ids",
"log_summary",
"archive"
],
"properties": {
"schema_version": {
"type": "integer",
"const": 1
},
"report": {
"type": "object",
"required": [
"type",
"captured_at",
"capture_session_id",
"app_version",
"app_build",
"platform",
"os_version"
],
"properties": {
"type": {
"type": "string",
"enum": [
"crash",
"anr",
"native_crash",
"hang",
"abnormal_exit",
"manual"
],
"maxLength": 32
},
"captured_at": {
"type": "string",
"format": "date-time",
"maxLength": 64
},
"capture_session_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"app_version": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"app_build": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"platform": {
"type": "string",
"enum": [
"android",
"android-tv",
"ios",
"tvos"
],
"maxLength": 32
},
"os_version": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"profile_id": {
"type": "string",
"maxLength": 128
}
},
"additionalProperties": true
},
"destination": {
"type": "object",
"required": [
"server_instance_id"
],
"properties": {
"server_instance_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
},
"additionalProperties": true
},
"consent": {
"type": "object",
"required": [
"mode",
"notice_version"
],
"properties": {
"mode": {
"type": "string",
"enum": [
"prompt",
"always",
"manual"
],
"maxLength": 32
},
"notice_version": {
"type": "integer",
"minimum": 1
}
},
"additionalProperties": true
},
"crash": {
"type": "object",
"required": [
"summary",
"source",
"provenance",
"occurred_at"
],
"properties": {
"summary": {
"type": "string",
"minLength": 1,
"description": "Free-text crash summary; may contain non-ASCII. maxLength counts characters — the server enforces 8192 UTF-8 bytes (see the schema-level string-length note).",
"maxLength": 8192
},
"stack_excerpt": {
"type": "string",
"description": "Free-text stack excerpt; may contain non-ASCII. maxLength counts characters — the server enforces 8192 UTF-8 bytes (see the schema-level string-length note).",
"maxLength": 8192
},
"thread": {
"type": "string",
"description": "Thread name; may contain non-ASCII. maxLength counts characters — the server enforces 128 UTF-8 bytes (see the schema-level string-length note).",
"maxLength": 128
},
"foreground": {
"type": "boolean"
},
"source": {
"type": "string",
"enum": [
"ueh",
"exit_info",
"metrickit",
"exit_sentinel"
],
"maxLength": 32
},
"provenance": {
"type": "string",
"enum": [
"pre_failure",
"post_restart",
"metric_reporting_period"
],
"maxLength": 32
},
"occurred_at": {
"type": "string",
"format": "date-time",
"maxLength": 64
}
},
"additionalProperties": true
},
"device_summary": {
"type": "object",
"required": [
"manufacturer",
"model",
"os",
"form_factor"
],
"properties": {
"manufacturer": {
"type": "string",
"minLength": 1,
"description": "Device manufacturer; may contain non-ASCII. maxLength counts characters — the server enforces 128 UTF-8 bytes (see the schema-level string-length note).",
"maxLength": 128
},
"model": {
"type": "string",
"minLength": 1,
"description": "Device model; may contain non-ASCII (e.g. CJK). maxLength counts characters — the server enforces 128 UTF-8 bytes, so a 128-character multi-byte value can pass this schema yet be rejected at ingest (see the schema-level string-length note).",
"maxLength": 128
},
"os": {
"type": "string",
"minLength": 1,
"description": "OS name/version; may contain non-ASCII. maxLength counts characters — the server enforces 128 UTF-8 bytes (see the schema-level string-length note).",
"maxLength": 128
},
"form_factor": {
"type": "string",
"minLength": 1,
"description": "Device form factor; may contain non-ASCII. maxLength counts characters — the server enforces 64 UTF-8 bytes (see the schema-level string-length note).",
"maxLength": 64
}
},
"additionalProperties": {
"type": "string",
"maxLength": 256
}
},
"playback_session_ids": {
"type": "array",
"maxItems": 20,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
},
"log_summary": {
"type": "object",
"required": [
"lines",
"bytes_gz",
"dropped_lines",
"categories",
"debug_logging"
],
"properties": {
"lines": {
"type": "integer",
"minimum": 0
},
"bytes_gz": {
"type": "integer",
"minimum": 0
},
"dropped_lines": {
"type": "integer",
"minimum": 0
},
"categories": {
"type": "array",
"uniqueItems": true,
"maxItems": 9,
"items": {
"type": "string",
"enum": [
"playback",
"focus",
"network",
"lifecycle",
"browse",
"cast",
"download",
"crash",
"other"
],
"maxLength": 32
}
},
"debug_logging": {
"type": "boolean"
}
},
"additionalProperties": true
},
"archive": {
"type": "object",
"required": [
"entries",
"bytes",
"uncompressed_bytes",
"sha256"
],
"properties": {
"entries": {
"type": "array",
"description": "Tar member names in bundle order. The first entry MUST be `manifest.json`: the server reads and validates the leading member before any other and compares this order against the actual bundle. `prefixItems` pins index 0, while `contains` keeps `manifest.json` required for validators that do not implement `prefixItems`.",
"uniqueItems": true,
"minItems": 1,
"maxItems": 8,
"prefixItems": [
{
"const": "manifest.json"
}
],
"contains": {
"const": "manifest.json"
},
"items": {
"type": "string",
"enum": [
"manifest.json",
"device.json",
"logs.jsonl",
"crash/summary.json",
"crash/stack.txt",
"crash/tombstone.pb",
"crash/metrickit.json",
"breadcrumbs.jsonl"
],
"maxLength": 64
}
},
"bytes": {
"type": "integer",
"minimum": 0
},
"uncompressed_bytes": {
"type": "integer",
"minimum": 0
},
"sha256": {
"type": "string",
"pattern": "^[A-Fa-f0-9]{64}$",
"maxLength": 64
}
},
"additionalProperties": true
}
},
"additionalProperties": true,
"allOf": [
{
"$comment": "Event reports (crash/anr/native_crash/hang/abnormal_exit) must carry the crash object; mirrors ValidateManifest in internal/diagnostics/contract/contract.go.",
"if": {
"properties": {
"report": {
"properties": {
"type": {
"enum": [
"crash",
"anr",
"native_crash",
"hang",
"abnormal_exit"
]
}
},
"required": [
"type"
]
}
},
"required": [
"report"
]
},
"then": {
"required": [
"crash"
]
}
},
{
"$comment": "Manual captures must not carry a crash object.",
"if": {
"properties": {
"report": {
"properties": {
"type": {
"const": "manual"
}
},
"required": [
"type"
]
}
},
"required": [
"report"
]
},
"then": {
"not": {
"required": [
"crash"
]
}
}
}
]
}
@@ -0,0 +1,822 @@
# Playback Protocol v3 Server Implementation Plan
**Status:** Proposed
**Scope:** `silo-server`, with coordinated contract fixtures in `silo-android`
**Protocol owner:** Silo server
**Client target:** Android Media3-only playback; legacy web and Apple playback remain supported
## 1. Goal
Implement the server half of Silo playback protocol v3 so a client can report
its current device and output capabilities and receive one complete executable
plan for:
- authenticated original HTTP playback;
- progressive or HLS remux;
- video-copy with audio adaptation;
- HLS video transcode;
- subtitle render, conversion, or burn-in; or
- a terminal `adaptation_unavailable` outcome.
The client must never choose FFmpeg recipes. The server must never claim that a
route preserves Dolby Vision, HDR, lossless audio, or subtitle fidelity unless
the source metadata, client/output capability, selected recipe, and installed
tooling support that claim.
The normative behavior remains in the sibling Android repository:
- `silo-android/docs/playback/01-media3-only-player-architecture.md`
- `silo-android/docs/playback/02-migration-compatibility-validation.md`
- `silo-android/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3.kt`
This document maps that contract onto the current server. It does not redefine
the protocol.
## 2. Verified server baseline
The implementation should reuse the current playback stack rather than replace
it:
| Existing behavior | Current owner | v3 implication |
| --- | --- | --- |
| Legacy start, authorization, profile checks, track preferences, session creation | `internal/api/handlers/playback.go` | Preserve the legacy branch byte-for-byte where possible; move shared operations behind helpers. |
| Direct/remux/transcode choice | `internal/playback/resolver.go` | Replace its broad codec/container booleans with a v3 planner for v3 requests only. Do not change legacy decisions during the first rollout. |
| Byte-range original delivery | `internal/playback/directplay.go`, `internal/api/handlers/stream.go` | Reuse as `original_http`. |
| Progressive MP4 remux and audio-to-AAC | `internal/playback/remux.go`, `internal/api/handlers/stream.go` | Reuse as `server_remux_progressive`; make every transformation explicit in the plan. |
| HLS copy/transcode, timelines, restart/reconstruct | `internal/playback/transcode.go`, `internal/playback/transcode_manager.go` | Reuse as `server_remux_hls` or `server_transcode_hls`. |
| Local and remote transcode start | `HandleStartTranscode`, `internal/transcodenode/server.go`, `internal/nodepool` | Extract a server-callable starter so v3 can return the final manifest without a second client recipe request. |
| Playback sessions, policy and limits | `internal/playback/session.go` | Extend session state with v3 attempt/plan identity; keep current admission controls authoritative. |
| Source video/audio/subtitle metadata | `internal/models/media.go`, `internal/scanner/probe.go` | Most required fields already exist, including DV profile, BL compatibility ID, EL presence, color/range, bit depth, layout, and channels. MEL/FEL classification and transformation provenance are not yet represented. |
| Subtitle extract/conversion/burn-in | `internal/playback/subtitles.go`, `subtitle_stream.go`, transcode arguments | Reuse the executors; add an explicit policy decision and artifact contract. |
| Restart-safe signed stream recipes | `internal/playback/recipecard.go`, `internal/streamtoken` | Continue using signed recipe tokens; v3 plans describe the resulting URL and timeline. |
The largest correctness gaps in the current resolver are expected and must not
be carried into v3:
- transcode-disabled incompatibility currently falls back to attempting direct
playback instead of returning a terminal result;
- codec support is modeled as a string union, without profile, level, bit depth,
frame rate, range, sink layout, or subtitle-fidelity validation;
- clients currently supply transcode codec, bitrate, segment, and burn-in
recipe details through `/playback/transcode/start`;
- the start response does not contain an authoritative effective recipe,
validation claims, transformations, or timeline for every delivery;
- no idempotent replan or attempt-scoped route-event API exists.
## 3. Architecture decision
### 3.1 Additive dispatch on the existing start endpoint
Keep `POST /api/v1/playback/start` for compatibility. Refactor its handler into
a small protocol dispatcher:
1. Decode only an envelope containing `protocol_version`.
2. When it equals `3`, decode strictly as `PlaybackStartRequestV3` and call the
v3 orchestration service.
3. When it is absent or not `3`, pass the original body to the existing legacy
decoder and behavior.
Use buffered request bytes or `json.RawMessage`; never decode the body twice
from the socket. Unknown fields remain tolerated for additive compatibility,
but required v3 fields and closed enum values are validated explicitly.
Do not overwrite the existing request/response structs with a union of legacy
and v3 fields. That would make missing nested capabilities silently look like a
valid legacy request and recreate the current false-direct failure mode.
### 3.2 Introduce one v3 orchestration service
Add a service in the `internal/playback` domain that owns:
- input normalization and validation;
- stable track resolution;
- source and capability evaluation;
- ordered candidate construction;
- policy and capacity checks;
- deterministic plan identity and loop prevention;
- transport startup;
- terminal outcome mapping;
- replan idempotency; and
- plan/session state transitions.
HTTP handlers authenticate, authorize, decode, call the service, and encode the
result. They do not contain route-selection rules.
Recommended files:
```text
internal/playback/protocol_v3.go wire/domain types and enums
internal/playback/plan_v3.go candidate planner
internal/playback/capabilities_v3.go source/capability validators
internal/playback/recipe_v3.go normalized executable recipes
internal/playback/plan_key_v3.go canonical IDs and attempt keys
internal/playback/tracks_v3.go stable track identities/remapping
internal/playback/subtitle_policy_v3.go subtitle decision policy
internal/playback/transformations_v3.go installed-tool/recipe registry
internal/playback/protocol_v3_test.go
internal/playback/testdata/protocol_v3/ golden contract/planner fixtures
internal/playback/planstore/store.go persistence interfaces
internal/playback/planstore/postgres.go attempts, replans and route events
internal/api/handlers/playback_v3.go start/replan/event handlers
internal/api/handlers/playback_transport.go reusable transport starter
internal/api/handlers/playback_v3_test.go
```
If implementation reveals that the wire and domain structs need different
types, keep JSON structs in `handlers` and convert at the boundary. Do not add
JSON tags throughout unrelated session and FFmpeg types merely to save mapping
code.
### 3.3 Reconcile additive client-contract gaps before freezing v3
The current Kotlin v3 types are sufficient to negotiate and execute a plan,
but they do not yet carry every fact the normative direct-play rules require.
The server cannot manufacture those facts. Phase 0 therefore includes a
coordinated additive Android contract change:
| Current gap | Required additive contract |
| --- | --- |
| Video capability is a codec-name list plus a global maximum resolution. | Add per-codec decode entries with supported profiles/levels, bit depths, maximum width/height/frame rate/bitrate, and hardware/software status. |
| Audio passthrough is a codec-name list plus one global maximum channel count. | Add per-codec supported channel counts/layouts; retain the old fields for tolerant readers. |
| A plan does not expose requested versus effective media file when alternate-version selection occurs. | Add requested/effective media file IDs and a normalized source descriptor to the plan. |
| Subtitle decision does not expose the effective fidelity policy. | Add `subtitle_fidelity_policy` beside mode/artifact. |
| Route events cannot explicitly report replan identity, local PCM recovery, retry outcome, decoder timing, or requested/effective quality. | Add optional structured fields or a versioned, bounded diagnostic schema; the server enriches delivery/recipe/source facts from stored plan state rather than trusting duplicates from the client. |
| Android's profile-quality canonicalizer passes unknown stored labels through and does not produce `original`. | Canonicalize the closed v3 values and aliases client-side; keep server normalization tolerant for rolling upgrades. |
Gate the detailed behavior with additive client feature tokens such as
`detailed_decode_capabilities`, `layout_aware_passthrough`, and
`playback_route_diagnostics`. A v3 client missing them remains parseable, but
unknown profile/layout facts cannot satisfy a strict direct claim. The planner
must choose a conservative adapted route or terminal result.
Freeze the exact JSON field names only after Go/Kotlin golden fixtures exist.
Do not implement a server-only guess and call the contract complete.
The response engine mapping is fixed:
| Delivery | `engine` |
| --- | --- |
| `original_http` | `media3_direct` |
| `server_remux_progressive` | `media3_progressive_remux` |
| `server_remux_hls` | `media3_hls` |
| `server_transcode_hls` | `media3_hls` |
Android currently accepts header refresh modes `none` and `session`; do not
emit `refresh_endpoint` until the client implements it.
Never emit legacy/client-owned runtime values in a v3 plan:
`mpv_direct`, `client_local_loopback`, `external_player`, or
`client_local_normalization`. They remain decode-only compatibility values on
Android.
### 3.4 Keep v3 dark until the complete route is executable
Add a dynamic server setting `playback.protocol_v3_enabled`, default `false`.
Read it through `PlaybackSettingsReader.Get` on each capability/start request,
as the existing `allow_4k_transcode` path does. Do not load it only through
`internal/config/db_loader.go`; that would turn rollback into a restart-required
change.
Also add `GET /api/v1/playback/capability` because `/api/v1` features use
capability detection rather than server-version sniffing.
When a v3 start arrives while disabled, return a successful negotiation
response with no allocated session and without `playback_plan_v3` in
`server_features`. This intentionally triggers Android's
`server_upgrade_required` path without leaking a legacy session.
The capability response should contain:
```json
{
"enabled": false,
"protocol_versions": [3],
"features": [],
"deliveries": [],
"transformations": [],
"reason": "disabled"
}
```
Only advertise `playback_plan_v3` after every enabled delivery and
transformation passes Phase 0 fixtures on the deployed build.
## 4. HTTP contract and ownership
### 4.1 Endpoints
| Endpoint | Success | Responsibility |
| --- | --- | --- |
| `GET /api/v1/playback/capability` | `200` | Feature/protocol and installed transformation discovery. |
| `POST /api/v1/playback/start` | `201` | Authorize source, create a session, select/start a route, return playable or terminal v3 response. |
| `POST /api/v1/playback/{session_id}/replan` | `200` | Idempotently replace the active plan at the supplied media position. |
| `POST /api/v1/playback/route-events` | `202` | Validate and enqueue attempt-scoped diagnostics. |
All four routes use normal account authentication. Start, replan, and route
events require profile auth. `X-Profile-Id` remains authoritative; a body
`profile_id` must match it. Replan additionally verifies session ownership and
that `playback_attempt_id` belongs to the session.
Playable and `adaptation_unavailable` are protocol outcomes, not HTTP errors.
Use HTTP errors only for transport/API failures such as malformed input,
unauthorized access, missing files, or an idempotency conflict.
### 4.2 Strict request validation
Reject before allocating a session when any of these fail:
- protocol is not exactly `3`;
- `playback_attempt_id` is absent or not a bounded UUID/ULID-like identifier;
- file/profile authorization fails;
- track ID and fallback index disagree;
- output-route generation is negative or disagrees with the nested output
context;
- codec/container/feature lists exceed bounded counts or string lengths;
- bandwidth, bitrate, resolution, attempt count, or position is outside sane
bounds; or
- a replan's failed plan is not the session's active plan.
Normalize codecs, containers, layouts, and dynamic-range labels once at the
boundary. Accept bounded known quality aliases such as `4k`; fold an unknown
rolling-upgrade quality value to `auto` with a decision warning instead of
hard-failing playback. Preserve the original values only in bounded
diagnostics.
### 4.3 Complete response invariant
A `playable` response is valid only after its transport can be fetched or has
successfully entered its startup state. It includes:
- protocol and feature negotiation;
- stable `session_id` and deterministic `plan_id`;
- delivery and Media3 engine;
- final URL, stream protocol, container/MIME, scoped headers, and supported
header-refresh mode;
- exact player/source timeline mapping and seek window;
- selected stable audio/subtitle identities;
- effective codec, range, dimensions, frame rate, bitrate, channels and layout;
- route validation claims;
- subtitle mode/artifact;
- named transformations and degradation warnings; and
- a stable decision reason code.
Never return a placeholder transcode URL and expect v3 Android to call the
legacy transcode-start endpoint.
## 5. Domain invariants
### 5.1 Stable tracks
Use the current Android-compatible identity initially:
```text
file:{media_file_id}:audio:{ffmpeg_audio_ordinal}
file:{media_file_id}:subtitle:{combined_subtitle_ordinal}
```
Freeze the combined subtitle ordering used by `buildSubtitleURLs`:
1. external subtitles in stored order, starting at zero;
2. embedded subtitles at `len(external) + embedded_ordinal`; skipped legacy
bitmap entries retain their ordinal hole; and
3. downloaded subtitles in repository `created_at` order at
`len(external) + len(all_embedded) + downloaded_ordinal`.
The identity is paired with the effective media file ID. Centralize generation,
parsing, URL mapping, and alternate-version remapping in `tracks_v3.go`, and
freeze the exact inventory in Phase 0 fixtures. A plan that switches file
version must return the effective file's identity after matching the requested
track by signature. Do not echo a requested-file track ID with an
effective-file index.
Longer term, scanner-persisted immutable stream IDs can replace ordinal IDs in a
future protocol version; do not change v3 identity semantics after release.
### 5.2 Deterministic plan IDs and loop prevention
The Android attempt key currently includes `plan_id`. Therefore a random plan
ID would let the same failed recipe reappear under a new key and defeat loop
prevention.
Derive `plan_id` deterministically within a playback attempt from:
- `playback_attempt_id`;
- effective file ID;
- delivery and stream protocol/container;
- normalized video/audio recipe;
- effective track IDs;
- subtitle mode/artifact type;
- sorted transformation names; and
- policy/recipe version.
Do not include expiring URLs, JWTs, timestamps, node hostnames, or the output
route generation in `plan_id`. The same effective recipe has the same plan ID;
an output-route change remains distinguishable because Android includes
`output_route_generation` in its attempt key.
Implement the client's FNV-1a 64-bit `v3:<16-lowercase-hex>` attempt-key
algorithm in Go. The UTF-8 canonical string is exactly:
```text
plan_id
|KOTLIN_DELIVERY_ENUM_NAME
|KOTLIN_STREAM_PROTOCOL_ENUM_NAME
|lowercase_container_or_empty
|lowercase_video_codec_or_empty
|lowercase_audio_codec_or_empty
|(width_or_0)x(height_or_0)
|bitrate_kbps_or_0
|lowercase_dynamic_range_or_empty
|KOTLIN_SUBTITLE_MODE_ENUM_NAME
|comma_joined_transformation_names_sorted_by_name
|output_route_generation
|comma_joined_local_mutations_sorted_lexically
```
The newlines above are explanatory only; the hashed value is one string joined
by literal `|` characters. Enum components are Kotlin constant names such as
`ORIGINAL_HTTP`, `HTTP_PROGRESSIVE`, `HLS`, and `BURN_IN`, not lowercase wire
tokens. Hash with offset basis `0xcbf29ce484222325` and prime
`0x100000001b3`, then zero-pad to 16 lowercase hex digits.
Before committing a candidate, compute its base key for the request's output
route and reject it when present in `attempted_plan_keys`. Canonical fixtures
must be generated by the checked-in Kotlin implementation and consumed as
opaque expected values by Go tests; a Go-generated fixture would make the
parity test circular. Include transformation-order, empty/default-field,
output-route, and local PCM-mutation cases.
Server-side ordered candidate IDs are not security tokens.
### 5.3 Session and replacement semantics
Treat replan as one replacement transaction, serialized with the per-session
lifecycle lock:
1. validate policy, source, candidate and tooling without touching the active
route;
2. reserve the active session's existing stream/transcode capacity slot so the
replacement is neither double-counted nor denied by its own predecessor;
3. start the successor in a plan-scoped staging directory/transport generation,
or under an explicitly replacement-linked new session ID;
4. wait until the successor URL is fetchable or its startup state is validated;
5. atomically commit effective file, tracks, recipe, plan ID, timeline and
signed route;
6. close and reap the predecessor; and
7. release or transfer the capacity reservation exactly once.
If any pre-commit step fails, leave the old route and session usable and return
a typed terminal/retryable result. Never run two FFmpeg writers against the
same output directory. Progressive request-scoped transports may drain until
the old client request disconnects; they do not require a destructive
close-first swap.
Prefer retaining the same logical playback session so progress, scrobble and
admin history remain continuous. If the transport design requires a new public
session ID, return it explicitly and keep the old ID alive until the successor
is ready. Android will stop the old ID afterward and ignore stop failures. Keep
the legacy stop endpoint's behavior unchanged; internal v3 replacement and
cleanup paths must treat an already-stopped session as success even though the
current `SessionManager.StopSession` returns `ErrSessionNotFound`.
### 5.4 Replan idempotency
Create a timestamped Goose migration, generated with:
```text
make migrate-create NAME=add_playback_protocol_v3
```
Add short-lived persistence for:
- playback attempt/session/current-plan state;
- `(session_id, replan_request_id)` request digest, state, lease and serialized
response; and
- route events.
Current native restart reconstruction is carried by signed stream tokens, not
a durable plan database. This plan store is the first persisted v3 control-
plane state; it complements rather than replaces recipe tokens and therefore
needs explicit expiry/cleanup and token-to-plan reconciliation tests.
For a duplicate replan ID:
- same digest and completed state returns the cached response;
- same digest and active lease waits on/single-flights the owner, then returns
the response;
- different digest returns `409 idempotency_key_reused`; and
- an expired lease may be reclaimed after reconciling the session's current
recipe/plan state.
Store only the minimum response needed for replay and expire it with the
playback attempt. Never put source paths or authorization headers in route
events. Signed URLs in cached responses inherit the attempt TTL and are removed
by cleanup.
## 6. Planner rules
### 6.1 Candidate order
Build candidates in least-destructive order, but validate every candidate
independently:
1. `original_http`;
2. `server_remux_progressive` when the container alone is incompatible and
progressive seek semantics are acceptable;
3. `server_remux_hls` when HLS packaging is required;
4. `server_remux_hls` with copied video plus audio adaptation, with the audio
change named in the effective recipe, transformations, claims and warnings;
5. `server_transcode_hls`; and
6. terminal `adaptation_unavailable`.
Remux is not mandatory before transcode. Skip directly to the first valid
candidate. A selected subtitle may independently add an artifact or force
burn-in.
### 6.2 Direct eligibility
Original delivery requires all of:
- validated source file and byte-range path;
- client container extractor support;
- selected video codec/profile/level/bit depth/resolution/frame rate/bitrate;
- compatible source dynamic range and current display output;
- selected audio local decode or exact sink passthrough by codec and channel
layout;
- renderable selected subtitle at the required fidelity, or an independent
converted artifact; and
- no requested quality reduction.
Missing metadata is `unknown`, not compatible. A probe repair may run before
planning, but the planner must terminal or adapt when required fields remain
unknown.
### 6.3 Quality policy
Move target selection out of clients. Add a pure `QualityPolicy` that maps:
- `original` to no user-requested reduction, while still permitting mandatory
compatibility adaptation;
- a fixed rung to a maximum height with no upscaling; and
- `auto` to the lowest of device maximum, bandwidth estimate with safety
margin, user cap, metered policy, and administrator constraints.
Start with one server-owned ladder. Reconcile it with the existing web constants
in `web/src/player/hooks/useTranscodeQuality.ts`; do not maintain two unrelated
bitrate tables. The v3 result returns the actual height/bitrate and decision
reason. Multi-variant ABR is explicitly outside this work.
### 6.4 Audio
Evaluate the selected track, not `MediaFile.CodecAudio` alone:
- exact sink passthrough codec plus channel count/layout permits audio copy and
a `passthrough=true` claim;
- client decode support permits copy with `passthrough=false`;
- otherwise use video-copy/audio adaptation when permitted;
- otherwise terminal with `transcoding_disabled`,
`audio_conversion_unsupported`, or policy/capacity reason.
The effective recipe must report the actual output channels/layout. Do not call
AAC stereo output Atmos-preserving. DTS core extraction, E-AC-3 conversion, or
other lossy/core-only changes are named transformations with warnings.
### 6.5 HDR and Dolby Vision
Use `VideoTrack` fields already populated by the scanner: DV profile, BL
compatibility ID, EL presence, range type, color metadata, bit depth, profile,
level, and dimensions.
Add an explicit enhancement-layer classification:
```text
none | mel | fel | unknown
```
An analyzer interface may use an installed `dovi_tool`/libdovi implementation;
without it, Profile 7 with an EL remains `unknown`. Unknown must never be
reported as MEL, FEL, or validated native DV.
Represent transformations in a registry with:
- stable name and recipe version;
- required binary/filter/hardware capability;
- accepted source metadata;
- promised output range;
- argument builder;
- validation fixture; and
- failure-to-terminal mapping.
Initial entries should cover only paths proven by fixtures, for example:
- `dv_metadata_strip_to_hdr10` using a compatible HDR10 base layer;
- `dv_p7_to_p8_1` only when the selected toolchain and fixture prove the output;
- `hdr_to_sdr_tonemap` only when integrated and remote transcode paths both
implement the same declared result.
FFmpeg's `dovi_rpu` bitstream filter can strip DV metadata without decoding,
and `dovi_split` can separate Profile 7 layers
([FFmpeg bitstream-filter documentation](https://ffmpeg.org/ffmpeg-bitstream-filters.html#dovi_005frpu)).
[`dovi_tool`](https://github.com/quietvoid/dovi_tool) can inspect RPU metadata
and convert compatible streams. Probe these capabilities at startup and expose
only registered, available transformations. Do not silently run the current
Profile 7 strip path and still claim Dolby Vision preservation.
The current progressive remux path is not v3-safe unchanged: when `dovi_rpu`
is present it strips Profile 7 metadata automatically, while the no-filter path
leaves the copied bitstream without that named adaptation. Refactor remux input
to require an explicit transformation choice. A Profile 7 progressive remux is
eligible only when it either preserves a fixture-validated native P7 stream or
runs the registered `dv_metadata_strip_to_hdr10` transformation against a
validated HDR10-compatible base layer. When the required filter/tool is
missing, disqualify that remux candidate rather than emitting an ambiguous
stream.
### 6.6 Subtitles
Resolve exactly one mode:
- `off`: no selected track;
- `render`: return the source artifact when Media3 can render it with required
fidelity;
- `convert`: return a v3 artifact URL, MIME, format and timing origin;
- `burn_in`: force video encoding and declare any HDR/range degradation; or
- terminal when the selected subtitle cannot meet policy.
Map `preserve` to `require_authored_fidelity` and `compatible` to
`allow_simplified_rendering`, subject to administrator policy. ASS/SSA styling,
font attachments, PGS, VobSub and DVB must each have fixtures; do not infer all
bitmap paths from one codec test.
Converted artifacts need session ownership, signed or authenticated fetches,
bounded cache lifetime, and timing relative to the returned stream origin.
## 7. Transport orchestration refactor
Extract the body of `HandleStartTranscode` into a server-callable operation that
accepts a validated normalized recipe and returns:
- final manifest URL;
- effective local/remote node and hardware encoder;
- effective file ID;
- final codecs/resolution/bitrate/channels/range;
- player/source timeline mapping;
- seek-window semantics; and
- typed terminal/startup failure.
Both the legacy endpoint and v3 service call this operation. The legacy handler
continues accepting its existing body and maps it to the normalized recipe;
there is no `/api/v1` breaking change.
Jellyfin compatibility is behaviorally out of scope for protocol v3. Its
handlers currently start local/remote transcodes through parallel paths and a
shared `TranscodeManager`, not through `HandleStartTranscode`. Preserve those
wire and lifecycle paths during this work and keep their regression tests
green. The extracted lower-level starter may be adopted by jellycompat only in
a separately reviewed convergence change; do not half-migrate one of its local
or remote branches.
Apply the same extraction to progressive remux startup where necessary. The v3
planner chooses a recipe; the transport starter is not allowed to silently
change it. If safety logic must change copy to encode—for example seeked HEVC or
subtitle burn-in—the starter returns the effective normalized recipe so the
plan, claims, warnings, recipe card, and FFmpeg process remain identical.
## 8. Route events and observability
Add `playback_route_events` with bounded columns for:
- playback/session/plan/plan-attempt identities;
- event and failure classification;
- fallback reason;
- output-route generation;
- sanitized diagnostics JSON;
- user/profile/client metadata derived from auth and headers; and
- received timestamp.
Accept only a closed event set initially:
```text
plan_selected
plan_invalidated
plan_failed
first_frame
terminal
stopped
```
Allowlist diagnostic keys and cap key count, key/value length, body size, and
events per user/attempt. Strip URLs, headers, tokens, file paths, free-form
stack traces, and unknown keys. Enqueue/batch writes so telemetry never blocks
playback planning.
Add exact release queries for:
- starts by delivery, recipe, dynamic range and client model;
- first-frame rate and latency;
- replan/failure rate by classification;
- terminal reasons;
- PCM retry and passthrough outcomes;
- repeated-key/loop prevention; and
- DV/HDR degradation transformations.
Operational logs include IDs and reason codes but no stream tokens.
## 9. Implementation sequence
Each phase should be a separate reviewable PR and link the playback capability
epic/sub-issue.
### Phase 0 — Freeze the contract
- Add Go structs/enums mirroring the Kotlin v3 wire model.
- Split only the start-envelope dispatch: protocol `3` returns the disabled
no-session negotiation response, while absent/non-3 requests replay the
buffered body through the unchanged legacy decoder.
- Resolve the additive capability, effective-file, subtitle-policy and route-
diagnostic gaps from section 3.3 in a coordinated Android change.
- Add request/response JSON golden fixtures and canonical attempt-key fixtures.
- Add strict validation tests, legacy-envelope tests, and unknown-field tests.
- Add `GET /playback/capability` and the disabled negotiation response.
- Keep `playback.protocol_v3_enabled=false`.
**Exit:** Go and Android decode the same fixtures and produce identical attempt
keys; Android populates the detailed capability fields; legacy start tests are
unchanged; disabled v3 starts allocate no legacy playback session.
### Phase 1 — Source facts and transformation registry
- Complete normalized source descriptors from `MediaFile` tracks.
- Add MEL/FEL/unknown metadata and conservative probe behavior.
- Add installed FFmpeg/dovi tool capability probes.
- Define transformation registry and terminal reasons.
- Add scanner/model migration or JSONB backfill handling as required.
**Exit:** every source fact used by the planner is present or explicitly
unknown; no unavailable transformation is advertised.
### Phase 2 — Pure planner
- Implement capability normalization, stable tracks, quality policy, direct
eligibility, candidate ordering, recipe claims, deterministic plan IDs and
attempt-key rejection.
- Cover direct, progressive remux, HLS copy, audio adaptation, full transcode,
subtitles and terminal outcomes with table-driven tests.
- Do not start sessions or FFmpeg from planner tests.
**Exit:** the full Phase 0 decision matrix passes as a pure deterministic
function.
### Phase 3 — Start orchestration
- Wire the Phase 0 dispatcher's protocol-3 branch to the completed v3
orchestration service; keep the legacy branch unchanged.
- Extract reusable transcode/remux transport starters.
- Start sessions and final transports from the v3 service.
- Return complete plans with final URLs and timelines.
- Persist attempt/current-plan state.
**Exit:** every playable fixture fetches and prepares its returned URL; a
terminal fixture allocates no leaked session/transcode.
### Phase 4 — Replan and idempotency
- Add route, handler, persistent idempotency records and per-session
serialization.
- Replan track/quality/output changes and classified runtime failures.
- Reuse session IDs when safe; close superseded transports exactly once.
- Reject attempted recipes and stale failed plan IDs.
- Add restart, duplicate request, concurrent request and crash-window tests.
**Exit:** repeated identical replan requests return the same result without a
second FFmpeg process; a different payload under the same ID conflicts.
### Phase 5 — Route events and rollout controls
- Add sanitized asynchronous route-event ingestion and retention cleanup.
- Add release queries/dashboards and structured plan logs.
- Add shadow planning for legacy starts on validation servers: compute and log
v3 choice without advertising or executing it.
- Compare v3 shadow decisions with current production routes.
**Exit:** telemetry is queryable, bounded, privacy-reviewed and cannot delay a
start/replan response.
### Phase 6 — Hardware and multi-repository validation
- Run original/remux/transcode fixtures locally and through proxy/transcode
nodes.
- Verify restart reconstruction, session limits, transcode-disabled policy,
alternate versions and output-route changes.
- Run Android Shield tests first on SDR/1080p, then the named 4K DV/AVR chain.
- Record plan, route events, decoder, TV range indicator and AVR format for the
same attempt.
- Publish minimum server revision and enable v3 on validation deployments.
**Exit:** all Phase 0 fixtures pass on the deployed revision and Android no
longer reports `server_upgrade_required`.
## 10. Test matrix
At minimum, include:
### Contract and compatibility
- absent/2/3/unknown protocol versions;
- disabled and enabled feature negotiation;
- v3 nested capabilities are consumed, not silently dropped;
- legacy start response and HTTP status remain unchanged;
- old web/Apple transcode-start requests still work;
- malformed plan/terminal responses cannot be emitted.
### Direct and adaptation
- H.264/AAC MP4 direct;
- HEVC MKV original when the exact Media3 envelope supports it;
- container-only progressive and HLS remux;
- unsupported TrueHD/DTS layout with video copy and audio conversion;
- resolution/user-cap quality transcode;
- transcode disabled, user denied, capacity unavailable, missing tool and
policy denied terminal results;
- alternate lower-resolution file with stable track remapping.
### HDR/DV
- HDR10, HDR10+, HLG and SDR direct gates;
- DV profiles 5, 7 MEL, 7 FEL, 7 unknown and relevant profile 8 variants;
- BL compatibility ID and range mismatch;
- native DV, validated HDR10 base-layer fallback and SDR tone map;
- missing `dovi_rpu`/`dovi_split`/dovi-tool behavior;
- no transformation claims when output was not validated.
### Audio and subtitles
- local decode versus passthrough by exact channels/layout;
- E-AC-3 JOC, TrueHD, DTS-HD/core and PCM fallback replan;
- text sidecar/embedded, ASS with/without authored fidelity, PGS, VobSub, DVB,
conversion and burn-in;
- artifact timing after seeked remux/transcode;
- burn-in forces encode and reports HDR degradation.
### Recovery and load
- transport/decoder failures, track/quality/output invalidation;
- deterministic same-recipe loop rejection;
- idempotent duplicate and concurrent replans;
- API and transcode-node restart between plan and first manifest;
- session expiry, explicit stop and terminal cleanup;
- no double capacity accounting or orphan FFmpeg process;
- route-event flood/body/diagnostic limits.
## 11. Verification commands
Commands assume the repository root is the cwd.
```text
GOWORK=off go test ./internal/playback ./internal/api/handlers ./internal/transcodenode ./internal/nodepool
GOWORK=off go test -race ./internal/playback ./internal/api/handlers
make verify-local-paths
make lint
make build
```
Add a focused integration target that starts PostgreSQL/Redis, creates a media
fixture, calls v3 start/replan, fetches the returned stream or manifest, submits
route events, and verifies cleanup. Contract fixtures must also run in Android
CI against its Kotlin serializers and attempt-key function. Android tests also
prove detailed codec/layout capability emission and a local PCM-recovery route
event.
## 12. Rollout and rollback
1. Merge schema/types with v3 disabled.
2. Deploy shadow planner and compare decisions without changing playback.
3. Enable v3 only on a validation server with the Android test build.
4. Complete SDR, HDR, DV, passthrough, subtitle, restart and node fixtures.
5. Publish the minimum server revision.
6. Enable v3 for production servers after the named soak and telemetry gates.
Rollback is the dynamic `playback.protocol_v3_enabled` setting. Disabling it
stops advertising the feature and returns the no-session negotiation response
to new v3 starts. Existing v3 sessions remain playable until stop/expiry; do
not terminate active streams during rollback. Legacy web and Apple endpoints
remain available throughout.
## 13. Definition of done
- The three Android canonical endpoints and playback capability endpoint are
implemented with profile/session ownership checks.
- Every playable response is complete, final and fetchable without client-owned
recipe decisions.
- Every enabled delivery/transformation has a passing integration fixture.
- Direct decisions validate the selected track and full source/output facts.
- Android supplies the detailed per-codec and per-layout capability facts those
direct decisions consume; missing facts fail conservatively.
- Terminal reasons replace unsafe direct fallbacks.
- Replans are idempotent, loop-safe, restart-aware and capacity-safe.
- Route telemetry is bounded, sanitized and queryable.
- Existing legacy server, web, Apple, Jellyfin compatibility and audiobook
playback tests remain green.
- Android passes direct/remux/transcode/replan tests against the deployed server
revision, followed by the documented 4K Dolby Vision/AVR validation.
@@ -199,6 +199,14 @@ Silo often sees sidecar files that mirror the media basename:
These are common and expected. Episode naming guidance in this page refers to the media files
themselves, but sidecars may legitimately reuse the same stem.
Some NFO sidecars are actively read: `movie.nfo`, `tvshow.nfo`, `<media basename>.nfo`,
`season.nfo`, `<episode basename>.nfo`, and sidecar artwork (season posters, episode
thumbnails) feed the built-in NFO metadata provider — including `<uniqueid>` provider IDs,
which act as trusted matching anchors just like `{tmdb-...}` folder tags. NFO files never
change how files are grouped; naming and layout still decide series/season/episode
structure. See [Local NFO Metadata](nfo-local-metadata.md) for the supported fields and
merge behavior.
Supplemental directories next to a movie (and directly under a series root)
are scanned as **extras** attached to that item, following the Jellyfin/Plex
folder convention:
+177
View File
@@ -0,0 +1,177 @@
---
title: Local NFO Metadata
description: How Silo reads Kodi/Jellyfin-style NFO sidecar files and merges them with online metadata providers.
summary: Supported NFO fields, merge and refresh semantics, and the naming-supplies-structure contract for curated libraries.
tags:
- silo
- docs
- wiki
- libraries
- metadata
- nfo
audience:
- operator
- end-user
last_reviewed: 2026-07-12
related:
- ../index.md
- media-folder-and-naming.md
---
# Local NFO Metadata
Silo ships a built-in **NFO Files** metadata provider that reads Kodi/Jellyfin-style `.nfo`
sidecar files. It runs inside the same provider chain as online providers (TMDB, TVDB, ...),
so one toggle and one priority per library control it. By default it sits at priority 1: the
NFO wins every field it declares, and online providers fill in whatever it leaves out.
Clients can detect whether a library type includes the NFO provider via
`GET /api/v1/libraries/provider-defaults`.
## Where Silo looks for NFO files
For each movie or series, Silo checks (in order):
1. `movie.nfo` / `tvshow.nfo` in the item's folder
2. `<media basename>.nfo` next to the media file (e.g. `My Movie (2021).nfo`)
For series, Silo additionally reads:
- `season.nfo` inside each season directory (`<season>` root: title, plot)
- `<episode basename>.nfo` next to each episode file (`<episodedetails>` root: title,
plot, air date, runtime, ratings)
- Season posters: `poster.jpg` (or `folder`/`cover`) inside the season directory, or
`seasonNN-poster.jpg` in the series root
- Episode thumbnails: `<episode basename>-thumb.jpg`
A sidecar only applies when its root element matches the item type: a stray `movie.nfo` in
a series folder is skipped in favor of `tvshow.nfo`, and a `tvshow.nfo` next to a movie
file is ignored. Files that fail to parse are skipped silently and the next candidate is
tried — a broken NFO never blocks matching.
Season and episode numbers declared inside an NFO (`<seasonnumber>`, `<season>`,
`<episode>`) are advisory: the directory name and the `SxxEyy` filename pattern decide
where metadata lands, and a mismatch is logged and resolved in the filename's favor.
Multi-episode NFO documents (several `<episodedetails>` blocks in one file) are not
supported; the first block is used and a warning is logged.
## Supported fields
| NFO element | Applies to | Silo field |
|---|---|---|
| `<title>` | movie, series | Title |
| `<originaltitle>` | movie, series | Original title |
| `<tagline>` | movie, series | Tagline |
| `<plot>` | movie, series | Overview |
| `<year>` | movie, series | Year (derived from the premiere date when omitted) |
| `<runtime>` | movie, series | Runtime in minutes (non-numeric values ignored) |
| `<premiered>` / `<releasedate>` | movie | Release date (`YYYY-MM-DD`) |
| `<premiered>` / `<aired>` | series | First air date (`YYYY-MM-DD`) |
| `<mpaa>` | movie, series | Content rating |
| `<genre>` (repeated) | movie, series | Genres |
| `<studio>` (repeated) | movie, series | Studios |
| `<country>` (repeated) | movie, series | Countries |
| `<tag>` (repeated) | movie, series | Keywords |
| `<ratings>` / `<rating>` | movie, series | Ratings (named sources: `imdb`, `tmdb`/`themoviedb`, `tomatometerallcritics`, `tomatometerallaudience`; a legacy bare `<rating>` fills the IMDB slot) |
| `<actor>` (`name`/`role`/`order`) | movie, series | Cast (actor `<thumb>` URLs are ignored) |
| `<director>`, `<credits>` | movie, series | Crew (directors and writers) |
| `<uniqueid type="tmdb|imdb|tvdb">` | movie, series | Trusted identity anchor for matching |
| `<title>`, `<plot>` (`season.nfo`) | season | Season name and overview |
| `<title>`, `<plot>`, `<aired>`, `<runtime>`, `<ratings>` (`<episodedetails>`) | episode | Episode title, overview, air date, runtime, ratings |
Not yet supported: `<set>` (collections) — a planned follow-up. `<userrating>` is read
but not stored (Silo has no per-user rating field). Unknown elements are ignored, so
exports from Kodi, Jellyfin, or tinyMediaManager work as-is.
## How NFO data merges with online providers
- **NFO wins what it declares.** With the NFO provider at priority 1, every field the NFO
populates beats the same field from online providers.
- **Online providers backfill the rest.** Anything the NFO leaves out (cast, artwork,
runtime, ratings, ...) is filled by the next providers in the chain, keyed by the
`<uniqueid>` values when present.
- **Genres are all-or-nothing.** The first provider that supplies any genres supplies the
whole list; later providers do not append to it. An NFO with no `<genre>` entries leaves
genres entirely to online providers.
- **Field locks are respected.** Fields locked in the Edit Metadata dialog (including
Year and release/air dates) are never overwritten by any provider, NFO included.
## Editing an NFO after the initial match
Scheduled background refreshes only fill empty fields — they intentionally do **not**
re-apply NFO changes over existing values. To pick up edits:
**Edit the NFO, then use Refresh metadata on the item.** A manual refresh re-reads the
sidecar and replaces all unlocked fields with the merged provider results, NFO first.
## Naming supplies structure, NFO supplies presentation
NFO files describe *what an item is called and about* — they do not change *how files are
grouped*. Folder layout and filenames still decide what is a series, a season, and an
episode (see [Supported Media Folder Structures and Naming](media-folder-and-naming.md)).
This makes fully local libraries with no online match work well, for example a fitness
library:
```
Fitness/
P90X/
tvshow.nfo # show title/plot (no <uniqueid> needed)
poster.jpg fanart.jpg
Season 01/
season.nfo # "Course A: Classic"
poster.jpg # season poster
P90X S01E01 - Chest and Back.mkv
P90X S01E01 - Chest and Back.nfo # episode title/plot
P90X S01E01 - Chest and Back-thumb.jpg # episode thumbnail
```
The folder and episode naming build the series/season/episode tree; the NFO sidecars
supply every name, description, and image — even when no online provider knows the
content. Episodes without an `.nfo` still appear with their generic "Episode N" title,
so partially curated libraries degrade gracefully.
## Mixed libraries (sports example)
A single library of type **Mixed** can hold movie-shaped and show-shaped content side by
side — the common case is sports, where pay-per-view events are movies and the weekly
show is a series, and most of it has no TMDB/TVDB presence:
```
WWE/
WrestleMania 41 (2025)/
WrestleMania 41 (2025).mkv
movie.nfo # event title/plot (a <uniqueid> is optional)
poster.jpg
WWE SmackDown/
tvshow.nfo
Season 27/
season.nfo
poster.jpg
WWE SmackDown S27E15.mkv
WWE SmackDown S27E15.nfo
WWE SmackDown S27E15-thumb.jpg
```
How classification works in a mixed library:
- **Naming decides the type, per file, before any metadata provider runs.** An `SxxEyy`
pattern or a `Season NN` folder routes a file to the series lane; everything else is a
movie. An NFO never flips the type: a `tvshow.nfo` sitting next to a movie-classified
file is ignored (the root element must match the item type), not applied.
- **NFOs supply metadata and identity only.** Once the type is fixed, the NFO provider
behaves exactly as in dedicated movie/series libraries — including season and episode
depth for the show-shaped content. An event whose `movie.nfo` carries a
`<uniqueid type="tmdb">` still gets full online enrichment, while ID-less events next
to it fall back to their local NFO and artwork.
- **Misclassified group?** Override its **Type** on the root in the library admin
(Auto → Movie/Series) and rescan — that is the correction path; fixing the NFO alone
will not re-route a file.
Tools that generate sports libraries (sportarr-style managers) should therefore write
standard `SxxEyy` naming for show content and standalone movie naming (`Title (Year)/`)
for events, and place NFOs/posters exactly as in the dedicated-library layouts above.
## Source References
- `internal/metadata/nfo/` — parser and provider
- `internal/metadata/merge.go` — merge semantics and field locks
+2
View File
@@ -35,6 +35,8 @@ rewriting.
reference for the folder layouts and filenames Silo can scan and match today.
- [Collection Templates](admin/collection-templates.md) - Curated, one-click starting points for
synced library collections sourced from TMDB, Trakt, and MDBList.
- [Local NFO Metadata](admin/nfo-local-metadata.md) - Supported NFO sidecar fields, how they merge
with online providers, and the naming-supplies-structure contract.
## Deployment
+14 -13
View File
@@ -3,9 +3,9 @@ module github.com/Silo-Server/silo-server
go 1.26.4
require (
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
github.com/aws/aws-sdk-go-v2 v1.42.1
github.com/aws/aws-sdk-go-v2/credentials v1.19.29
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2
github.com/go-chi/chi/v5 v5.2.5
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
@@ -22,6 +22,7 @@ require (
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/abadojack/whatlanggo v1.0.1
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.34
github.com/go-chi/cors v1.2.2
github.com/gorilla/websocket v1.5.3
github.com/h2non/bimg v1.1.9
@@ -109,15 +110,15 @@ require (
require (
github.com/Silo-Server/silo-plugin-sdk v0.10.0
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
github.com/aws/smithy-go v1.27.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
@@ -144,7 +145,7 @@ require (
golang.org/x/sync v0.21.0
golang.org/x/text v0.38.0
golang.org/x/time v0.15.0
google.golang.org/grpc v1.81.1
google.golang.org/grpc v1.82.1
google.golang.org/protobuf v1.36.11
)
+40 -26
View File
@@ -16,30 +16,44 @@ github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x0
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
github.com/aws/aws-sdk-go-v2/config v1.32.30 h1:XwsEzpTJfQYJbFicz/QMLwAZdyeNVVoOEkbF7R3gPJk=
github.com/aws/aws-sdk-go-v2/config v1.32.30/go.mod h1:Ud32SuMc+/9BGxfpSVld7HrE2o05JwKmXY4M3jOQNZU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.29 h1:WHZGssHH887cO0ox07SIQZsFx3MKD4ps6w0xUEmnKYQ=
github.com/aws/aws-sdk-go-v2/credentials v1.19.29/go.mod h1:Mhl0xR6zjguiuj00XRx2wMx22sAltk7oya39sT7fdg8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.34 h1:Pn7OsMwBLbkZ6OnCxWHAjf0L/22H8cnhxZC0uPwtMtg=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.34/go.mod h1:eToXR/Gk1uqpn04eSmdgVXwfS0WvH8aG4eBFr8ygbpU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23/go.mod h1:iMoT2f1tClxrWAAnKCXjZQ6LOmfLrMG14wmnWpM+F14=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I=
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2 h1:5C00eQYpTrgQXnp6V3P6P7zPElna3AXvlukbANE6nJI=
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 h1:V7ZZ300WPXGjvkyore5DGe0ljVPOxCXie/thWdtSBXE=
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg=
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 h1:gYFYh4iLLcAOJRLNPY2aD2g9DIhKn4eof8UkIrr1rTk=
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 h1:arjT9Cm3/WYbGmD5TUZHk4UQn4Lle1fUNZs5FC6CtF0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84=
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 h1:RvfHDg+xvAeZ+5741vUEjpOVtYSIm93W2zhx10Xtydw=
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@@ -442,8 +456,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:
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=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+1 -1
View File
@@ -124,7 +124,7 @@ func (s stubStore) DeleteHomeDismissal(context.Context, string, string, string)
panic("unused")
}
func (s stubStore) AddFavorite(context.Context, string, string) error { panic("unused") }
func (s stubStore) AddFavoriteAt(context.Context, string, string, time.Time) error {
func (s stubStore) AddFavoriteAt(context.Context, string, string, time.Time) (bool, error) {
panic("unused")
}
func (s stubStore) RemoveFavorite(context.Context, string, string) error {
+125 -2
View File
@@ -30,6 +30,7 @@ import (
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/diagnostics"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/markers"
"github.com/Silo-Server/silo-server/internal/models"
@@ -64,6 +65,12 @@ type ServerSettingsStore interface {
GetAll(ctx context.Context) (map[string]string, error)
}
type DiagnosticsEnablementStore interface {
PutStream(ctx context.Context, bucket, key string, r io.Reader, contentType string) error
DeleteObject(ctx context.Context, bucket, key string) error
Bucket() string
}
type AdminJobCreator interface {
Create(ctx context.Context, input adminjob.CreateJobInput) (*models.AdminJob, error)
CreateLibraryRefresh(
@@ -97,6 +104,7 @@ type AdminHandler struct {
EventBus cache.EventBus
EventsHub *evt.Hub
SettingsRepo ServerSettingsStore
DiagnosticsStore DiagnosticsEnablementStore
JobRepo AdminJobCreator
ItemRefreshResolver ItemRefreshScopeResolver
ImpersonationService ImpersonationService
@@ -2215,6 +2223,38 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
return
}
req.Value = strconv.FormatBool(enabled)
case diagnostics.KeyUploadsEnabled:
enabled, err := strconv.ParseBool(strings.TrimSpace(req.Value))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", diagnostics.KeyUploadsEnabled+" must be true or false")
return
}
req.Value = strconv.FormatBool(enabled)
if enabled {
if err := h.validateDiagnosticsUploadsEnabled(r.Context()); err != nil {
writeError(w, http.StatusBadRequest, "storage_unavailable", err.Error())
return
}
}
case diagnostics.KeyMaxBundleBytes,
diagnostics.KeyMaxUncompressedBytes,
diagnostics.KeyMaxReportsPerUserDay,
diagnostics.KeyRetentionDays,
diagnostics.KeyMaxBytesPerUser:
normalized, err := h.normalizeDiagnosticsNumericSetting(r.Context(), key, req.Value)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
req.Value = normalized
case diagnostics.KeyConsentNoticeVersion:
n, err := strconv.Atoi(strings.TrimSpace(req.Value))
if err != nil || n < 1 {
writeError(w, http.StatusBadRequest, "bad_request",
diagnostics.KeyConsentNoticeVersion+" must be an integer greater than 0")
return
}
req.Value = strconv.Itoa(n)
case policy.SettingDecisionLogScopeSampleRate:
n, err := strconv.Atoi(strings.TrimSpace(req.Value))
if err != nil || n <= 0 {
@@ -2243,10 +2283,11 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
"subtitle_ai.transcribe_quota_period must be day, week, or month")
return
}
case notifications.SettingApplePushDeliveryEnabled:
case notifications.SettingApplePushDeliveryEnabled,
notifications.SettingAndroidPushDeliveryEnabled:
enabled, err := strconv.ParseBool(strings.TrimSpace(req.Value))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "notifications.apple_push_delivery_enabled must be true or false")
writeError(w, http.StatusBadRequest, "bad_request", key+" must be true or false")
return
}
req.Value = strconv.FormatBool(enabled)
@@ -2382,3 +2423,85 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
}
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value, RestartRequired: restartRequired})
}
func (h *AdminHandler) validateDiagnosticsUploadsEnabled(ctx context.Context) error {
if h.DiagnosticsStore == nil || strings.TrimSpace(h.DiagnosticsStore.Bucket()) == "" {
return errors.New("diagnostics uploads require configured private object storage")
}
const probeKey = "diagnostics/.probe"
if err := h.DiagnosticsStore.PutStream(
ctx,
h.DiagnosticsStore.Bucket(),
probeKey,
strings.NewReader("ok"),
"application/octet-stream",
); err != nil {
return fmt.Errorf("diagnostics storage probe write failed: %w", err)
}
if err := h.DiagnosticsStore.DeleteObject(ctx, h.DiagnosticsStore.Bucket(), probeKey); err != nil {
return fmt.Errorf("diagnostics storage probe delete failed: %w", err)
}
return nil
}
func (h *AdminHandler) normalizeDiagnosticsNumericSetting(ctx context.Context, key, raw string) (string, error) {
const (
mib = int64(1024 * 1024)
gib = int64(1024 * mib)
)
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil {
return "", fmt.Errorf("%s must be an integer", key)
}
settings := diagnostics.DefaultSettings()
if h.SettingsRepo != nil {
loaded, loadErr := diagnostics.LoadSettings(ctx, h.SettingsRepo)
if loadErr != nil {
return "", fmt.Errorf("load diagnostics settings: %w", loadErr)
}
settings = loaded
}
switch key {
case diagnostics.KeyMaxBundleBytes:
if value < mib || value > 256*mib {
return "", fmt.Errorf("%s must be between 1 MiB (%d bytes) and 256 MiB (%d bytes)", key, mib, 256*mib)
}
if value > settings.MaxUncompressedBytes {
return "", fmt.Errorf("%s must not exceed %s (%d bytes)", key, diagnostics.KeyMaxUncompressedBytes, settings.MaxUncompressedBytes)
}
// A single bundle can never exceed the per-user byte cap, or every upload
// at this size would fail quota; keep the two bounds consistent.
if value > settings.MaxBytesPerUser {
return "", fmt.Errorf("%s must not exceed %s (%d bytes)", key, diagnostics.KeyMaxBytesPerUser, settings.MaxBytesPerUser)
}
case diagnostics.KeyMaxUncompressedBytes:
if value < settings.MaxBundleBytes || value > gib {
return "", fmt.Errorf("%s must be between %s (%d bytes) and 1 GiB (%d bytes)", key, diagnostics.KeyMaxBundleBytes, settings.MaxBundleBytes, gib)
}
case diagnostics.KeyMaxReportsPerUserDay:
if value < 1 || value > 1000 {
return "", fmt.Errorf("%s must be between 1 and 1000", key)
}
case diagnostics.KeyRetentionDays:
if value < 1 || value > 365 {
return "", fmt.Errorf("%s must be between 1 and 365", key)
}
case diagnostics.KeyMaxBytesPerUser:
if value < 10*mib || value > 10*gib {
return "", fmt.Errorf("%s must be between 10 MiB (%d bytes) and 10 GiB (%d bytes)", key, 10*mib, 10*gib)
}
// The per-user cap must leave room for at least one max-size bundle, or
// /diagnostics/status would advertise a bundle size InsertReceiving always
// rejects as quota_exceeded.
if value < settings.MaxBundleBytes {
return "", fmt.Errorf("%s must be at least %s (%d bytes)", key, diagnostics.KeyMaxBundleBytes, settings.MaxBundleBytes)
}
default:
return "", fmt.Errorf("unsupported diagnostics numeric setting %s", key)
}
return strconv.FormatInt(value, 10), nil
}
+19 -5
View File
@@ -1,6 +1,7 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"math"
@@ -64,8 +65,21 @@ type adminPushRelayRegisterResponse struct {
// HandleTest handles POST /admin/notifications/push/apple/test.
func (h *AdminApplePushHandler) HandleTest(w http.ResponseWriter, r *http.Request) {
h.handleTest(w, r, "Apple", func(ctx context.Context, profileID, serverDeviceID string) (*notifications.ApplePushTestResult, error) {
return h.system.SendApplePushTest(ctx, profileID, serverDeviceID)
})
}
// HandleTestAndroid handles POST /admin/notifications/push/fcm/test.
func (h *AdminApplePushHandler) HandleTestAndroid(w http.ResponseWriter, r *http.Request) {
h.handleTest(w, r, "Android", func(ctx context.Context, profileID, serverDeviceID string) (*notifications.ApplePushTestResult, error) {
return h.system.SendAndroidPushTest(ctx, profileID, serverDeviceID)
})
}
func (h *AdminApplePushHandler) handleTest(w http.ResponseWriter, r *http.Request, platformLabel string, send func(context.Context, string, string) (*notifications.ApplePushTestResult, error)) {
if h == nil || h.system == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Apple push delivery is not available")
writeError(w, http.StatusServiceUnavailable, "unavailable", platformLabel+" push delivery is not available")
return
}
var req adminApplePushTestRequest
@@ -73,17 +87,17 @@ func (h *AdminApplePushHandler) HandleTest(w http.ResponseWriter, r *http.Reques
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
result, err := h.system.SendApplePushTest(r.Context(), req.ProfileID, req.ServerDeviceID)
result, err := send(r.Context(), req.ProfileID, req.ServerDeviceID)
if err != nil {
switch {
case errors.Is(err, notifications.ErrPushDeliveryInvalid):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
case errors.Is(err, notifications.ErrPushDeliveryNotFound):
writeError(w, http.StatusNotFound, "not_found", "Apple push device not found")
writeError(w, http.StatusNotFound, "not_found", platformLabel+" push device not found")
case errors.Is(err, notifications.ErrPushDeliveryUnavailable):
writeError(w, http.StatusServiceUnavailable, "unavailable", "Apple push delivery is not available")
writeError(w, http.StatusServiceUnavailable, "unavailable", platformLabel+" push delivery is not available")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to send Apple push test")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to send "+platformLabel+" push test")
}
return
}
+265
View File
@@ -0,0 +1,265 @@
package handlers
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/diagnostics"
)
const diagnosticsDownloadExpiry = 15 * time.Minute
type AdminDiagnosticsService interface {
ListForAdmin(ctx context.Context, filters diagnostics.ListFilters) (diagnostics.ListResult, error)
GetReport(ctx context.Context, id string) (*diagnostics.Report, error)
PresignReportDownload(ctx context.Context, report *diagnostics.Report, expiry time.Duration) (string, error)
OpenReportDownload(ctx context.Context, report *diagnostics.Report) (io.ReadCloser, error)
DeleteReport(ctx context.Context, id string) (*diagnostics.Report, error)
}
type adminDiagnosticsEffectiveTTL interface {
EffectiveReportDownloadTTL(requested time.Duration) time.Duration
}
type diagnosticsDownloadURLResponse struct {
DownloadURL string `json:"download_url"`
ExpiresAt time.Time `json:"expires_at"`
}
func RegisterAdminDiagnosticsRoutes(r chi.Router, h *DiagnosticsHandler) {
if h == nil {
return
}
r.Route("/diagnostics/reports", func(r chi.Router) {
r.Get("/", h.HandleAdminListReports)
r.Get("/{id}", h.HandleAdminGetReport)
r.Get("/{id}/download", h.HandleAdminDownloadReport)
r.Delete("/{id}", h.HandleAdminDeleteReport)
})
}
func (h *DiagnosticsHandler) HandleAdminListReports(w http.ResponseWriter, r *http.Request) {
admin, ok := h.admin()
if !ok {
writeDiagnosticsAdminUnavailable(w)
return
}
filters, err := parseDiagnosticsReportListFilters(r)
if err != nil {
writeDiagnosticsAdminError(w, err)
return
}
result, err := admin.ListForAdmin(r.Context(), filters)
if err != nil {
writeDiagnosticsAdminError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *DiagnosticsHandler) HandleAdminGetReport(w http.ResponseWriter, r *http.Request) {
admin, ok := h.admin()
if !ok {
writeDiagnosticsAdminUnavailable(w)
return
}
report, err := admin.GetReport(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeDiagnosticsAdminError(w, err)
return
}
writeJSON(w, http.StatusOK, report)
}
func (h *DiagnosticsHandler) HandleAdminDownloadReport(w http.ResponseWriter, r *http.Request) {
admin, ok := h.admin()
if !ok {
writeDiagnosticsAdminUnavailable(w)
return
}
reportID := chi.URLParam(r, "id")
report, err := admin.GetReport(r.Context(), reportID)
if err != nil {
writeDiagnosticsAdminError(w, err)
return
}
if report.State != diagnostics.StateReady {
writeDiagnosticsAdminError(w, diagnostics.ErrReportNotReady)
return
}
ttl := diagnosticsEffectiveDownloadTTL(admin, diagnosticsDownloadExpiry)
if !diagnosticsProxyDownloadRequested(r) {
if url, err := admin.PresignReportDownload(r.Context(), report, ttl); err == nil && strings.TrimSpace(url) != "" {
h.auditDownload(r, report.ID)
writeJSON(w, http.StatusOK, diagnosticsDownloadURLResponse{
DownloadURL: url,
ExpiresAt: time.Now().UTC().Add(ttl),
})
return
}
}
body, err := admin.OpenReportDownload(r.Context(), report)
if err != nil {
writeDiagnosticsAdminError(w, err)
return
}
defer body.Close()
h.auditDownload(r, report.ID)
w.Header().Set("Content-Type", diagnostics.ReportDownloadContentType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", diagnosticsReportFilename(report)))
if report.BlobBytes != nil && *report.BlobBytes >= 0 {
w.Header().Set("Content-Length", strconv.FormatInt(*report.BlobBytes, 10))
}
w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, body); err != nil {
h.diagnosticsLogger().WarnContext(r.Context(), "diagnostic report download stream failed",
"component", "diagnostics",
"admin_user_id", currentAdminUserID(r),
"report_id", report.ID,
"error", err,
)
}
}
func (h *DiagnosticsHandler) HandleAdminDeleteReport(w http.ResponseWriter, r *http.Request) {
admin, ok := h.admin()
if !ok {
writeDiagnosticsAdminUnavailable(w)
return
}
reportID := chi.URLParam(r, "id")
report, err := admin.DeleteReport(r.Context(), reportID)
if err != nil {
writeDiagnosticsAdminError(w, err)
return
}
h.diagnosticsLogger().InfoContext(r.Context(), "diagnostic report deleted",
"component", "diagnostics",
"admin_user_id", currentAdminUserID(r),
"report_id", report.ID,
)
w.WriteHeader(http.StatusNoContent)
}
func (h *DiagnosticsHandler) admin() (AdminDiagnosticsService, bool) {
if h.adminService == nil {
return nil, false
}
return h.adminService, true
}
func (h *DiagnosticsHandler) diagnosticsLogger() *slog.Logger {
if h.logger == nil {
return slog.Default()
}
return h.logger
}
func (h *DiagnosticsHandler) auditDownload(r *http.Request, reportID string) {
h.diagnosticsLogger().InfoContext(r.Context(), "diagnostic report downloaded",
"component", "diagnostics",
"admin_user_id", currentAdminUserID(r),
"report_id", reportID,
)
}
func parseDiagnosticsReportListFilters(r *http.Request) (diagnostics.ListFilters, error) {
filters := diagnostics.ListFilters{
Platform: strings.TrimSpace(r.URL.Query().Get("platform")),
ReportType: strings.TrimSpace(r.URL.Query().Get("report_type")),
Cursor: strings.TrimSpace(r.URL.Query().Get("cursor")),
Limit: parseLimit(r, 50),
}
userID, err := parseOptionalIntQuery(r, "user_id")
if err != nil {
return diagnostics.ListFilters{}, err
}
filters.UserID = userID
if from, err := parseTimeQuery(r, "from"); err != nil {
return diagnostics.ListFilters{}, err
} else {
filters.From = from
}
if to, err := parseTimeQuery(r, "to"); err != nil {
return diagnostics.ListFilters{}, err
} else {
filters.To = to
}
shortID := strings.TrimSpace(r.URL.Query().Get("short_id"))
if shortID != "" {
normalized, err := diagnostics.ParseShortID(shortID)
if err != nil {
return diagnostics.ListFilters{}, err
}
filters.ShortID = normalized
}
return filters, nil
}
func diagnosticsReportFilename(report *diagnostics.Report) string {
name := strings.TrimSpace(report.ShortID)
if name == "" {
name = strings.TrimSpace(report.ID)
}
if name == "" {
name = "report"
}
return "silo-diagnostics-" + name + ".tar.gz"
}
func diagnosticsProxyDownloadRequested(r *http.Request) bool {
raw := strings.TrimSpace(r.URL.Query().Get("proxy"))
return raw == "1" || strings.EqualFold(raw, "true") || strings.EqualFold(raw, "yes")
}
func diagnosticsEffectiveDownloadTTL(admin AdminDiagnosticsService, requested time.Duration) time.Duration {
ttl := requested
if effective, ok := admin.(adminDiagnosticsEffectiveTTL); ok {
ttl = effective.EffectiveReportDownloadTTL(requested)
}
if ttl <= 0 {
return requested
}
return ttl
}
func writeDiagnosticsAdminUnavailable(w http.ResponseWriter) {
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "Diagnostics reports are not configured")
}
func writeDiagnosticsAdminError(w http.ResponseWriter, err error) {
var parseErr *requestParseError
switch {
case errors.As(err, &parseErr):
writeError(w, http.StatusBadRequest, "bad_request", parseErr.Error())
case errors.Is(err, diagnostics.ErrInvalidShortID):
writeError(w, http.StatusBadRequest, "bad_request", "Invalid short_id")
case errors.Is(err, diagnostics.ErrInvalidCursor):
writeError(w, http.StatusBadRequest, "bad_request", "Invalid cursor")
case errors.Is(err, diagnostics.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", "Diagnostic report not found")
case errors.Is(err, diagnostics.ErrReportNotReady):
writeError(w, http.StatusConflict, "not_ready", "Diagnostic report is not ready")
case errors.Is(err, diagnostics.ErrStorageUnavailable):
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "Diagnostics storage is not available")
case diagnostics.IsObjectNotFound(err):
writeError(w, http.StatusNotFound, "not_found", "Diagnostic report bundle not found")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Diagnostics report operation failed")
}
}
+52 -65
View File
@@ -7,10 +7,12 @@ import (
"fmt"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/metadata"
"github.com/Silo-Server/silo-server/internal/models"
)
@@ -52,6 +54,7 @@ type AdminImageHandler struct {
imageSvc ImageService
imageResolver ImageURLResolver
detailSvc *catalog.DetailService
EventsHub *evt.Hub
}
// NewAdminImageHandler creates a handler for admin image selection endpoints.
@@ -110,6 +113,8 @@ type applyItemImageResponse struct {
ContentID string `json:"content_id"`
StoredPath string `json:"stored_path"`
Thumbhash string `json:"thumbhash"`
ImageURL string `json:"image_url,omitempty"`
Revision string `json:"revision,omitempty"`
}
// resolvedItem holds the result of looking up a content ID across all three tables.
@@ -304,6 +309,18 @@ func (h *AdminImageHandler) HandleApplyItemImage(w http.ResponseWriter, r *http.
}
imageType := metadata.ImageTypeFromString(req.Type)
// Episodes only have stills. Clients historically sent "poster" here (the
// old flow silently dropped it), so coerce rather than reject.
if resolved.contentType == "episode" {
imageType = metadata.ImageStill
}
// Reject unsupported target/image combinations before spending a download
// and an S3 upload on an image that can never be published.
if err := catalog.ValidateArtworkSelectionTarget(resolved.contentType, metadata.ImageTypeToString(imageType)); err != nil {
writeError(w, http.StatusBadRequest, "unsupported_image_type",
fmt.Sprintf("%s items do not accept %s images", resolved.contentType, metadata.ImageTypeToString(imageType)))
return
}
providerID := req.ProviderID
if providerID == "" {
providerID = primaryProvider(resolved.parentItem)
@@ -344,52 +361,54 @@ func (h *AdminImageHandler) HandleApplyItemImage(w http.ResponseWriter, r *http.
return
}
// Build the MetadataUpdate targeting only the relevant image field.
upd := buildImageUpdate(imageType, result.StoredPath, result.Thumbhash)
// Lock FieldImages on the parent media_items row to prevent
// automatic refreshes from overwriting the manual selection.
if resolved.contentType == "movie" || resolved.contentType == "series" {
locked := mergeLockedField(resolved.parentItem.LockedFields, int(metadata.FieldImages))
upd.LockedFields = &locked
}
// Persist to the correct table.
if err := h.persistImageUpdate(r.Context(), contentID, resolved, &upd); err != nil {
err = h.detailSvc.PublishArtworkSelection(r.Context(), catalog.ArtworkSelection{
TargetType: resolved.contentType,
TargetContentID: contentID,
ParentContentID: resolved.parentItem.ContentID,
ImageType: metadata.ImageTypeToString(imageType),
StoredPath: result.StoredPath,
SourcePath: req.OriginalURL,
Thumbhash: result.Thumbhash,
LockField: int(metadata.FieldImages),
})
if err != nil {
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")
if cleanupErr := h.detailSvc.QueueArtworkRevisionGC(
r.Context(), result.StoredPath, metadata.ImageTypeToString(imageType), time.Now().Add(time.Hour),
); cleanupErr != nil {
slog.ErrorContext(r.Context(), "admin images: failed to queue unpublished artwork cleanup", "component", "api",
"content_id", contentID, "stored_path", result.StoredPath, "error", cleanupErr)
}
switch {
case errors.Is(err, catalog.ErrItemNotFound),
errors.Is(err, catalog.ErrSeasonNotFound),
errors.Is(err, catalog.ErrEpisodeNotFound):
writeError(w, http.StatusNotFound, "not_found", "Item not found")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Image cached but failed to update item")
}
return
}
// Also lock FieldImages on the parent series for seasons/episodes.
if resolved.contentType == "season" || resolved.contentType == "episode" {
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.ErrorContext(r.Context(), "admin images: failed to lock FieldImages on parent series", "component", "api",
"content_id", contentID, "parent_id", resolved.parentItem.ContentID, "error", lockErr)
}
publishEventMetadataUpdate(r.Context(), h.EventsHub, 0, contentID)
if resolved.parentItem.ContentID != contentID {
publishEventMetadataUpdate(r.Context(), h.EventsHub, 0, resolved.parentItem.ContentID)
}
imageURL := ""
if h.imageResolver != nil {
imageURL = h.imageResolver.ResolveImageURL(r.Context(), result.StoredPath, "original")
}
writeJSON(w, http.StatusOK, applyItemImageResponse{
ContentID: contentID,
StoredPath: result.StoredPath,
Thumbhash: result.Thumbhash,
ImageURL: imageURL,
Revision: result.Revision,
})
}
// persistImageUpdate writes the image path update to the correct table.
func (h *AdminImageHandler) persistImageUpdate(ctx context.Context, contentID string, resolved *resolvedItem, upd *catalog.MetadataUpdate) error {
switch resolved.contentType {
case "season":
return h.detailSvc.UpdateSeasonMetadata(ctx, contentID, upd)
case "episode":
return h.detailSvc.UpdateEpisodeMetadata(ctx, contentID, upd)
default:
return h.detailSvc.UpdateMediaItemMetadata(ctx, contentID, upd)
}
}
// resolveImageFolderID finds the primary library folder for a content ID.
func (h *AdminImageHandler) resolveImageFolderID(ctx context.Context, contentID string) (int, error) {
if h.folders == nil {
@@ -417,38 +436,6 @@ func buildProviderIDs(item *models.MediaItem) map[string]string {
return ids
}
// buildImageUpdate constructs a MetadataUpdate targeting a single image field.
func buildImageUpdate(imageType metadata.ImageType, storedPath, thumbhash string) catalog.MetadataUpdate {
var upd catalog.MetadataUpdate
switch imageType {
case metadata.ImageBackdrop:
upd.BackdropPath = &storedPath
upd.BackdropThumbhash = &thumbhash
case metadata.ImageLogo:
upd.LogoPath = &storedPath
case metadata.ImageStill:
upd.StillPath = &storedPath
upd.StillThumbhash = &thumbhash
default: // poster
upd.PosterPath = &storedPath
upd.PosterThumbhash = &thumbhash
}
return upd
}
// mergeLockedField adds a field value to the locked fields slice if not already present.
func mergeLockedField(existing []int, field int) []int {
for _, f := range existing {
if f == field {
return existing
}
}
result := make([]int, len(existing)+1)
copy(result, existing)
result[len(existing)] = field
return result
}
// primaryProvider returns the primary provider slug for a media item.
func primaryProvider(item *models.MediaItem) string {
if item.TmdbID != "" {
+373
View File
@@ -0,0 +1,373 @@
package handlers
import (
"context"
"errors"
"io"
"log/slog"
"mime"
"mime/multipart"
"net/http"
"strings"
"sync"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/diagnostics"
)
const (
diagnosticsMultipartOverheadBytes = int64(128 * 1024)
diagnosticsBusyRetryAfter = "5"
diagnosticsQuotaRetryAfter = "60"
// diagnosticsUploadReadTimeout replaces the shared 30s server ReadTimeout
// for this route only. Bundles are up to 10 MiB (admin-tunable higher) and
// mobile crash uploads over slow uplinks routinely exceed 30s; without this
// net/http aborts the read mid-stream before Ingest can return a retryable
// error. The bound stays generous but finite so a stalled upload still can't
// hold the connection open indefinitely.
diagnosticsUploadReadTimeout = 10 * time.Minute
)
var (
errDiagnosticsPartTooLarge = errors.New("diagnostics multipart part too large")
errDiagnosticsUnexpectedPart = errors.New("diagnostics multipart contains unexpected part")
)
type DiagnosticsService interface {
Status(ctx context.Context, userID int) (diagnostics.Status, error)
Ingest(ctx context.Context, userID int, profileID *string, manifestJSON []byte, bundle io.Reader) (diagnostics.IngestResult, error)
}
type DiagnosticsHandler struct {
service DiagnosticsService
adminService AdminDiagnosticsService
inflight *diagnosticsInFlightLimiter
logger *slog.Logger
}
func NewDiagnosticsHandler(service DiagnosticsService) *DiagnosticsHandler {
handler := &DiagnosticsHandler{
service: service,
inflight: newDiagnosticsInFlightLimiter(4),
logger: slog.Default(),
}
if adminService, ok := service.(AdminDiagnosticsService); ok {
handler.adminService = adminService
}
return handler
}
func (h *DiagnosticsHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
userID, ok := diagnosticsUserID(w, r)
if !ok {
return
}
status, err := h.service.Status(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load diagnostics status")
return
}
writeJSON(w, http.StatusOK, status)
}
func (h *DiagnosticsHandler) HandleUpload(w http.ResponseWriter, r *http.Request) {
// Extend the read deadline for this request only; the server-wide
// ReadTimeout is too short for slow, large bundle uploads (see the constant).
rc := http.NewResponseController(w)
if err := rc.SetReadDeadline(time.Now().Add(diagnosticsUploadReadTimeout)); err != nil {
h.diagnosticsLogger().WarnContext(r.Context(), "diagnostics upload read deadline not extended",
"component", "diagnostics",
"error", err,
)
}
// Extend the write deadline the same way. cmd/silo's integrated server sets
// WriteTimeout 120s, but a slow upload can take longer than that within the
// read window above; Ingest may store and mark the report ready and then the
// final writeJSON would miss the 120s write deadline, so the client sees a
// timeout and retries a report that already succeeded.
if err := rc.SetWriteDeadline(time.Now().Add(diagnosticsUploadReadTimeout)); err != nil {
h.diagnosticsLogger().WarnContext(r.Context(), "diagnostics upload write deadline not extended",
"component", "diagnostics",
"error", err,
)
}
userID, ok := diagnosticsUserID(w, r)
if !ok {
claims := apimw.GetClaims(r.Context())
if claims != nil && claims.TokenType == auth.TokenTypeAPIKey {
h.logRejected(r.Context(), claims.UserID, "api_key_not_allowed")
}
return
}
status, err := h.service.Status(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load diagnostics status")
return
}
maxBundleBytes := status.MaxBundleBytes
if maxBundleBytes <= 0 {
maxBundleBytes = diagnostics.DefaultMaxBundleBytes
}
r.Body = http.MaxBytesReader(w, r.Body, maxBundleBytes+diagnosticsMultipartOverheadBytes)
switch status.Status {
case diagnostics.StatusDisabled:
writeError(w, http.StatusForbidden, "disabled", "Diagnostics uploads are disabled")
return
case diagnostics.StatusStorageUnavailable:
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "Diagnostics storage is not configured")
return
case diagnostics.StatusAvailable:
default:
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "Diagnostics storage is not available")
return
}
release, acquired := h.inflight.acquire(userID)
if !acquired {
h.logRejected(r.Context(), userID, "busy")
w.Header().Set("Retry-After", diagnosticsBusyRetryAfter)
writeError(w, http.StatusServiceUnavailable, "busy", "Diagnostics upload capacity is busy")
return
}
defer release()
mr, err := r.MultipartReader()
if err != nil {
h.writeDiagnosticsMultipartError(r.Context(), userID, w, err)
return
}
manifestPart, err := nextDiagnosticsPart(mr, "manifest", "application/json")
if err != nil {
h.writeDiagnosticsMultipartError(r.Context(), userID, w, err)
return
}
manifestJSON, err := readDiagnosticsPart(manifestPart, diagnostics.MaxManifestBytes)
_ = manifestPart.Close()
if err != nil {
h.writeDiagnosticsMultipartError(r.Context(), userID, w, err)
return
}
bundlePart, err := nextDiagnosticsPart(mr, "bundle", diagnostics.BundleContentType)
if err != nil {
h.writeDiagnosticsMultipartError(r.Context(), userID, w, err)
return
}
defer bundlePart.Close()
profileID := strings.TrimSpace(r.Header.Get("X-Profile-Id"))
var profileIDPtr *string
if profileID != "" {
profileIDPtr = &profileID
}
result, err := h.service.Ingest(
r.Context(),
userID,
profileIDPtr,
manifestJSON,
&exactlyTwoPartBundleReader{part: bundlePart, mr: mr},
)
if err != nil {
writeDiagnosticsServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, result)
}
func diagnosticsUserID(w http.ResponseWriter, r *http.Request) (int, bool) {
if !hasBearerAuthorizationHeader(r) {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authorization bearer token required")
return 0, false
}
claims := apimw.GetClaims(r.Context())
if claims == nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return 0, false
}
if claims.TokenType == auth.TokenTypeAPIKey {
writeError(w, http.StatusForbidden, "api_key_not_allowed", "API keys cannot upload diagnostics")
return 0, false
}
if claims.TokenType != auth.TokenTypeAccess {
writeError(w, http.StatusForbidden, "forbidden", "Diagnostics require a user access token")
return 0, false
}
if claims.UserID <= 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return 0, false
}
return claims.UserID, true
}
func hasBearerAuthorizationHeader(r *http.Request) bool {
header := r.Header.Get("Authorization")
if header == "" {
return false
}
parts := strings.SplitN(header, " ", 2)
return len(parts) == 2 && strings.EqualFold(parts[0], "bearer") && strings.TrimSpace(parts[1]) != ""
}
func nextDiagnosticsPart(mr *multipart.Reader, expectedName, expectedContentType string) (*multipart.Part, error) {
part, err := mr.NextPart()
if err != nil {
return nil, err
}
// Reject a mismatched part without calling part.Close(): Close drains the
// unread part body first, so a max-size wrongly-named/typed first part would
// stream up to the bundle limit — holding the per-user/global in-flight slot —
// before we return 400. Abandoning the part returns immediately; net/http then
// discards only a small bounded prefix of the request before closing the
// connection, so malformed uploads fail promptly under load.
if part.FormName() != expectedName {
return nil, errDiagnosticsUnexpectedPart
}
if !diagnosticsContentTypeMatches(part.Header.Get("Content-Type"), expectedContentType) {
return nil, errDiagnosticsUnexpectedPart
}
return part, nil
}
func diagnosticsContentTypeMatches(raw, expected string) bool {
mediaType, _, err := mime.ParseMediaType(raw)
if err != nil {
return false
}
return strings.EqualFold(mediaType, expected)
}
func readDiagnosticsPart(part *multipart.Part, limit int64) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(part, limit+1))
if err != nil {
return nil, err
}
if int64(len(data)) > limit {
return nil, errDiagnosticsPartTooLarge
}
return data, nil
}
type exactlyTwoPartBundleReader struct {
part *multipart.Part
mr *multipart.Reader
checked bool
}
func (r *exactlyTwoPartBundleReader) Read(p []byte) (int, error) {
n, err := r.part.Read(p)
if errors.Is(err, io.EOF) && !r.checked {
r.checked = true
next, nextErr := r.mr.NextPart()
if errors.Is(nextErr, io.EOF) {
return n, io.EOF
}
if nextErr != nil {
return n, nextErr
}
_ = next.Close()
return n, errDiagnosticsUnexpectedPart
}
return n, err
}
func (h *DiagnosticsHandler) writeDiagnosticsMultipartError(ctx context.Context, userID int, w http.ResponseWriter, err error) {
var maxBytesErr *http.MaxBytesError
switch {
case errors.As(err, &maxBytesErr), errors.Is(err, errDiagnosticsPartTooLarge):
h.logRejected(ctx, userID, "too_large")
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Diagnostics upload is too large")
default:
h.logRejected(ctx, userID, "invalid_bundle")
writeError(w, http.StatusBadRequest, "invalid_bundle", "Invalid diagnostics upload")
}
}
func (h *DiagnosticsHandler) logRejected(ctx context.Context, userID int, reason string) {
logger := h.logger
if logger == nil {
logger = slog.Default()
}
args := []any{
"component", "diagnostics",
"result", "rejected",
"reason", reason,
}
if userID > 0 {
args = append(args, "user_id", userID)
}
logger.InfoContext(ctx, "diagnostic report rejected", args...)
}
func writeDiagnosticsServiceError(w http.ResponseWriter, err error) {
var maxBytesErr *http.MaxBytesError
switch {
case errors.As(err, &maxBytesErr), errors.Is(err, diagnostics.ErrTooLarge):
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Diagnostics upload is too large")
case errors.Is(err, diagnostics.ErrDisabled):
writeError(w, http.StatusForbidden, "disabled", "Diagnostics uploads are disabled")
case errors.Is(err, diagnostics.ErrStorageUnavailable):
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "Diagnostics storage is not configured")
case errors.Is(err, diagnostics.ErrQuotaExceeded):
w.Header().Set("Retry-After", diagnosticsQuotaRetryAfter)
writeError(w, http.StatusTooManyRequests, "quota_exceeded", "Diagnostics upload quota exceeded")
case errors.Is(err, diagnostics.ErrUnsupportedSchema):
writeError(w, http.StatusBadRequest, "unsupported_schema", "Diagnostics schema version is not supported")
case errors.Is(err, diagnostics.ErrDestinationMismatch):
writeError(w, http.StatusBadRequest, "destination_mismatch", "Diagnostics destination does not match this server")
case errors.Is(err, diagnostics.ErrStaleConsent):
writeError(w, http.StatusBadRequest, "stale_consent", "Diagnostics consent notice is stale")
case errors.Is(err, diagnostics.ErrArchiveMismatch):
writeError(w, http.StatusBadRequest, "archive_mismatch", "Diagnostics archive metadata does not match")
case errors.Is(err, diagnostics.ErrProfileMismatch):
writeError(w, http.StatusBadRequest, "profile_mismatch", "Diagnostics profile does not match the captured report")
case errors.Is(err, diagnostics.ErrChildProfileForbidden):
writeError(w, http.StatusForbidden, "child_profile_forbidden", "Diagnostics cannot be attributed to a child profile")
case errors.Is(err, diagnostics.ErrInvalidBundle):
writeError(w, http.StatusBadRequest, "invalid_bundle", "Invalid diagnostics bundle")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Diagnostics upload failed")
}
}
type diagnosticsInFlightLimiter struct {
mu sync.Mutex
active map[int]struct{}
global chan struct{}
}
func newDiagnosticsInFlightLimiter(globalLimit int) *diagnosticsInFlightLimiter {
if globalLimit <= 0 {
globalLimit = 1
}
return &diagnosticsInFlightLimiter{
active: make(map[int]struct{}),
global: make(chan struct{}, globalLimit),
}
}
func (l *diagnosticsInFlightLimiter) acquire(userID int) (func(), bool) {
l.mu.Lock()
defer l.mu.Unlock()
if _, ok := l.active[userID]; ok {
return nil, false
}
select {
case l.global <- struct{}{}:
l.active[userID] = struct{}{}
default:
return nil, false
}
return func() {
l.mu.Lock()
delete(l.active, userID)
l.mu.Unlock()
<-l.global
}, true
}
+876
View File
@@ -0,0 +1,876 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/diagnostics"
)
func TestDiagnosticsUploadHappyPath(t *testing.T) {
service := newFakeDiagnosticsService()
handler := NewDiagnosticsHandler(service)
rec := httptest.NewRecorder()
handler.HandleUpload(rec, newDiagnosticsUploadRequest(t, []diagnosticsPart{
{name: "manifest", contentType: "application/json", body: []byte(`{"ok":true}`)},
{name: "bundle", contentType: "application/gzip", body: []byte("bundle")},
}, accessClaims()))
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String())
}
var resp diagnostics.IngestResult
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.ShortID != "SILO-ABCDEF123456" {
t.Fatalf("short_id = %q, want SILO-ABCDEF123456", resp.ShortID)
}
if service.ingestCalls != 1 {
t.Fatalf("ingest calls = %d, want 1", service.ingestCalls)
}
}
func TestDiagnosticsUploadManifestTooBig(t *testing.T) {
service := newFakeDiagnosticsService()
handler := NewDiagnosticsHandler(service)
logs := captureDiagnosticsLogs(handler)
rec := httptest.NewRecorder()
handler.HandleUpload(rec, newDiagnosticsUploadRequest(t, []diagnosticsPart{
{name: "manifest", contentType: "application/json", body: bytes.Repeat([]byte("x"), int(diagnostics.MaxManifestBytes)+1)},
{name: "bundle", contentType: "application/gzip", body: []byte("bundle")},
}, accessClaims()))
assertDiagnosticsError(t, rec, http.StatusRequestEntityTooLarge, "too_large")
if service.ingestCalls != 0 {
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
}
assertDiagnosticsRejectionLog(t, logs, "too_large", 42)
}
func TestDiagnosticsUploadWrongPartOrder(t *testing.T) {
service := newFakeDiagnosticsService()
handler := NewDiagnosticsHandler(service)
logs := captureDiagnosticsLogs(handler)
rec := httptest.NewRecorder()
handler.HandleUpload(rec, newDiagnosticsUploadRequest(t, []diagnosticsPart{
{name: "bundle", contentType: "application/gzip", body: []byte("bundle")},
{name: "manifest", contentType: "application/json", body: []byte(`{"ok":true}`)},
}, accessClaims()))
assertDiagnosticsError(t, rec, http.StatusBadRequest, "invalid_bundle")
if service.ingestCalls != 0 {
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
}
assertDiagnosticsRejectionLog(t, logs, "invalid_bundle", 42)
}
func TestDiagnosticsUploadWrongFirstPartNotDrained(t *testing.T) {
service := newFakeDiagnosticsService()
handler := NewDiagnosticsHandler(service)
// A large, wrongly-named first part must be rejected without draining its
// body: an invalid upload must not be allowed to stream its whole payload
// (holding the in-flight slot) before it gets a 400.
var body bytes.Buffer
writer := multipart.NewWriter(&body)
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", `form-data; name="bundle"`)
header.Set("Content-Type", "application/gzip")
w, err := writer.CreatePart(header)
if err != nil {
t.Fatalf("create multipart part: %v", err)
}
if _, err := w.Write(bytes.Repeat([]byte("x"), 1<<20)); err != nil {
t.Fatalf("write multipart part: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
counter := &countingReader{r: &body}
req := httptest.NewRequest(http.MethodPost, "/api/v1/diagnostics/reports", counter)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer token")
req = req.WithContext(apimw.SetClaims(req.Context(), accessClaims()))
rec := httptest.NewRecorder()
handler.HandleUpload(rec, req)
assertDiagnosticsError(t, rec, http.StatusBadRequest, "invalid_bundle")
if service.ingestCalls != 0 {
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
}
// Only the small multipart framing/headers should have been read; the 1 MiB
// wrong-first-part body must not have been drained.
if counter.n > 128*1024 {
t.Fatalf("read %d bytes from request body, want the wrong first part rejected without draining (< 128 KiB)", counter.n)
}
}
type countingReader struct {
r io.Reader
n int64
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.n += int64(n)
return n, err
}
func TestDiagnosticsUploadBodyTooLargeLogsRejection(t *testing.T) {
service := newFakeDiagnosticsService()
service.status.MaxBundleBytes = 1
handler := NewDiagnosticsHandler(service)
logs := captureDiagnosticsLogs(handler)
body := strings.NewReader(strings.Repeat("x\n", int(diagnosticsMultipartOverheadBytes/2)+2))
req := httptest.NewRequest(http.MethodPost, "/api/v1/diagnostics/reports", body)
req.Header.Set("Content-Type", "multipart/form-data; boundary=diagnostics-boundary")
req.Header.Set("Authorization", "Bearer token")
req = req.WithContext(apimw.SetClaims(req.Context(), accessClaims()))
rec := httptest.NewRecorder()
handler.HandleUpload(rec, req)
assertDiagnosticsError(t, rec, http.StatusRequestEntityTooLarge, "too_large")
if service.ingestCalls != 0 {
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
}
assertDiagnosticsRejectionLog(t, logs, "too_large", 42)
}
func TestDiagnosticsUploadMissingPart(t *testing.T) {
service := newFakeDiagnosticsService()
handler := NewDiagnosticsHandler(service)
rec := httptest.NewRecorder()
handler.HandleUpload(rec, newDiagnosticsUploadRequest(t, []diagnosticsPart{
{name: "manifest", contentType: "application/json", body: []byte(`{"ok":true}`)},
}, accessClaims()))
assertDiagnosticsError(t, rec, http.StatusBadRequest, "invalid_bundle")
if service.ingestCalls != 0 {
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
}
}
func TestDiagnosticsUploadDisabled(t *testing.T) {
service := newFakeDiagnosticsService()
service.status.Status = diagnostics.StatusDisabled
handler := NewDiagnosticsHandler(service)
rec := httptest.NewRecorder()
handler.HandleUpload(rec, newDiagnosticsUploadRequest(t, validDiagnosticsParts(), accessClaims()))
assertDiagnosticsError(t, rec, http.StatusForbidden, "disabled")
if service.ingestCalls != 0 {
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
}
}
func TestDiagnosticsUploadStorageUnavailable(t *testing.T) {
service := newFakeDiagnosticsService()
service.status.Status = diagnostics.StatusStorageUnavailable
handler := NewDiagnosticsHandler(service)
rec := httptest.NewRecorder()
handler.HandleUpload(rec, newDiagnosticsUploadRequest(t, validDiagnosticsParts(), accessClaims()))
assertDiagnosticsError(t, rec, http.StatusServiceUnavailable, "storage_unavailable")
if service.ingestCalls != 0 {
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
}
}
func TestDiagnosticsUploadQuotaExceededSetsRetryAfter(t *testing.T) {
service := newFakeDiagnosticsService()
service.ingestErr = &diagnostics.QuotaError{Kind: diagnostics.QuotaKindReportsPerDay, Limit: 20}
handler := NewDiagnosticsHandler(service)
rec := httptest.NewRecorder()
handler.HandleUpload(rec, newDiagnosticsUploadRequest(t, validDiagnosticsParts(), accessClaims()))
assertDiagnosticsError(t, rec, http.StatusTooManyRequests, "quota_exceeded")
if got := rec.Header().Get("Retry-After"); got != diagnosticsQuotaRetryAfter {
t.Fatalf("Retry-After = %q, want %q", got, diagnosticsQuotaRetryAfter)
}
}
func TestDiagnosticsUploadRejectsAPIKey(t *testing.T) {
service := newFakeDiagnosticsService()
handler := NewDiagnosticsHandler(service)
logs := captureDiagnosticsLogs(handler)
rec := httptest.NewRecorder()
req := newDiagnosticsUploadRequest(t, validDiagnosticsParts(), &auth.Claims{
UserID: 42,
TokenType: auth.TokenTypeAPIKey,
})
req.Header.Set("Authorization", "Bearer sa_test")
handler.HandleUpload(rec, req)
assertDiagnosticsError(t, rec, http.StatusForbidden, "api_key_not_allowed")
if service.statusCalls != 0 {
t.Fatalf("status calls = %d, want 0", service.statusCalls)
}
assertDiagnosticsRejectionLog(t, logs, "api_key_not_allowed", 42)
}
func TestDiagnosticsUploadBusy(t *testing.T) {
service := newFakeDiagnosticsService()
service.started = make(chan struct{})
service.block = make(chan struct{})
handler := NewDiagnosticsHandler(service)
logs := captureDiagnosticsLogs(handler)
firstReq := newDiagnosticsUploadRequest(t, validDiagnosticsParts(), accessClaims())
firstDone := make(chan *httptest.ResponseRecorder, 1)
go func() {
rec := httptest.NewRecorder()
handler.HandleUpload(rec, firstReq)
firstDone <- rec
}()
<-service.started
rec := httptest.NewRecorder()
handler.HandleUpload(rec, newDiagnosticsUploadRequest(t, validDiagnosticsParts(), accessClaims()))
assertDiagnosticsError(t, rec, http.StatusServiceUnavailable, "busy")
if got := rec.Header().Get("Retry-After"); got != diagnosticsBusyRetryAfter {
t.Fatalf("Retry-After = %q, want %q", got, diagnosticsBusyRetryAfter)
}
assertDiagnosticsRejectionLog(t, logs, "busy", 42)
close(service.block)
first := <-firstDone
if first.Code != http.StatusCreated {
t.Fatalf("first status = %d, want %d; body=%s", first.Code, http.StatusCreated, first.Body.String())
}
}
func TestDiagnosticsAdminListReportsParsesFilters(t *testing.T) {
service := newFakeDiagnosticsService()
handler := NewDiagnosticsHandler(service)
req := httptest.NewRequest(
http.MethodGet,
"/api/v1/admin/diagnostics/reports?user_id=42&platform=ios&report_type=crash&from=2026-07-19T10:00:00Z&to=2026-07-20T10:00:00Z&short_id=abcdef123456&limit=25&cursor=next",
nil,
)
rec := httptest.NewRecorder()
handler.HandleAdminListReports(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if service.listCalls != 1 {
t.Fatalf("list calls = %d, want 1", service.listCalls)
}
filters := service.listFilters
if filters.UserID == nil || *filters.UserID != 42 {
t.Fatalf("UserID = %v, want 42", filters.UserID)
}
if filters.Platform != "ios" || filters.ReportType != "crash" {
t.Fatalf("filters = %#v, want platform ios report_type crash", filters)
}
if filters.From == nil || !filters.From.Equal(time.Date(2026, 7, 19, 10, 0, 0, 0, time.UTC)) {
t.Fatalf("From = %v, want expected timestamp", filters.From)
}
if filters.To == nil || !filters.To.Equal(time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)) {
t.Fatalf("To = %v, want expected timestamp", filters.To)
}
if filters.ShortID != "SILO-ABCDEF123456" || filters.Limit != 25 || filters.Cursor != "next" {
t.Fatalf("filters = %#v, want normalized short id, limit, cursor", filters)
}
}
func TestDiagnosticsAdminDownloadReturnsPresignedURLWhenAvailable(t *testing.T) {
service := newFakeDiagnosticsService()
service.getReport = readyDiagnosticsReport()
service.presignURL = "https://storage.example.test/report"
service.effectiveTTL = 5 * time.Minute
handler := NewDiagnosticsHandler(service)
before := time.Now().UTC()
rec := httptest.NewRecorder()
adminDiagnosticsRouter(handler).ServeHTTP(rec, adminDiagnosticsRequest(http.MethodGet, "/diagnostics/reports/report-1/download", adminClaims()))
after := time.Now().UTC()
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp diagnosticsDownloadURLResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.DownloadURL != service.presignURL {
t.Fatalf("download_url = %q, want %q", resp.DownloadURL, service.presignURL)
}
if service.presignExpiry != service.effectiveTTL {
t.Fatalf("presign expiry = %s, want %s", service.presignExpiry, service.effectiveTTL)
}
if resp.ExpiresAt.Before(before.Add(service.effectiveTTL)) || resp.ExpiresAt.After(after.Add(service.effectiveTTL)) {
t.Fatalf("expires_at = %s, want within effective TTL window", resp.ExpiresAt)
}
if service.openCalls != 0 {
t.Fatalf("open calls = %d, want 0", service.openCalls)
}
}
func TestDiagnosticsAdminDownloadStreamsWhenProxyRequested(t *testing.T) {
service := newFakeDiagnosticsService()
service.getReport = readyDiagnosticsReport()
service.presignURL = "https://storage.example.test/report"
service.openData = []byte("gzipped report")
handler := NewDiagnosticsHandler(service)
rec := httptest.NewRecorder()
adminDiagnosticsRouter(handler).ServeHTTP(rec, adminDiagnosticsRequest(http.MethodGet, "/diagnostics/reports/report-1/download?proxy=1", adminClaims()))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if service.presignCalls != 0 {
t.Fatalf("presign calls = %d, want 0", service.presignCalls)
}
if service.openCalls != 1 {
t.Fatalf("open calls = %d, want 1", service.openCalls)
}
if rec.Body.String() != "gzipped report" {
t.Fatalf("body = %q, want streamed data", rec.Body.String())
}
}
func TestDiagnosticsAdminDownloadStreamsWhenPresignUnavailable(t *testing.T) {
service := newFakeDiagnosticsService()
service.getReport = readyDiagnosticsReport()
service.presignErr = errors.New("presign unavailable")
service.openData = []byte("gzipped report")
handler := NewDiagnosticsHandler(service)
rec := httptest.NewRecorder()
adminDiagnosticsRouter(handler).ServeHTTP(rec, adminDiagnosticsRequest(http.MethodGet, "/diagnostics/reports/report-1/download", adminClaims()))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); got != diagnostics.ReportDownloadContentType {
t.Fatalf("Content-Type = %q, want %q", got, diagnostics.ReportDownloadContentType)
}
if !strings.Contains(rec.Header().Get("Content-Disposition"), "silo-diagnostics-SILO-ABCDEF123456.tar.gz") {
t.Fatalf("Content-Disposition = %q, want diagnostic filename", rec.Header().Get("Content-Disposition"))
}
if rec.Body.String() != "gzipped report" {
t.Fatalf("body = %q, want streamed data", rec.Body.String())
}
}
func TestDiagnosticsAdminListReportsRejectsMalformedQuery(t *testing.T) {
service := newFakeDiagnosticsService()
handler := NewDiagnosticsHandler(service)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/diagnostics/reports?user_id=abc", nil)
rec := httptest.NewRecorder()
handler.HandleAdminListReports(rec, req)
assertDiagnosticsError(t, rec, http.StatusBadRequest, "bad_request")
if service.listCalls != 0 {
t.Fatalf("list calls = %d, want 0", service.listCalls)
}
}
func TestDiagnosticsAdminListReportsRejectsMalformedCursor(t *testing.T) {
service := newFakeDiagnosticsService()
service.listErr = diagnostics.ErrInvalidCursor
handler := NewDiagnosticsHandler(service)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/diagnostics/reports?cursor=bad", nil)
rec := httptest.NewRecorder()
handler.HandleAdminListReports(rec, req)
assertDiagnosticsError(t, rec, http.StatusBadRequest, "bad_request")
}
func TestDiagnosticsAdminDeleteEmitsAuditEvent(t *testing.T) {
service := newFakeDiagnosticsService()
service.deleteReport = readyDiagnosticsReport()
var logs bytes.Buffer
handler := NewDiagnosticsHandler(service)
handler.logger = slog.New(slog.NewJSONHandler(&logs, nil))
rec := httptest.NewRecorder()
adminDiagnosticsRouter(handler).ServeHTTP(rec, adminDiagnosticsRequest(http.MethodDelete, "/diagnostics/reports/report-1", adminClaims()))
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNoContent, rec.Body.String())
}
logLine := logs.String()
if !strings.Contains(logLine, `"msg":"diagnostic report deleted"`) ||
!strings.Contains(logLine, `"admin_user_id":7`) ||
!strings.Contains(logLine, `"report_id":"report-1"`) {
t.Fatalf("audit log = %s", logLine)
}
}
func TestDiagnosticsAdminRoutesRejectNonActingAdmin(t *testing.T) {
service := newFakeDiagnosticsService()
handler := NewDiagnosticsHandler(service)
router := chi.NewRouter()
router.Use(apimw.RequireActingAdmin(nil))
RegisterAdminDiagnosticsRoutes(router, handler)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, adminDiagnosticsRequest(http.MethodGet, "/diagnostics/reports", &auth.Claims{
UserID: 8,
Role: "user",
TokenType: auth.TokenTypeAccess,
}))
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusForbidden, rec.Body.String())
}
if service.listCalls != 0 {
t.Fatalf("list calls = %d, want 0", service.listCalls)
}
}
func TestDiagnosticsUploadsEnablementRequiresStorageProbe(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{}}
handler := &AdminHandler{SettingsRepo: settings}
router := chi.NewRouter()
router.Put("/admin/settings/{key}", handler.HandleUpdateSetting)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+diagnostics.KeyUploadsEnabled,
strings.NewReader(`{"value":"true"}`),
))
assertDiagnosticsError(t, rec, http.StatusBadRequest, "storage_unavailable")
if settings.values[diagnostics.KeyUploadsEnabled] != "" {
t.Fatalf("setting persisted = %q, want empty", settings.values[diagnostics.KeyUploadsEnabled])
}
store := &fakeDiagnosticsEnablementStore{bucket: "private"}
handler.DiagnosticsStore = store
rec = httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+diagnostics.KeyUploadsEnabled,
strings.NewReader(`{"value":"true"}`),
))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if settings.values[diagnostics.KeyUploadsEnabled] != "true" {
t.Fatalf("setting persisted = %q, want true", settings.values[diagnostics.KeyUploadsEnabled])
}
if !sameStringSlice(store.ops, []string{"put:diagnostics/.probe", "delete:diagnostics/.probe"}) {
t.Fatalf("probe ops = %v, want put/delete probe", store.ops)
}
}
func TestDiagnosticsNumericSettingsAcceptBoundaries(t *testing.T) {
cases := []struct {
name string
key string
value string
initial map[string]string
}{
{name: "bundle minimum", key: diagnostics.KeyMaxBundleBytes, value: "1048576", initial: map[string]string{diagnostics.KeyMaxUncompressedBytes: "1073741824"}},
{name: "bundle maximum", key: diagnostics.KeyMaxBundleBytes, value: "268435456", initial: map[string]string{diagnostics.KeyMaxUncompressedBytes: "1073741824", diagnostics.KeyMaxBytesPerUser: "10737418240"}},
{name: "uncompressed bundle floor", key: diagnostics.KeyMaxUncompressedBytes, value: "1048576", initial: map[string]string{diagnostics.KeyMaxBundleBytes: "1048576"}},
{name: "uncompressed maximum", key: diagnostics.KeyMaxUncompressedBytes, value: "1073741824", initial: map[string]string{diagnostics.KeyMaxBundleBytes: "1048576"}},
{name: "reports minimum", key: diagnostics.KeyMaxReportsPerUserDay, value: "1"},
{name: "reports maximum", key: diagnostics.KeyMaxReportsPerUserDay, value: "1000"},
{name: "retention minimum", key: diagnostics.KeyRetentionDays, value: "1"},
{name: "retention maximum", key: diagnostics.KeyRetentionDays, value: "365"},
{name: "user bytes minimum", key: diagnostics.KeyMaxBytesPerUser, value: "10485760"},
{name: "user bytes maximum", key: diagnostics.KeyMaxBytesPerUser, value: "10737418240"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
settings := &fakeServerSettingsStore{values: tc.initial}
rec := updateDiagnosticsSetting(t, &AdminHandler{SettingsRepo: settings}, tc.key, tc.value)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if settings.values[tc.key] != tc.value {
t.Fatalf("stored value = %q, want %q", settings.values[tc.key], tc.value)
}
})
}
}
func TestDiagnosticsNumericSettingsRejectOutOfRangeValues(t *testing.T) {
cases := []struct {
name string
key string
value string
initial map[string]string
}{
{name: "bundle below minimum", key: diagnostics.KeyMaxBundleBytes, value: "1048575"},
{name: "bundle above maximum", key: diagnostics.KeyMaxBundleBytes, value: "268435457", initial: map[string]string{diagnostics.KeyMaxUncompressedBytes: "1073741824"}},
{name: "bundle above uncompressed", key: diagnostics.KeyMaxBundleBytes, value: "67108865", initial: map[string]string{diagnostics.KeyMaxUncompressedBytes: "67108864"}},
{name: "uncompressed below bundle", key: diagnostics.KeyMaxUncompressedBytes, value: "10485759", initial: map[string]string{diagnostics.KeyMaxBundleBytes: "10485760"}},
{name: "bundle above per-user cap", key: diagnostics.KeyMaxBundleBytes, value: "104857600", initial: map[string]string{diagnostics.KeyMaxUncompressedBytes: "1073741824", diagnostics.KeyMaxBytesPerUser: "10485760"}},
{name: "per-user below bundle", key: diagnostics.KeyMaxBytesPerUser, value: "10485760", initial: map[string]string{diagnostics.KeyMaxBundleBytes: "104857600", diagnostics.KeyMaxUncompressedBytes: "1073741824"}},
{name: "uncompressed above maximum", key: diagnostics.KeyMaxUncompressedBytes, value: "1073741825"},
{name: "reports below minimum", key: diagnostics.KeyMaxReportsPerUserDay, value: "0"},
{name: "reports above maximum", key: diagnostics.KeyMaxReportsPerUserDay, value: "1001"},
{name: "retention below minimum", key: diagnostics.KeyRetentionDays, value: "0"},
{name: "retention above maximum", key: diagnostics.KeyRetentionDays, value: "366"},
{name: "user bytes below minimum", key: diagnostics.KeyMaxBytesPerUser, value: "10485759"},
{name: "user bytes above maximum", key: diagnostics.KeyMaxBytesPerUser, value: "10737418241"},
{name: "not an integer", key: diagnostics.KeyRetentionDays, value: "thirty"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
settings := &fakeServerSettingsStore{values: tc.initial}
rec := updateDiagnosticsSetting(t, &AdminHandler{SettingsRepo: settings}, tc.key, tc.value)
body := rec.Body.String()
assertDiagnosticsError(t, rec, http.StatusBadRequest, "bad_request")
if _, stored := settings.values[tc.key]; stored {
t.Fatalf("invalid value was stored: %#v", settings.values)
}
if !strings.Contains(body, tc.key) {
t.Fatalf("error body = %s, want clear error naming %s", body, tc.key)
}
})
}
}
type diagnosticsPart struct {
name string
contentType string
body []byte
}
func validDiagnosticsParts() []diagnosticsPart {
return []diagnosticsPart{
{name: "manifest", contentType: "application/json", body: []byte(`{"ok":true}`)},
{name: "bundle", contentType: "application/gzip", body: []byte("bundle")},
}
}
func newDiagnosticsUploadRequest(t *testing.T, parts []diagnosticsPart, claims *auth.Claims) *http.Request {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
for _, part := range parts {
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", `form-data; name="`+part.name+`"`)
header.Set("Content-Type", part.contentType)
w, err := writer.CreatePart(header)
if err != nil {
t.Fatalf("create multipart part: %v", err)
}
if _, err := w.Write(part.body); err != nil {
t.Fatalf("write multipart part: %v", err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/diagnostics/reports", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer token")
if claims != nil {
req = req.WithContext(apimw.SetClaims(req.Context(), claims))
}
return req
}
func accessClaims() *auth.Claims {
return &auth.Claims{
UserID: 42,
TokenType: auth.TokenTypeAccess,
}
}
func assertDiagnosticsError(t *testing.T, rec *httptest.ResponseRecorder, status int, code string) {
t.Helper()
if rec.Code != status {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, status, rec.Body.String())
}
var resp struct {
Error string `json:"error"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode error response: %v", err)
}
if resp.Error != code {
t.Fatalf("error = %q, want %q", resp.Error, code)
}
}
func captureDiagnosticsLogs(handler *DiagnosticsHandler) *bytes.Buffer {
var logs bytes.Buffer
handler.logger = slog.New(slog.NewJSONHandler(&logs, nil))
return &logs
}
func assertDiagnosticsRejectionLog(t *testing.T, logs *bytes.Buffer, reason string, userID int) {
t.Helper()
line := logs.String()
for _, want := range []string{
`"msg":"diagnostic report rejected"`,
`"component":"diagnostics"`,
`"result":"rejected"`,
`"reason":"` + reason + `"`,
`"user_id":` + strconv.Itoa(userID),
} {
if !strings.Contains(line, want) {
t.Fatalf("rejection log = %s, want %s", line, want)
}
}
}
func updateDiagnosticsSetting(t *testing.T, handler *AdminHandler, key, value string) *httptest.ResponseRecorder {
t.Helper()
router := chi.NewRouter()
router.Put("/admin/settings/{key}", handler.HandleUpdateSetting)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+key,
strings.NewReader(`{"value":"`+value+`"}`),
))
return rec
}
func readyDiagnosticsReport() *diagnostics.Report {
blobBucket := "private"
blobKey := "diagnostics/7/report-1.tar.gz"
blobBytes := int64(13)
return &diagnostics.Report{
ID: "report-1",
ShortID: "SILO-ABCDEF123456",
UserID: 7,
State: diagnostics.StateReady,
ReportType: "crash",
Platform: "android-tv",
BlobBucket: &blobBucket,
BlobKey: &blobKey,
BlobBytes: &blobBytes,
}
}
func adminDiagnosticsRouter(handler *DiagnosticsHandler) chi.Router {
router := chi.NewRouter()
RegisterAdminDiagnosticsRoutes(router, handler)
return router
}
func adminDiagnosticsRequest(method, target string, claims *auth.Claims) *http.Request {
req := httptest.NewRequest(method, target, nil)
if claims != nil {
req = req.WithContext(apimw.SetClaims(req.Context(), claims))
}
return req
}
func adminClaims() *auth.Claims {
return &auth.Claims{
UserID: 7,
Role: "admin",
TokenType: auth.TokenTypeAccess,
}
}
type fakeDiagnosticsService struct {
mu sync.Mutex
status diagnostics.Status
statusCalls int
ingestCalls int
ingestErr error
started chan struct{}
block chan struct{}
listCalls int
listFilters diagnostics.ListFilters
listResult diagnostics.ListResult
listErr error
getReport *diagnostics.Report
getErr error
presignURL string
presignErr error
presignCalls int
presignExpiry time.Duration
effectiveTTL time.Duration
openCalls int
openData []byte
openErr error
deleteReport *diagnostics.Report
deleteErr error
}
func newFakeDiagnosticsService() *fakeDiagnosticsService {
return &fakeDiagnosticsService{
status: diagnostics.Status{
Status: diagnostics.StatusAvailable,
ServerInstanceID: "server-1",
AcceptedSchemaVersions: []int{1},
MaxBundleBytes: diagnostics.DefaultMaxBundleBytes,
MaxManifestBytes: diagnostics.MaxManifestBytes,
RetentionDays: diagnostics.DefaultRetentionDays,
ConsentNoticeVersion: diagnostics.DefaultConsentNoticeVer,
},
}
}
func (f *fakeDiagnosticsService) Status(context.Context, int) (diagnostics.Status, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.statusCalls++
return f.status, nil
}
func (f *fakeDiagnosticsService) Ingest(_ context.Context, _ int, _ *string, _ []byte, bundle io.Reader) (diagnostics.IngestResult, error) {
f.mu.Lock()
f.ingestCalls++
started := f.started
block := f.block
err := f.ingestErr
f.mu.Unlock()
if started != nil {
close(started)
}
if block != nil {
<-block
}
if err != nil {
return diagnostics.IngestResult{}, err
}
if _, err := io.ReadAll(bundle); err != nil {
return diagnostics.IngestResult{}, err
}
return diagnostics.IngestResult{
ReportID: "11111111-1111-1111-1111-111111111111",
ShortID: "SILO-ABCDEF123456",
}, nil
}
func (f *fakeDiagnosticsService) ListForAdmin(_ context.Context, filters diagnostics.ListFilters) (diagnostics.ListResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.listCalls++
f.listFilters = filters
if f.listErr != nil {
return diagnostics.ListResult{}, f.listErr
}
return f.listResult, nil
}
func (f *fakeDiagnosticsService) GetReport(context.Context, string) (*diagnostics.Report, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.getErr != nil {
return nil, f.getErr
}
if f.getReport != nil {
return f.getReport, nil
}
return readyDiagnosticsReport(), nil
}
func (f *fakeDiagnosticsService) PresignReportDownload(_ context.Context, _ *diagnostics.Report, expiry time.Duration) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.presignCalls++
f.presignExpiry = expiry
if f.presignErr != nil {
return "", f.presignErr
}
return f.presignURL, nil
}
func (f *fakeDiagnosticsService) EffectiveReportDownloadTTL(requested time.Duration) time.Duration {
f.mu.Lock()
defer f.mu.Unlock()
if f.effectiveTTL > 0 {
return f.effectiveTTL
}
return requested
}
func (f *fakeDiagnosticsService) OpenReportDownload(context.Context, *diagnostics.Report) (io.ReadCloser, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.openCalls++
if f.openErr != nil {
return nil, f.openErr
}
return io.NopCloser(bytes.NewReader(f.openData)), nil
}
func (f *fakeDiagnosticsService) DeleteReport(context.Context, string) (*diagnostics.Report, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.deleteErr != nil {
return nil, f.deleteErr
}
if f.deleteReport != nil {
return f.deleteReport, nil
}
return readyDiagnosticsReport(), nil
}
type fakeDiagnosticsEnablementStore struct {
bucket string
ops []string
}
func (f *fakeDiagnosticsEnablementStore) PutStream(_ context.Context, _ string, key string, r io.Reader, _ string) error {
if _, err := io.ReadAll(r); err != nil {
return err
}
f.ops = append(f.ops, "put:"+key)
return nil
}
func (f *fakeDiagnosticsEnablementStore) DeleteObject(_ context.Context, _ string, key string) error {
f.ops = append(f.ops, "delete:"+key)
return nil
}
func (f *fakeDiagnosticsEnablementStore) Bucket() string {
return f.bucket
}
func sameStringSlice(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+15
View File
@@ -108,6 +108,21 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
conn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {
// The Android/KMP client has a long history of silent handshake
// failures here (gorilla writes the 4xx itself); log the exact
// upgrade-relevant request shape so a failing client is diagnosable
// from the server alone.
slog.WarnContext(r.Context(), "events websocket upgrade failed", "component", "api",
"error", err,
"user_id", claims.UserID,
"proto", r.Proto,
"method", r.Method,
"connection_header", r.Header.Get("Connection"),
"upgrade_header", r.Header.Get("Upgrade"),
"ws_version", r.Header.Get("Sec-Websocket-Version"),
"ws_key_present", r.Header.Get("Sec-Websocket-Key") != "",
"user_agent", r.UserAgent(),
)
return
}
defer conn.Close()
+4 -1
View File
@@ -344,6 +344,7 @@ type episodeResponse struct {
StillThumbhash string `json:"still_thumbhash,omitempty"`
UserData *catalog.SeasonUserData `json:"user_data,omitempty"`
Files []episodeFileResponse `json:"files,omitempty"`
OverlaySummary *models.OverlaySummary `json:"overlay_summary,omitempty"`
}
type episodeImageFallback struct {
@@ -959,8 +960,10 @@ func (h *ItemsHandler) buildEpisodeResponses(r *http.Request, episodes []*models
}
for i := range resp {
resp[i].StillURL = stillURLs[stillPaths[i]].URL
resp[i].Files = episodeFileResponses(filesByEpisode[resp[i].ContentID], filter)
files := catalog.FilterMediaFilesByAccess(filesByEpisode[resp[i].ContentID], filter)
resp[i].Files = episodeFileResponses(files, filter)
resp[i].UserData = userData[resp[i].ContentID]
resp[i].OverlaySummary = overlays.BuildSummary(files)
}
return resp
}
+53 -57
View File
@@ -8,7 +8,6 @@ import (
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"slices"
"sort"
@@ -31,6 +30,7 @@ import (
"github.com/Silo-Server/silo-server/internal/metadata"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/rootcheck"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/scantrigger"
"github.com/Silo-Server/silo-server/internal/sections"
@@ -251,6 +251,11 @@ type libraryMountCheckRootResponse struct {
Reachable bool `json:"reachable"`
ErrorCode *string `json:"error_code"`
ErrorMessage *string `json:"error_message"`
// SuspectEmpty is set when the root is reachable but the library holds
// only missing-marked files under it — the signature of a lost mount
// exposing an empty mountpoint directory, which a reachability probe
// alone cannot detect. Additive field; absent/false for healthy roots.
SuspectEmpty bool `json:"suspect_empty"`
}
type libraryMountCheckResponse struct {
@@ -847,8 +852,9 @@ func (h *LibraryHandler) HandleCheckLibraryMount(w http.ResponseWriter, r *http.
return
}
resp := checkLibraryMount(folder)
if resp.Healthy && folder.ScanWarningCode != nil && *folder.ScanWarningCode == "empty_root" {
resp := h.checkLibraryMount(r.Context(), folder)
if resp.Healthy && folder.ScanWarningCode != nil &&
(*folder.ScanWarningCode == "empty_root" || *folder.ScanWarningCode == "dead_root") {
if err := h.folderRepo.ClearScanWarning(r.Context(), folder.ID); err != nil {
if errors.Is(err, catalog.ErrFolderNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Library not found")
@@ -1256,7 +1262,7 @@ func scanBoolMetric(result *libraryingest.Result, pick func(*scanner.ScanResult)
return pick(result.ScanResult)
}
func checkLibraryMount(folder *models.MediaFolder) libraryMountCheckResponse {
func (h *LibraryHandler) checkLibraryMount(ctx context.Context, folder *models.MediaFolder) libraryMountCheckResponse {
resp := libraryMountCheckResponse{
Status: "ok",
LibraryID: folder.ID,
@@ -1273,40 +1279,58 @@ func checkLibraryMount(folder *models.MediaFolder) libraryMountCheckResponse {
}
unreachable := 0
for _, path := range folder.Paths {
emptyPaths := make([]string, 0, len(folder.Paths))
probes := rootcheck.ProbeManyWithTimeout(ctx, folder.Paths, rootcheck.DefaultProbeTimeout)
for i, path := range folder.Paths {
probe := probes[i]
root := libraryMountCheckRootResponse{
Path: path,
Reachable: true,
Reachable: probe.Reachable,
}
info, err := os.Stat(path)
if err != nil {
code, message := classifyMountCheckError(err, false)
root.Reachable = false
root.ErrorCode = stringPtr(code)
root.ErrorMessage = stringPtr(message)
} else if !info.IsDir() {
root.Reachable = false
root.ErrorCode = stringPtr("not_directory")
root.ErrorMessage = stringPtr("Path is not a directory")
} else if _, err := os.ReadDir(path); err != nil {
code, message := classifyMountCheckError(err, true)
root.Reachable = false
root.ErrorCode = stringPtr(code)
root.ErrorMessage = stringPtr(message)
}
if !root.Reachable {
if !probe.Reachable {
root.ErrorCode = stringPtr(probe.ErrorCode)
root.ErrorMessage = stringPtr(probe.ErrorMessage)
unreachable++
resp.Healthy = false
} else if probe.Empty {
emptyPaths = append(emptyPaths, path)
}
resp.Roots = append(resp.Roots, root)
}
switch unreachable {
case 0:
// A reachable but literally empty root can be a lost mount that left its
// bare mountpoint behind. Cross-check against the catalog: an empty root
// holding only missing-marked files is suspect, and reporting it healthy
// would both mislead the operator and wrongly clear the dead_root
// warning. Best-effort — probe results stand on a query error.
suspect := 0
if h.pool != nil && len(emptyPaths) > 0 {
suspectRoots, err := scanner.NewFileRepository(h.pool).ListRootsWithOnlyMissingFiles(ctx, folder.ID, emptyPaths)
if err != nil {
slog.WarnContext(ctx, "mount check: suspect-empty root query failed", "component", "api", "library_id", folder.ID, "error", err)
} else if len(suspectRoots) > 0 {
suspectSet := make(map[string]bool, len(suspectRoots))
for _, root := range suspectRoots {
suspectSet[root] = true
}
for i := range resp.Roots {
if resp.Roots[i].Reachable && suspectSet[resp.Roots[i].Path] {
resp.Roots[i].SuspectEmpty = true
suspect++
resp.Healthy = false
}
}
}
}
switch {
case unreachable == 0 && suspect == 0:
resp.Summary = "All configured roots are reachable"
case 1:
case unreachable == 0:
resp.Summary = fmt.Sprintf("%d of %d roots reachable but empty while the library still has cataloged files (lost mount?)", suspect, len(folder.Paths))
case suspect > 0:
resp.Summary = fmt.Sprintf("%d of %d roots unreachable; %d more reachable but empty while the library still has cataloged files", unreachable, len(folder.Paths), suspect)
case unreachable == 1:
resp.Summary = fmt.Sprintf("1 of %d roots unreachable", len(folder.Paths))
default:
resp.Summary = fmt.Sprintf("%d of %d roots unreachable", unreachable, len(folder.Paths))
@@ -1315,19 +1339,6 @@ func checkLibraryMount(folder *models.MediaFolder) libraryMountCheckResponse {
return resp
}
func classifyMountCheckError(err error, isRead bool) (string, string) {
switch {
case errors.Is(err, os.ErrNotExist):
return "not_found", "Path does not exist"
case errors.Is(err, os.ErrPermission):
return "permission_denied", "Permission denied"
case isRead:
return "read_failed", "Failed to read directory"
default:
return "stat_failed", "Failed to stat path"
}
}
func stringPtr(v string) *string {
return &v
}
@@ -2191,22 +2202,7 @@ func buildSeededChainEntries(level string, candidates []seedCandidate) []metadat
}
func metadataContentLevelsForLibraryType(libraryType string) []string {
switch libraryType {
case "series":
return []string{"series", "season", "episode"}
case "movies", "movie":
return []string{"movie"}
case "audiobooks", "audiobook":
return []string{"audiobook"}
case "ebooks", "ebook":
return []string{"ebook"}
case "manga":
return []string{"manga"}
case "mixed":
return []string{"movie", "series", "season", "episode", "audiobook", "ebook"}
default:
return nil
}
return metadata.ContentLevelsForLibraryType(libraryType)
}
// HandleListStaleIDs handles GET /libraries/stale-ids.
+16 -7
View File
@@ -388,21 +388,30 @@ func (h *NotificationsHandler) HandleCapability(w http.ResponseWriter, r *http.R
}
}
applePush := capabilityPush{Available: false, Provider: "off", SupportedModes: []string{"in_app_only"}}
androidPush := capabilityPush{Available: false, Provider: "off", SupportedModes: []string{"in_app_only"}}
// Like web push above, availability requires both the wiring (cipher/store)
// and the admin delivery toggle: Available must mean "setup will actually
// deliver", not "the server could store a token".
if h.system.PushDevices != nil && h.system.PushDevices.Available() &&
h.system.Settings.ApplePushDeliveryEnabled(r.Context()) {
applePush = capabilityPush{
Available: true,
Provider: notifications.PushProviderSiloRelay,
SupportedModes: []string{notifications.PushModePrivatePush, notifications.PushModeInAppOnly},
if h.system.PushDevices != nil && h.system.PushDevices.Available() {
if h.system.Settings.ApplePushDeliveryEnabled(r.Context()) {
applePush = capabilityPush{
Available: true,
Provider: notifications.PushProviderSiloRelay,
SupportedModes: []string{notifications.PushModePrivatePush, notifications.PushModeInAppOnly},
}
}
if h.system.Settings.AndroidPushDeliveryEnabled(r.Context()) {
androidPush = capabilityPush{
Available: true,
Provider: notifications.PushProviderSiloRelay,
SupportedModes: []string{notifications.PushModePrivatePush, notifications.PushModeInAppOnly},
}
}
}
writeJSON(w, http.StatusOK, capabilityResponse{
InApp: capabilityInApp{Enabled: h.system.Settings.UIEnabled(r.Context())},
ApplePush: applePush,
AndroidPush: capabilityPush{Available: false, Provider: "off", SupportedModes: []string{"in_app_only"}},
AndroidPush: androidPush,
WebPush: webPush,
Webhooks: webhooks,
Email: email,
@@ -7,6 +7,7 @@ import (
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/go-chi/chi/v5"
)
type applePushRegisterRequest struct {
@@ -73,3 +74,80 @@ func (h *NotificationsHandler) HandleRegisterApplePushDevice(w http.ResponseWrit
PushMode: device.PushMode,
})
}
type pushDeviceRegisterRequest struct {
Platform string `json:"platform"`
Token string `json:"token"`
DeviceID string `json:"device_id"`
PushMode string `json:"push_mode"`
}
// HandleRegisterPushDevice handles POST /notifications/push/devices, the
// platform-generic registration used by the Android client. Apple installs
// keep the dedicated /devices/push/apple route because their registration
// carries APNs-specific fields (environment, topic).
func (h *NotificationsHandler) HandleRegisterPushDevice(w http.ResponseWriter, r *http.Request) {
service := h.pushDevices()
if service == nil || !service.Available() {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Push registration is not available")
return
}
var req pushDeviceRegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.Platform != notifications.PushPlatformAndroid {
writeError(w, http.StatusUnprocessableEntity, "unsupported_push_device", "platform is not supported on this endpoint")
return
}
device, err := service.RegisterFCM(r.Context(), apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context()), notifications.FCMPushRegistrationInput{
DeviceID: req.DeviceID,
FCMToken: req.Token,
PushMode: req.PushMode,
})
if err != nil {
switch {
case errors.Is(err, notifications.ErrPushDeviceInvalid):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
case errors.Is(err, notifications.ErrPushDeviceUnsupported):
writeError(w, http.StatusUnprocessableEntity, "unsupported_push_device", err.Error())
case errors.Is(err, notifications.ErrPushDeviceUnavailable):
writeError(w, http.StatusServiceUnavailable, "unavailable", "Push registration is not available")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to register push device")
}
return
}
writeJSON(w, http.StatusOK, applePushRegisterResponse{
ID: device.ID,
ServerDeviceID: device.ServerDeviceID,
Enabled: device.Enabled,
PushMode: device.PushMode,
})
}
// HandleUnregisterPushDevice handles DELETE /notifications/push/devices/{device_id}.
func (h *NotificationsHandler) HandleUnregisterPushDevice(w http.ResponseWriter, r *http.Request) {
service := h.pushDevices()
if service == nil || !service.Available() {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Push registration is not available")
return
}
err := service.Unregister(r.Context(), apimw.GetProfileID(r.Context()), chi.URLParam(r, "device_id"))
if err != nil {
switch {
case errors.Is(err, notifications.ErrPushDeviceInvalid):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
case errors.Is(err, notifications.ErrPushDeviceUnavailable):
writeError(w, http.StatusServiceUnavailable, "unavailable", "Push registration is not available")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to unregister push device")
}
return
}
w.WriteHeader(http.StatusNoContent)
}
@@ -15,10 +15,12 @@ import (
)
type handlerPushStore struct {
got notifications.ApplePushDeviceRegistration
calls int
device *notifications.PushDevice
err error
got notifications.ApplePushDeviceRegistration
gotFCM notifications.FCMPushDeviceRegistration
calls int
device *notifications.PushDevice
err error
deleted []string
}
func (f *handlerPushStore) UpsertApple(ctx context.Context, registration notifications.ApplePushDeviceRegistration, cipher *secret.Cipher) (*notifications.PushDevice, error) {
@@ -38,6 +40,29 @@ func (f *handlerPushStore) UpsertApple(ctx context.Context, registration notific
}, nil
}
func (f *handlerPushStore) UpsertFCM(ctx context.Context, registration notifications.FCMPushDeviceRegistration, cipher *secret.Cipher) (*notifications.PushDevice, error) {
f.calls++
f.gotFCM = registration
if f.err != nil {
return nil, f.err
}
if f.device != nil {
return f.device, nil
}
return &notifications.PushDevice{
ID: "push-row-android",
Platform: notifications.PushPlatformAndroid,
ServerDeviceID: "server-device-android",
Enabled: true,
PushMode: registration.PushMode,
}, nil
}
func (f *handlerPushStore) DeleteByProfileDevice(ctx context.Context, profileID, deviceID string) error {
f.deleted = append(f.deleted, profileID+"/"+deviceID)
return f.err
}
func handlerPushCipher(t *testing.T) *secret.Cipher {
t.Helper()
cipher, err := secret.New([]byte("01234567890123456789012345678901"))
@@ -88,6 +113,68 @@ func TestHandleRegisterApplePushDevice(t *testing.T) {
}
}
func newPushDevicesRequest(method, target, body string) *http.Request {
req := httptest.NewRequest(method, target, strings.NewReader(body))
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 42, Role: "user", TokenType: auth.TokenTypeAccess})
ctx = apimw.SetProfileID(ctx, "profile-1")
return req.WithContext(ctx)
}
func TestHandleRegisterPushDeviceAndroid(t *testing.T) {
store := &handlerPushStore{}
handler := NewNotificationsHandler(&notifications.System{
PushDevices: notifications.NewPushDeviceService(store, handlerPushCipher(t)),
}, nil)
token := strings.Repeat("F", 140)
body := `{
"platform":"android",
"token":"` + token + `",
"device_id":"local-device",
"push_mode":"private_push"
}`
rr := httptest.NewRecorder()
handler.HandleRegisterPushDevice(rr, newPushDevicesRequest(http.MethodPost, "/api/v1/notifications/push/devices", body))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var response applePushRegisterResponse
if err := json.NewDecoder(rr.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.ID != "push-row-android" || response.PushMode != notifications.PushModePrivatePush {
t.Fatalf("unexpected response: %+v", response)
}
if store.gotFCM.UserID != 42 || store.gotFCM.ProfileID != "profile-1" || store.gotFCM.FCMToken != token {
t.Fatalf("unexpected stored registration: %+v", store.gotFCM)
}
}
func TestHandleRegisterPushDeviceRejectsUnknownPlatformAndBadToken(t *testing.T) {
newHandler := func() *NotificationsHandler {
return NewNotificationsHandler(&notifications.System{
PushDevices: notifications.NewPushDeviceService(&handlerPushStore{}, handlerPushCipher(t)),
}, nil)
}
rr := httptest.NewRecorder()
newHandler().HandleRegisterPushDevice(rr, newPushDevicesRequest(http.MethodPost,
"/api/v1/notifications/push/devices",
`{"platform":"apple","token":"`+strings.Repeat("F", 140)+`","device_id":"local-device"}`))
if rr.Code != http.StatusUnprocessableEntity {
t.Fatalf("apple platform status = %d, want 422", rr.Code)
}
rr = httptest.NewRecorder()
newHandler().HandleRegisterPushDevice(rr, newPushDevicesRequest(http.MethodPost,
"/api/v1/notifications/push/devices",
`{"platform":"android","token":"short","device_id":"local-device"}`))
if rr.Code != http.StatusBadRequest {
t.Fatalf("bad token status = %d, want 400", rr.Code)
}
}
func TestHandleRegisterApplePushDeviceErrors(t *testing.T) {
tests := []struct {
name string
File diff suppressed because it is too large Load Diff
+101
View File
@@ -72,6 +72,32 @@ type playbackSessionRow struct {
RequestedVideoResolution string `json:"requested_video_resolution,omitempty"`
VideoDecision string `json:"video_decision,omitempty"`
AudioDecision string `json:"audio_decision,omitempty"`
EffectivePlayMethod string `json:"effective_play_method,omitempty"`
IsJellyfinClient bool `json:"is_jellyfin_client,omitempty"`
}
// playbackSessionsCapabilitiesResponse advertises the additive fields of the
// live admin session payload so independently deployed clients (Android,
// Apple) can feature-detect them. Both fields are omitempty on the wire, so
// absence on a row is otherwise indistinguishable from an older server.
type playbackSessionsCapabilitiesResponse struct {
// EffectivePlayMethod reports that rows carry effective_play_method.
EffectivePlayMethod bool `json:"effective_play_method"`
// EffectivePlayMethodValues is the closed bucket vocabulary a supported
// server emits (absent field = unknown).
EffectivePlayMethodValues []string `json:"effective_play_method_values"`
// IsJellyfinClient reports that rows carry is_jellyfin_client.
IsJellyfinClient bool `json:"is_jellyfin_client"`
}
// HandleGetSessionsCapabilities exposes additive feature support for the live
// admin session payload (GET /admin/sessions/capabilities).
func (h *AdminHandler) HandleGetSessionsCapabilities(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, playbackSessionsCapabilitiesResponse{
EffectivePlayMethod: true,
EffectivePlayMethodValues: []string{"direct", "remux", "transcode", "audio"},
IsJellyfinClient: true,
})
}
// PlaybackSessionsQuery scopes live session listing.
@@ -248,6 +274,8 @@ func enrichPlaybackSessionRow(row *playbackSessionRow, audioTracksJSON []byte) {
}
row.VideoDecision, row.AudioDecision = sessionComponentDecision(row.PlayMethod, row.TranscodeAudio, row.TargetVideoCodec)
row.EffectivePlayMethod = effectivePlayMethod(row.VideoDecision, row.AudioDecision)
row.IsJellyfinClient = isJellyfinEcosystemClient(row.ClientName, row.ClientUserAgent)
var audioTracks []models.AudioTrack
if len(audioTracksJSON) > 0 {
@@ -335,6 +363,79 @@ func sessionComponentDecision(playMethod string, transcodeAudio bool, targetVide
}
}
// effectivePlayMethod reduces the per-stream decisions to the single bucket
// the admin activity views aggregate on. Raw play_method is misleading there:
// an HLS repackage reports play_method "transcode" with a copied video stream,
// and an audio-only re-encode reports "remux" — the decisions carry what
// actually costs CPU.
// - video re-encoded -> "transcode"
// - only audio re-encoded -> "audio"
// - streams only repackaged -> "remux"
// - nothing touched -> "direct"
//
// Returns "" when the decisions are unknown (empty or unrecognized
// play_method, e.g. a stale row from an older node), so consumers can
// distinguish "unknown" from a definite bucket instead of inventing one.
func effectivePlayMethod(videoDecision, audioDecision string) string {
switch {
case videoDecision == "" && audioDecision == "":
return ""
case videoDecision == "transcode":
return "transcode"
case audioDecision == "transcode":
return "audio"
case videoDecision == "direct" && audioDecision == "direct":
return "direct"
default:
return "remux"
}
}
// jellyfinClientTokens identifies Jellyfin-ecosystem clients — sessions served
// through the Jellyfin compatibility surface — by client name (parsed from the
// MediaBrowser auth header) or user agent. This is the single source of truth
// behind is_jellyfin_client, which the admin UIs surface as the "JF" pill;
// keep it in step with the display-labeling rules in
// playbackClientDisplayName so a client that gets a label also gets the flag.
// Kodi and mpv reach Silo only through the Jellyfin compat surface today
// (jellyfin-kodi/JellyCon, jellyfin-mpv-shim). Generic browser tokens are
// deliberately excluded: the native web player shares those user agents.
var jellyfinClientTokens = []string{
"jellyfin",
"findroid",
"streamyfin",
"swiftfin",
"jellycon",
"wholphin",
"fladder",
"vidhub",
"senplayer",
"infuse",
"delfin",
"finamp",
"kodi",
"mpv",
}
// isJellyfinEcosystemClient reports whether the session's client metadata
// matches a known Jellyfin-ecosystem client. This is a heuristic: an
// unrecognized fork simply gets no JF pill (cosmetic). Stamping a compat-origin
// flag on the session at creation would be exact and is the eventual fix.
func isJellyfinEcosystemClient(clientName, userAgent string) bool {
for _, value := range []string{clientName, userAgent} {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
continue
}
for _, token := range jellyfinClientTokens {
if strings.Contains(value, token) {
return true
}
}
}
return false
}
func firstNonEmptyValue(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
@@ -1,6 +1,11 @@
package handlers
import "testing"
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestSessionComponentDecisionLabelsCopiedAudioDuringHLSAsRemux(t *testing.T) {
videoDecision, audioDecision := sessionComponentDecision("transcode", false, "copy")
@@ -12,3 +17,89 @@ func TestSessionComponentDecisionLabelsCopiedAudioDuringHLSAsRemux(t *testing.T)
t.Fatalf("audioDecision = %q, want remux", audioDecision)
}
}
// TestEffectivePlayMethodBuckets pins the bucket for every decision pair
// sessionComponentDecision can produce, plus the unknown case.
func TestEffectivePlayMethodBuckets(t *testing.T) {
cases := []struct {
name string
playMethod string
transcodeAudio bool
targetVideo string
want string
}{
{"direct play", "direct", false, "", "direct"},
{"plain remux", "remux", false, "", "remux"},
{"audio-only re-encode via remux", "remux", true, "", "audio"},
{"full video transcode", "transcode", true, "h264", "transcode"},
{"video transcode with copied audio", "transcode", false, "h264", "transcode"},
{"video-copy HLS repackage", "transcode", false, "copy", "remux"},
{"video-copy HLS with audio re-encode", "transcode", true, "copy", "audio"},
// Unknown play_method (stale row from an older node): the bucket must
// stay empty rather than inventing a method from transcode_audio.
{"unknown method with transcode_audio set", "hls", true, "", ""},
{"empty method", "", false, "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
video, audio := sessionComponentDecision(tc.playMethod, tc.transcodeAudio, tc.targetVideo)
if got := effectivePlayMethod(video, audio); got != tc.want {
t.Fatalf("effectivePlayMethod(%q, %q) = %q, want %q", video, audio, got, tc.want)
}
})
}
}
// TestSessionsCapabilitiesAdvertisesActivityFields pins the feature-detection
// contract: the additive session fields are omitempty on the wire, so this
// endpoint is how independently deployed clients distinguish an older server
// from a supported one reporting an unknown method / non-Jellyfin session.
func TestSessionsCapabilitiesAdvertisesActivityFields(t *testing.T) {
rr := httptest.NewRecorder()
(&AdminHandler{}).HandleGetSessionsCapabilities(rr, httptest.NewRequest(http.MethodGet, "/admin/sessions/capabilities", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
var resp playbackSessionsCapabilitiesResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode capabilities: %v", err)
}
if !resp.EffectivePlayMethod || !resp.IsJellyfinClient {
t.Fatalf("capabilities must advertise both fields: %+v", resp)
}
want := []string{"direct", "remux", "transcode", "audio"}
if len(resp.EffectivePlayMethodValues) != len(want) {
t.Fatalf("bucket vocabulary = %v, want %v", resp.EffectivePlayMethodValues, want)
}
for i, v := range want {
if resp.EffectivePlayMethodValues[i] != v {
t.Fatalf("bucket vocabulary = %v, want %v", resp.EffectivePlayMethodValues, want)
}
}
}
func TestIsJellyfinEcosystemClient(t *testing.T) {
cases := []struct {
name string
clientName string
userAgent string
want bool
}{
{"jellyfin web by name", "Jellyfin Web", "", true},
{"findroid by name", "Findroid", "Findroid/0.15", true},
{"infuse by user agent only", "", "Infuse-Direct/8.4.6", true},
{"kodi addon by name", "Kodi", "Kodi/21.0", true},
{"mpv shim by user agent", "", "mpv 0.38.0", true},
{"native android client", "Silo Android", "okhttp/4.12", false},
{"generic browser", "", "Mozilla/5.0 (X11) Chrome/120.0 Safari/537.36", false},
{"no metadata", "", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isJellyfinEcosystemClient(tc.clientName, tc.userAgent); got != tc.want {
t.Fatalf("isJellyfinEcosystemClient(%q, %q) = %v, want %v", tc.clientName, tc.userAgent, got, tc.want)
}
})
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/transcodenode"
)
// startLocalPlaybackTransport is the shared local ffmpeg launch primitive for
// legacy and protocol-v3 orchestration. Callers retain ownership of lifecycle
// locking and decide whether registration is immediate or transactionally
// staged.
func (h *PlaybackHandler) startLocalPlaybackTransport(ctx context.Context, opts playback.TranscodeOpts) (*playback.TranscodeSession, error) {
return playback.StartTranscode(context.WithoutCancel(ctx), opts)
}
// startRemotePlaybackTransport is the shared remote-node launch primitive.
// It returns the node's HTTP status separately so legacy and v3 can preserve
// their existing public error envelopes while executing identical transport
// startup and response parsing.
func (h *PlaybackHandler) startRemotePlaybackTransport(ctx context.Context, nodeURL string, request transcodenode.TranscodeStartRequest) (transcodenode.TranscodeStartResponse, int, error) {
body, err := json.Marshal(request)
if err != nil {
return transcodenode.TranscodeStartResponse{}, 0, err
}
requestCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
httpRequest, err := http.NewRequestWithContext(requestCtx, http.MethodPost, nodeURL+"/transcode/start", bytes.NewReader(body))
if err != nil {
return transcodenode.TranscodeStartResponse{}, 0, err
}
httpRequest.Header.Set("Content-Type", "application/json")
httpRequest.Header.Set("Authorization", "Bearer "+h.JWTSecret)
response, err := http.DefaultClient.Do(httpRequest)
if err != nil {
return transcodenode.TranscodeStartResponse{}, 0, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusAccepted {
// Drain the (small) error body so the transport can reuse the
// connection instead of tearing it down on every failed start.
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
return transcodenode.TranscodeStartResponse{}, response.StatusCode, nil
}
var result transcodenode.TranscodeStartResponse
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
slog.WarnContext(ctx, "remote transcode start response decode failed", "component", "api", "node", nodeURL, "error", err)
}
return result, response.StatusCode, nil
}
func fetchRemoteTranscodeCapabilities(ctx context.Context, nodeURL, jwtSecret string) (playback.HWAccelInfo, error) {
requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, strings.TrimRight(nodeURL, "/")+"/hw-capabilities", nil)
if err != nil {
return playback.HWAccelInfo{}, err
}
request.Header.Set("Authorization", "Bearer "+jwtSecret)
response, err := http.DefaultClient.Do(request)
if err != nil {
return playback.HWAccelInfo{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return playback.HWAccelInfo{}, fmt.Errorf("node returned %d", response.StatusCode)
}
var info playback.HWAccelInfo
if err := json.NewDecoder(response.Body).Decode(&info); err != nil {
return playback.HWAccelInfo{}, err
}
info.Source = "transcode_node"
info.NodeURL = nodeURL
return info, nil
}
File diff suppressed because it is too large Load Diff
+121
View File
@@ -0,0 +1,121 @@
package handlers
import (
"context"
"log/slog"
"runtime/debug"
"time"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
)
const playbackV3ShadowSetting = "playback.protocol_v3_shadow_enabled"
func (h *PlaybackHandler) protocolV3ShadowEnabled(ctx context.Context) bool {
return h.settingFlagCachedV3(ctx, playbackV3ShadowSetting)
}
// shadowLegacyPlaybackV3 compares the production legacy route with a v3 plan
// without advertising or executing that plan. Legacy capabilities lack the
// detailed v3 fields, so exact source facts are used only as an explicitly
// labelled validation inference; this is never a production compatibility
// claim and cannot enable v3 playback.
func (h *PlaybackHandler) shadowLegacyPlaybackV3(ctx context.Context, req startPlaybackRequest, requestedFile, effectiveFile *models.MediaFile, audioIndex int, productionMethod playback.PlayMethod, productionTranscodeAudio bool, sessionID string) {
// The shadow planner runs on a bare goroutine off the production start
// path; an escaped panic here would take down the whole process for a
// telemetry-only comparison, so it must fail closed instead.
defer func() {
if recovered := recover(); recovered != nil {
slog.ErrorContext(ctx, "playback v3 shadow planner panicked",
"component", "playback", "playback_session_id", sessionID,
"panic", recovered, "stack", string(debug.Stack()))
}
}()
if !h.protocolV3ShadowEnabled(ctx) || requestedFile == nil || effectiveFile == nil {
return
}
request := legacyShadowRequestV3(req, effectiveFile, audioIndex, sessionID)
result := playback.PlanPlaybackV3(playback.PlannerInputV3{
Request: request,
RequestedFile: requestedFile,
EffectiveFile: effectiveFile,
AudioTrackIndex: audioIndex,
Settings: h.plannerSettingsV3(ctx),
Registry: h.transformationRegistryV3(ctx),
HLSRegistry: h.lazyHLSPlanningRegistryV3(ctx),
Now: time.Now(),
})
attrs := []any{
"component", "playback",
"playback_session_id", sessionID,
"requested_file_id", requestedFile.ID,
"effective_file_id", effectiveFile.ID,
"production_method", productionMethod,
"production_transcode_audio", productionTranscodeAudio,
"capability_basis", "legacy_source_exact_inference",
}
if result.Plan != nil {
attrs = append(attrs,
"shadow_plan_id", result.Plan.PlanID,
"shadow_delivery", result.Plan.Delivery,
"shadow_method", result.PlayMethod,
"shadow_transcode_audio", result.TranscodeAudio,
"shadow_reason", result.Plan.DecisionReason,
"route_match", result.PlayMethod == productionMethod && result.TranscodeAudio == productionTranscodeAudio,
)
} else if result.Terminal != nil {
attrs = append(attrs, "shadow_terminal", result.Terminal.Reason, "route_match", false)
}
slog.InfoContext(ctx, "playback v3 shadow decision", attrs...)
}
func legacyShadowRequestV3(req startPlaybackRequest, file *models.MediaFile, audioIndex int, sessionID string) playback.StartRequestV3 {
source := playback.SourceDescriptorFromFileV3(file, audioIndex)
hdr := legacyShadowHDRV3(req)
features := []string{playback.FeaturePlaybackPlanV3, playback.FeatureMedia3Only, playback.FeatureDetailedDecodeV3}
capabilities := playback.ClientCodecCapabilitiesV3{
CodecsVideo: append([]string(nil), req.CodecsVideo...),
CodecsAudio: append([]string(nil), req.CodecsAudio...),
Containers: append([]string(nil), req.Containers...),
MaxResolution: req.MaxResolution,
HDR: req.HDR,
HDRDetails: hdr,
}
if containsStringFoldV3(req.CodecsVideo, source.VideoCodec) {
capabilities.VideoDecode = []playback.VideoDecodeCapabilityV3{{
Codec: source.VideoCodec, Profiles: []string{source.VideoProfile}, Levels: []int{source.VideoLevel}, BitDepths: []int{source.BitDepth},
MaxWidth: source.Width, MaxHeight: source.Height, MaxFrameRate: source.FrameRate, MaxBitrateKbps: source.BitrateKbps, Hardware: true,
}}
}
var passthrough *playback.AudioPassthroughV3
if req.AudioPassthrough != nil {
passthrough = &playback.AudioPassthroughV3{PassthroughCodecs: append([]string(nil), req.AudioPassthrough.PassthroughCodecs...), SpatializerEnabled: req.AudioPassthrough.SpatializerEnabled, MaxChannels: req.AudioPassthrough.MaxChannels}
if containsStringFoldV3(req.AudioPassthrough.PassthroughCodecs, source.AudioCodec) && source.AudioChannels > 0 && source.AudioLayout != "" && source.AudioChannels <= req.AudioPassthrough.MaxChannels {
passthrough.Entries = []playback.AudioPassthroughEntryV3{{Codec: source.AudioCodec, ChannelCounts: []int{source.AudioChannels}, Layouts: []string{source.AudioLayout}}}
features = append(features, playback.FeatureLayoutPassthrough)
}
capabilities.AudioPassthrough = passthrough
}
engines := map[string]playback.EngineCapabilityV3{
string(playback.EngineMedia3DirectV3): {Enabled: true, SupportedOnDevice: true, Subtitles: playback.EngineSubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true, EmbeddedBitmap: true, SidecarBitmap: true}},
string(playback.EngineMedia3ProgressiveRemuxV3): {Enabled: true, SupportedOnDevice: true},
string(playback.EngineMedia3HLSV3): {Enabled: true, SupportedOnDevice: true},
}
return playback.StartRequestV3{
ProtocolVersion: playback.ProtocolV3, ClientFeatures: features, FileID: file.ID, ProfileID: req.ProfileID,
PlaybackAttemptID: "shadow-" + sessionID, QualityPreference: "original", SubtitleFidelityPreference: playback.SubtitleFidelityCompatibleV3,
StartPosition: req.StartPosition, AudioTrackIndex: &audioIndex, Capabilities: capabilities,
ClientPlaybackContext: playback.ClientPlaybackContextV3{ProtocolVersion: playback.ProtocolV3, Features: features, Platform: "legacy-shadow", Output: playback.OutputContextV3{HDRDetails: hdr, AudioPassthrough: passthrough}, Engines: engines},
}
}
func legacyShadowHDRV3(req startPlaybackRequest) *playback.HDRCapabilitiesV3 {
if req.HdrDetails != nil {
return &playback.HDRCapabilitiesV3{HDR10: req.HdrDetails.HDR10, HDR10Plus: req.HdrDetails.HDR10Plus, HLG: req.HdrDetails.HLG, DolbyVisionProfiles: append([]int(nil), req.HdrDetails.DolbyVisionProfiles...)}
}
if req.HDR {
return &playback.HDRCapabilitiesV3{HDR10: true}
}
return nil
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,198 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
)
// enumeratingNodePlannerV3 is a SessionPlanner stub that also exposes pooled
// transcode node URLs, matching *nodepool.Planner's production shape.
type enumeratingNodePlannerV3 struct {
staticNodePlannerV3
urls []string
}
func (p enumeratingNodePlannerV3) TranscodeNodeURLs() []string { return p.urls }
// presetLocalRegistryV3 pins the handler's local transformation registry so
// tests never probe the machine's real ffmpeg.
func presetLocalRegistryV3(h *PlaybackHandler, registry *playback.TransformationRegistryV3) {
h.v3RegistryOnce.Do(func() {})
h.v3Registry = registry
}
func TestHLSPlanningRegistryV3UnionsPooledNodeCapabilities(t *testing.T) {
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/hw-capabilities" {
w.WriteHeader(http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, playback.HWAccelInfo{Transformations: []playback.TransformationV3{
{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"},
{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"},
}})
}))
defer remote.Close()
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
presetLocalRegistryV3(handler, playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{
{Name: "video_to_h264", RecipeVersion: "1"},
{Name: "audio_to_aac", RecipeVersion: "1"},
{Name: "server_dv7_to_hdr10", RecipeVersion: "1"},
}))
handler.NodePlanner = enumeratingNodePlannerV3{urls: []string{remote.URL}}
registry := handler.hlsPlanningRegistryV3(context.Background())
if !registry.Available("video_to_h264") || !registry.Available("audio_to_aac") {
t.Fatal("pooled node capabilities must widen the HLS planning registry")
}
if registry.Available("server_dv7_to_hdr10") {
t.Fatal("transformations no node advertises must stay unavailable")
}
if handler.transformationRegistryV3(context.Background()).Available("video_to_h264") {
t.Fatal("the local registry must not be widened by node capabilities")
}
}
func TestHLSPlanningRegistryV3WithoutEnumeratorIsLocal(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
local := playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{{Name: "audio_to_aac", RecipeVersion: "1", Available: true}})
presetLocalRegistryV3(handler, local)
handler.NodePlanner = staticNodePlannerV3{plan: nodepool.Plan{}}
if registry := handler.hlsPlanningRegistryV3(context.Background()); registry != local {
t.Fatal("a planner without node enumeration must plan from the local registry")
}
}
func TestRemoteTransformationsV3FailureCacheSplit(t *testing.T) {
hits := 0
fail := true
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
if fail {
w.WriteHeader(http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, playback.HWAccelInfo{Transformations: []playback.TransformationV3{{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"}}})
}))
defer remote.Close()
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
if _, err := handler.remoteTransformationsPlanningV3(context.Background(), remote.URL); err == nil {
t.Fatal("fetch against a failing node must error")
}
if _, err := handler.remoteTransformationsPlanningV3(context.Background(), remote.URL); err == nil {
t.Fatal("planning lookups must surface the memoized failure")
}
if hits != 1 {
t.Fatalf("failing node was fetched %d times; planning must memoize the failure", hits)
}
// The transport path must fetch through the memoized failure: it may
// have been produced by a planning deadline far shorter than this
// path's budget, and rejecting the already-selected node on it would
// fail a start a fresh fetch could still validate.
fail = false
transformations, err := handler.remoteTransformationsV3(context.Background(), remote.URL)
if err != nil || len(transformations) != 1 {
t.Fatalf("transport lookup must refetch through a memoized failure: %v %#v", err, transformations)
}
if hits != 2 {
t.Fatalf("transport lookup fetched %d times, want 2", hits)
}
// The refetched success replaces the failure for planning too.
if _, err := handler.remoteTransformationsPlanningV3(context.Background(), remote.URL); err != nil {
t.Fatalf("planning lookup after transport success: %v", err)
}
if hits != 2 {
t.Fatalf("cached success was refetched (%d hits)", hits)
}
}
// In a heterogeneous pool, a plan that needs server transformations must be
// placed on a node advertising them even when load balancing prefers an
// incapable node, while transformation-free plans keep load-based selection.
func TestPlanNodeSessionV3PrefersCapabilityMatchingNode(t *testing.T) {
capable := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, playback.HWAccelInfo{Transformations: []playback.TransformationV3{
{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"},
{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"},
}})
}))
defer capable.Close()
incapable := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, playback.HWAccelInfo{})
}))
defer incapable.Close()
transcodes := nodepool.NewTranscodePool()
transcodes.SetNodes([]*nodepool.Node{
{ID: 1, Name: "incapable", Type: nodepool.NodeTypeTranscode, URL: incapable.URL, Enabled: true, Healthy: true, ActiveJobs: 0},
{ID: 2, Name: "capable", Type: nodepool.NodeTypeTranscode, URL: capable.URL, Enabled: true, Healthy: true, ActiveJobs: 5},
})
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
handler.NodePlanner = nodepool.NewPlanner(nodepool.NewProxyPool(), transcodes)
plan := &playback.PlanV3{
PlanID: "plan:heterogeneous",
Delivery: playback.DeliveryTranscodeHLSV3,
Transformations: []playback.TransformationV3{
{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"},
{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"},
},
}
selected := handler.planNodeSessionV3(context.Background(), &playback.Session{ID: "session-hetero"}, playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayTranscode})
if selected.TranscodeNode == nil || selected.TranscodeNode.URL != capable.URL {
t.Fatalf("capability-requiring plan selected %+v, want the capable node", selected.TranscodeNode)
}
free := &playback.PlanV3{PlanID: "plan:copy", Delivery: playback.DeliveryRemuxHLSV3, Transformations: []playback.TransformationV3{}}
loadBased := handler.planNodeSessionV3(context.Background(), &playback.Session{ID: "session-copy"}, playback.PlannerResultV3{Plan: free, PlayMethod: playback.PlayRemux})
if loadBased.TranscodeNode == nil || loadBased.TranscodeNode.URL != incapable.URL {
t.Fatalf("transformation-free plan selected %+v, want load-based selection", loadBased.TranscodeNode)
}
}
func TestPrepareTransportV3LocalFallbackRejectsUnavailableTransformations(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
presetLocalRegistryV3(handler, playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{
{Name: "video_to_h264", RecipeVersion: "1"},
{Name: "audio_to_aac", RecipeVersion: "1"},
}))
plan := &playback.PlanV3{
PlanID: "plan:local-capability",
Delivery: playback.DeliveryTranscodeHLSV3,
Transformations: []playback.TransformationV3{
{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"},
{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"},
},
}
request := httptest.NewRequest(http.MethodPost, "/", nil)
_, transportErr := handler.prepareTransportV3(request, &playback.Session{ID: "session-local-capability"}, v3HandlerFixtureFile(t), playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayTranscode, TargetVideoCodec: "h264", TargetAudioCodec: "aac"})
if transportErr == nil || transportErr.reason != "transcode_node_capability_unavailable" || !transportErr.retryable {
t.Fatalf("transport error = %#v", transportErr)
}
}
func TestPlanRequiresServerTransformationsV3(t *testing.T) {
if planRequiresServerTransformationsV3(nil) {
t.Fatal("nil plan must not require server transformations")
}
clientOnly := &playback.PlanV3{Transformations: []playback.TransformationV3{{Name: playback.ClientDV7ToDV81V3, Executor: "client", RecipeVersion: "1"}}}
if planRequiresServerTransformationsV3(clientOnly) {
t.Fatal("client-executed transformations must not require a server executor")
}
server := &playback.PlanV3{Transformations: []playback.TransformationV3{{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"}}}
if !planRequiresServerTransformationsV3(server) {
t.Fatal("server-executed transformations must require executor validation")
}
}
+60 -1
View File
@@ -157,6 +157,7 @@ type pluginInstallationResponse struct {
Version string `json:"version"`
InstallPath string `json:"install_path"`
Enabled bool `json:"enabled"`
Kind string `json:"kind"`
UpdatePolicy string `json:"update_policy"`
AvailableVersion *string `json:"available_version,omitempty"`
SourceKind string `json:"source_kind"`
@@ -320,6 +321,11 @@ type pluginUserSettingsSummary struct {
UserConfigSchema []pluginConfigSchemaJSON `json:"user_config_schema"`
Routes []pluginRouteJSON `json:"routes"`
Assets []pluginAssetJSON `json:"assets"`
// Category is the manifest's optional slash-delimited grouping path
// (e.g. "Tools/Utilities") used to group plugin entries in the
// user-facing Apps navigation. Empty (omitted) when the manifest
// declares no category. Additive-only per v1 API rules.
Category string `json:"category,omitempty"`
}
type pluginUserSettingsListResponse struct {
@@ -877,6 +883,10 @@ func (h *PluginHandler) HandleUpdateInstallation(w http.ResponseWriter, r *http.
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin installation")
return
}
if currentInstallation.IsBuiltin() {
writeError(w, http.StatusConflict, "builtin_installation", "Built-in host providers cannot be modified")
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) {
@@ -928,6 +938,9 @@ func (h *PluginHandler) HandleApplyUpdate(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusBadRequest, "bad_request", "Invalid installation ID")
return
}
if h.rejectBuiltinInstallation(w, r, id) {
return
}
installation, err := h.service.UpdateToAvailableVersion(r.Context(), id)
if err != nil {
@@ -969,6 +982,9 @@ func (h *PluginHandler) HandlePutInstallationConfig(w http.ResponseWriter, r *ht
writeError(w, http.StatusInternalServerError, "internal_error", "Plugin service not configured")
return
}
if h.rejectBuiltinInstallation(w, r, id) {
return
}
if err := h.service.SetGlobalConfig(r.Context(), id, req.Key, req.Value); err != nil {
var validationErr *plugins.ConfigValidationError
@@ -1007,6 +1023,9 @@ func (h *PluginHandler) HandleTestInstallationConfig(w http.ResponseWriter, r *h
writeError(w, http.StatusBadRequest, "bad_request", "key is required")
return
}
if h.rejectBuiltinInstallation(w, r, id) {
return
}
if err := h.service.TestGlobalConfig(r.Context(), id, req.Key, req.Value); err != nil {
if errors.Is(err, plugins.ErrInstallationNotFound) {
@@ -1041,6 +1060,10 @@ func (h *PluginHandler) HandlePutAuthBinding(w http.ResponseWriter, r *http.Requ
return
}
if h.rejectBuiltinInstallation(w, r, id) {
return
}
var req pluginAuthBindingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
@@ -1074,6 +1097,10 @@ func (h *PluginHandler) HandlePutTaskBinding(w http.ResponseWriter, r *http.Requ
return
}
if h.rejectBuiltinInstallation(w, r, id) {
return
}
capabilityID := chi.URLParam(r, "capability_id")
if strings.TrimSpace(capabilityID) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "capability_id is required")
@@ -1101,6 +1128,21 @@ func (h *PluginHandler) HandlePutTaskBinding(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusOK, pluginTaskBindingUpdateResponse{RestartRequired: true})
}
// rejectBuiltinInstallation writes a 409 and returns true when the target
// installation is the reserved builtin row, which no plugin-management
// endpoint may mutate. Lookup errors are left to the caller's own handling.
func (h *PluginHandler) rejectBuiltinInstallation(w http.ResponseWriter, r *http.Request, id int) bool {
installation, err := h.installations.GetByID(r.Context(), id)
if err != nil {
return false
}
if !installation.IsBuiltin() {
return false
}
writeError(w, http.StatusConflict, "builtin_installation", "Built-in host providers cannot be modified")
return true
}
func (h *PluginHandler) HandleDeleteInstallation(w http.ResponseWriter, r *http.Request) {
id, err := parseNamedIDParam(r, "id")
if err != nil {
@@ -1113,6 +1155,10 @@ func (h *PluginHandler) HandleDeleteInstallation(w http.ResponseWriter, r *http.
writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found")
return
}
if errors.Is(err, plugins.ErrBuiltinInstallationImmutable) {
writeError(w, http.StatusConflict, "builtin_installation", "Built-in host providers cannot be uninstalled")
return
}
slog.ErrorContext(r.Context(), "deleting plugin installation", "component", "api", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete plugin installation")
return
@@ -1151,6 +1197,11 @@ func (h *PluginHandler) HandleListUserPluginSettings(w http.ResponseWriter, r *h
Installations: make([]pluginUserSettingsSummary, 0, len(installations)),
}
for _, installation := range installations {
// The reserved builtin row has no manifest on disk; without this skip
// the whole user-scoped settings list would 500.
if installation.IsBuiltin() {
continue
}
manifest, err := plugins.LoadManifestFile(plugins.InstalledManifestPath(installation.InstallPath))
if err != nil {
slog.ErrorContext(r.Context(), "loading plugin manifest", "component", "api", "installation_id", installation.ID, "error", err)
@@ -1233,7 +1284,7 @@ func (h *PluginHandler) loadUserConfigInstallation(
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin installation")
return nil, nil, err
}
if !installation.Enabled {
if !installation.Enabled || installation.IsBuiltin() {
writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found")
return nil, nil, plugins.ErrInstallationNotFound
}
@@ -1277,6 +1328,12 @@ func (h *PluginHandler) buildInstallationResponses(
}
response := make([]pluginInstallationResponse, 0, len(installations))
for _, installation := range installations {
// The reserved builtin row is not a manageable plugin: old web builds
// would render a phantom entry with uninstall/upgrade buttons that
// error, and the chain editor does not need it in this list.
if installation.IsBuiltin() {
continue
}
item, err := h.buildInstallationResponseWithBindings(
ctx,
installation,
@@ -1390,6 +1447,7 @@ func (h *PluginHandler) buildInstallationResponseWithBindings(
Version: installation.Version,
InstallPath: installation.InstallPath,
Enabled: installation.Enabled,
Kind: installation.Kind,
UpdatePolicy: installation.UpdatePolicy,
AvailableVersion: installation.AvailableVersion,
SourceKind: sourceKind,
@@ -1465,6 +1523,7 @@ func toUserPluginSettingsSummary(
UserConfigSchema: configSchemasToJSON(manifest.GetUserConfigSchema()),
Routes: routesToJSON(manifest.GetHttpRoutes()),
Assets: assetsToJSON(manifest.GetAssets()),
Category: manifest.GetCategory(),
}
}
@@ -0,0 +1,170 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/plugins"
)
func pluginBuiltinTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
return pool
}
func builtinTestHandler(pool *pgxpool.Pool) *PluginHandler {
return &PluginHandler{
repositories: plugins.NewRepositoryStore(pool),
installations: plugins.NewInstallationStore(pool),
configs: plugins.NewRuntimeConfigStore(pool),
}
}
func seedHandlerBuiltinInstallation(t *testing.T, pool *pgxpool.Pool) int {
t.Helper()
var id int
pluginID := fmt.Sprintf("test.builtin.handler-%d", time.Now().UnixNano())
err := pool.QueryRow(context.Background(),
`INSERT INTO plugin_installations (plugin_id, version, install_path, enabled, update_policy, kind)
VALUES ($1, '0', '/nonexistent/silo-builtin-test', true, 'manual', 'builtin')
RETURNING id`, pluginID).Scan(&id)
if err != nil {
t.Fatalf("seed builtin installation: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM plugin_installations WHERE id = $1`, id)
})
return id
}
func requestWithIDParam(method, target, param string, id int) *http.Request {
return requestWithIDParamBody(method, target, param, id, nil)
}
func requestWithIDParamBody(method, target, param string, id int, body io.Reader) *http.Request {
req := httptest.NewRequest(method, target, body)
rctx := chi.NewRouteContext()
rctx.URLParams.Add(param, strconv.Itoa(id))
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
}
// The manifest-less builtin row must not 500 the user-scoped plugin settings
// list the moment the migration lands (ship-blocker: the web sidebar fetches
// this for every logged-in user).
func TestHandleListUserPluginSettings_SkipsBuiltinRow(t *testing.T) {
pool := pluginBuiltinTestPool(t)
builtinID := seedHandlerBuiltinInstallation(t, pool)
h := builtinTestHandler(pool)
rec := httptest.NewRecorder()
h.HandleListUserPluginSettings(rec, httptest.NewRequest(http.MethodGet, "/api/v1/settings/plugins", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Installations []struct {
ID int `json:"id"`
} `json:"installations"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
for _, inst := range resp.Installations {
if inst.ID == builtinID {
t.Errorf("builtin installation %d leaked into user plugin settings", builtinID)
}
}
}
// loadUserConfigInstallation must 404 builtin ids.
func TestHandleGetUserPluginSettings_BuiltinIDIs404(t *testing.T) {
pool := pluginBuiltinTestPool(t)
builtinID := seedHandlerBuiltinInstallation(t, pool)
h := builtinTestHandler(pool)
rec := httptest.NewRecorder()
h.HandleGetUserPluginSettings(rec, requestWithIDParam(http.MethodGet, "/api/v1/settings/plugins/0", "installation_id", builtinID))
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
}
// GET /plugins/installations must filter builtin rows server-side, and the
// remaining rows carry the additive kind field.
func TestHandleListInstallations_FiltersBuiltinAndExposesKind(t *testing.T) {
pool := pluginBuiltinTestPool(t)
builtinID := seedHandlerBuiltinInstallation(t, pool)
h := builtinTestHandler(pool)
rec := httptest.NewRecorder()
h.HandleListInstallations(rec, httptest.NewRequest(http.MethodGet, "/api/v1/plugins/installations", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp []struct {
ID int `json:"id"`
Kind string `json:"kind"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
for _, inst := range resp {
if inst.ID == builtinID {
t.Errorf("builtin installation %d leaked into installations list", builtinID)
}
if inst.Kind == "" {
t.Errorf("installation %d missing additive kind field", inst.ID)
}
}
}
// Mutating endpoints must reject the builtin row with a clear 4xx.
func TestBuiltinInstallationMutationsRejected(t *testing.T) {
pool := pluginBuiltinTestPool(t)
builtinID := seedHandlerBuiltinInstallation(t, pool)
h := builtinTestHandler(pool)
cases := []struct {
name string
call func(rec *httptest.ResponseRecorder)
}{
{"delete", func(rec *httptest.ResponseRecorder) {
h.HandleDeleteInstallation(rec, requestWithIDParam(http.MethodDelete, "/api/v1/plugins/installations/0", "id", builtinID))
}},
{"update", func(rec *httptest.ResponseRecorder) {
req := requestWithIDParamBody(http.MethodPatch, "/api/v1/plugins/installations/0", "id", builtinID,
strings.NewReader(`{"enabled": false}`))
h.HandleUpdateInstallation(rec, req)
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
tc.call(rec)
if rec.Code < 400 || rec.Code >= 500 {
t.Fatalf("status = %d, want 4xx; body=%s", rec.Code, rec.Body.String())
}
})
}
}
@@ -0,0 +1,60 @@
package handlers
import (
"encoding/json"
"strings"
"testing"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
"github.com/Silo-Server/silo-server/internal/plugins"
)
func TestToUserPluginSettingsSummaryIncludesManifestCategory(t *testing.T) {
t.Parallel()
installation := &plugins.Installation{
ID: 42,
PluginID: "example-audiobooks",
Version: "1.2.3",
}
manifest := &pluginv1.PluginManifest{
Category: "Tools/Utilities",
}
got := toUserPluginSettingsSummary(installation, manifest)
if got.Category != "Tools/Utilities" {
t.Fatalf("Category = %q, want %q", got.Category, "Tools/Utilities")
}
if got.ID != 42 || got.PluginID != "example-audiobooks" || got.Version != "1.2.3" {
t.Fatalf("identity fields = %#v", got)
}
}
func TestToUserPluginSettingsSummaryOmitsEmptyCategory(t *testing.T) {
t.Parallel()
installation := &plugins.Installation{
ID: 7,
PluginID: "example-plain",
Version: "0.1.0",
}
got := toUserPluginSettingsSummary(installation, &pluginv1.PluginManifest{})
if got.Category != "" {
t.Fatalf("Category = %q, want empty", got.Category)
}
// The field is additive-only; ensure it stays absent from the JSON
// payload for manifests without a category so existing clients see an
// unchanged response shape.
encoded, err := json.Marshal(got)
if err != nil {
t.Fatalf("marshaling summary: %v", err)
}
if strings.Contains(string(encoded), "\"category\"") {
t.Fatalf("JSON payload should omit empty category, got %s", encoded)
}
}
+21 -7
View File
@@ -159,7 +159,7 @@ func (h *StreamHandler) HandleStream(w http.ResponseWriter, r *http.Request) {
seekSeconds = s
}
}
if err := playback.ServeRemux(w, r, file.FilePath, "mp4", seekSeconds, session.TranscodeAudio, session.AudioTrackIndex, file.PrimaryDVProfile()); err != nil {
if err := playback.ServeRemuxWithDVMode(w, r, file.FilePath, "mp4", seekSeconds, session.TranscodeAudio, session.AudioTrackIndex, file.PrimaryDVProfile(), session.RemuxDVMode, h.ffmpegPath()); err != nil {
h.handleTransportStartFailure(r.Context(), session, file, err)
}
@@ -191,7 +191,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
setPlaybackSessionLogContext(r, sessionID)
trackParam := chi.URLParam(r, "track")
trackIndex, _, err := playback.ParseSubtitleTrackParam(trackParam)
trackIndex, requestedFormat, err := playback.ParseSubtitleTrackParam(trackParam)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid subtitle track index")
return
@@ -224,7 +224,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
sub := file.ExternalSubtitles[trackIndex]
// Serve ASS/SSA external subtitles as raw data for client-side rendering.
if playback.IsASS(sub.Format) {
if playback.IsASS(sub.Format) && requestedFormat != "vtt" {
data, err := playback.LoadExternalSubtitleRaw(sub.Path)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error",
@@ -264,7 +264,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
// demuxed, so the first byte lands within ~1s even on network
// storage. Works identically for direct-play, remux, and
// transcode because it doesn't depend on any other ffmpeg.
h.streamEmbeddedSubtitle(w, r, file, embeddedIndex, session)
h.streamEmbeddedSubtitle(w, r, file, embeddedIndex, session, requestedFormat)
return
}
@@ -295,7 +295,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
}
// Serve ASS/SSA downloaded subtitles as raw data.
if playback.IsASS(string(dl.Format)) {
if playback.IsASS(string(dl.Format)) && requestedFormat != "vtt" {
playback.ServeSubtitle(w, data, "ass")
return
}
@@ -307,7 +307,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
}
// Convert to VTT using the playback conversion pipeline.
vttData, err := playback.ConvertToVTT(data, string(dl.Format))
vttData, err := playback.ConvertToVTTWithFFmpeg(r.Context(), data, string(dl.Format), h.ffmpegPath())
if err != nil {
writeError(w, http.StatusInternalServerError, "convert_error", "Failed to convert subtitle")
return
@@ -488,7 +488,7 @@ func (h *StreamHandler) handleTransportStartFailure(ctx context.Context, session
// track, seeked to the best-known playback position, and pipes its
// stdout directly to w. Because this ffmpeg is independent of the video
// pipeline, it works the same for direct play, remux, and transcode.
func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Request, file *models.MediaFile, embeddedIndex int, session *playback.Session) {
func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Request, file *models.MediaFile, embeddedIndex int, session *playback.Session, requestedFormat ...string) {
track := file.SubtitleTracks[embeddedIndex]
outFormat := "vtt"
switch {
@@ -537,6 +537,20 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
AllowWindow: allowWindow,
FFmpegPath: h.ffmpegPath(),
}
if len(requestedFormat) > 0 && requestedFormat[0] == "vtt" {
// Only text sources can be converted to WebVTT. A bitmap track (PGS
// reaches here because it is deliverable as .sup; DVD/DVB are rejected
// upstream) carries no text, so honoring the override would spawn an
// ffmpeg that always fails after the 200 and headers are committed.
// Reject before any spawn or header write.
if playback.NeedsBurnIn(track.Codec) {
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media_type",
"Bitmap subtitle tracks cannot be converted to WebVTT")
return
}
opts.TargetFormat = "vtt"
outFormat = "vtt"
}
w.Header().Set("Access-Control-Allow-Origin", "*")
+45
View File
@@ -196,6 +196,51 @@ func TestHandleSubtitle_NilMediaFileReturns404(t *testing.T) {
}
}
// A .vtt request for a bitmap (PGS) embedded track must be rejected up front:
// PGS passes the burn-in guard because it is deliverable as .sup, but it has
// no text to convert, so forcing WebVTT would spawn an ffmpeg that always
// fails after the 200 and headers are committed.
func TestHandleSubtitle_BitmapTrackVTTRequestReturns415(t *testing.T) {
file := &models.MediaFile{
ID: 42,
ContentID: "movie-1",
FilePath: "/tmp/movie.mkv",
Duration: 3600,
SubtitleTracks: []models.SubtitleTrack{
{Index: 0, Language: "eng", Codec: "hdmv_pgs_subtitle"},
},
}
baseMgr := playback.NewSessionManager(0, 0)
session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false)
if err != nil {
t.Fatalf("StartSession: %v", err)
}
handler := NewStreamHandler(baseMgr, testPlaybackFileResolver{file: file})
req := httptest.NewRequest(http.MethodGet, "/api/v1/stream/"+session.ID+"/subtitles/0.vtt", nil)
req = req.WithContext(newAuthorizedPlaybackContext())
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("session_id", session.ID)
routeCtx.URLParams.Add("track", "0.vtt")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rr := httptest.NewRecorder()
handler.HandleSubtitle(rr, req)
if rr.Code != http.StatusUnsupportedMediaType {
t.Fatalf("status = %d, body = %s; want 415", rr.Code, rr.Body.String())
}
var body struct {
Error string `json:"error"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("decode error body: %v (body = %s)", err, rr.Body.String())
}
if body.Error != "unsupported_media_type" {
t.Fatalf("error code = %q, want %q", body.Error, "unsupported_media_type")
}
}
func TestSubtitleSourceFileIDPinsURLAcrossEffectiveFileSwitch(t *testing.T) {
session := &playback.Session{MediaFileID: 200, RequestedMediaFileID: 100}
+1 -27
View File
@@ -2,11 +2,8 @@ package handlers
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/Silo-Server/silo-server/internal/buildinfo"
"github.com/Silo-Server/silo-server/internal/nodepool"
@@ -57,28 +54,5 @@ func (h *SystemHandler) HandleBuildInfo(w http.ResponseWriter, _ *http.Request)
}
func (h *SystemHandler) fetchRemoteHWAccel(ctx context.Context, node *nodepool.Node) (playback.HWAccelInfo, error) {
client := &http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, node.URL+"/hw-capabilities", nil)
if err != nil {
return playback.HWAccelInfo{}, err
}
req.Header.Set("Authorization", "Bearer "+h.jwtSecret)
resp, err := client.Do(req)
if err != nil {
return playback.HWAccelInfo{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return playback.HWAccelInfo{}, fmt.Errorf("node returned %d", resp.StatusCode)
}
var info playback.HWAccelInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return playback.HWAccelInfo{}, err
}
info.Source = "transcode_node"
info.NodeURL = node.URL
return info, nil
return fetchRemoteTranscodeCapabilities(ctx, node.URL, h.jwtSecret)
}
+4 -4
View File
@@ -47,6 +47,8 @@ type AuthMiddleware struct {
sessionValidator SessionValidator
apiKeyValidator APIKeyValidator // nil if API keys not configured
apiKeyUserLoader APIKeyUserLoader // nil if API keys not configured
apiKeyLastUsed *auth.APIKeyLastUsedTracker
}
// NewAuthMiddleware creates a new AuthMiddleware with the given token validator
@@ -57,6 +59,7 @@ func NewAuthMiddleware(tv TokenValidator, sv SessionValidator, akv APIKeyValidat
sessionValidator: sv,
apiKeyValidator: akv,
apiKeyUserLoader: akul,
apiKeyLastUsed: auth.NewAPIKeyLastUsedTracker(akv, nil),
}
}
@@ -98,10 +101,7 @@ func (am *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
return
}
// Update last_used_at asynchronously.
go func(id int64) {
_ = am.apiKeyValidator.UpdateLastUsed(context.Background(), id)
}(apiKey.ID)
am.apiKeyLastUsed.Touch(apiKey.ID)
claims = &auth.Claims{
UserID: user.ID,
+5 -1
View File
@@ -17,7 +17,8 @@ type DemoSettingsReader interface {
// Allowed: browsing, playback, favorites, watchlist, ratings, collections,
// profiles, playback progress, watched state.
//
// Blocked: API key management, downloads, history imports, subtitle downloads.
// Blocked: API key management, downloads, history imports, subtitle downloads,
// diagnostics report uploads/deletes.
type DemoGuard struct {
settings DemoSettingsReader
}
@@ -41,6 +42,9 @@ var demoBlockedRoutes = []blockedRoute{
{methods: []string{"POST"}, prefix: "/api/v1/subtitles/download"},
{methods: []string{"POST"}, prefix: "/api/v1/subtitles/upload"},
{methods: []string{"DELETE"}, prefix: "/api/v1/subtitles/"},
// Diagnostics report uploads/deletes write DB rows and private-bucket blobs;
// GET /api/v1/diagnostics/status is unaffected (GETs always pass).
{methods: []string{"POST", "DELETE"}, prefix: "/api/v1/diagnostics/reports"},
}
// Guard is an HTTP middleware that enforces demo mode restrictions.
+95 -8
View File
@@ -32,6 +32,7 @@ import (
"github.com/Silo-Server/silo-server/internal/catalogseed"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/diagnostics"
"github.com/Silo-Server/silo-server/internal/downloads"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/historyimport"
@@ -50,6 +51,7 @@ import (
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/opslog"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/playback/planstore"
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/policy"
"github.com/Silo-Server/silo-server/internal/ratelimit"
@@ -253,14 +255,22 @@ func NewRouter(deps Dependencies) chi.Router {
// acting-admin profile policy, degrading admin routes to the plain
// role check.
var checkPrimaryProfile apimw.PrimaryProfileChecker
// lookupProfile resolves a profile for the given user, returning nil when it
// does not exist. Shared by the acting-admin primary check and the
// diagnostics profile-attribution validator so both read profile state
// (IsPrimary, IsChild) the same way.
var lookupProfile func(ctx context.Context, userID int, profileID string) (*userstore.Profile, error)
if deps.UserStoreProvider != nil {
userStores := deps.UserStoreProvider
checkPrimaryProfile = func(ctx context.Context, userID int, profileID string) (bool, bool, error) {
lookupProfile = func(ctx context.Context, userID int, profileID string) (*userstore.Profile, error) {
store, err := userStores.ForUser(ctx, userID)
if err != nil {
return false, false, err
return nil, err
}
profile, err := store.GetProfile(ctx, profileID)
return store.GetProfile(ctx, profileID)
}
checkPrimaryProfile = func(ctx context.Context, userID int, profileID string) (bool, bool, error) {
profile, err := lookupProfile(ctx, userID, profileID)
if err != nil {
return false, false, err
}
@@ -307,6 +317,39 @@ func NewRouter(deps Dependencies) chi.Router {
if deps.DB != nil {
accessGroupStore = access.NewGroupStore(deps.DB)
}
var diagnosticsStore diagnostics.ObjectStore
if deps.S3Private != nil {
diagnosticsStore = diagnostics.NewS3ObjectStore(deps.S3Private)
}
var diagnosticsHandler *handlers.DiagnosticsHandler
if deps.DB != nil {
diagnosticsService := diagnostics.NewService(
diagnostics.NewPostgresRepository(deps.DB),
settingsRepo,
diagnosticsStore,
slog.Default(),
)
if lookupProfile != nil {
// Attribute reports only to a profile that belongs to the account and
// is not a child profile; child profiles must not perform diagnostics
// actions per the design, so reject their attribution here.
diagnosticsService.SetProfileAttributionValidator(diagnostics.NewProfileAttributionValidator(
func(ctx context.Context, userID int, profileID string) (bool, bool, error) {
profile, err := lookupProfile(ctx, userID, profileID)
if err != nil {
return false, false, err
}
if profile == nil {
return false, false, nil
}
return true, profile.IsChild, nil
},
))
}
diagnosticsHandler = handlers.NewDiagnosticsHandler(
diagnosticsService,
)
}
// Build auth handler and auth middleware if DB and config are available.
var userRepo *auth.UserRepository
@@ -805,6 +848,12 @@ func NewRouter(deps Dependencies) chi.Router {
} else {
playbackHandler = handlers.NewPlaybackHandler(deps.SessionMgr)
}
if deps.DB != nil {
playbackHandler.PlanStoreV3 = planstore.NewPostgres(deps.DB)
}
// Maintenance also bounds the in-memory fallback store: without it a
// DB-less deployment accumulates attempts and replans forever.
playbackHandler.StartV3Maintenance(deps.AppContext)
// Wire UserStoreProvider for progress/history persistence.
if deps.UserStoreProvider != nil {
@@ -850,11 +899,13 @@ func NewRouter(deps Dependencies) chi.Router {
playbackHandler.PlaybackConfig = func() config.PlaybackConfig {
return deps.CurrentConfig().Playback
}
if cleaned, err := playbackHandler.CleanupOrphanedTranscodes(); err != nil {
slog.Warn("playback transcode cleanup failed", "dir", deps.Config.Playback.TranscodeDir, "error", err)
} else if cleaned > 0 {
slog.Info("playback transcode cleanup removed orphaned dirs", "dir", deps.Config.Playback.TranscodeDir, "count", cleaned)
}
// In integrated mode this and the jellycompat sweep both scan the same
// TranscodeDir but each snapshots only its own manager's live set, so a
// >24h idle dir owned by the other manager can be reaped. Bounded and
// safe: active dirs stay mtime-fresh (spared) and either side rebuilds
// from its token/recipe, so the worst case is a wasted rebuild. A shared
// active-set source across both managers would remove even that.
playback.StartPeriodicOrphanCleanup(deps.AppContext, "api", deps.Config.Playback.TranscodeDir, playbackHandler.CleanupOrphanedTranscodes, playback.OrphanCleanupInterval)
}
playbackHandler.ProbeEnsurer = deps.ProbeEnsurer
playbackHandler.ChapterThumbnailQueuer = deps.ChapterThumbnailQueuer
@@ -949,6 +1000,7 @@ func NewRouter(deps Dependencies) chi.Router {
adminHandler.BootstrapSensitiveValues = deps.BootstrapSensitiveValues
adminHandler.RestartStatus = restartStatus
adminHandler.CatalogSearchStatus = catalogSearchService
adminHandler.DiagnosticsStore = diagnosticsStore
if settingsRepo != nil {
adminHandler.SettingsRepo = settingsRepo
}
@@ -1028,6 +1080,7 @@ func NewRouter(deps Dependencies) chi.Router {
deps.PluginImageResolver,
detailSvc,
)
adminImageHandler.EventsHub = deps.EventsHub
}
var adminIntroHandler *handlers.AdminIntroHandler
@@ -1757,6 +1810,28 @@ func NewRouter(deps Dependencies) chi.Router {
})
}
// Client diagnostics are account-scoped and must work before profile
// selection, so this route intentionally uses auth only plus the
// generic rate limiter, not viewer/profile middleware.
if diagnosticsHandler != nil && authMiddleware != nil {
r.Group(func(r chi.Router) {
r.Use(authMiddleware.RequireAuth)
// Demo mode blocks non-admin report uploads (a write to the
// private bucket and DB); the read-only status endpoint stays
// available because DemoGuard always lets GETs through.
if demoGuard != nil {
r.Use(demoGuard.Guard)
}
if deps.RateLimitMW != nil {
r.Use(deps.RateLimitMW.Handler)
}
r.Route("/diagnostics", func(r chi.Router) {
r.Get("/status", diagnosticsHandler.HandleStatus)
r.Post("/reports", diagnosticsHandler.HandleUpload)
})
})
}
// All remaining routes require auth.
if authMiddleware != nil {
r.Group(func(r chi.Router) {
@@ -1817,6 +1892,10 @@ func NewRouter(deps Dependencies) chi.Router {
r.Get("/preferences", notificationsHandler.HandleGetPreferences)
r.Put("/preferences", notificationsHandler.HandleUpdatePreferences)
r.Get("/push/apple/display/{delivery_id}", notificationsHandler.HandleApplePushDisplay)
// Platform-generic registration used by the Android
// client; Apple keeps its dedicated route above.
r.Post("/push/devices", notificationsHandler.HandleRegisterPushDevice)
r.Delete("/push/devices/{device_id}", notificationsHandler.HandleUnregisterPushDevice)
r.Get("/email-preferences", notificationsHandler.HandleGetEmailPreferences)
r.Put("/email-preferences", notificationsHandler.HandleUpdateEmailPreferences)
r.Put("/email-preferences/address", notificationsHandler.HandleRequestEmailAddress)
@@ -2279,6 +2358,7 @@ func NewRouter(deps Dependencies) chi.Router {
playbackHandler.FFmpegLogSink = deps.FFmpegLogSink
r.Route("/playback", func(r chi.Router) {
r.Get("/capability", playbackHandler.HandlePlaybackCapabilityV3)
// HLS transcode delivery — no profile auth needed;
// session ID (UUID) serves as the access token, same
// pattern as /stream/{session_id}.
@@ -2292,6 +2372,8 @@ func NewRouter(deps Dependencies) chi.Router {
r.Group(func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Post("/start", playbackHandler.HandleStartPlayback)
r.Post("/{session_id}/replan", playbackHandler.HandleReplanPlaybackV3)
r.Post("/route-events", playbackHandler.HandlePlaybackRouteEventV3)
r.Post("/{session_id}/progress", playbackHandler.HandleUpdateProgress)
r.Patch("/{session_id}/audio", playbackHandler.HandleChangeAudioTrack)
r.Delete("/{session_id}", playbackHandler.HandleStopPlayback)
@@ -2475,6 +2557,7 @@ func NewRouter(deps Dependencies) chi.Router {
}
r.Get("/sessions", adminHandler.HandleListSessions)
r.Get("/sessions/capabilities", adminHandler.HandleGetSessionsCapabilities)
r.Get("/playback-history", adminHandler.HandleListPlaybackHistory)
r.Get("/unmatched", adminHandler.HandleListUnmatched)
r.Get("/stats", adminHandler.HandleGetStats)
@@ -2537,6 +2620,7 @@ func NewRouter(deps Dependencies) chi.Router {
applePushHandler := handlers.NewAdminApplePushHandler(deps.Notifications, settingsRepo)
if deps.Notifications != nil {
r.Post("/notifications/push/apple/test", applePushHandler.HandleTest)
r.Post("/notifications/push/fcm/test", applePushHandler.HandleTestAndroid)
}
if settingsRepo != nil {
r.Post("/notifications/push/relay/register", applePushHandler.HandleRegisterRelay)
@@ -2864,6 +2948,9 @@ func NewRouter(deps Dependencies) chi.Router {
r.Get("/logs/audit", adminLogsHandler.HandleListAuditLogs)
r.Get("/logs/ws", adminLogsHandler.HandleLogStreamWebSocket)
}
if diagnosticsHandler != nil {
handlers.RegisterAdminDiagnosticsRoutes(r, diagnosticsHandler)
}
if adminPlaybackControlHandler != nil {
r.Post("/sessions/{session_id}/pause", adminPlaybackControlHandler.HandlePauseSession)
r.Post("/sessions/{session_id}/resume", adminPlaybackControlHandler.HandleResumeSession)
+115
View File
@@ -0,0 +1,115 @@
// Package artworkkey owns the object-key naming contract for cached artwork.
// Legacy names such as original.webp and revisioned names such as
// original.<revision>.webp are both supported.
package artworkkey
import (
"path"
"strconv"
"strings"
)
const OriginalVariant = "original"
// Build returns an object key for a variant under basePath.
func Build(basePath, variant, revision, ext string) string {
basePath = strings.TrimRight(strings.TrimSpace(basePath), "/")
variant = strings.TrimSpace(variant)
revision = strings.TrimSpace(revision)
if basePath == "" || variant == "" {
return ""
}
if ext == "" {
ext = ".webp"
} else if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
if revision == "" {
return basePath + "/" + variant + ext
}
return basePath + "/" + variant + "." + revision + ext
}
// Original returns the original-variant key under basePath.
func Original(basePath, revision, ext string) string {
return Build(basePath, OriginalVariant, revision, ext)
}
// Variant rewrites an original key to another variant while retaining any
// revision and extension. Unrecognized paths pass through unchanged.
func Variant(originalPath, variant string) string {
if originalPath == "" || variant == "" || variant == OriginalVariant {
return originalPath
}
dir := path.Dir(originalPath)
base := path.Base(originalPath)
if dir == "." || !strings.HasPrefix(base, OriginalVariant+".") {
return originalPath
}
return strings.TrimRight(dir, "/") + "/" + variant + strings.TrimPrefix(base, OriginalVariant)
}
// Directory returns the image-type prefix containing every revision and
// variant for an artwork key, including a trailing slash.
func Directory(objectPath string) string {
objectPath = strings.TrimSpace(objectPath)
if objectPath == "" || strings.Contains(objectPath, "://") {
return ""
}
dir := path.Dir(objectPath)
if dir == "." || dir == "/" {
return ""
}
return strings.TrimRight(dir, "/") + "/"
}
// Revision extracts the content revision from a revisioned key. Legacy keys
// return an empty string.
func Revision(objectPath string) string {
name := path.Base(strings.TrimSpace(objectPath))
ext := path.Ext(name)
stem := strings.TrimSuffix(name, ext)
firstDot := strings.IndexByte(stem, '.')
if firstDot < 0 || firstDot == len(stem)-1 {
return ""
}
return stem[firstDot+1:]
}
// VariantWidths returns the resize widths generated for an artwork type. This
// is the single source of truth for the variant ladder: image generation,
// object-key expansion, and garbage collection all derive from it.
func VariantWidths(imageType string) []int {
switch strings.ToLower(strings.TrimSpace(imageType)) {
case "backdrop":
return []int{1920, 1280, 300}
case "logo":
return []int{500}
default: // poster, still, profile
return []int{500, 300}
}
}
// VariantNames returns the cached variants generated for an artwork type.
func VariantNames(imageType string) []string {
widths := VariantWidths(imageType)
names := make([]string, 0, len(widths)+1)
names = append(names, OriginalVariant)
for _, width := range widths {
names = append(names, "w"+strconv.Itoa(width))
}
return names
}
// ObjectKeys expands an original key to every expected key for its image type.
func ObjectKeys(originalPath, imageType string) []string {
if originalPath == "" || strings.Contains(originalPath, "://") {
return nil
}
names := VariantNames(imageType)
keys := make([]string, 0, len(names))
for _, name := range names {
keys = append(keys, Variant(originalPath, name))
}
return keys
}
+38
View File
@@ -0,0 +1,38 @@
package artworkkey
import "testing"
func TestRevisionedArtworkKeys(t *testing.T) {
base := "tmdb/movies/550/poster"
original := Original(base, "abc123", ".webp")
if original != base+"/original.abc123.webp" {
t.Fatalf("Original() = %q", original)
}
if got := Variant(original, "w500"); got != base+"/w500.abc123.webp" {
t.Fatalf("Variant() = %q", got)
}
if got := Revision(original); got != "abc123" {
t.Fatalf("Revision() = %q", got)
}
if got := Directory(original); got != base+"/" {
t.Fatalf("Directory() = %q", got)
}
}
func TestLegacyArtworkKeysRemainSupported(t *testing.T) {
original := "tmdb/movies/550/poster/original.webp"
if got := Variant(original, "w300"); got != "tmdb/movies/550/poster/w300.webp" {
t.Fatalf("Variant() = %q", got)
}
if got := Revision(original); got != "" {
t.Fatalf("Revision() = %q, want empty", got)
}
}
func TestVariantOnlyRewritesOriginalFilename(t *testing.T) {
original := "tmdb/movies/original.segment/550/poster/original.abc123.webp"
want := "tmdb/movies/original.segment/550/poster/w500.abc123.webp"
if got := Variant(original, "w500"); got != want {
t.Fatalf("Variant() = %q, want %q", got, want)
}
}
+2 -15
View File
@@ -37,7 +37,7 @@ import (
// method set by structural typing. Anything implementing CacheAudiobookCover can
// be passed to scanner.ExtractAndUploadAudiobookCover via this interface.
type audiobookCoverCacher interface {
CacheAudiobookCover(ctx context.Context, data []byte, contentID string) (basePath string, ext string, thumbhash string, err error)
CacheAudiobookCover(ctx context.Context, data []byte, contentID string) (storedPath string, thumbhash string, err error)
}
const (
@@ -570,7 +570,7 @@ func (e *Enricher) cacheRemotePoster(ctx context.Context, contentID string, resu
return
}
if storedPath := cachedOriginalImagePath(cached.BasePath, cached.Ext); storedPath != "" {
if storedPath := metadata.CachedImageOriginalPath(cached); storedPath != "" {
result.PosterPath = storedPath
}
if cached.Thumbhash != "" {
@@ -578,19 +578,6 @@ func (e *Enricher) cacheRemotePoster(ctx context.Context, contentID string, resu
}
}
func cachedOriginalImagePath(basePath, ext string) string {
if basePath == "" {
return ""
}
if strings.Contains(basePath, "/original.") {
return basePath
}
if ext == "" {
ext = ".jpg"
}
return strings.TrimRight(basePath, "/") + "/original" + ext
}
// persist writes the enriched metadata back to the database.
func (e *Enricher) persist(ctx context.Context, contentID string, providerIDs map[string]string, result *metadata.MetadataResult) error {
// Build the MetadataUpdate — only set fields that the provider returned.
+7 -6
View File
@@ -125,7 +125,7 @@ func TestCacheRemotePosterCachesProviderURL(t *testing.T) {
if cacher.req.ContentType != "audiobooks" || cacher.req.ContentID != "content-1" {
t.Fatalf("cache target = %q/%q", cacher.req.ContentType, cacher.req.ContentID)
}
if result.PosterPath != "audiobook-metadata/audiobooks/content-1/poster/original.webp" {
if result.PosterPath != "audiobook-metadata/audiobooks/content-1/poster/original.test-revision.webp" {
t.Fatalf("PosterPath = %q", result.PosterPath)
}
if result.PosterThumbhash != "thumb" {
@@ -155,16 +155,17 @@ type fakeAudiobookImageCacher struct {
req metadata.CacheImageRequest
}
func (f *fakeAudiobookImageCacher) CacheAudiobookCover(context.Context, []byte, string) (string, string, string, error) {
return "", "", "", nil
func (f *fakeAudiobookImageCacher) CacheAudiobookCover(context.Context, []byte, string) (string, string, error) {
return "", "", nil
}
func (f *fakeAudiobookImageCacher) CacheImage(_ context.Context, req metadata.CacheImageRequest) (*metadata.CacheImageResult, error) {
f.calls++
f.req = req
return &metadata.CacheImageResult{
BasePath: req.ProviderID + "/" + req.ContentType + "/" + req.ContentID + "/poster",
Thumbhash: "thumb",
Ext: ".webp",
BasePath: req.ProviderID + "/" + req.ContentType + "/" + req.ContentID + "/poster",
OriginalPath: req.ProviderID + "/" + req.ContentType + "/" + req.ContentID + "/poster/original.test-revision.webp",
Thumbhash: "thumb",
Ext: ".webp",
}, nil
}
+80
View File
@@ -0,0 +1,80 @@
package auth
import (
"context"
"log/slog"
"sync"
"time"
)
const (
apiKeyLastUsedInterval = time.Minute
apiKeyLastUsedTimeout = 5 * time.Second
)
// APIKeyLastUsedUpdater persists an API key's last-used timestamp.
type APIKeyLastUsedUpdater interface {
UpdateLastUsed(ctx context.Context, id int64) error
}
// APIKeyLastUsedTracker coalesces last-used writes per key without blocking
// authentication requests. Stale entries are pruned inline once per interval,
// avoiding both unbounded historical-key retention and another goroutine.
type APIKeyLastUsedTracker struct {
updater APIKeyLastUsedUpdater
now func() time.Time
mu sync.Mutex
lastUsedAt map[int64]time.Time
nextPrune time.Time
}
// NewAPIKeyLastUsedTracker creates a tracker. A nil clock uses time.Now.
func NewAPIKeyLastUsedTracker(updater APIKeyLastUsedUpdater, now func() time.Time) *APIKeyLastUsedTracker {
if now == nil {
now = time.Now
}
return &APIKeyLastUsedTracker{
updater: updater,
now: now,
lastUsedAt: make(map[int64]time.Time),
}
}
// Touch records key usage asynchronously, at most once per interval per key.
func (t *APIKeyLastUsedTracker) Touch(id int64) {
if t == nil || t.updater == nil || !t.shouldUpdate(id) {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), apiKeyLastUsedTimeout)
defer cancel()
if err := t.updater.UpdateLastUsed(ctx, id); err != nil {
slog.DebugContext(ctx, "api key last-used update failed", "component", "auth", "id", id, "error", err)
}
}()
}
func (t *APIKeyLastUsedTracker) shouldUpdate(id int64) bool {
now := t.now()
t.mu.Lock()
defer t.mu.Unlock()
if t.nextPrune.IsZero() || !now.Before(t.nextPrune) {
cutoff := now.Add(-apiKeyLastUsedInterval)
for keyID, lastUsed := range t.lastUsedAt {
if !lastUsed.After(cutoff) {
delete(t.lastUsedAt, keyID)
}
}
t.nextPrune = now.Add(apiKeyLastUsedInterval)
}
if lastUsed, ok := t.lastUsedAt[id]; ok && now.Sub(lastUsed) < apiKeyLastUsedInterval {
return false
}
t.lastUsedAt[id] = now
return true
}
+82
View File
@@ -0,0 +1,82 @@
package auth
import (
"context"
"testing"
"time"
)
type recordingLastUsedUpdater struct {
calls chan lastUsedCall
}
type lastUsedCall struct {
id int64
deadline time.Time
}
func (u *recordingLastUsedUpdater) UpdateLastUsed(ctx context.Context, id int64) error {
deadline, _ := ctx.Deadline()
u.calls <- lastUsedCall{id: id, deadline: deadline}
return nil
}
func TestAPIKeyLastUsedTrackerThrottlesPerKey(t *testing.T) {
now := time.Date(2026, time.July, 20, 12, 0, 0, 0, time.UTC)
tracker := NewAPIKeyLastUsedTracker(nil, func() time.Time { return now })
if !tracker.shouldUpdate(1) {
t.Fatal("first use should update")
}
if tracker.shouldUpdate(1) {
t.Fatal("repeat use inside the interval should be throttled")
}
if !tracker.shouldUpdate(2) {
t.Fatal("a different key should update independently")
}
now = now.Add(apiKeyLastUsedInterval)
if !tracker.shouldUpdate(1) {
t.Fatal("use at the next interval should update")
}
}
func TestAPIKeyLastUsedTrackerPrunesExpiredKeys(t *testing.T) {
now := time.Date(2026, time.July, 20, 12, 0, 0, 0, time.UTC)
tracker := NewAPIKeyLastUsedTracker(nil, func() time.Time { return now })
tracker.shouldUpdate(1)
tracker.shouldUpdate(2)
now = now.Add(apiKeyLastUsedInterval)
tracker.shouldUpdate(3)
if len(tracker.lastUsedAt) != 1 {
t.Fatalf("retained entries = %d, want 1", len(tracker.lastUsedAt))
}
if _, ok := tracker.lastUsedAt[3]; !ok {
t.Fatal("current key was not retained")
}
}
func TestAPIKeyLastUsedTrackerAddsWriteDeadline(t *testing.T) {
updater := &recordingLastUsedUpdater{calls: make(chan lastUsedCall, 1)}
tracker := NewAPIKeyLastUsedTracker(updater, nil)
tracker.Touch(42)
select {
case call := <-updater.calls:
if call.id != 42 {
t.Fatalf("key id = %d, want 42", call.id)
}
if call.deadline.IsZero() {
t.Fatal("last-used update context has no deadline")
}
remaining := time.Until(call.deadline)
if remaining <= 0 || remaining > apiKeyLastUsedTimeout {
t.Fatalf("deadline after %s, want within (0, %s]", remaining, apiKeyLastUsedTimeout)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for last-used update")
}
}
+17
View File
@@ -49,6 +49,23 @@ func (s Snapshot) ThemeColor() string {
return DefaultThemeColor
}
// RenderKey returns a stable identity covering every snapshot field the
// Render* functions read, so callers can cache rendered output and re-render
// only when the branding configuration actually changes. Keep in sync with
// RenderIndexHTML and RenderManifest.
func (s Snapshot) RenderKey() string {
return strings.Join([]string{
s.ServerName,
s.LoginSubtitle,
s.AccentColor,
s.DefaultTheme,
s.assets[KindWordmark],
s.assets[KindMark],
s.assets[KindFavicon],
s.assets[KindLoginBg],
}, "\x00")
}
// Favicon link literal as it appears in web/index.html. Kept in sync with that
// file; RenderIndexHTML rewrites it to a custom favicon when one is set.
const indexFaviconLink = `<link rel="icon" href="/favicon.ico" sizes="any" />`
+8 -3
View File
@@ -137,10 +137,15 @@ func intInSlice(value int, values []int) bool {
return false
}
// FilterMediaFilesByAccess drops file versions that exceed the viewer's
// effective quality ceiling.
// FilterMediaFilesByAccess drops file versions the viewer cannot access:
// files in libraries outside their allowed set, in libraries they disabled,
// or above their effective quality ceiling — the same predicate as
// FileAllowedByAccess.
func FilterMediaFilesByAccess(files []*models.MediaFile, filter AccessFilter) []*models.MediaFile {
if len(files) == 0 || strings.TrimSpace(filter.MaxPlaybackQuality) == "" {
unrestricted := filter.AllowedLibraryIDs == nil &&
len(filter.DisabledLibraryIDs) == 0 &&
strings.TrimSpace(filter.MaxPlaybackQuality) == ""
if len(files) == 0 || unrestricted {
return files
}
+51
View File
@@ -0,0 +1,51 @@
package catalog
import (
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestFilterMediaFilesByAccess(t *testing.T) {
allowed := &models.MediaFile{ID: 1, MediaFolderID: 1, Resolution: "1080p"}
otherLibrary := &models.MediaFile{ID: 2, MediaFolderID: 2, Resolution: "2160p"}
files := []*models.MediaFile{allowed, otherLibrary}
t.Run("no restrictions returns all files", func(t *testing.T) {
got := FilterMediaFilesByAccess(files, AccessFilter{})
if len(got) != 2 {
t.Fatalf("expected 2 files, got %d", len(got))
}
})
t.Run("allowed library ids filter without quality ceiling", func(t *testing.T) {
got := FilterMediaFilesByAccess(files, AccessFilter{AllowedLibraryIDs: []int{1}})
if len(got) != 1 || got[0].ID != allowed.ID {
t.Fatalf("expected only file %d, got %v", allowed.ID, got)
}
})
t.Run("disabled library ids filter without quality ceiling", func(t *testing.T) {
got := FilterMediaFilesByAccess(files, AccessFilter{DisabledLibraryIDs: []int{2}})
if len(got) != 1 || got[0].ID != allowed.ID {
t.Fatalf("expected only file %d, got %v", allowed.ID, got)
}
})
t.Run("quality ceiling filters", func(t *testing.T) {
got := FilterMediaFilesByAccess(files, AccessFilter{MaxPlaybackQuality: "1080p"})
if len(got) != 1 || got[0].ID != allowed.ID {
t.Fatalf("expected only file %d, got %v", allowed.ID, got)
}
})
t.Run("matches FileAllowedByAccess predicate", func(t *testing.T) {
filter := AccessFilter{AllowedLibraryIDs: []int{1}, MaxPlaybackQuality: "1080p"}
got := FilterMediaFilesByAccess(files, filter)
for _, f := range got {
if !FileAllowedByAccess(f, filter) {
t.Fatalf("file %d returned despite failing FileAllowedByAccess", f.ID)
}
}
})
}
+322
View File
@@ -0,0 +1,322 @@
package catalog
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
defaultArtworkGCGracePeriod = 24 * time.Hour
artworkTargetSeason = "season"
artworkTargetEpisode = "episode"
artworkImagePoster = "poster"
artworkImageBackdrop = "backdrop"
artworkImageLogo = "logo"
artworkImageStill = "still"
artworkPosterPathColumn = "poster_path"
artworkBackdropPathColumn = "backdrop_path"
artworkLogoPathColumn = "logo_path"
artworkPosterSourceColumn = "poster_source_path"
artworkBackdropSourceColumn = "backdrop_source_path"
artworkLogoSourceColumn = "logo_source_path"
artworkPosterThumbColumn = "poster_thumbhash"
artworkBackdropThumbColumn = "backdrop_thumbhash"
)
// ErrUnsupportedArtworkSelection reports a target/image-type combination that
// has no artwork column. Handlers should reject these before caching anything.
var ErrUnsupportedArtworkSelection = errors.New("catalog: unsupported artwork selection")
// ArtworkRevisionTracker registers every immutable artwork upload before its
// objects are written, allowing the collector to reclaim uploads that are
// never published. Confirmed live revisions remain dormant in the registry so
// database triggers can later reactivate them.
type ArtworkRevisionTracker struct {
pool *pgxpool.Pool
gracePeriod time.Duration
}
func NewArtworkRevisionTracker(pool *pgxpool.Pool) *ArtworkRevisionTracker {
if pool == nil {
return nil
}
return &ArtworkRevisionTracker{pool: pool, gracePeriod: defaultArtworkGCGracePeriod}
}
// TrackArtworkRevision records the exact object manifest for a revision before
// its upload starts. Image caching calls this before uploading, so it
// serializes with a collector that may already be deleting an older,
// currently-unreferenced copy. Revisions parked as referenced stay dormant: a
// re-cache of live artwork is not garbage, and displacement triggers re-arm
// the row if the reference later moves away.
func (t *ArtworkRevisionTracker) TrackArtworkRevision(ctx context.Context, originalPath, imageType string, objectKeys []string) error {
if t == nil || t.pool == nil {
return fmt.Errorf("catalog: artwork revision tracking is not configured")
}
originalPath = strings.TrimSpace(originalPath)
keys := compactArtworkObjectKeys(objectKeys)
if originalPath == "" || strings.Contains(originalPath, "://") || len(keys) == 0 {
return nil
}
notBefore := time.Now().Add(t.gracePeriod)
// deleted_at is cleared because this upsert precedes a re-upload of the
// exact manifest: the objects exist again once the cacher finishes.
_, err := t.pool.Exec(ctx, `
INSERT INTO artwork_revision_gc_candidates (
original_path, image_type, object_keys, not_before, next_attempt_at
) VALUES ($1, $2, $3, $4, $4)
ON CONFLICT (original_path) DO UPDATE SET
object_keys = EXCLUDED.object_keys,
image_type = CASE
WHEN artwork_revision_gc_candidates.image_type = '' THEN EXCLUDED.image_type
ELSE artwork_revision_gc_candidates.image_type
END,
not_before = CASE
WHEN artwork_revision_gc_candidates.next_attempt_at IS NULL THEN artwork_revision_gc_candidates.not_before
ELSE EXCLUDED.not_before
END,
next_attempt_at = CASE
WHEN artwork_revision_gc_candidates.next_attempt_at IS NULL THEN NULL
ELSE EXCLUDED.next_attempt_at
END,
deleted_at = NULL,
attempt_count = 0,
locked_at = NULL,
locked_by = '',
last_error = '',
updated_at = NOW()`, originalPath, strings.ToLower(strings.TrimSpace(imageType)), keys, notBefore)
if err != nil {
return fmt.Errorf("catalog: track artwork revision: %w", err)
}
return nil
}
// ArtworkSelection describes a manually selected, already-cached artwork
// revision. PublishArtworkSelection makes the database pointer and image lock
// visible atomically, then schedules the displaced revision for delayed cleanup.
type ArtworkSelection struct {
TargetType string
TargetContentID string
ParentContentID string
ImageType string
StoredPath string
SourcePath string
Thumbhash string
LockField int
}
// ValidateArtworkSelectionTarget reports whether a target/image-type pair maps
// to a real artwork column, so handlers can reject unsupported requests before
// downloading or uploading anything.
func ValidateArtworkSelectionTarget(targetType, imageType string) error {
_, _, _, _, _, err := artworkTargetColumns(
strings.ToLower(strings.TrimSpace(targetType)),
strings.ToLower(strings.TrimSpace(imageType)),
)
return err
}
// PublishArtworkSelection atomically changes the selected artwork, records its
// source, locks automatic image refreshes, and queues the old immutable revision
// for reference-aware garbage collection after a grace period.
func (s *DetailService) PublishArtworkSelection(ctx context.Context, selection ArtworkSelection) error {
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
return fmt.Errorf("catalog: artwork persistence is not configured")
}
selection.TargetType = strings.ToLower(strings.TrimSpace(selection.TargetType))
selection.ImageType = strings.ToLower(strings.TrimSpace(selection.ImageType))
selection.TargetContentID = strings.TrimSpace(selection.TargetContentID)
selection.ParentContentID = strings.TrimSpace(selection.ParentContentID)
selection.StoredPath = strings.TrimSpace(selection.StoredPath)
if selection.TargetContentID == "" || selection.StoredPath == "" {
return fmt.Errorf("catalog: artwork target and stored path are required")
}
table, pathColumn, sourceColumn, thumbhashColumn, notFound, err := artworkTargetColumns(selection.TargetType, selection.ImageType)
if err != nil {
return err
}
tx, err := s.itemRepo.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("catalog: begin artwork publication: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var previousPath string
selectSQL := fmt.Sprintf("SELECT COALESCE(%s, '') FROM %s WHERE content_id = $1 FOR UPDATE", pathColumn, table)
if err := tx.QueryRow(ctx, selectSQL, selection.TargetContentID).Scan(&previousPath); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return notFound
}
return fmt.Errorf("catalog: lock artwork target: %w", err)
}
setThumbhash := ""
args := []any{selection.StoredPath, selection.SourcePath, selection.TargetContentID}
if thumbhashColumn != "" {
setThumbhash = fmt.Sprintf(", %s = $4", thumbhashColumn)
args = append(args, selection.Thumbhash)
}
updateSQL := fmt.Sprintf(`
UPDATE %s
SET %s = $1, %s = $2%s, updated_at = NOW()
WHERE content_id = $3`, table, pathColumn, sourceColumn, setThumbhash)
if _, err := tx.Exec(ctx, updateSQL, args...); err != nil {
return fmt.Errorf("catalog: publish artwork pointer: %w", err)
}
lockContentID := selection.TargetContentID
if selection.TargetType == artworkTargetSeason || selection.TargetType == artworkTargetEpisode {
lockContentID = selection.ParentContentID
}
if lockContentID == "" {
return fmt.Errorf("catalog: parent content ID is required for %s artwork", selection.TargetType)
}
tag, err := tx.Exec(ctx, `
UPDATE media_items
SET locked_fields = CASE
WHEN $2 = ANY(COALESCE(locked_fields, '{}'::integer[])) THEN COALESCE(locked_fields, '{}'::integer[])
ELSE array_append(COALESCE(locked_fields, '{}'::integer[]), $2)
END,
updated_at = NOW()
WHERE content_id = $1`, lockContentID, selection.LockField)
if err != nil {
return fmt.Errorf("catalog: lock manual artwork: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrItemNotFound
}
// The selected revision is referenced by construction once this transaction
// commits, so park it dormant immediately: no first-pass verification cycle
// is needed, and database triggers re-arm it if a later writer displaces it.
if err := parkArtworkRevision(ctx, tx, selection.StoredPath, selection.ImageType, time.Now().Add(defaultArtworkGCGracePeriod)); err != nil {
return err
}
if previousPath != "" && previousPath != selection.StoredPath {
if err := queueArtworkRevisionGC(ctx, tx, previousPath, selection.ImageType, time.Now().Add(defaultArtworkGCGracePeriod)); err != nil {
return err
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("catalog: commit artwork publication: %w", err)
}
return nil
}
// QueueArtworkRevisionGC schedules an unreferenced cached revision for cleanup.
// It is used to reclaim an upload when publication fails after object storage
// succeeded. Cleanup still verifies database references before deleting.
func (s *DetailService) QueueArtworkRevisionGC(ctx context.Context, originalPath, imageType string, notBefore time.Time) error {
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
return fmt.Errorf("catalog: artwork persistence is not configured")
}
return queueArtworkRevisionGC(ctx, s.itemRepo.pool, originalPath, imageType, notBefore)
}
type artworkGCExecer interface {
Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error)
}
// queueArtworkRevisionGC arms a revision for verification after notBefore. The
// stored manifest (when one was tracked pre-upload) is kept; rows without one
// carry the image type so the collector can expand the object keys itself.
func queueArtworkRevisionGC(ctx context.Context, db artworkGCExecer, originalPath, imageType string, notBefore time.Time) error {
return upsertArtworkRevision(ctx, db, originalPath, imageType, notBefore, false)
}
// parkArtworkRevision registers a revision as referenced and dormant. Only a
// displacement trigger or the collector's dormant sweep re-arms it.
func parkArtworkRevision(ctx context.Context, db artworkGCExecer, originalPath, imageType string, notBefore time.Time) error {
return upsertArtworkRevision(ctx, db, originalPath, imageType, notBefore, true)
}
func upsertArtworkRevision(
ctx context.Context,
db artworkGCExecer,
originalPath string,
imageType string,
notBefore time.Time,
dormant bool,
) error {
originalPath = strings.TrimSpace(originalPath)
imageType = strings.ToLower(strings.TrimSpace(imageType))
if originalPath == "" || strings.Contains(originalPath, "://") || imageType == "" {
return nil
}
_, err := db.Exec(ctx, `
INSERT INTO artwork_revision_gc_candidates (
original_path, image_type, object_keys, not_before, next_attempt_at
) VALUES ($1, $2, '{}', $3::timestamptz, CASE WHEN $4 THEN NULL ELSE $3::timestamptz END)
ON CONFLICT (original_path) DO UPDATE SET
image_type = CASE
WHEN artwork_revision_gc_candidates.image_type = '' THEN EXCLUDED.image_type
ELSE artwork_revision_gc_candidates.image_type
END,
not_before = EXCLUDED.not_before,
next_attempt_at = EXCLUDED.next_attempt_at,
attempt_count = 0,
locked_at = NULL,
locked_by = '',
last_error = '',
updated_at = NOW()`, originalPath, imageType, notBefore, dormant)
if err != nil {
return fmt.Errorf("catalog: queue artwork revision cleanup: %w", err)
}
return nil
}
func compactArtworkObjectKeys(keys []string) []string {
seen := make(map[string]struct{}, len(keys))
result := make([]string, 0, len(keys))
for _, key := range keys {
key = strings.TrimSpace(key)
if key == "" || strings.Contains(key, "://") {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, key)
}
return result
}
// artworkTargetColumns maps a selection target to its artwork columns. Season
// and episode rows have dedicated tables; every other catalog item type
// (movie, series, audiobook, ebook, manga, ...) stores artwork on its
// media_items row.
func artworkTargetColumns(targetType, imageType string) (table, pathColumn, sourceColumn, thumbhashColumn string, notFound error, err error) {
switch targetType {
case artworkTargetSeason:
if imageType == artworkImagePoster {
return "seasons", artworkPosterPathColumn, artworkPosterSourceColumn, artworkPosterThumbColumn, ErrSeasonNotFound, nil
}
case artworkTargetEpisode:
if imageType == artworkImageStill {
return "episodes", "still_path", "still_source_path", "still_thumbhash", ErrEpisodeNotFound, nil
}
default:
table = "media_items"
notFound = ErrItemNotFound
switch imageType {
case artworkImagePoster:
return table, artworkPosterPathColumn, artworkPosterSourceColumn, artworkPosterThumbColumn, notFound, nil
case artworkImageBackdrop:
return table, artworkBackdropPathColumn, artworkBackdropSourceColumn, artworkBackdropThumbColumn, notFound, nil
case artworkImageLogo:
return table, artworkLogoPathColumn, artworkLogoSourceColumn, "", notFound, nil
}
}
return "", "", "", "", nil, fmt.Errorf("%w: %s does not accept %q images", ErrUnsupportedArtworkSelection, targetType, imageType)
}
+101
View File
@@ -0,0 +1,101 @@
package catalog
import (
"context"
"fmt"
"os"
"slices"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func newArtworkSelectionTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
return pool
}
func TestQueueAndParkArtworkRevisionUpserts(t *testing.T) {
pool := newArtworkSelectionTestPool(t)
ctx := context.Background()
suffix := time.Now().UnixNano()
path := fmt.Sprintf("tmdb/movies/%d/poster/original.rev.webp", suffix)
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM artwork_revision_gc_candidates WHERE original_path = $1`, path)
})
// Arming registers the revision for verification after the grace period.
if err := queueArtworkRevisionGC(ctx, pool, path, "poster", time.Now().Add(time.Hour)); err != nil {
t.Fatalf("queueArtworkRevisionGC: %v", err)
}
var imageType string
var nextAttempt *time.Time
if err := pool.QueryRow(ctx, `
SELECT image_type, next_attempt_at FROM artwork_revision_gc_candidates
WHERE original_path = $1`, path).Scan(&imageType, &nextAttempt); err != nil {
t.Fatalf("load armed candidate: %v", err)
}
if imageType != "poster" {
t.Fatalf("image_type = %q, want poster", imageType)
}
if nextAttempt == nil {
t.Fatal("armed candidate has NULL next_attempt_at")
}
// Publication parks the selected revision: referenced by construction.
if err := parkArtworkRevision(ctx, pool, path, "poster", time.Now().Add(time.Hour)); err != nil {
t.Fatalf("parkArtworkRevision: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT next_attempt_at FROM artwork_revision_gc_candidates
WHERE original_path = $1`, path).Scan(&nextAttempt); err != nil {
t.Fatalf("load parked candidate: %v", err)
}
if nextAttempt != nil {
t.Fatalf("parked candidate next_attempt_at = %v, want NULL", *nextAttempt)
}
}
func TestTrackArtworkRevisionKeepsDormantRowsDormant(t *testing.T) {
pool := newArtworkSelectionTestPool(t)
ctx := context.Background()
suffix := time.Now().UnixNano()
path := fmt.Sprintf("tmdb/movies/%d/poster/original.live.webp", suffix)
keys := []string{path, fmt.Sprintf("tmdb/movies/%d/poster/w500.live.webp", suffix)}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM artwork_revision_gc_candidates WHERE original_path = $1`, path)
})
if err := parkArtworkRevision(ctx, pool, path, "poster", time.Now().Add(time.Hour)); err != nil {
t.Fatalf("parkArtworkRevision: %v", err)
}
// A re-cache of live artwork must not re-arm the parked row.
tracker := NewArtworkRevisionTracker(pool)
if err := tracker.TrackArtworkRevision(ctx, path, "poster", keys); err != nil {
t.Fatalf("TrackArtworkRevision: %v", err)
}
var nextAttempt *time.Time
var storedKeys []string
if err := pool.QueryRow(ctx, `
SELECT next_attempt_at, object_keys FROM artwork_revision_gc_candidates
WHERE original_path = $1`, path).Scan(&nextAttempt, &storedKeys); err != nil {
t.Fatalf("load candidate: %v", err)
}
if nextAttempt != nil {
t.Fatalf("re-cache re-armed dormant row: next_attempt_at = %v", *nextAttempt)
}
if !slices.Equal(storedKeys, keys) {
t.Fatalf("stored manifest = %v, want exact tracked manifest %v", storedKeys, keys)
}
}
+10
View File
@@ -2253,6 +2253,16 @@ func filterCatalogSearchItems(items []*models.MediaItem, raw string) []*models.M
filtered = append(filtered, item)
}
}
if len(filtered) == 0 && len(items) > 0 && eligibleForFuzzy(parsed) {
// The strict per-token substring filter erases the repo layer's
// typo-tolerant (trigram) matches: a misspelled token is never a
// substring of the titles it fuzzy-matched, so a typo query that
// found results via the fuzzy fallback would be filtered to zero here
// while the plain search box shows matches. When a fuzzy-eligible
// query would be emptied entirely, trust the repo's relevance
// ordering instead.
return items
}
return filtered
}
+2 -4
View File
@@ -14,6 +14,7 @@ import (
"time"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/artworkkey"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/overlays"
"github.com/Silo-Server/silo-server/internal/playback"
@@ -3620,10 +3621,7 @@ func cachedImageVariantPath(path, imageType, size string) string {
if variant == "" {
return path
}
if strings.Contains(path, "/original.") {
return strings.Replace(path, "/original.", "/"+variant+".", 1)
}
return path
return artworkkey.Variant(path, variant)
}
// imageTypeFromCachedPath returns the image type segment ("poster", "backdrop",
+45 -6
View File
@@ -11,6 +11,7 @@ import (
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/artworkkey"
"github.com/Silo-Server/silo-server/internal/models"
)
@@ -894,14 +895,26 @@ func dirSetToSlice(set map[string]struct{}) []string {
return dirs
}
// imageDeletePrefix returns the S3 prefix to sweep when deleting the content
// that owns the cached image at path. Local artwork keys carry a content-hash
// segment (local/{contentType}/{contentID}/{hash8}/{imageType}/...), so local
// paths trim to local/{contentType}/{contentID}/ — deleting every stale hash
// prefix, not just the live one. Other keys keep the per-image-type dir.
func imageDeletePrefix(path string) string {
if !strings.HasPrefix(path, "local/") {
return pathDir(path)
}
segments := strings.SplitN(path, "/", 4)
if len(segments) < 4 {
return pathDir(path)
}
return segments[0] + "/" + segments[1] + "/" + segments[2] + "/"
}
// pathDir extracts the directory portion of an S3 image path.
// e.g. "tmdb/movies/550/poster/original.webp" → "tmdb/movies/550/poster/"
func pathDir(path string) string {
idx := strings.LastIndex(path, "/")
if idx <= 0 {
return ""
}
return path[:idx+1]
return artworkkey.Directory(path)
}
// rowQuerier is satisfied by both *pgxpool.Pool and pgx.Tx, letting read
@@ -967,7 +980,7 @@ func collectRawImageDirs(ctx context.Context, q rowQuerier, contentIDs []string)
}
for _, p := range []string{p1, p2, p3} {
if p != "" && !strings.Contains(p, "://") {
if dir := pathDir(p); dir != "" {
if dir := imageDeletePrefix(p); dir != "" {
dirSet[dir] = struct{}{}
}
}
@@ -1035,6 +1048,32 @@ func filterUnreferencedImageDirs(ctx context.Context, q rowQuerier, dirs, deleti
}
// UpdateLastScanned sets the last_scanned_at timestamp for the given folder.
// LibraryRootsForContent returns the media folder root paths the given
// content belongs to, resolved through media_item_libraries. The metadata
// image-cache processor uses this to confine local file:// artwork sources
// (metadata.LibraryRootResolver).
func (r *FolderRepository) LibraryRootsForContent(ctx context.Context, contentID string) ([]string, error) {
rows, err := r.pool.Query(ctx, `
SELECT DISTINCT mfp.path
FROM media_folder_paths mfp
JOIN media_item_libraries mil ON mil.media_folder_id = mfp.media_folder_id
WHERE mil.content_id = $1
`, contentID)
if err != nil {
return nil, fmt.Errorf("querying library roots for content %q: %w", contentID, err)
}
defer rows.Close()
var paths []string
for rows.Next() {
var path string
if err := rows.Scan(&path); err != nil {
return nil, fmt.Errorf("scanning library root path: %w", err)
}
paths = append(paths, path)
}
return paths, rows.Err()
}
func (r *FolderRepository) UpdateLastScanned(ctx context.Context, id int, scannedAt time.Time) error {
tag, err := r.pool.Exec(ctx,
"UPDATE media_folders SET last_scanned_at = $1 WHERE id = $2",
@@ -0,0 +1,77 @@
package catalog
import (
"context"
"fmt"
"os"
"sort"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestLibraryRootsForContent(t *testing.T) {
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
suffix := time.Now().UnixNano()
contentID := fmt.Sprintf("roots-test-%d", suffix)
var folderID int
if err := pool.QueryRow(ctx,
`INSERT INTO media_folders (type, name, enabled) VALUES ('movies', $1, true) RETURNING id`,
fmt.Sprintf("roots-test-%d", suffix)).Scan(&folderID); err != nil {
t.Fatalf("seed folder: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_item_libraries WHERE content_id = $1`, contentID)
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, contentID)
_, _ = pool.Exec(ctx, `DELETE FROM media_folder_paths WHERE media_folder_id = $1`, folderID)
_, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID)
})
if _, err := pool.Exec(ctx,
`INSERT INTO media_items (content_id, type, title, status, genres) VALUES ($1, 'movie', 'Roots Test', 'matched', '{}'::text[])`,
contentID); err != nil {
t.Fatalf("seed item: %v", err)
}
for _, path := range []string{"/media/movies-a", "/media/movies-b"} {
if _, err := pool.Exec(ctx,
`INSERT INTO media_folder_paths (media_folder_id, path) VALUES ($1, $2)`,
folderID, fmt.Sprintf("%s-%d", path, suffix)); err != nil {
t.Fatalf("seed folder path: %v", err)
}
}
if _, err := pool.Exec(ctx,
`INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)`,
contentID, folderID); err != nil {
t.Fatalf("seed membership: %v", err)
}
repo := NewFolderRepository(pool)
roots, err := repo.LibraryRootsForContent(ctx, contentID)
if err != nil {
t.Fatalf("LibraryRootsForContent: %v", err)
}
sort.Strings(roots)
want := []string{
fmt.Sprintf("/media/movies-a-%d", suffix),
fmt.Sprintf("/media/movies-b-%d", suffix),
}
if len(roots) != 2 || roots[0] != want[0] || roots[1] != want[1] {
t.Fatalf("roots = %v, want %v", roots, want)
}
// Unknown content has no roots (and no error).
none, err := repo.LibraryRootsForContent(ctx, contentID+"-missing")
if err != nil || len(none) != 0 {
t.Fatalf("missing content: roots=%v err=%v", none, err)
}
}
+442 -17
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
@@ -131,6 +132,40 @@ func (r *ItemRepository) GetPosterPath(ctx context.Context, contentID string) (s
// incidental one-mention hits that flooded results before.
const overviewMatchFloor = 0.15
// fuzzyFallbackThreshold is the FTS result count below which SearchPage reaches
// for the trigram fuzzy fallback (buildFuzzySearchSQL). At or above it the FTS
// result set is considered rich enough that a "did you mean" pass would only add
// noise, so common searches stay on the unchanged FTS path and the fuzzy query
// fires only in the sparse/misspelling case.
const fuzzyFallbackThreshold = 5
// fuzzyMaxResults caps how many trigram fuzzy matches the fallback contributes.
// The combined result the fuzzy path serves is therefore bounded at
// (fuzzyFallbackThreshold-1) FTS rows + fuzzyMaxResults fuzzy rows, small enough
// to materialize once and slice per page. A "did you mean" fallback has no need
// to paginate hundreds of low-similarity typo matches; when the true match count
// exceeds this, the overflow is logged and the total is reported as inexact.
const fuzzyMaxResults = 50
// trgmWordSimilarityThreshold pins pg_trgm.strict_word_similarity_threshold
// for the fuzzy query via SET LOCAL. The <<% operator's selectivity is
// otherwise governed by a cluster-wide GUC a DBA (or another application) can
// change, silently altering both match quality and scan cost per deployment —
// and its server default (0.6) would reject ordinary one-edit typos
// ("avegners" vs "avengers" scores ~0.38), so pinning is load-bearing, not
// just hygiene.
const trgmWordSimilarityThreshold = 0.30
// fuzzyAugmentSimilarityFloor is the minimum WHOLE-TITLE similarity demanded of
// fuzzy rows when the sparse FTS block is non-empty. A correctly spelled query
// with a few genuine hits ("coraline") should only gain near-identical titles,
// not every title clearing the permissive base threshold — word similarity is
// deliberately not used here, since it scores embedded prefix words far too
// high ("coral" is 0.5 to "coraline"). A zero-hit query (a likely misspelling)
// skips the floor entirely so typo recall, including word-extent matches into
// long titles, stays high.
const fuzzyAugmentSimilarityFloor = 0.45
// itemColumnNames lists, in scan order, every column selected by media_items
// item queries. Shared by itemColumns, qualifiedItemColumns, and
// qualifiedListItemColumns so the select lists can never drift from each
@@ -194,6 +229,17 @@ func qualifiedItemColumns(alias string) string {
return joinItemColumns(alias)
}
// qualifiedItemColumnRefs renders plain alias-qualified column references
// without COALESCE or AS aliases, for contexts like GROUP BY where output
// aliases are invalid.
func qualifiedItemColumnRefs(alias string) string {
refs := make([]string, len(itemColumnNames))
for i, col := range itemColumnNames {
refs[i] = alias + "." + col
}
return strings.Join(refs, ", ")
}
func qualifiedListItemColumns(alias string) string {
exprs := make([]string, len(itemColumnNames))
for i, col := range itemColumnNames {
@@ -823,7 +869,7 @@ func (r *ItemRepository) Delete(ctx context.Context, contentID string) ([]string
}
for _, p := range []string{p1, p2, p3} {
if p != "" && !strings.Contains(p, "://") {
if dir := pathDir(p); dir != "" {
if dir := imageDeletePrefix(p); dir != "" {
imageDirs = append(imageDirs, dir)
}
}
@@ -873,26 +919,241 @@ func (r *ItemRepository) SearchPage(
if filter.AllowedLibraryIDs != nil && len(filter.AllowedLibraryIDs) == 0 {
return []*models.MediaItem{}, 0, false, includeTotal, nil
}
parsed := parseSearchQuery(query)
// FTS block (the common path). queryLimit fetches one extra row in cursor
// mode so execSearchBlock can derive hasMore without a separate count. When
// the fuzzy fallback below could fire (first page of a fuzzy-eligible
// query) the probe is additionally floored at fuzzyFallbackThreshold rows:
// a tiny caller limit (e.g. an autocomplete asking for 2) with a few
// incidental exact hits would otherwise report hasMore and hide the FTS
// block's true size, so the sparse test below could never fire. Fetching up
// to the threshold reveals a genuinely sparse block regardless of limit;
// the returned page is still trimmed to limit. Where the fallback cannot
// fire, fetching past limit+1 would be pure waste, so the floor is skipped.
queryLimit := limit
if !includeTotal {
queryLimit = limit + 1
if offset == 0 && eligibleForFuzzy(parsed) && queryLimit < fuzzyFallbackThreshold {
queryLimit = fuzzyFallbackThreshold
}
}
sql, countSQL, args := r.buildSearchSQLWithTotal(query, itemTypes, queryLimit, offset, filter, includeTotal)
sql, countSQL, args := r.buildSearchSQLFromParsed(parsed, itemTypes, queryLimit, offset, filter, includeTotal)
if sql == "" {
return []*models.MediaItem{}, 0, false, includeTotal, nil
}
rows, err := r.pool.Query(ctx, sql, args...)
ftsItems, ftsTotal, ftsHasMore, ftsUntrimmed, err := r.execSearchBlock(ctx, r.pool, sql, countSQL, args, limit, offset, includeTotal)
if err != nil {
return nil, 0, false, includeTotal, fmt.Errorf("searching media items: %w", err)
return nil, 0, false, includeTotal, err
}
// Fuzzy fallback trigger. The trigram (typo-tolerant) arm is deliberately NOT
// part of the FTS query: OR-ing the pg_trgm % operator into that WHERE forces
// a lossy bitmap heap recheck that rebuilds the three title tsvectors for
// every one of the thousands of near-miss rows the % operator surfaces,
// making *every* search take seconds. Instead we reach for the trigram index
// only when the FTS result set is fully known and sparse (a likely
// misspelling or word-boundary miss).
//
// Exact mode judges sparsity by the window count (page-independent), so every
// page of a sparse query agrees and the combined result paginates fully.
//
// Cursor mode has no window count, so it uses the pre-trim size of a probe
// floored at fuzzyFallbackThreshold rows: fewer than the threshold came
// back ⇒ the whole FTS block is smaller than the threshold ⇒ sparse,
// independent of the caller's page size. It only trusts this on the first
// page (offset 0), where searchWithFuzzyFallback serves fuzzy as a terminal
// augmentation (no phantom next page a bare offset cursor couldn't locate),
// and only when the whole FTS block fits inside the caller's page: a
// terminal page must never hide exact matches the plain cursor path would
// have surfaced via hasMore. Past offset 0 an empty FTS page can't be told
// from a rich one, so sparse stays false.
var sparse bool
if includeTotal {
sparse = ftsTotal < fuzzyFallbackThreshold
} else if offset == 0 {
sparse = len(ftsUntrimmed) < fuzzyFallbackThreshold && len(ftsUntrimmed) <= limit
}
if !sparse || !eligibleForFuzzy(parsed) {
return ftsItems, ftsTotal, ftsHasMore, includeTotal, nil
}
// Hand the fallback the FTS block when the rows just fetched provably form
// the complete block, sparing it an identical second FTS query. Cursor mode
// always qualifies here (the floored probe returned fewer rows than it
// asked for); exact mode qualifies on the first page when the window count
// fit within the page.
ftsBlock, haveFTSBlock := []*models.MediaItem(nil), false
if !includeTotal {
ftsBlock, haveFTSBlock = ftsUntrimmed, true
} else if offset == 0 && ftsTotal <= len(ftsItems) {
ftsBlock, haveFTSBlock = ftsItems, true
}
return r.searchWithFuzzyFallback(ctx, parsed, itemTypes, limit, offset, filter, includeTotal, ftsBlock, haveFTSBlock)
}
// searchWithFuzzyFallback serves the combined [FTS block][fuzzy block] result
// for a sparse, fuzzy-eligible query. Because fuzzy only fires when the FTS
// block is sparse (< fuzzyFallbackThreshold rows) and the fuzzy contribution is
// capped at fuzzyMaxResults, the ENTIRE combined result is small
// (< fuzzyFallbackThreshold + fuzzyMaxResults rows). We materialize it once and
// slice the requested page in memory. That keeps two-block pagination correct on
// every page — stable total, no cross-page duplicates, no offset arithmetic
// straddling the block boundary — for both exact and cursor callers. The
// FTS-only path in SearchPage is untouched and still streams strictly per page.
//
// When haveFTSBlock is true, ftsBlock is the complete sparse FTS block the
// caller already fetched and no second FTS query is issued; otherwise (exact
// mode past the first page, or a first page smaller than the block) the block
// is re-fetched here at offset 0 — its ids drive fuzzy dedup below.
func (r *ItemRepository) searchWithFuzzyFallback(
ctx context.Context,
parsed parsedSearchQuery,
itemTypes []string,
limit, offset int,
filter AccessFilter,
includeTotal bool,
ftsBlock []*models.MediaItem,
haveFTSBlock bool,
) ([]*models.MediaItem, int, bool, bool, error) {
if !haveFTSBlock {
ftsSQL, ftsCountSQL, ftsArgs := r.buildSearchSQLFromParsed(parsed, itemTypes, fuzzyFallbackThreshold, 0, filter, true)
var err error
ftsBlock, _, _, _, err = r.execSearchBlock(ctx, r.pool, ftsSQL, ftsCountSQL, ftsArgs, fuzzyFallbackThreshold, 0, true)
if err != nil {
return nil, 0, false, includeTotal, err
}
}
// Fuzzy block, excluding every FTS-block id so no title appears in both
// blocks and the combined total is stable per page. Cursor mode serves the
// combined result as a single terminal page, so it never needs more fuzzy
// rows than the page has room for; exact mode materializes up to the cap.
// Either way one extra row is fetched so truncation is detectable without
// paying a COUNT(*) OVER () window count over the whole trigram match set.
// When the FTS block found real hits the fuzzy arm demands near-certain
// corrections (fuzzyAugmentSimilarityFloor) instead of base-threshold noise.
fuzzyLimit := fuzzyMaxResults
if !includeTotal && limit-len(ftsBlock) < fuzzyLimit {
fuzzyLimit = limit - len(ftsBlock)
}
minSimilarity := 0.0
if len(ftsBlock) > 0 {
minSimilarity = fuzzyAugmentSimilarityFloor
}
var fuzzyBlock []*models.MediaItem
fuzzyTruncated := false
if fuzzyLimit > 0 {
fuzzySQL, _, fuzzyArgs := r.buildFuzzySearchFromParsed(parsed, itemTypes, fuzzyLimit+1, 0, filter, false, contentIDsFromMediaItems(ftsBlock), minSimilarity)
if fuzzySQL != "" {
var err error
fuzzyBlock, fuzzyTruncated, err = r.execFuzzyBlock(ctx, fuzzySQL, fuzzyArgs, fuzzyLimit)
if err != nil {
return nil, 0, false, includeTotal, err
}
if fuzzyTruncated {
slog.DebugContext(ctx, "fuzzy search fallback truncated to cap",
"query", parsed.Text, "cap", fuzzyLimit)
}
}
}
combined := make([]*models.MediaItem, 0, len(ftsBlock)+len(fuzzyBlock))
combined = append(combined, ftsBlock...)
combined = append(combined, fuzzyBlock...)
total := len(combined)
// Slice the requested page out of the materialized list, clamped so a
// caller passing a negative offset or an overflowing limit (the exported
// SearchPage is reachable outside the HTTP parser, which otherwise
// guarantees offset>=0 and limit>0) gets a sane page instead of a
// reversed-slice panic.
lo := min(max(offset, 0), total)
hi := lo + max(limit, 0)
if hi < lo || hi > total { // hi < lo: lo+limit overflowed; serve the rest
hi = total
}
page := combined[lo:hi]
if !includeTotal {
// Cursor mode reaches here only at offset 0 with the whole FTS block
// inside the page (see SearchPage gate), so no exact match is hidden;
// only fuzzy augmentation past the page is cut. Serve as a terminal
// page: report only what we return and no next page, since a bare
// offset cursor can't locate the FTS/fuzzy boundary on a follow-up
// request.
return page, len(page), false, false, nil
}
// When the fuzzy arm hit its cap the true match count is larger than the
// materialized list, so surface the total as inexact rather than presenting
// the cap as an exact count.
return page, total, hi < total, !fuzzyTruncated, nil
}
// execFuzzyBlock runs the fuzzy data query inside a transaction that pins
// pg_trgm.strict_word_similarity_threshold, so the <<% operator's selectivity
// cannot drift with cluster configuration (its 0.6 server default would reject
// ordinary typos outright). The query was built with LIMIT fuzzyLimit+1; the
// extra row only signals truncation and is trimmed from the returned block.
func (r *ItemRepository) execFuzzyBlock(ctx context.Context, dataSQL string, args []any, fuzzyLimit int) ([]*models.MediaItem, bool, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, false, fmt.Errorf("beginning fuzzy search tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, fmt.Sprintf("SET LOCAL pg_trgm.strict_word_similarity_threshold = %g", trgmWordSimilarityThreshold)); err != nil {
return nil, false, fmt.Errorf("pinning trigram word-similarity threshold: %w", err)
}
block, _, truncated, _, err := r.execSearchBlock(ctx, tx, dataSQL, "", args, fuzzyLimit, 0, false)
if err != nil {
return nil, false, err
}
if err := tx.Commit(ctx); err != nil {
return nil, false, fmt.Errorf("committing fuzzy search tx: %w", err)
}
return block, truncated, nil
}
// searchQuerier is the subset of pgxpool.Pool / pgx.Tx that execSearchBlock
// needs, so a block can run either directly on the pool or inside a
// transaction (the fuzzy block pins a GUC with SET LOCAL).
type searchQuerier interface {
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
// execSearchBlock runs one search data query (FTS or fuzzy fallback) and its
// count-fallback sibling, returning that block's page. It is the shared engine
// behind SearchPage's two blocks so both derive hasMore/total identically.
//
// In cursor mode (includeTotal == false) the caller fetches limit+1 rows so
// hasMore is len(items) > limit; the extra row is trimmed here. In exact mode
// the data query carries COUNT(*) OVER () for the total, which emits no rows on
// an empty page (e.g. OFFSET past the last row); only then, and only past
// offset 0, is the count sibling re-run to recover the real total. Both queries
// must end with the trailing limit/offset args so the count sibling can drop
// them.
//
// The fourth return value is the untrimmed row set the data query actually
// returned (before the cursor-mode +1 row is trimmed; in exact mode it equals
// the page). SearchPage uses it in cursor mode to judge FTS-block sparsity
// independently of the caller's page size and to hand the fuzzy fallback the
// complete block without a second FTS query.
func (r *ItemRepository) execSearchBlock(ctx context.Context, q searchQuerier, dataSQL, countSQL string, args []any, limit, offset int, includeTotal bool) ([]*models.MediaItem, int, bool, []*models.MediaItem, error) {
rows, err := q.Query(ctx, dataSQL, args...)
if err != nil {
return nil, 0, false, nil, fmt.Errorf("searching media items: %w", err)
}
defer rows.Close()
if !includeTotal {
items, err := scanItems(rows)
if err != nil {
return nil, 0, false, false, err
return nil, 0, false, nil, err
}
untrimmed := items
hasMore := len(items) > limit
if hasMore {
items = items[:limit]
@@ -901,28 +1162,23 @@ func (r *ItemRepository) SearchPage(
if hasMore {
total++
}
return items, total, hasMore, false, nil
return items, total, hasMore, untrimmed, nil
}
items, total, err := scanItemsWithTotal(rows)
if err != nil {
return nil, 0, false, true, err
return nil, 0, false, nil, err
}
hasMore := total > offset+len(items)
// COUNT(*) OVER () emits no rows when the data SELECT is empty, so total
// stays 0 even when the broader result set has matching rows (e.g. OFFSET
// past the last page). Re-query the count to give callers the real total.
// Skip when offset == 0 because in that case an empty page genuinely means
// total = 0.
if len(items) == 0 && offset > 0 {
// Drop the trailing limit/offset args from the data query.
countArgs := args[:len(args)-2]
if err := r.pool.QueryRow(ctx, countSQL, countArgs...).Scan(&total); err != nil {
return nil, 0, false, true, fmt.Errorf("count fallback for empty search page: %w", err)
if err := q.QueryRow(ctx, countSQL, countArgs...).Scan(&total); err != nil {
return nil, 0, false, nil, fmt.Errorf("count fallback for empty search page: %w", err)
}
hasMore = total > offset+len(items)
}
return items, total, hasMore, true, nil
return items, total, hasMore, items, nil
}
// buildSearchSQL assembles the unified search query, returning the SQL string
@@ -949,12 +1205,181 @@ func (r *ItemRepository) SearchPage(
// parsed.Year (or NULL)
// parsed.Phrase
// limit, offset
//
// searchTextFromParsed derives the effective search text shared by the FTS and
// fuzzy builders: the parsed Text, or (when the query was only quotes/whitespace
// that parsed to empty Text) a whitespace-collapsed, quote-stripped fallback off
// the raw query. Centralized so the two builders can never search different text.
func searchTextFromParsed(parsed parsedSearchQuery) string {
if parsed.Text != "" {
return parsed.Text
}
return collapseSearchWhitespace(strings.ReplaceAll(strings.TrimSpace(parsed.Raw), "\"", " "))
}
func (r *ItemRepository) buildSearchSQL(query string, itemTypes []string, limit, offset int, filter AccessFilter) (dataSQL, countSQL string, args []any) {
return r.buildSearchSQLWithTotal(query, itemTypes, limit, offset, filter, true)
}
func (r *ItemRepository) buildSearchSQLWithTotal(query string, itemTypes []string, limit, offset int, filter AccessFilter, includeTotal bool) (dataSQL, countSQL string, args []any) {
return r.buildMixedSearchSQLWithTotal(query, itemTypes, limit, offset, filter, includeTotal)
return r.buildSearchSQLFromParsed(parseSearchQuery(query), itemTypes, limit, offset, filter, includeTotal)
}
// buildSearchSQLFromParsed is the FTS query builder proper; the string-taking
// wrappers above parse first. SearchPage parses once and calls this directly so
// the same parsedSearchQuery feeds the FTS builder, the fuzzy builder, and the
// eligibility gate without re-parsing. The mixed builder unions media_items and
// episode candidates into one ranked page.
func (r *ItemRepository) buildSearchSQLFromParsed(parsed parsedSearchQuery, itemTypes []string, limit, offset int, filter AccessFilter, includeTotal bool) (dataSQL, countSQL string, args []any) {
return r.buildMixedSearchSQLFromParsed(parsed, itemTypes, limit, offset, filter, includeTotal)
}
// appendSearchScopeFilters appends the scope predicates shared by the FTS search
// (buildSearchSQLWithTotal) and the trigram fuzzy fallback (buildFuzzySearchSQL):
// item type, allowed/disabled libraries, the access filter, and the
// manga-chapter exclusion. It mutates conditions/args/argIdx in place and
// returns the FROM clause (always "media_items mi"; library scoping is enforced
// with independent EXISTS/NOT EXISTS subqueries rather than a JOIN). Both callers
// append these in the same order so a single helper keeps the two queries'
// filtering provably identical.
func appendSearchScopeFilters(itemTypes []string, filter AccessFilter, conditions *[]string, args *[]any, argIdx *int) (fromClause string) {
fromClause = "media_items mi"
if len(itemTypes) > 0 {
placeholders := make([]string, 0, len(itemTypes))
for _, itemType := range itemTypes {
if strings.TrimSpace(itemType) == "" {
continue
}
placeholders = append(placeholders, fmt.Sprintf("$%d", *argIdx))
*args = append(*args, strings.ToLower(strings.TrimSpace(itemType)))
*argIdx++
}
if len(placeholders) > 0 {
*conditions = append(*conditions, fmt.Sprintf("mi.type IN (%s)", strings.Join(placeholders, ", ")))
}
}
// Library allow/deny via the shared leak-safe helper: an item linked to both a
// passing and a disabled library must not slip through. The old JOIN +
// NOT(mil.media_folder_id = ANY(...)) form let the passing membership row
// satisfy the deny check (audit 2026-05-01 §3.3), so both the FTS search and
// the fuzzy fallback that share this helper used it. appendLibraryAccessConditions
// emits independent EXISTS/NOT EXISTS subqueries keyed on mi.content_id and
// needs no JOIN.
appendLibraryAccessConditions("mi.content_id", filter, conditions, args, argIdx)
applyAccessFilter("mi", AccessFilter{MaxContentRating: filter.MaxContentRating, ExcludedMediaTypes: filter.ExcludedMediaTypes}, conditions, args, argIdx)
// Manga chapters (type='ebook' rows linked into a manga series) are internal
// sub-units and must never surface as standalone search results.
*conditions = append(*conditions, MangaChapterExclusionWhere("mi"))
return fromClause
}
// buildFuzzySearchSQL assembles the trigram fuzzy-fallback query invoked by
// SearchPage only when the FTS query is sparse. Unlike buildSearchSQLWithTotal,
// the sole match predicate is the pg_trgm strict-word-similarity operator
// (<<%) against the indexed title_normalized generated column (migration 105's
// gin_trgm_ops index serves it), and the ranking signals are
// strict_word_similarity()/similarity() on that same column. Crucially it
// never references the title tsvectors, so the low-similarity rows the
// operator can surface never pay a per-row tsvector rebuild — that rebuild
// over the fuzzy candidate set was the entire cause of the multi-second search
// regression. The operator's base selectivity is pinned by the caller
// (execFuzzyBlock sets pg_trgm.strict_word_similarity_threshold via SET LOCAL)
// so it cannot drift with cluster configuration.
//
// Scope note: only title_normalized — a generated column over `title` — is
// fuzzy-matched, because it is the only column with a gin_trgm_ops index
// (migration 105). Typos of original_title or sort_title, which the FTS arm
// covers exactly, are deliberately out of the fuzzy arm's scope; extending it
// would need matching normalized columns + trigram indexes (follow-up).
//
// includeTotal toggles the COUNT(*) OVER () window total the same way
// buildSearchSQLWithTotal does, so the fuzzy block honors the caller's cursor
// vs. exact-count mode. excludeContentIDs drops rows already returned by the FTS
// block so a title matching both is not shown twice. minSimilarity > 0 adds an
// explicit similarity floor above the base threshold — used when the FTS block
// had real hits so augmentation only admits near-certain corrections. Argument
// order: $1 searchText, then the similarity floor (if any), then the shared
// scope placeholders, then the exclusion array, then limit/offset.
func (r *ItemRepository) buildFuzzySearchSQL(query string, itemTypes []string, limit, offset int, filter AccessFilter, includeTotal bool, excludeContentIDs []string, minSimilarity float64) (dataSQL, countSQL string, args []any) {
return r.buildFuzzySearchFromParsed(parseSearchQuery(query), itemTypes, limit, offset, filter, includeTotal, excludeContentIDs, minSimilarity)
}
// buildFuzzySearchFromParsed is the fuzzy query builder proper; the string-taking
// wrapper above parses first. SearchPage parses once and calls this directly.
func (r *ItemRepository) buildFuzzySearchFromParsed(parsed parsedSearchQuery, itemTypes []string, limit, offset int, filter AccessFilter, includeTotal bool, excludeContentIDs []string, minSimilarity float64) (dataSQL, countSQL string, args []any) {
searchText := searchTextFromParsed(parsed)
if searchText == "" {
return "", "", nil
}
args = []any{searchText}
argIdx := 2
// Strict word similarity (<<%) rather than full-string similarity (%): a
// typo of one word must still reach long titles ("avegners" → "Avengers:
// Endgame"), where full-string similarity is diluted by every extra trigram
// the rest of the title contributes. <<% scores the query against the best
// word-boundary extent of the title instead, and at equal thresholds it is
// a strict superset of % (the whole string is itself a valid extent). The
// same gin_trgm_ops index serves both operators.
conditions := []string{"public.normalize_search_text($1) <<% mi.title_normalized"}
if minSimilarity > 0 {
// The floor is whole-title similarity, NOT word similarity: augmenting
// a query that already has hits must only admit near-identical titles,
// and word similarity rates embedded prefix words far too high (see
// fuzzyAugmentSimilarityFloor).
conditions = append(conditions, fmt.Sprintf("similarity(public.normalize_search_text($1), mi.title_normalized) >= $%d", argIdx))
args = append(args, minSimilarity)
argIdx++
}
fromClause := appendSearchScopeFilters(itemTypes, filter, &conditions, &args, &argIdx)
if len(excludeContentIDs) > 0 {
conditions = append(conditions, fmt.Sprintf("NOT (mi.content_id = ANY($%d))", argIdx))
args = append(args, excludeContentIDs)
argIdx++
}
whereClause := "WHERE " + strings.Join(conditions, " AND ")
// GROUP BY is required so the MAX(similarity(...)) ranking aggregate is legal,
// mirroring buildSearchSQLWithTotal; COUNT(*) OVER () then counts distinct
// content_ids. qualifiedItemColumns aliases the coalesced columns so the outer
// SELECT can re-reference them; GROUP BY uses the raw refs.
qualifiedCols := qualifiedItemColumns("mi")
groupByCols := qualifiedItemColumnRefs("mi")
// fuzzy_rank orders by how well the query matches SOME word extent of the
// title; fuzzy_full_rank tie-breaks by whole-title closeness so "The
// Avengers" sorts above "Avengers: Endgame" for the typo "avegners". Both
// are computed only over the matched candidate set.
scoredCTE := fmt.Sprintf(`
WITH scored AS (
SELECT
%s,
MAX(strict_word_similarity(public.normalize_search_text($1), mi.title_normalized)) AS fuzzy_rank,
MAX(similarity(public.normalize_search_text($1), mi.title_normalized)) AS fuzzy_full_rank
FROM %s
%s
GROUP BY %s
)
`, qualifiedCols, fromClause, whereClause, groupByCols)
totalColumn := ""
if includeTotal {
totalColumn = ", COUNT(*) OVER () AS total_count"
}
dataSQL = scoredCTE + fmt.Sprintf(`
SELECT %s%s
FROM scored
ORDER BY fuzzy_rank DESC, fuzzy_full_rank DESC, LOWER(title) ASC, content_id ASC
LIMIT $%d OFFSET $%d`, itemColumns, totalColumn, argIdx, argIdx+1)
countSQL = scoredCTE + `SELECT COUNT(*) FROM scored`
args = append(args, limit, offset)
return dataSQL, countSQL, args
}
// ListUnmatchedByFolderAndPathPrefix returns content IDs for unmatched-style
+217
View File
@@ -382,6 +382,223 @@ func TestItemRepo_Search_EmptyQueryReturnsEmpty(t *testing.T) {
}
}
// TestEligibleForFuzzy pins the min-token gate that guards the fuzzy title
// fallback: the longest normalized token must clear fuzzyMinTokenLen, so short,
// non-selective queries stay on the exact FTS/prefix path.
func TestEligibleForFuzzy(t *testing.T) {
cases := []struct {
query string
want bool
}{
{"avegners", true}, // 8-char typo
{"sponge bob", true}, // longest token "sponge" (6)
{"a vengers", true}, // judged on "vengers" (7), not "a"
{"dune", true}, // exactly at the floor
{"the", false}, // 3 chars
{"a b c", false}, // all short
{"and the", false}, // "and" normalized away, "the" is 3
{" ", false}, // empty
}
for _, tc := range cases {
if got := eligibleForFuzzy(parseSearchQuery(tc.query)); got != tc.want {
t.Errorf("eligibleForFuzzy(%q) = %v, want %v", tc.query, got, tc.want)
}
}
}
// TestSearchTextFromParsed pins the shared searchText derivation used by both
// the FTS and fuzzy builders: parsed Text when present, else a quote-stripped
// fallback off the raw query. Both builders must agree so they search the same
// text.
func TestSearchTextFromParsed(t *testing.T) {
cases := []struct {
raw string
want string
}{
{"interstellar", "interstellar"},
{`"the matrix"`, "the matrix"},
{" spaced out ", "spaced out"},
{`"`, ""},
{" ", ""},
}
for _, tc := range cases {
if got := searchTextFromParsed(parseSearchQuery(tc.raw)); got != tc.want {
t.Errorf("searchTextFromParsed(%q) = %q, want %q", tc.raw, got, tc.want)
}
}
}
// TestParseSearchQuery_NormalizedText asserts parseSearchQuery precomputes the
// full normalized text that eligibleForFuzzy's token gate reads (so the gate
// does not re-normalize on every sparse search).
func TestParseSearchQuery_NormalizedText(t *testing.T) {
if got := parseSearchQuery("The Avengers").NormalizedText; got != "the avengers" {
t.Fatalf("NormalizedText = %q, want %q", got, "the avengers")
}
// "and" is dropped and folded, matching normalizeTitleForComparison.
if got := parseSearchQuery("law and order").NormalizedText; got != "law order" {
t.Fatalf("NormalizedText = %q, want %q", got, "law order")
}
}
// TestItemRepo_Search_FTSQueryHasNoFuzzyArm asserts that the FTS query never
// carries the trigram % arm or its similarity ranking. Fusing them forced a
// lossy bitmap recheck that rebuilt the title tsvectors for thousands of
// near-miss rows on every search; the fuzzy path is now a separate query
// (buildFuzzySearchSQL) invoked only as a fallback by SearchPage.
func TestItemRepo_Search_FTSQueryHasNoFuzzyArm(t *testing.T) {
repo := &ItemRepository{}
dataSQL, countSQL, _ := repo.buildSearchSQL("avegners", []string{"movie"}, 20, 0, AccessFilter{})
for _, sql := range []string{dataSQL, countSQL} {
if strings.Contains(sql, "% mi.title_normalized") {
t.Fatalf("FTS query must not include the trigram %% arm; got:\n%s", sql)
}
if strings.Contains(sql, "fuzzy_rank") {
t.Fatalf("FTS query must not include fuzzy_rank; got:\n%s", sql)
}
if strings.Contains(sql, "similarity(") {
t.Fatalf("FTS query must not compute similarity(); got:\n%s", sql)
}
}
}
// TestItemRepo_BuildFuzzySearchSQL asserts that the fuzzy fallback query scores
// only on the indexed title_normalized column (no title tsvector rebuild),
// matches via strict word similarity so long titles stay reachable, ranks by
// descending word similarity with whole-title closeness as tie-break, excludes
// already-seen content_ids, and applies the same scope filters (type, manga
// exclusion) as the FTS query.
func TestItemRepo_BuildFuzzySearchSQL(t *testing.T) {
repo := &ItemRepository{}
dataSQL, countSQL, args := repo.buildFuzzySearchSQL("avegners", []string{"movie"}, 20, 0, AccessFilter{}, true, []string{"abc", "def"}, 0)
// <<%, not %: full-string similarity is diluted by long titles ("avegners"
// must reach "Avengers: Endgame"); the same gin_trgm_ops index serves both.
if !strings.Contains(dataSQL, "public.normalize_search_text($1) <<% mi.title_normalized") {
t.Fatalf("expected strict-word-similarity arm against title_normalized; got:\n%s", dataSQL)
}
if !strings.Contains(dataSQL, "MAX(strict_word_similarity(public.normalize_search_text($1), mi.title_normalized)) AS fuzzy_rank") {
t.Fatalf("expected strict word similarity ranking on title_normalized; got:\n%s", dataSQL)
}
if !strings.Contains(dataSQL, "MAX(similarity(public.normalize_search_text($1), mi.title_normalized)) AS fuzzy_full_rank") {
t.Fatalf("expected whole-title similarity tie-break rank; got:\n%s", dataSQL)
}
// The whole point of the separate query: it must never rebuild the title
// tsvectors that made the fused query slow.
if strings.Contains(dataSQL, "to_tsvector") || strings.Contains(dataSQL, "ts_rank_cd") {
t.Fatalf("fuzzy query must not rebuild title tsvectors; got:\n%s", dataSQL)
}
if !strings.Contains(dataSQL, "ORDER BY fuzzy_rank DESC, fuzzy_full_rank DESC, LOWER(title) ASC, content_id ASC") {
t.Fatalf("expected similarity-ordered results; got:\n%s", dataSQL)
}
if !strings.Contains(dataSQL, "NOT (mi.content_id = ANY($") {
t.Fatalf("expected exclusion of already-seen content_ids; got:\n%s", dataSQL)
}
if !strings.Contains(dataSQL, "mi.type IN ($") {
t.Fatalf("expected shared type scope filter; got:\n%s", dataSQL)
}
if !strings.Contains(countSQL, "SELECT COUNT(*) FROM scored") {
t.Fatalf("expected count sibling over the scored CTE; got:\n%s", countSQL)
}
// Arg order: $1 searchText, $2 type, $3 exclusion array, $4 limit, $5 offset.
if len(args) != 5 {
t.Fatalf("expected 5 args, got %d: %#v", len(args), args)
}
if args[0] != "avegners" {
t.Fatalf("expected $1=searchText; got %#v", args[0])
}
if args[len(args)-2] != 20 || args[len(args)-1] != 0 {
t.Fatalf("expected trailing limit/offset args; got %#v", args[len(args)-2:])
}
}
// TestItemRepo_BuildFuzzySearchSQL_CursorModeOmitsWindowCount asserts that the
// fuzzy fallback honors cursor mode: with includeTotal=false it must not carry
// COUNT(*) OVER (), matching buildSearchSQLWithTotal's skip-total contract so
// SearchPage's cursor path stays a pure keyset page.
func TestItemRepo_BuildFuzzySearchSQL_CursorModeOmitsWindowCount(t *testing.T) {
repo := &ItemRepository{}
dataSQL, _, _ := repo.buildFuzzySearchSQL("avegners", []string{"movie"}, 21, 0, AccessFilter{}, false, nil, 0)
if strings.Contains(dataSQL, "COUNT(*) OVER") {
t.Fatalf("cursor-mode fuzzy query must omit window count; got:\n%s", dataSQL)
}
if strings.Contains(dataSQL, "total_count") {
t.Fatalf("cursor-mode fuzzy query must not select total_count; got:\n%s", dataSQL)
}
}
// TestItemRepo_BuildFuzzySearchSQL_SimilarityFloor asserts the augment floor
// contract: minSimilarity > 0 adds an explicit similarity() >= $n predicate
// (used when the FTS block had real hits, so augmentation only admits
// near-certain corrections), and minSimilarity == 0 omits it so zero-hit typo
// queries keep the base pinned threshold's recall.
func TestItemRepo_BuildFuzzySearchSQL_SimilarityFloor(t *testing.T) {
repo := &ItemRepository{}
floorSQL, _, floorArgs := repo.buildFuzzySearchSQL("avegners", []string{"movie"}, 20, 0, AccessFilter{}, true, nil, fuzzyAugmentSimilarityFloor)
// Whole-title similarity, not word similarity: the augment floor must not
// admit embedded prefix words ("coral" scores 0.5 word-similarity to
// "coraline").
if !strings.Contains(floorSQL, "AND similarity(public.normalize_search_text($1), mi.title_normalized) >= $2") {
t.Fatalf("expected explicit whole-title similarity floor predicate as $2; got:\n%s", floorSQL)
}
if len(floorArgs) < 2 || floorArgs[1] != fuzzyAugmentSimilarityFloor {
t.Fatalf("expected $2 = fuzzyAugmentSimilarityFloor; got args %#v", floorArgs)
}
// Trailing limit/offset must still close the arg list for the count sibling.
if floorArgs[len(floorArgs)-2] != 20 || floorArgs[len(floorArgs)-1] != 0 {
t.Fatalf("expected trailing limit/offset args; got %#v", floorArgs[len(floorArgs)-2:])
}
baseSQL, _, baseArgs := repo.buildFuzzySearchSQL("avegners", []string{"movie"}, 20, 0, AccessFilter{}, true, nil, 0)
if strings.Contains(baseSQL, "AND similarity(public.normalize_search_text($1), mi.title_normalized) >= $2") {
t.Fatalf("zero floor must not add a similarity predicate; got:\n%s", baseSQL)
}
if len(baseArgs) != len(floorArgs)-1 {
t.Fatalf("zero floor should bind one fewer arg: base %d vs floor %d", len(baseArgs), len(floorArgs))
}
}
// TestItemRepo_BuildFuzzySearchSQL_EmptyQueryReturnsEmpty guards the same
// empty-input contract as buildSearchSQL.
func TestItemRepo_BuildFuzzySearchSQL_EmptyQueryReturnsEmpty(t *testing.T) {
repo := &ItemRepository{}
dataSQL, countSQL, args := repo.buildFuzzySearchSQL(" ", []string{"movie"}, 20, 0, AccessFilter{}, true, nil, 0)
if dataSQL != "" || countSQL != "" || args != nil {
t.Fatalf("expected empty result for blank query; got dataSQL=%q countSQL=%q args=%#v", dataSQL, countSQL, args)
}
}
// TestItemRepo_Search_LibraryScopeUsesIndependentExistsPredicates pins the
// leak-safe library scoping shared by the FTS search and the fuzzy fallback via
// appendSearchScopeFilters. The prior JOIN + NOT(mil.media_folder_id = ANY(...))
// form let an item linked to BOTH a disabled and a non-disabled library survive
// the deny check on the passing membership row, so a typo (fuzzy) or exact
// search could surface items from a disabled library. Both builders must instead
// emit independent EXISTS/NOT EXISTS subqueries and no membership JOIN.
func TestItemRepo_Search_LibraryScopeUsesIndependentExistsPredicates(t *testing.T) {
repo := &ItemRepository{}
filter := AccessFilter{DisabledLibraryIDs: []int{9}}
ftsSQL, _, _ := repo.buildSearchSQL("avatar", []string{"movie"}, 20, 0, filter)
fuzzySQL, _, _ := repo.buildFuzzySearchSQL("avatar", []string{"movie"}, 20, 0, filter, true, nil, 0)
for name, sql := range map[string]string{"fts": ftsSQL, "fuzzy": fuzzySQL} {
if strings.Contains(sql, "JOIN media_item_libraries") {
t.Fatalf("%s query must not JOIN media_item_libraries for scoping; got:\n%s", name, sql)
}
// Disabled-only path requires positive membership (argument-free EXISTS)
// so orphan items don't slip through the vacuous NOT EXISTS...
if !strings.Contains(sql, "EXISTS (SELECT 1 FROM media_item_libraries mil WHERE mil.content_id = mi.content_id)") {
t.Fatalf("%s query must require library membership via an argument-free EXISTS; got:\n%s", name, sql)
}
// ...plus the disabled NOT EXISTS.
if !strings.Contains(sql, "NOT EXISTS (SELECT 1 FROM media_item_libraries mil WHERE mil.content_id = mi.content_id AND mil.media_folder_id = ANY($") {
t.Fatalf("%s query must exclude disabled libraries via NOT EXISTS; got:\n%s", name, sql)
}
}
}
// TestItemRepo_Search_UsesTitleNormalizedColumn asserts that buildSearchSQL
// reads the mi.title_normalized stored generated column for the title rank
// arms (exact_title_match and contiguous_title_match), so the LIKE
+118 -6
View File
@@ -12,6 +12,7 @@ import (
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/pathscope"
)
// LibraryItemRepository provides CRUD operations for the media_item_libraries
@@ -421,7 +422,19 @@ func (r *LibraryItemRepository) Delete(ctx context.Context, contentID string, fo
// longer has any non-missing files in the given folder. It also deletes orphaned
// media items once they no longer belong to any library. Returns removed
// membership count, deleted item count, orphaned S3 image dirs, and any error.
func (r *LibraryItemRepository) ReconcileFolderMembership(ctx context.Context, folderID int) (int, int, []string, error) {
//
// protectedPathPrefixes lists library roots that are currently unreachable
// (dead drive, lost mount). Membership removal proceeds regardless — browse
// and home queries hide items via media_item_libraries, so removal is what
// keeps a title with no playable files out of the catalog — but items whose
// files in this folder sit under a protected prefix are exempt from the
// orphan delete. Deleting them is not losslessly recoverable (user
// collections cascade via library_collection_items, manual metadata edits and
// cached artwork are lost), whereas a hidden membership-less item restores
// automatically: when the root returns, the scanner clears missing_since on
// its surviving media_files rows and syncPresentLibraryState re-inserts the
// membership from those rows.
func (r *LibraryItemRepository) ReconcileFolderMembership(ctx context.Context, folderID int, protectedPathPrefixes []string) (int, int, []string, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return 0, 0, nil, fmt.Errorf("beginning membership reconciliation transaction: %w", err)
@@ -470,11 +483,28 @@ func (r *LibraryItemRepository) ReconcileFolderMembership(ctx context.Context, f
deletedItems := 0
var orphanedImageDirs []string
if len(removedContentIDs) > 0 {
// Find items that will become orphaned (no remaining library memberships).
orphanIDs, err := collectOrphanIDs(ctx, tx, removedContentIDs)
if err != nil {
return 0, 0, nil, err
// Find both newly orphaned items and items preserved by an earlier
// protected-root pass. The latter no longer have a membership to return from
// the DELETE above, but their surviving media_files row still ties them to
// this folder so they can be reconsidered after the root recovers.
orphanIDs, err := collectOrphanIDs(ctx, tx, removedContentIDs)
if err != nil {
return 0, 0, nil, err
}
previouslyProtected, err := collectFolderFileOrphanIDs(ctx, tx, folderID)
if err != nil {
return 0, 0, nil, err
}
orphanIDs = appendUniqueStrings(orphanIDs, previouslyProtected...)
if len(orphanIDs) > 0 {
// Exempt orphans whose files sit under an unreachable root: the files
// still exist, the root is just offline. See the doc comment above.
if len(orphanIDs) > 0 && len(protectedPathPrefixes) > 0 {
orphanIDs, err = excludeOrphansUnderProtectedPrefixes(ctx, tx, orphanIDs, folderID, protectedPathPrefixes)
if err != nil {
return 0, 0, nil, err
}
}
// Collect image paths before deletion.
@@ -516,3 +546,85 @@ func (r *LibraryItemRepository) ReconcileFolderMembership(ctx context.Context, f
return len(removedContentIDs), deletedItems, orphanedImageDirs, nil
}
func collectFolderFileOrphanIDs(ctx context.Context, tx pgx.Tx, folderID int) ([]string, error) {
rows, err := tx.Query(ctx, `
SELECT DISTINCT mf.content_id
FROM media_files mf
WHERE mf.media_folder_id = $1
AND mf.content_id IS NOT NULL
AND mf.content_id <> ''
AND NOT EXISTS (
SELECT 1 FROM media_item_libraries mil WHERE mil.content_id = mf.content_id
)
`, folderID)
if err != nil {
return nil, fmt.Errorf("finding previously protected folder orphans: %w", err)
}
defer rows.Close()
ids, err := pgx.CollectRows(rows, pgx.RowTo[string])
if err != nil {
return nil, fmt.Errorf("collecting previously protected folder orphans: %w", err)
}
return ids, nil
}
func appendUniqueStrings(values []string, additions ...string) []string {
seen := make(map[string]struct{}, len(values)+len(additions))
for _, value := range values {
seen[value] = struct{}{}
}
for _, value := range additions {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
values = append(values, value)
}
return values
}
// excludeOrphansUnderProtectedPrefixes returns the subset of orphanIDs that
// have no media_files row in the folder at or under any protected prefix.
// Matching uses the exact-path + escaped prefix-LIKE shape shared with the
// scanner's root matching, so a sibling root that merely shares a string
// prefix (/mnt/movies2 vs /mnt/movies) is never protected by accident.
//
// Known limitation: the check is scoped to the reconciled folder's own files
// (mf.media_folder_id = folderID). An item shared across two libraries whose
// only surviving files sit under ANOTHER folder's currently-unreachable root
// is not exempted here — if its membership in this folder is genuinely
// removed while the other folder's root is offline, the item is purged even
// though its files under the dead root would have resurrected. Closing this
// would require probing every enabled folder's roots (or persisting per-
// folder unreachable state) on each reconcile; accepted for now given how
// narrow the window is.
func excludeOrphansUnderProtectedPrefixes(ctx context.Context, tx pgx.Tx, orphanIDs []string, folderID int, prefixes []string) ([]string, error) {
conds, condArgs := pathscope.CoverageClauses("mf.file_path", prefixes, 3)
args := append([]any{orphanIDs, folderID}, condArgs...)
rows, err := tx.Query(ctx, fmt.Sprintf(`
SELECT cid FROM unnest($1::text[]) AS cid
WHERE NOT EXISTS (
SELECT 1
FROM media_files mf
WHERE mf.media_folder_id = $2
AND mf.content_id = cid
AND (%s)
)
`, strings.Join(conds, " OR ")), args...)
if err != nil {
return nil, fmt.Errorf("filtering orphans under protected prefixes: %w", err)
}
defer rows.Close()
ids := make([]string, 0, len(orphanIDs))
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scanning unprotected orphan id: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
@@ -0,0 +1,133 @@
package catalog
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// TestReconcileFolderMembershipProtectsUnreachableRoots verifies the
// dead-root exemption in the orphan purge: memberships are still removed for
// content with no non-missing files (that is what hides the item from
// browse/home, which gate on media_item_libraries), but the media_items row
// of an item whose files sit under a protected (unreachable) root must
// survive so user collections, metadata edits, and artwork are not destroyed
// by a temporary outage. An orphan with no protected files is still purged.
func TestReconcileFolderMembershipProtectsUnreachableRoots(t *testing.T) {
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
suffix := time.Now().UnixNano()
deadRoot := fmt.Sprintf("/drp-dead-%d", suffix)
goneItem := fmt.Sprintf("drp-gone-%d", suffix)
protectedItem := fmt.Sprintf("drp-protected-%d", suffix)
var folderID int
if err := pool.QueryRow(ctx,
`INSERT INTO media_folders (type, name, enabled) VALUES ('movies', 'DRP Test', true) RETURNING id`,
).Scan(&folderID); err != nil {
t.Fatalf("seed folder: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_files WHERE media_folder_id = $1`, folderID)
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, []string{goneItem, protectedItem})
_, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID)
})
for _, contentID := range []string{goneItem, protectedItem} {
if _, err := pool.Exec(ctx, `
INSERT INTO media_items (content_id, type, title, status, genres)
VALUES ($1, 'movie', 'DRP Item', 'matched', '{}'::text[])
`, contentID); err != nil {
t.Fatalf("seed item %s: %v", contentID, err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO media_item_libraries (content_id, media_folder_id, first_seen_at)
VALUES ($1, $2, NOW())
`, contentID, folderID); err != nil {
t.Fatalf("seed membership %s: %v", contentID, err)
}
}
missingSince := time.Now().UTC().Add(-72 * time.Hour)
seedFile := func(contentID, path string) {
if _, err := pool.Exec(ctx, `
INSERT INTO media_files (content_id, media_folder_id, file_path, file_size, missing_since)
VALUES ($1, $2, $3, 1024, $4)
`, contentID, folderID, path, missingSince); err != nil {
t.Fatalf("seed file %s: %v", path, err)
}
}
// The protected item's only file lives under the unreachable root; the
// other item's file was under a root that is reachable (its absence is a
// genuine deletion).
seedFile(protectedItem, deadRoot+"/Alpha (2020)/Alpha (2020).mkv")
seedFile(goneItem, fmt.Sprintf("/drp-live-%d/Beta (2021)/Beta (2021).mkv", suffix))
repo := NewLibraryItemRepository(pool)
removed, deletedItems, _, err := repo.ReconcileFolderMembership(ctx, folderID, []string{deadRoot})
if err != nil {
t.Fatalf("ReconcileFolderMembership: %v", err)
}
if removed != 2 {
t.Fatalf("removed memberships = %d, want 2 (hiding still requires removal)", removed)
}
if deletedItems != 1 {
t.Fatalf("deleted items = %d, want 1 (only the unprotected orphan)", deletedItems)
}
var exists bool
if err := pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM media_items WHERE content_id = $1)`, protectedItem,
).Scan(&exists); err != nil {
t.Fatalf("check protected item: %v", err)
}
if !exists {
t.Fatal("protected item was purged despite its files sitting under an unreachable root")
}
if err := pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM media_items WHERE content_id = $1)`, goneItem,
).Scan(&exists); err != nil {
t.Fatalf("check gone item: %v", err)
}
if exists {
t.Fatal("unprotected orphan item survived; purge regressed")
}
if err := pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM media_item_libraries WHERE media_folder_id = $1)`, folderID,
).Scan(&exists); err != nil {
t.Fatalf("check memberships: %v", err)
}
if exists {
t.Fatal("memberships remain; items with only missing files must be hidden")
}
// Once the root is reachable again, reconciliation must revisit the item
// even though its membership was removed during the protected pass.
removed, deletedItems, _, err = repo.ReconcileFolderMembership(ctx, folderID, nil)
if err != nil {
t.Fatalf("ReconcileFolderMembership after recovery: %v", err)
}
if removed != 0 || deletedItems != 1 {
t.Fatalf("recovery reconciliation removed/deleted = %d/%d, want 0/1", removed, deletedItems)
}
if err := pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM media_items WHERE content_id = $1)`, protectedItem,
).Scan(&exists); err != nil {
t.Fatalf("check recovered orphan: %v", err)
}
if exists {
t.Fatal("previously protected orphan survived after its root recovered")
}
}
@@ -0,0 +1,37 @@
package catalog
import "testing"
func TestImageTypeFromCachedPathLocalHashedLayout(t *testing.T) {
// Local keys interpose a content-hash segment BEFORE the image type
// (local/{contentType}/{contentID}/{hash8}/{imageType}/{variant}.{ext})
// so the variant's parent directory stays the image type.
cases := map[string]string{
"local/movies/movie-1/deadbeef/poster/original.webp": "poster",
"local/movies/movie-1/deadbeef/backdrop/original.webp": "backdrop",
"local/series/show-1/cafef00d/logo/w500.webp": "logo",
}
for path, want := range cases {
if got := imageTypeFromCachedPath(path); got != want {
t.Errorf("imageTypeFromCachedPath(%q) = %q, want %q", path, got, want)
}
}
}
func TestImageDeletePrefixTrimsLocalPathsToContentRoot(t *testing.T) {
// On item deletion, local paths trim to local/{contentType}/{contentID}
// so every stale hash prefix is swept, not just the live one.
cases := map[string]string{
"local/movies/movie-1/deadbeef/poster/original.webp": "local/movies/movie-1/",
"local/series/show-1/cafef00d/logo/w500.webp": "local/series/show-1/",
// Remote cached keys keep the existing per-image-type prefix.
"tmdb/movies/550/poster/original.webp": "tmdb/movies/550/poster/",
// Legacy scanner keys without a hash segment stay whole-path dirs.
"local/ebooks/book-1/poster/original.webp": "local/ebooks/book-1/",
}
for path, want := range cases {
if got := imageDeletePrefix(path); got != want {
t.Errorf("imageDeletePrefix(%q) = %q, want %q", path, got, want)
}
}
}
+4 -8
View File
@@ -69,22 +69,18 @@ const mixedSearchOrder = `exact_title_match DESC, contiguous_title_match DESC, y
phrase_rank DESC, title_rank DESC, title_prefix_rank DESC, overview_rank DESC,
LOWER(title) ASC, content_id ASC`
// buildMixedSearchSQLWithTotal builds one ranked candidate set from the two
// buildMixedSearchSQLFromParsed builds one ranked candidate set from the two
// physical catalog sources. The scored CTE deliberately carries only ranking
// fields; the wide MediaItem projection is hydrated after LIMIT/OFFSET so a
// broad match never sorts posters, arrays, or metadata blobs for every hit.
func (r *ItemRepository) buildMixedSearchSQLWithTotal(
query string,
func (r *ItemRepository) buildMixedSearchSQLFromParsed(
parsed parsedSearchQuery,
itemTypes []string,
limit, offset int,
filter AccessFilter,
includeTotal bool,
) (dataSQL, countSQL string, args []any) {
parsed := parseSearchQuery(query)
searchText := parsed.Text
if searchText == "" {
searchText = collapseSearchWhitespace(strings.ReplaceAll(strings.TrimSpace(query), "\"", " "))
}
searchText := searchTextFromParsed(parsed)
if searchText == "" {
return "", "", nil
}
+26
View File
@@ -11,6 +11,11 @@ type parsedSearchQuery struct {
Text string
Phrase string
ExactTitleHint string
// NormalizedText is normalizeTitleForComparison(Text) computed once at parse
// time. Text already folds phrase + remainder together, so this is the full
// normalized query used by eligibleForFuzzy's token gate (which would
// otherwise re-normalize on every sparse search).
NormalizedText string
Year *int
}
@@ -37,10 +42,31 @@ func parseSearchQuery(raw string) parsedSearchQuery {
Text: text,
Phrase: phrase,
ExactTitleHint: normalizeTitleForComparison(firstNonEmptySearchValue(phrase, text)),
NormalizedText: normalizeTitleForComparison(text),
Year: year,
}
}
// fuzzyMinTokenLen is the shortest normalized token that may enable the trigram
// fuzzy title fallback. A token shorter than this forms too few trigrams to use
// the gin_trgm_ops index selectively (and a 1-2 char token can't use it at
// all), so for short queries the fuzzy fallback is skipped and search stays on
// the exact FTS/prefix path. The gate is applied to the longest token so a stray
// short token ("a vengers") is judged on "vengers", not "a".
const fuzzyMinTokenLen = 4
// eligibleForFuzzy reports whether a parsed query clears the min-token gate for
// the trigram fuzzy title fallback.
func eligibleForFuzzy(parsed parsedSearchQuery) bool {
longest := 0
for _, tok := range strings.Fields(parsed.NormalizedText) {
if n := len([]rune(tok)); n > longest {
longest = n
}
}
return longest >= fuzzyMinTokenLen
}
func extractBalancedPhrase(input string) (string, string) {
start := strings.Index(input, "\"")
if start == -1 {
+1
View File
@@ -2580,6 +2580,7 @@ func toVideoTrackRecords(tracks []models.VideoTrack) []VideoTrackRecord {
Bitrate: track.Bitrate,
VideoRange: track.VideoRange,
VideoRangeType: track.VideoRangeType,
ColorRange: track.ColorRange,
ColorPrimaries: track.ColorPrimaries,
ColorSpace: track.ColorSpace,
ColorTransfer: track.ColorTransfer,
+22
View File
@@ -3,8 +3,30 @@ package catalogseed
import (
"reflect"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestToVideoTrackRecordsPreservesColorRange(t *testing.T) {
got := toVideoTrackRecords([]models.VideoTrack{
{ColorRange: "tv"},
{ColorRange: "pc"},
{ColorRange: "unknown"},
})
if len(got) != 3 {
t.Fatalf("records length = %d, want 3", len(got))
}
if got[0].ColorRange != "tv" || got[1].ColorRange != "pc" || got[2].ColorRange != "unknown" {
t.Fatalf(
"ColorRange values = [%q, %q, %q], want [tv, pc, unknown]",
got[0].ColorRange,
got[1].ColorRange,
got[2].ColorRange,
)
}
}
func TestCatalogSeedSearchUpsertIDsIncludesChangedItemsAndEmbeddings(t *testing.T) {
itemStates := map[string]bool{
"movie-1": true,
+1
View File
@@ -156,6 +156,7 @@ type VideoTrackRecord struct {
Bitrate int `json:"bitrate,omitempty"`
VideoRange string `json:"video_range,omitempty"`
VideoRangeType string `json:"video_range_type,omitempty"`
ColorRange string `json:"color_range,omitempty"`
ColorPrimaries string `json:"color_primaries,omitempty"`
ColorSpace string `json:"color_space,omitempty"`
ColorTransfer string `json:"color_transfer,omitempty"`
+121
View File
@@ -0,0 +1,121 @@
package diagnostics
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
)
const ReportDownloadContentType = BundleContentType
var ErrReportNotReady = errors.New("diagnostic report is not ready")
func (s *Service) ListForAdmin(ctx context.Context, filters ListFilters) (ListResult, error) {
if s.reports == nil {
return ListResult{}, ErrReportStoreUnavailable
}
return s.reports.ListForAdmin(ctx, filters)
}
func (s *Service) GetReport(ctx context.Context, id string) (*Report, error) {
if s.reports == nil {
return nil, ErrReportStoreUnavailable
}
return s.reports.GetByID(ctx, id)
}
func (s *Service) PresignReportDownload(ctx context.Context, report *Report, expiry time.Duration) (string, error) {
bucket, key, err := s.readyReportBlobLocation(report)
if err != nil {
return "", err
}
if expiry <= 0 {
expiry = 15 * time.Minute
}
return s.store.PresignGetURL(ctx, bucket, key, expiry)
}
func (s *Service) EffectiveReportDownloadTTL(requested time.Duration) time.Duration {
if requested <= 0 {
return requested
}
effectiveStore, ok := s.store.(interface {
EffectivePresignTTL(time.Duration) time.Duration
})
if !ok {
return requested
}
effective := effectiveStore.EffectivePresignTTL(requested)
if effective <= 0 {
return requested
}
return effective
}
func (s *Service) OpenReportDownload(ctx context.Context, report *Report) (io.ReadCloser, error) {
bucket, key, err := s.readyReportBlobLocation(report)
if err != nil {
return nil, err
}
return s.store.GetObject(ctx, bucket, key)
}
func (s *Service) DeleteReport(ctx context.Context, id string) (*Report, error) {
if s.reports == nil {
return nil, ErrReportStoreUnavailable
}
// Delete the row before the blob: a DB failure after the object is gone
// would otherwise leave a visible report whose bundle can no longer be
// downloaded. DeleteByID returns the deleted row (and ErrNotFound when
// absent), so the blob location is captured from it. If the blob delete
// fails the row is already gone, so log the bucket/key for an operator (and
// the orphan reconciler) to reap instead of failing the delete.
deleted, err := s.reports.DeleteByID(ctx, id)
if err != nil {
return nil, err
}
if err := deleteReportObjects(ctx, s.store, deleted, s.logger); err != nil {
logDeferredBlobDeletion(ctx, s.logger, s.store, deleted, err)
}
return deleted, nil
}
func reportBlobBucket(report *Report, store ObjectStore) string {
bucket := stringValue(report.BlobBucket)
if bucket == "" && store != nil {
bucket = store.Bucket()
}
return bucket
}
func (s *Service) readyReportBlobLocation(report *Report) (string, string, error) {
if report == nil {
return "", "", ErrNotFound
}
if report.State != StateReady {
return "", "", ErrReportNotReady
}
if s.store == nil || strings.TrimSpace(s.store.Bucket()) == "" {
return "", "", ErrStorageUnavailable
}
bucket := stringValue(report.BlobBucket)
if bucket == "" {
bucket = s.store.Bucket()
}
key := stringValue(report.BlobKey)
if key == "" {
return "", "", fmt.Errorf("%w: diagnostic report has no blob key", ErrStorageUnavailable)
}
return bucket, key, nil
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}
+581
View File
@@ -0,0 +1,581 @@
package diagnostics
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash"
"io"
"math"
"path"
"strings"
"github.com/Silo-Server/silo-server/internal/diagnostics/contract"
)
const (
DefaultMaxBundleEntries = 16
DefaultMaxCompressionRatio = int64(200)
defaultBundleReadBufferSize = 32 * 1024
// Standard tar writers (GNU tar, Python tarfile, Apache Commons Compress)
// pad the archive with zero blocks to a record boundary, 10240 bytes by
// default; 64 KiB also covers writers configured with larger blocking
// factors.
maxTarTrailingPaddingBytes = 64 * 1024
)
var (
ErrCompressedTooLarge = errors.New("diagnostics bundle compressed size exceeds limit")
ErrUncompressedTooLarge = errors.New("diagnostics bundle uncompressed size exceeds limit")
ErrEntryTooLarge = errors.New("diagnostics bundle entry size exceeds limit")
ErrTooManyEntries = errors.New("diagnostics bundle has too many entries")
ErrInvalidBundle = errors.New("invalid diagnostics bundle")
ErrCompressionRatio = errors.New("diagnostics bundle compression ratio exceeds limit")
)
type BundleLimits struct {
MaxCompressedBytes int64
MaxUncompressedBytes int64
MaxEntryBytes int64
MaxEntries int
MaxCompressionRatio int64
}
type BundleInfo struct {
CompressedBytes int64
// UncompressedBytes is the total size of the decompressed tar stream —
// headers, entry payloads, end-of-archive marker, and padding — matching
// what a client counts on the way into its gzip writer (and `gzip -l`).
UncompressedBytes int64
SHA256 string
Entries []string
// EmbeddedManifest is the raw bytes of the archive's first entry
// (manifest.json). Per the bundle contract this is the part-1 manifest with
// the `archive` object removed; Ingest compares the two so a stored archive
// can never carry a manifest that disagrees with the accepted report.
EmbeddedManifest []byte
}
var allowedBundleEntries = allowlistMap(contract.ArchiveEntryAllowlist)
func ValidateBundle(r io.Reader, limits BundleLimits) (BundleInfo, error) {
limits = normalizeBundleLimits(limits)
metered := newCompressedMeter(r, limits.MaxCompressedBytes)
gz, err := gzip.NewReader(metered)
if err != nil {
if isBundleUploadAbortError(err) {
return BundleInfo{}, err
}
if errors.Is(err, ErrCompressedTooLarge) {
return BundleInfo{}, ErrCompressedTooLarge
}
return BundleInfo{}, fmt.Errorf("%w: open gzip: %v", ErrInvalidBundle, err)
}
gz.Multistream(false)
gzipClosed := false
defer func() {
if !gzipClosed {
_ = gz.Close()
}
}()
uncompressed := &uncompressedCounter{r: gz}
tr := tar.NewReader(uncompressed)
info := BundleInfo{
Entries: make([]string, 0, len(allowedBundleEntries)),
}
buffer := make([]byte, defaultBundleReadBufferSize)
var payloadBytes int64
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return BundleInfo{}, classifyBundleReadError(err)
}
if len(info.Entries) >= limits.MaxEntries {
return BundleInfo{}, ErrTooManyEntries
}
name, err := validateEntryHeader(hdr)
if err != nil {
return BundleInfo{}, err
}
isManifestEntry := len(info.Entries) == 0
if isManifestEntry && name != "manifest.json" {
return BundleInfo{}, fmt.Errorf("%w: first entry must be manifest.json", ErrInvalidBundle)
}
info.Entries = append(info.Entries, name)
if hdr.Size > limits.MaxEntryBytes {
return BundleInfo{}, ErrEntryTooLarge
}
if isManifestEntry && hdr.Size > MaxManifestBytes {
return BundleInfo{}, fmt.Errorf("%w: embedded manifest exceeds %d bytes", ErrInvalidBundle, MaxManifestBytes)
}
if payloadBytes+hdr.Size > limits.MaxUncompressedBytes {
return BundleInfo{}, ErrUncompressedTooLarge
}
entry := &tarEntryReader{tr: tr, limits: limits, metered: metered, total: &payloadBytes}
var manifestCapture *bytes.Buffer
var consumeErr error
switch {
case isManifestEntry:
// Capture the embedded manifest so Ingest can compare it against the
// part-1 manifest; it is the only entry buffered whole.
manifestCapture = &bytes.Buffer{}
manifestCapture.Grow(int(hdr.Size))
_, consumeErr = io.Copy(manifestCapture, entry)
case isNDJSONBundleEntry(name):
// logs.jsonl / breadcrumbs.jsonl: newline-delimited JSON objects,
// validated line-by-line so memory stays bounded to one capped line.
consumeErr = validateNDJSONObjectEntry(entry)
case isJSONObjectBundleEntry(name):
// device.json / crash/*.json: a single JSON object, validated with a
// streaming token decoder (no whole-entry buffering).
consumeErr = validateJSONObjectEntry(entry)
default:
// Opaque members (crash/stack.txt, crash/tombstone.pb) carry no JSON
// contract; drain them so their bytes still count toward the size,
// ratio, and count limits.
_, consumeErr = io.Copy(io.Discard, entry)
}
if consumeErr != nil {
return BundleInfo{}, consumeErr
}
if entry.entryBytes != hdr.Size {
return BundleInfo{}, fmt.Errorf("%w: entry size mismatch for %s", ErrInvalidBundle, name)
}
if manifestCapture != nil {
info.EmbeddedManifest = manifestCapture.Bytes()
}
}
if len(info.Entries) == 0 {
return BundleInfo{}, fmt.Errorf("%w: empty archive", ErrInvalidBundle)
}
if err := rejectPostTarData(uncompressed, buffer); err != nil {
return BundleInfo{}, err
}
if err := gz.Close(); err != nil {
return BundleInfo{}, classifyBundleReadError(err)
}
gzipClosed = true
if err := rejectTrailingCompressedData(metered); err != nil {
return BundleInfo{}, err
}
if uncompressed.count > limits.MaxUncompressedBytes {
return BundleInfo{}, ErrUncompressedTooLarge
}
if ratioExceeded(uncompressed.count, metered.Count(), limits.MaxCompressionRatio) {
return BundleInfo{}, ErrCompressionRatio
}
info.UncompressedBytes = uncompressed.count
info.CompressedBytes = metered.Count()
info.SHA256 = hex.EncodeToString(metered.Sum())
return info, nil
}
func normalizeBundleLimits(limits BundleLimits) BundleLimits {
if limits.MaxCompressedBytes <= 0 {
limits.MaxCompressedBytes = DefaultMaxBundleBytes
}
if limits.MaxUncompressedBytes <= 0 {
limits.MaxUncompressedBytes = DefaultMaxUncompressed
}
if limits.MaxEntryBytes <= 0 || limits.MaxEntryBytes > limits.MaxUncompressedBytes {
limits.MaxEntryBytes = limits.MaxUncompressedBytes
}
if limits.MaxEntries <= 0 {
limits.MaxEntries = DefaultMaxBundleEntries
}
if limits.MaxCompressionRatio <= 0 {
limits.MaxCompressionRatio = DefaultMaxCompressionRatio
}
return limits
}
func validateEntryHeader(hdr *tar.Header) (string, error) {
if hdr == nil {
return "", fmt.Errorf("%w: missing tar header", ErrInvalidBundle)
}
// PAX/GNU extension records are consumed by archive/tar before the next
// header surfaces, so bytes carried in them never reach the allowlist and
// count checks. The contract fixtures are plain USTAR; reject anything else
// rather than let extension records smuggle data past validation.
if hdr.Format == tar.FormatPAX || hdr.Format == tar.FormatGNU {
return "", fmt.Errorf("%w: unsupported tar format", ErrInvalidBundle)
}
if len(hdr.PAXRecords) > 0 {
return "", fmt.Errorf("%w: unsupported tar extension records", ErrInvalidBundle)
}
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
return "", fmt.Errorf("%w: unsupported tar entry type", ErrInvalidBundle)
}
if hdr.Size < 0 {
return "", fmt.Errorf("%w: negative tar entry size", ErrInvalidBundle)
}
// Reject names that are not already trimmed instead of normalizing them:
// otherwise a padded name like "manifest.json " would be recorded as the
// allowlisted entry while the archive stores a different literal member.
name := hdr.Name
if name != strings.TrimSpace(name) {
return "", fmt.Errorf("%w: unsafe entry name", ErrInvalidBundle)
}
if name == "" || strings.Contains(name, "\\") || strings.HasPrefix(name, "/") || path.IsAbs(name) {
return "", fmt.Errorf("%w: unsafe entry name", ErrInvalidBundle)
}
clean := path.Clean(name)
if clean == "." || clean != name || strings.HasPrefix(clean, "../") || strings.Contains(clean, "/../") {
return "", fmt.Errorf("%w: unsafe entry name", ErrInvalidBundle)
}
if _, ok := allowedBundleEntries[name]; !ok {
return "", fmt.Errorf("%w: disallowed entry %q", ErrInvalidBundle, name)
}
return name, nil
}
// tarEntryReader streams one tar entry's payload while charging every read
// against the bundle's entry-size, total-size, and compression-ratio limits —
// the same accounting the opaque drain path applied — so structural validators
// can pull the payload without letting a member slip past the guards. It also
// tracks entryBytes so the caller can compare it against the declared header
// size.
type tarEntryReader struct {
tr *tar.Reader
limits BundleLimits
metered *compressedMeter
total *int64
entryBytes int64
}
func (er *tarEntryReader) Read(p []byte) (int, error) {
n, err := er.tr.Read(p)
if n > 0 {
er.entryBytes += int64(n)
if er.entryBytes > er.limits.MaxEntryBytes {
return n, ErrEntryTooLarge
}
*er.total += int64(n)
if *er.total > er.limits.MaxUncompressedBytes {
return n, ErrUncompressedTooLarge
}
if ratioExceeded(*er.total, er.metered.Count(), er.limits.MaxCompressionRatio) {
return n, ErrCompressionRatio
}
}
if err != nil && !errors.Is(err, io.EOF) {
return n, classifyBundleReadError(err)
}
return n, err
}
func isNDJSONBundleEntry(name string) bool {
return name == "logs.jsonl" || name == "breadcrumbs.jsonl"
}
func isJSONObjectBundleEntry(name string) bool {
return name == "device.json" ||
(strings.HasPrefix(name, "crash/") && strings.HasSuffix(name, ".json"))
}
// validateJSONObjectEntry streams a single JSON object from r, rejecting
// anything that is not exactly one top-level object. It uses the token decoder
// so memory stays bounded to the nesting depth rather than the entry size.
func validateJSONObjectEntry(r io.Reader) error {
dec := json.NewDecoder(r)
tok, err := dec.Token()
if err != nil {
return classifyEntryContentError(err)
}
if delim, ok := tok.(json.Delim); !ok || delim != '{' {
return fmt.Errorf("%w: entry is not a JSON object", ErrInvalidBundle)
}
depth := 1
for depth > 0 {
tok, err := dec.Token()
if err != nil {
return classifyEntryContentError(err)
}
if delim, ok := tok.(json.Delim); ok {
switch delim {
case '{', '[':
depth++
case '}', ']':
depth--
}
}
}
// A well-formed entry ends here; anything after the top-level object is
// smuggled content.
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
if err == nil {
return fmt.Errorf("%w: trailing data after JSON object", ErrInvalidBundle)
}
return classifyEntryContentError(err)
}
return nil
}
// validateNDJSONObjectEntry streams newline-delimited JSON, requiring each
// non-blank line to be a single JSON object. Lines are validated one at a time
// with a byte cap so a pathological entry can never buffer unbounded memory.
func validateNDJSONObjectEntry(r io.Reader) error {
buffer := make([]byte, defaultBundleReadBufferSize)
line := make([]byte, 0, 512)
for {
n, err := r.Read(buffer)
if err != nil && !errors.Is(err, io.EOF) {
return classifyEntryContentError(err)
}
chunk := buffer[:n]
for len(chunk) > 0 {
idx := bytes.IndexByte(chunk, '\n')
if idx < 0 {
line = append(line, chunk...)
if len(line) > contract.MaxLogLineBytes {
return errLogLineTooLarge()
}
break
}
line = append(line, chunk[:idx]...)
if len(line) > contract.MaxLogLineBytes {
return errLogLineTooLarge()
}
if verr := validateJSONObjectLine(line); verr != nil {
return verr
}
line = line[:0]
chunk = chunk[idx+1:]
}
if errors.Is(err, io.EOF) {
break
}
}
// Handle a trailing line without a closing newline.
if len(bytes.TrimSpace(line)) > 0 {
if len(line) > contract.MaxLogLineBytes {
return errLogLineTooLarge()
}
return validateJSONObjectLine(line)
}
return nil
}
func validateJSONObjectLine(line []byte) error {
trimmed := bytes.TrimSpace(line)
if len(trimmed) == 0 {
return nil
}
if trimmed[0] != '{' || !json.Valid(trimmed) {
return fmt.Errorf("%w: entry contains a line that is not a JSON object", ErrInvalidBundle)
}
return nil
}
func errLogLineTooLarge() error {
return fmt.Errorf("%w: entry line exceeds %d bytes", ErrInvalidBundle, contract.MaxLogLineBytes)
}
// classifyEntryContentError maps a decode-time error to the right bundle
// sentinel: the streaming limit sentinels and existing bundle errors pass
// through unchanged; a truncated/empty stream or malformed JSON becomes
// ErrInvalidBundle.
func classifyEntryContentError(err error) error {
switch {
case errors.Is(err, ErrEntryTooLarge),
errors.Is(err, ErrUncompressedTooLarge),
errors.Is(err, ErrCompressionRatio),
errors.Is(err, ErrCompressedTooLarge),
errors.Is(err, ErrInvalidBundle):
return err
case isBundleUploadAbortError(err):
return err
case errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF):
return fmt.Errorf("%w: truncated or empty JSON entry", ErrInvalidBundle)
default:
return fmt.Errorf("%w: %v", ErrInvalidBundle, err)
}
}
func rejectPostTarData(r io.Reader, buffer []byte) error {
var padding int64
for {
n, err := r.Read(buffer)
if errors.Is(err, ErrCompressedTooLarge) {
return ErrCompressedTooLarge
}
if n > 0 {
for _, b := range buffer[:n] {
if b != 0 {
return fmt.Errorf("%w: trailing data after tar archive", ErrInvalidBundle)
}
}
padding += int64(n)
if padding > maxTarTrailingPaddingBytes {
return fmt.Errorf("%w: excessive padding after tar archive", ErrInvalidBundle)
}
}
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return classifyBundleReadError(err)
}
}
}
func rejectTrailingCompressedData(metered *compressedMeter) error {
var one [1]byte
n, err := metered.Read(one[:])
if errors.Is(err, ErrCompressedTooLarge) {
return ErrCompressedTooLarge
}
if n > 0 {
return fmt.Errorf("%w: trailing data after gzip stream", ErrInvalidBundle)
}
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return classifyBundleReadError(err)
}
return nil
}
func ratioExceeded(uncompressed, compressed, maxRatio int64) bool {
if compressed <= 0 || maxRatio <= 0 {
return false
}
if compressed > math.MaxInt64/maxRatio {
return false
}
return uncompressed > compressed*maxRatio
}
func classifyBundleReadError(err error) error {
if isBundleUploadAbortError(err) {
return err
}
if errors.Is(err, ErrCompressedTooLarge) {
return ErrCompressedTooLarge
}
if errors.Is(err, io.ErrUnexpectedEOF) {
return fmt.Errorf("%w: truncated archive", ErrInvalidBundle)
}
return fmt.Errorf("%w: %v", ErrInvalidBundle, err)
}
type bundleUploadAbortError struct {
err error
}
func (e *bundleUploadAbortError) Error() string {
if e == nil || e.err == nil {
return "diagnostics bundle upload aborted"
}
return e.err.Error()
}
func (e *bundleUploadAbortError) Unwrap() error {
if e == nil {
return nil
}
return e.err
}
func isBundleUploadAbortError(err error) bool {
var abortErr *bundleUploadAbortError
return errors.As(err, &abortErr)
}
func allowlistMap(entries []string) map[string]struct{} {
allowed := make(map[string]struct{}, len(entries))
for _, entry := range entries {
allowed[entry] = struct{}{}
}
return allowed
}
// uncompressedCounter tracks the total number of decompressed bytes read out
// of the gzip stream, including tar headers, end-of-archive blocks, and
// record padding.
type uncompressedCounter struct {
r io.Reader
count int64
}
func (c *uncompressedCounter) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.count += int64(n)
return n, err
}
type compressedMeter struct {
r io.Reader
max int64
n int64
hash hash.Hash
limit bool
}
func newCompressedMeter(r io.Reader, max int64) *compressedMeter {
return &compressedMeter{
r: r,
max: max,
hash: sha256.New(),
limit: max > 0,
}
}
func (m *compressedMeter) Read(p []byte) (int, error) {
if m.limit {
remaining := m.max - m.n
if remaining < 0 {
return 0, ErrCompressedTooLarge
}
if int64(len(p)) > remaining+1 {
p = p[:int(remaining+1)]
}
}
n, err := m.r.Read(p)
if n > 0 {
m.n += int64(n)
_, _ = m.hash.Write(p[:n])
if m.limit && m.n > m.max {
return n, ErrCompressedTooLarge
}
}
return n, err
}
func (m *compressedMeter) ReadByte() (byte, error) {
var one [1]byte
n, err := m.Read(one[:])
if n == 1 {
if errors.Is(err, io.EOF) {
err = nil
}
return one[0], err
}
return 0, err
}
func (m *compressedMeter) Count() int64 {
return m.n
}
func (m *compressedMeter) Sum() []byte {
return m.hash.Sum(nil)
}
+402
View File
@@ -0,0 +1,402 @@
package diagnostics
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"errors"
"io"
"strings"
"testing"
)
type testArchiveEntry struct {
name string
body []byte
typeflag byte
}
func TestValidateBundle(t *testing.T) {
validEntries := []testArchiveEntry{
{name: "manifest.json", body: []byte(`{"schema_version":1}`)},
{name: "logs.jsonl", body: []byte("{}\n")},
}
tests := []struct {
name string
entries []testArchiveEntry
limits BundleLimits
mutate func([]byte) []byte
wantErr error
wantEntries string
}{
{
name: "happy path",
entries: validEntries,
},
{
name: "wrong first entry",
entries: []testArchiveEntry{
{name: "device.json", body: []byte(`{}`)},
{name: "manifest.json", body: []byte(`{}`)},
},
wantErr: ErrInvalidBundle,
},
{
name: "disallowed name",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "extra.txt", body: []byte("nope")},
},
wantErr: ErrInvalidBundle,
},
{
name: "traversal name",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "../logs.jsonl", body: []byte("nope")},
},
wantErr: ErrInvalidBundle,
},
{
name: "ratio bomb",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
// Valid newline-delimited JSON objects that still compress far
// beyond the ratio limit, so the ratio guard trips before the
// content validator can pass judgement on every line.
{name: "logs.jsonl", body: bytes.Repeat([]byte("{}\n"), 400*1024)},
},
limits: BundleLimits{
MaxUncompressedBytes: 4 * 1024 * 1024,
},
wantErr: ErrCompressionRatio,
},
{
name: "rejects malformed device.json",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "device.json", body: []byte(`{"identity": }`)},
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects non-object device.json",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "device.json", body: []byte(`[1,2,3]`)},
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects trailing data in device.json",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "device.json", body: []byte(`{}{}`)},
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects malformed logs.jsonl line",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "logs.jsonl", body: []byte("{}\nnot json\n")},
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects non-object logs.jsonl line",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "logs.jsonl", body: []byte("{}\n[1,2]\n")},
},
wantErr: ErrInvalidBundle,
},
{
name: "accepts well-formed device.json and crash json",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "device.json", body: []byte(`{"identity":{"model":"Pixel"}}` + "\n")},
{name: "logs.jsonl", body: []byte(`{"lvl":"I","msg":"hi"}` + "\n")},
{name: "crash/summary.json", body: []byte(`{"summary":"boom"}`)},
{name: "crash/tombstone.pb", body: []byte{0x00, 0x01, 0x02, 0x03}},
},
wantEntries: "manifest.json,device.json,logs.jsonl,crash/summary.json,crash/tombstone.pb",
},
{
name: "over count",
entries: append([]testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
}, repeatedEntries("logs.jsonl", 16)...),
wantErr: ErrTooManyEntries,
},
{
name: "oversize",
entries: validEntries,
limits: BundleLimits{MaxCompressedBytes: 64},
wantErr: ErrCompressedTooLarge,
},
{
name: "truncated gzip",
entries: validEntries,
mutate: func(raw []byte) []byte {
return raw[:len(raw)-8]
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects trailing tar data inside gzip stream",
entries: validEntries,
mutate: func([]byte) []byte {
return buildTestArchiveWithPostTarPayload(t, validEntries, []byte("hidden"))
},
wantErr: ErrInvalidBundle,
},
{
name: "accepts tar record padding",
entries: validEntries,
mutate: func([]byte) []byte {
return buildTestArchiveWithPostTarPayload(t, validEntries, make([]byte, 8*1024))
},
},
{
name: "rejects nonzero byte inside trailing padding",
entries: validEntries,
mutate: func([]byte) []byte {
padding := make([]byte, 8*1024)
padding[len(padding)-1] = 1
return buildTestArchiveWithPostTarPayload(t, validEntries, padding)
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects excessive trailing padding",
entries: validEntries,
mutate: func([]byte) []byte {
return buildTestArchiveWithPostTarPayload(t, validEntries, make([]byte, maxTarTrailingPaddingBytes+1))
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects concatenated gzip member",
entries: validEntries,
mutate: func(raw []byte) []byte {
return append(raw, buildTestGzipMember(t, bytes.Repeat([]byte{0}, 1024))...)
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects symlinks",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "logs.jsonl", typeflag: tar.TypeSymlink},
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects padded entry name",
entries: []testArchiveEntry{
{name: "manifest.json ", body: []byte(`{}`)},
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects pax extension records",
entries: validEntries,
mutate: func([]byte) []byte {
return buildTestArchivePAX(t)
},
wantErr: ErrInvalidBundle,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
raw := buildTestArchive(t, tt.entries)
if tt.mutate != nil {
raw = tt.mutate(raw)
}
info, err := ValidateBundle(bytes.NewReader(raw), tt.limits)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("ValidateBundle error = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("ValidateBundle error = %v", err)
}
if info.CompressedBytes != int64(len(raw)) {
t.Fatalf("compressed bytes = %d, want %d", info.CompressedBytes, len(raw))
}
sum := sha256.Sum256(raw)
if info.SHA256 != fmtHex(sum[:]) {
t.Fatalf("sha256 = %s, want %s", info.SHA256, fmtHex(sum[:]))
}
wantEntries := tt.wantEntries
if wantEntries == "" {
wantEntries = "manifest.json,logs.jsonl"
}
if got := strings.Join(info.Entries, ","); got != wantEntries {
t.Fatalf("entries = %s, want %s", got, wantEntries)
}
gzr, err := gzip.NewReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("open gzip for uncompressed size: %v", err)
}
wantUncompressed, err := io.Copy(io.Discard, gzr)
if err != nil {
t.Fatalf("measure uncompressed size: %v", err)
}
if info.UncompressedBytes != wantUncompressed {
t.Fatalf("uncompressed bytes = %d, want %d", info.UncompressedBytes, wantUncompressed)
}
})
}
}
func repeatedEntries(name string, count int) []testArchiveEntry {
entries := make([]testArchiveEntry, 0, count)
for i := 0; i < count; i++ {
entries = append(entries, testArchiveEntry{name: name, body: []byte("{}\n")})
}
return entries
}
func buildTestArchive(t *testing.T, entries []testArchiveEntry) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for _, entry := range entries {
typeflag := entry.typeflag
if typeflag == 0 {
typeflag = tar.TypeReg
}
hdr := &tar.Header{
Name: entry.name,
Mode: 0o600,
Size: int64(len(entry.body)),
Typeflag: typeflag,
}
if typeflag != tar.TypeReg && typeflag != tar.TypeRegA {
hdr.Size = 0
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("write header: %v", err)
}
if hdr.Size > 0 {
if _, err := tw.Write(entry.body); err != nil {
t.Fatalf("write body: %v", err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatalf("close tar: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
return buf.Bytes()
}
func buildTestArchiveWithPostTarPayload(t *testing.T, entries []testArchiveEntry, trailing []byte) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for _, entry := range entries {
typeflag := entry.typeflag
if typeflag == 0 {
typeflag = tar.TypeReg
}
hdr := &tar.Header{
Name: entry.name,
Mode: 0o600,
Size: int64(len(entry.body)),
Typeflag: typeflag,
}
if typeflag != tar.TypeReg && typeflag != tar.TypeRegA {
hdr.Size = 0
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("write header: %v", err)
}
if hdr.Size > 0 {
if _, err := tw.Write(entry.body); err != nil {
t.Fatalf("write body: %v", err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatalf("close tar: %v", err)
}
if _, err := gz.Write(trailing); err != nil {
t.Fatalf("write trailing gzip payload: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
return buf.Bytes()
}
// buildTestArchivePAX writes an archive whose first entry uses PAX extended
// headers (forced by a custom PAX record). archive/tar consumes those records
// before the entry header surfaces, so validation must reject the format even
// though the visible name is allowlisted.
func buildTestArchivePAX(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
body := []byte(`{"schema_version":1}`)
hdr := &tar.Header{
Name: "manifest.json",
Mode: 0o600,
Size: int64(len(body)),
Typeflag: tar.TypeReg,
Format: tar.FormatPAX,
PAXRecords: map[string]string{"SILO.smuggled": "payload"},
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("write pax header: %v", err)
}
if _, err := tw.Write(body); err != nil {
t.Fatalf("write pax body: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatalf("close tar: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
return buf.Bytes()
}
func buildTestGzipMember(t *testing.T, payload []byte) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(payload); err != nil {
t.Fatalf("write gzip payload: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
return buf.Bytes()
}
func fmtHex(raw []byte) string {
const table = "0123456789abcdef"
out := make([]byte, len(raw)*2)
for i, b := range raw {
out[i*2] = table[b>>4]
out[i*2+1] = table[b&0x0f]
}
return string(out)
}
+257
View File
@@ -0,0 +1,257 @@
package diagnostics
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
)
const (
ObjectPrefix = diagnosticObjectPrefix + "/"
DefaultReceivingGrace = time.Hour
)
type CleanupRepository interface {
DeleteByID(ctx context.Context, id string) (*Report, error)
MarkFailed(ctx context.Context, id string) error
RetentionCandidates(ctx context.Context, olderThan time.Time, perUserByteCap int64) ([]Report, error)
StaleReceiving(ctx context.Context, grace time.Duration) ([]Report, error)
LiveBlobKeys(ctx context.Context, keys []string) (map[string]ReportState, error)
}
type CleanupOptions struct {
Now func() time.Time
StaleReceivingGrace time.Duration
Logger *slog.Logger
}
type CleanupResult struct {
RetentionReportsDeleted int `json:"retention_reports_deleted"`
StaleReportsDeleted int `json:"stale_reports_deleted"`
OrphanObjectsDeleted int `json:"orphan_objects_deleted"`
}
func (r CleanupResult) ReportsDeleted() int {
return r.RetentionReportsDeleted + r.StaleReportsDeleted
}
func CleanupOnce(
ctx context.Context,
repo CleanupRepository,
settingsStore SettingsStore,
store ObjectStore,
logger *slog.Logger,
) (CleanupResult, error) {
settings, err := LoadSettings(ctx, settingsStore)
if err != nil {
return CleanupResult{}, fmt.Errorf("load diagnostics cleanup settings: %w", err)
}
return CleanupReports(ctx, repo, store, settings, CleanupOptions{Logger: logger})
}
func CleanupReports(
ctx context.Context,
repo CleanupRepository,
store ObjectStore,
settings Settings,
opts CleanupOptions,
) (CleanupResult, error) {
if repo == nil {
return CleanupResult{}, nil
}
now := func() time.Time { return time.Now().UTC() }
if opts.Now != nil {
now = opts.Now
}
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
grace := opts.StaleReceivingGrace
if grace <= 0 {
grace = DefaultReceivingGrace
}
cutoff := time.Time{}
if settings.RetentionDays > 0 {
cutoff = now().Add(-time.Duration(settings.RetentionDays) * 24 * time.Hour)
}
// A single poisoned report (e.g. a permission error on one S3 object) must
// not abort the whole run, or every scheduled pass re-hits it first and
// blocks retention, stale reconciliation, and orphan cleanup indefinitely.
// Log-and-continue per report; aggregate per-item failures and still surface
// genuine query/iteration errors.
var result CleanupResult
var errs []error
candidates, err := repo.RetentionCandidates(ctx, cutoff, settings.MaxBytesPerUser)
if err != nil {
errs = append(errs, fmt.Errorf("load diagnostic retention candidates: %w", err))
return result, errors.Join(errs...)
}
for _, report := range candidates {
// Row first: a DB failure after the object is gone would leave a visible
// ready report whose bundle 404s on download. If the blob delete then
// fails the row is already gone, so log it for the orphan pass below to
// reap rather than aborting the run (see the admin DeleteReport path).
if _, err := repo.DeleteByID(ctx, report.ID); err != nil && !IsReportNotFound(err) {
errs = append(errs, fmt.Errorf("delete diagnostic retention row %s: %w", report.ID, err))
continue
}
if err := deleteReportObjects(ctx, store, &report, logger); err != nil {
logDeferredBlobDeletion(ctx, logger, store, &report, err)
}
result.RetentionReportsDeleted++
}
stale, err := repo.StaleReceiving(ctx, grace)
if err != nil {
errs = append(errs, fmt.Errorf("load stale diagnostic reports: %w", err))
return result, errors.Join(errs...)
}
for _, report := range stale {
// Same rows-first ordering as retention: mark/delete the row before the
// blob so a mid-cleanup DB failure never leaves a row pointing at a
// missing object, and let orphan cleanup reap a blob that fails to delete.
if report.State == StateReceiving {
if err := repo.MarkFailed(ctx, report.ID); err != nil && !IsReportNotFound(err) {
errs = append(errs, fmt.Errorf("mark stale diagnostic report failed %s: %w", report.ID, err))
continue
}
}
if _, err := repo.DeleteByID(ctx, report.ID); err != nil && !IsReportNotFound(err) {
errs = append(errs, fmt.Errorf("delete stale diagnostic row %s: %w", report.ID, err))
continue
}
if err := deleteReportObjects(ctx, store, &report, logger); err != nil {
logDeferredBlobDeletion(ctx, logger, store, &report, err)
}
result.StaleReportsDeleted++
}
if store == nil || strings.TrimSpace(store.Bucket()) == "" {
return result, errors.Join(errs...)
}
keys, err := store.ListObjects(ctx, ObjectPrefix)
if err != nil {
errs = append(errs, fmt.Errorf("list diagnostic objects: %w", err))
return result, errors.Join(errs...)
}
if len(keys) == 0 {
return result, errors.Join(errs...)
}
liveKeys, err := repo.LiveBlobKeys(ctx, keys)
if err != nil {
errs = append(errs, fmt.Errorf("load live diagnostic blob keys: %w", err))
return result, errors.Join(errs...)
}
for _, key := range keys {
if _, ok := liveKeys[key]; ok {
continue
}
if err := deleteObjectIfPresent(ctx, store, store.Bucket(), key); err != nil {
errs = append(errs, fmt.Errorf("delete orphan diagnostic object %s: %w", key, err))
continue
}
logger.InfoContext(ctx, "diagnostic orphan object deleted",
"component", "diagnostics",
"key", key,
)
result.OrphanObjectsDeleted++
}
return result, errors.Join(errs...)
}
func deleteReportObjects(ctx context.Context, store ObjectStore, report *Report, logger *slog.Logger) error {
if report == nil {
return nil
}
keys := reportObjectKeys(*report)
if store == nil || strings.TrimSpace(store.Bucket()) == "" {
if len(keys) == 0 {
return nil
}
logSkippedObjectDeletion(ctx, logger, report.ID, keys)
return nil
}
bucket := stringValue(report.BlobBucket)
if bucket == "" {
bucket = store.Bucket()
}
for _, key := range keys {
if err := deleteObjectIfPresent(ctx, store, bucket, key); err != nil {
return err
}
}
return nil
}
func logSkippedObjectDeletion(ctx context.Context, logger *slog.Logger, reportID string, keys []string) {
if logger == nil {
logger = slog.Default()
}
logger.WarnContext(ctx, "diagnostic report blob deletion skipped because storage is unavailable",
"component", "diagnostics",
"report_id", reportID,
"keys", keys,
)
}
// logDeferredBlobDeletion records a report blob that could not be deleted after
// its DB row was already removed. The row is the source of truth, so the object
// is now an orphan for the orphan-cleanup pass (this run or a later one) to
// reap; we log the bucket and keys rather than fail so one unreachable object
// can't block row deletion. Shared by the retention/stale loops and the admin
// DeleteReport path.
func logDeferredBlobDeletion(ctx context.Context, logger *slog.Logger, store ObjectStore, report *Report, err error) {
if logger == nil {
logger = slog.Default()
}
logger.ErrorContext(ctx, "diagnostic report blob deletion failed after row deletion",
"component", "diagnostics",
"report_id", report.ID,
"bucket", reportBlobBucket(report, store),
"keys", reportObjectKeys(*report),
"error", err,
)
}
func deleteObjectIfPresent(ctx context.Context, store ObjectStore, bucket, key string) error {
key = strings.TrimSpace(key)
if key == "" {
return nil
}
if err := store.DeleteObject(ctx, bucket, key); err != nil && !IsObjectNotFound(err) {
return err
}
return nil
}
func reportObjectKeys(report Report) []string {
keys := make([]string, 0, 2)
if key := stringValue(report.BlobKey); key != "" {
keys = append(keys, key)
}
if report.ID != "" && report.UserID > 0 {
deterministic := reportObjectKey(report.UserID, report.ID)
seen := false
for _, key := range keys {
if key == deterministic {
seen = true
break
}
}
if !seen {
keys = append(keys, deterministic)
}
}
return keys
}
func IsReportNotFound(err error) bool {
return errors.Is(err, ErrNotFound)
}
+296
View File
@@ -0,0 +1,296 @@
package diagnostics
import (
"context"
"errors"
"io"
"log/slog"
"testing"
"time"
)
func TestCleanupReportsDeletesRowBeforeBlobAndToleratesMissingObject(t *testing.T) {
ops := []string{}
repo := &fakeCleanupRepo{
retention: []Report{
testCleanupReport("r1", 7, StateReady, "diagnostics/7/r1.tar.gz"),
testCleanupReport("r2", 7, StateReady, "diagnostics/7/r2.tar.gz"),
},
ops: &ops,
}
store := &fakeCleanupStore{
bucket: "private",
missingKeys: map[string]bool{"diagnostics/7/r1.tar.gz": true},
ops: &ops,
}
result, err := CleanupReports(context.Background(), repo, store, Settings{
RetentionDays: 30,
MaxBytesPerUser: DefaultMaxBytesPerUser,
}, CleanupOptions{
Now: func() time.Time { return time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) },
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatalf("CleanupReports: %v", err)
}
if result.RetentionReportsDeleted != 2 {
t.Fatalf("RetentionReportsDeleted = %d, want 2", result.RetentionReportsDeleted)
}
wantOps := []string{
"delete-row:r1",
"delete-object:diagnostics/7/r1.tar.gz",
"delete-row:r2",
"delete-object:diagnostics/7/r2.tar.gz",
}
assertStrings(t, ops, wantOps)
}
func TestCleanupReportsTreatsBlobFailureAsNonFatal(t *testing.T) {
ops := []string{}
repo := &fakeCleanupRepo{
retention: []Report{
testCleanupReport("r1", 7, StateReady, "diagnostics/7/r1.tar.gz"),
testCleanupReport("r2", 7, StateReady, "diagnostics/7/r2.tar.gz"),
},
ops: &ops,
}
store := &fakeCleanupStore{
bucket: "private",
deleteErrs: map[string]error{"diagnostics/7/r1.tar.gz": errors.New("s3 access denied")},
ops: &ops,
}
result, err := CleanupReports(context.Background(), repo, store, Settings{
RetentionDays: 30,
MaxBytesPerUser: DefaultMaxBytesPerUser,
}, CleanupOptions{
Now: func() time.Time { return time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) },
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
// r1's row is deleted first, so its blob delete failing does not abort the
// run or roll back the row: it is logged for orphan cleanup to reap, both
// rows count as deleted, and no error is surfaced.
if err != nil {
t.Fatalf("CleanupReports: %v", err)
}
if result.RetentionReportsDeleted != 2 {
t.Fatalf("RetentionReportsDeleted = %d, want 2", result.RetentionReportsDeleted)
}
assertStrings(t, ops, []string{
"delete-row:r1",
"delete-object:diagnostics/7/r1.tar.gz",
"delete-row:r2",
"delete-object:diagnostics/7/r2.tar.gz",
})
}
func TestCleanupReportsCleansStaleReceiving(t *testing.T) {
ops := []string{}
repo := &fakeCleanupRepo{
stale: []Report{
testCleanupReport("r1", 7, StateReceiving, ""),
},
ops: &ops,
}
store := &fakeCleanupStore{bucket: "private", ops: &ops}
result, err := CleanupReports(context.Background(), repo, store, Settings{
RetentionDays: 30,
MaxBytesPerUser: DefaultMaxBytesPerUser,
}, CleanupOptions{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))})
if err != nil {
t.Fatalf("CleanupReports: %v", err)
}
if result.StaleReportsDeleted != 1 {
t.Fatalf("StaleReportsDeleted = %d, want 1", result.StaleReportsDeleted)
}
assertStrings(t, ops, []string{
"mark-failed:r1",
"delete-row:r1",
"delete-object:diagnostics/7/r1.tar.gz",
})
}
func TestCleanupReportsDeletesRowsWhenStorageUnavailable(t *testing.T) {
ops := []string{}
repo := &fakeCleanupRepo{
retention: []Report{
testCleanupReport("r1", 7, StateReady, "diagnostics/7/r1.tar.gz"),
},
stale: []Report{
testCleanupReport("r2", 7, StateReceiving, "diagnostics/7/r2.tar.gz"),
},
ops: &ops,
}
result, err := CleanupReports(context.Background(), repo, nil, Settings{
RetentionDays: 30,
MaxBytesPerUser: DefaultMaxBytesPerUser,
}, CleanupOptions{
Now: func() time.Time { return time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) },
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatalf("CleanupReports: %v", err)
}
if result.RetentionReportsDeleted != 1 || result.StaleReportsDeleted != 1 {
t.Fatalf("result = %#v, want one retention and one stale deletion", result)
}
assertStrings(t, ops, []string{
"delete-row:r1",
"mark-failed:r2",
"delete-row:r2",
})
}
func TestCleanupReportsDeletesUnmatchedObjects(t *testing.T) {
ops := []string{}
repo := &fakeCleanupRepo{
live: map[string]ReportState{
"diagnostics/7/ready.tar.gz": StateReady,
"diagnostics/7/receiving.tar.gz": StateReceiving,
},
ops: &ops,
}
store := &fakeCleanupStore{
bucket: "private",
list: []string{
"diagnostics/7/ready.tar.gz",
"diagnostics/7/receiving.tar.gz",
"diagnostics/7/orphan.tar.gz",
"diagnostics/7/failed.tar.gz",
},
ops: &ops,
}
result, err := CleanupReports(context.Background(), repo, store, Settings{
RetentionDays: 30,
MaxBytesPerUser: DefaultMaxBytesPerUser,
}, CleanupOptions{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))})
if err != nil {
t.Fatalf("CleanupReports: %v", err)
}
if result.OrphanObjectsDeleted != 2 {
t.Fatalf("OrphanObjectsDeleted = %d, want 2", result.OrphanObjectsDeleted)
}
wantDeleted := []string{"diagnostics/7/orphan.tar.gz", "diagnostics/7/failed.tar.gz"}
if !sameStrings(store.deleted, wantDeleted) {
t.Fatalf("deleted objects = %v, want %v", store.deleted, wantDeleted)
}
}
func testCleanupReport(id string, userID int, state ReportState, blobKey string) Report {
report := Report{ID: id, UserID: userID, State: state}
if blobKey != "" {
report.BlobKey = &blobKey
}
return report
}
type fakeCleanupRepo struct {
retention []Report
stale []Report
live map[string]ReportState
ops *[]string
}
func (f *fakeCleanupRepo) DeleteByID(_ context.Context, id string) (*Report, error) {
f.record("delete-row:" + id)
return &Report{ID: id}, nil
}
func (f *fakeCleanupRepo) MarkFailed(_ context.Context, id string) error {
f.record("mark-failed:" + id)
return nil
}
func (f *fakeCleanupRepo) RetentionCandidates(context.Context, time.Time, int64) ([]Report, error) {
return append([]Report(nil), f.retention...), nil
}
func (f *fakeCleanupRepo) StaleReceiving(context.Context, time.Duration) ([]Report, error) {
return append([]Report(nil), f.stale...), nil
}
func (f *fakeCleanupRepo) LiveBlobKeys(_ context.Context, keys []string) (map[string]ReportState, error) {
live := make(map[string]ReportState)
for _, key := range keys {
if state, ok := f.live[key]; ok {
live[key] = state
}
}
return live, nil
}
func (f *fakeCleanupRepo) record(op string) {
if f.ops != nil {
*f.ops = append(*f.ops, op)
}
}
type fakeCleanupStore struct {
bucket string
list []string
missingKeys map[string]bool
deleteErrs map[string]error
deleted []string
ops *[]string
}
func (f *fakeCleanupStore) PutStream(context.Context, string, string, io.Reader, string) error {
return nil
}
func (f *fakeCleanupStore) GetObject(context.Context, string, string) (io.ReadCloser, error) {
return nil, errors.New("not implemented")
}
func (f *fakeCleanupStore) DeleteObject(_ context.Context, _ string, key string) error {
f.record("delete-object:" + key)
f.deleted = append(f.deleted, key)
if f.missingKeys[key] {
return ErrObjectNotFound
}
if err := f.deleteErrs[key]; err != nil {
return err
}
return nil
}
func (f *fakeCleanupStore) ListObjects(context.Context, string) ([]string, error) {
return append([]string(nil), f.list...), nil
}
func (f *fakeCleanupStore) PresignGetURL(context.Context, string, string, time.Duration) (string, error) {
return "", errors.New("not implemented")
}
func (f *fakeCleanupStore) Bucket() string {
return f.bucket
}
func (f *fakeCleanupStore) record(op string) {
if f.ops != nil {
*f.ops = append(*f.ops, op)
}
}
func assertStrings(t *testing.T, got, want []string) {
t.Helper()
if !sameStrings(got, want) {
t.Fatalf("ops = %v, want %v", got, want)
}
}
func sameStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,323 @@
package contract
import (
"bufio"
"bytes"
"encoding/json"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"testing"
)
const contractSchemaRoot = "../../../docs/design/schemas/client-diagnostics"
func TestValidFixtures(t *testing.T) {
fixtures := mustGlob(t, "v1/fixtures/valid/*")
if len(fixtures) == 0 {
t.Fatal("valid fixtures missing")
}
for _, fixture := range fixtures {
fixture := fixture
t.Run(filepath.Base(fixture), func(t *testing.T) {
data := mustReadFixture(t, fixture)
switch {
case strings.HasSuffix(fixture, "device.json"):
if _, err := ValidateDevice(data); err != nil {
t.Fatalf("ValidateDevice() error = %v", err)
}
case strings.HasSuffix(fixture, ".jsonl"):
validateLogLinesFixture(t, data)
case strings.HasSuffix(fixture, ".json"):
if _, err := ValidateManifest(data); err != nil {
t.Fatalf("ValidateManifest() error = %v", err)
}
default:
t.Fatalf("unexpected fixture extension: %s", fixture)
}
})
}
}
func TestInvalidManifestFixtures(t *testing.T) {
fixtures := mustGlob(t, "v1/fixtures/invalid/*.json")
if len(fixtures) < 6 {
t.Fatalf("invalid fixtures = %d, want at least 6", len(fixtures))
}
seenErrors := map[string]string{}
for _, fixture := range fixtures {
fixture := fixture
t.Run(filepath.Base(fixture), func(t *testing.T) {
_, err := ValidateManifest(mustReadFixture(t, fixture))
if err == nil {
t.Fatal("ValidateManifest() error = nil, want failure")
}
msg := err.Error()
if prior := seenErrors[msg]; prior != "" {
t.Fatalf("error %q also used by %s", msg, prior)
}
seenErrors[msg] = filepath.Base(fixture)
})
}
}
func TestValidateManifestRequiresCrashForEventReports(t *testing.T) {
// A non-manual report with the crash object stripped must be rejected so
// event reports always carry crash summary/source/occurred_at.
var m map[string]json.RawMessage
if err := json.Unmarshal(mustReadFixture(t, "v1/fixtures/valid/android-tv-crash-ueh.json"), &m); err != nil {
t.Fatalf("parse crash fixture: %v", err)
}
delete(m, "crash")
stripped, err := json.Marshal(m)
if err != nil {
t.Fatalf("marshal stripped manifest: %v", err)
}
_, err = ValidateManifest(stripped)
if err == nil || !strings.Contains(err.Error(), "crash") {
t.Fatalf("ValidateManifest() error = %v, want crash required for event report", err)
}
}
func TestValidateManifestRejectsCrashOnManualReports(t *testing.T) {
var m map[string]json.RawMessage
if err := json.Unmarshal(mustReadFixture(t, "v1/fixtures/valid/android-tv-crash-ueh.json"), &m); err != nil {
t.Fatalf("parse crash fixture: %v", err)
}
var report map[string]json.RawMessage
if err := json.Unmarshal(m["report"], &report); err != nil {
t.Fatalf("parse report: %v", err)
}
report["type"] = json.RawMessage(`"manual"`)
reportJSON, err := json.Marshal(report)
if err != nil {
t.Fatalf("marshal report: %v", err)
}
m["report"] = reportJSON
withCrash, err := json.Marshal(m)
if err != nil {
t.Fatalf("marshal manifest: %v", err)
}
_, err = ValidateManifest(withCrash)
if err == nil || !strings.Contains(err.Error(), "crash") {
t.Fatalf("ValidateManifest() error = %v, want crash rejected for manual report", err)
}
}
func TestSchemaEnumsAndRequiredFieldsStayInSync(t *testing.T) {
manifest := mustReadObject(t, "v1/manifest.schema.json")
assertStringsEqual(t, "manifest.required", schemaStrings(t, manifest, "required"), manifestRequiredFields)
assertConstInt(t, "manifest.schema_version.const", schemaValue(t, manifest, "properties", "schema_version", "const"), SchemaVersion)
assertStringsEqual(t, "manifest.report.required", schemaStrings(t, manifest, "properties", "report", "required"), manifestReportRequiredFields)
assertStringsEqual(t, "manifest.destination.required", schemaStrings(t, manifest, "properties", "destination", "required"), manifestDestinationRequiredFields)
assertStringsEqual(t, "manifest.consent.required", schemaStrings(t, manifest, "properties", "consent", "required"), manifestConsentRequiredFields)
assertStringsEqual(t, "manifest.crash.required", schemaStrings(t, manifest, "properties", "crash", "required"), manifestCrashRequiredFields)
assertStringsEqual(t, "manifest.device_summary.required", schemaStrings(t, manifest, "properties", "device_summary", "required"), manifestDeviceSummaryFields)
assertStringsEqual(t, "manifest.log_summary.required", schemaStrings(t, manifest, "properties", "log_summary", "required"), manifestLogSummaryRequiredFields)
assertStringsEqual(t, "manifest.archive.required", schemaStrings(t, manifest, "properties", "archive", "required"), manifestArchiveRequiredFields)
assertStringsEqual(t, "manifest.report.type.enum", schemaStrings(t, manifest, "properties", "report", "properties", "type", "enum"), reportTypes)
assertStringsEqual(t, "manifest.report.platform.enum", schemaStrings(t, manifest, "properties", "report", "properties", "platform", "enum"), platforms)
assertStringsEqual(t, "manifest.consent.mode.enum", schemaStrings(t, manifest, "properties", "consent", "properties", "mode", "enum"), consentModes)
assertStringsEqual(t, "manifest.crash.source.enum", schemaStrings(t, manifest, "properties", "crash", "properties", "source", "enum"), crashSources)
assertStringsEqual(t, "manifest.crash.provenance.enum", schemaStrings(t, manifest, "properties", "crash", "properties", "provenance", "enum"), crashProvenances)
assertStringsEqual(t, "manifest.log_summary.categories.enum", schemaStrings(t, manifest, "properties", "log_summary", "properties", "categories", "items", "enum"), logCategories)
assertStringsEqual(t, "manifest.archive.entries.enum", schemaStrings(t, manifest, "properties", "archive", "properties", "entries", "items", "enum"), ArchiveEntryAllowlist)
logline := mustReadObject(t, "v1/logline.schema.json")
assertStringsEqual(t, "logline.required", schemaStrings(t, logline, "required"), []string{"ts", "run", "lvl", "cat", "tag", "msg"})
assertStringsEqual(t, "logline.lvl.enum", schemaStrings(t, logline, "properties", "lvl", "enum"), logLevels)
assertStringsEqual(t, "logline.cat.enum", schemaStrings(t, logline, "properties", "cat", "enum"), logCategories)
device := mustReadObject(t, "v1/device.schema.json")
assertStringsEqual(t, "device.required", schemaStrings(t, device, "required"), deviceRequiredFields)
assertStringsEqual(t, "device.provenance.enum", schemaStrings(t, device, "properties", "provenance", "enum"), deviceProvenances)
}
func TestAttrRegistryStaysInSync(t *testing.T) {
var registry struct {
Categories map[string]map[string]struct {
Type string `json:"type"`
} `json:"categories"`
}
if err := json.Unmarshal(mustReadFixture(t, "v1/attr-registry.json"), &registry); err != nil {
t.Fatalf("parse attr-registry.json: %v", err)
}
got := make(map[string]map[string]string, len(registry.Categories))
for category, keys := range registry.Categories {
got[category] = make(map[string]string, len(keys))
for key, spec := range keys {
got[category][key] = spec.Type
}
}
want := make(map[string]map[string]string, len(attrRegistry))
for category, keys := range attrRegistry {
want[category] = make(map[string]string, len(keys))
for key, valueType := range keys {
want[category][key] = string(valueType)
}
}
if !mapsEqual(got, want) {
t.Fatalf("attr registry mismatch\ngot: %#v\nwant: %#v", got, want)
}
}
func TestValidateLogLineDropsUnregisteredAttrs(t *testing.T) {
line := []byte(`{"ts":"2026-07-19T18:22:29Z","run":"run_1","lvl":"I","cat":"playback","tag":"Player","msg":"started","attrs":{"sink":"HDMI","unregistered":"drop-me"}}`)
got, err := ValidateLogLine(line)
if err != nil {
t.Fatalf("ValidateLogLine() error = %v", err)
}
if _, ok := got.Attrs["sink"]; !ok {
t.Fatalf("registered attr missing: %#v", got.Attrs)
}
if _, ok := got.Attrs["unregistered"]; ok {
t.Fatalf("unregistered attr was not dropped: %#v", got.Attrs)
}
}
func TestValidateLogLineRejectsRegisteredAttrTypeMismatch(t *testing.T) {
line := []byte(`{"ts":"2026-07-19T18:22:29Z","run":"run_1","lvl":"I","cat":"network","tag":"HTTP","msg":"done","attrs":{"status":"200"}}`)
if _, err := ValidateLogLine(line); err == nil {
t.Fatal("ValidateLogLine() error = nil, want type mismatch")
}
}
func validateLogLinesFixture(t *testing.T, data []byte) {
t.Helper()
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 0, 1024), 1024*1024)
lineCount := 0
for scanner.Scan() {
line := bytes.TrimSpace(scanner.Bytes())
if len(line) == 0 {
continue
}
lineCount++
if _, err := ValidateLogLine(line); err != nil {
t.Fatalf("line %d: ValidateLogLine() error = %v", lineCount, err)
}
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan loglines fixture: %v", err)
}
if lineCount == 0 {
t.Fatal("loglines fixture has no log lines")
}
}
func mustGlob(t *testing.T, pattern string) []string {
t.Helper()
matches, err := filepath.Glob(filepath.Join(contractSchemaRoot, filepath.FromSlash(pattern)))
if err != nil {
t.Fatalf("glob %s: %v", pattern, err)
}
for i, match := range matches {
rel, err := filepath.Rel(contractSchemaRoot, match)
if err != nil {
t.Fatalf("rel %s: %v", match, err)
}
matches[i] = filepath.ToSlash(rel)
}
sort.Strings(matches)
return matches
}
func mustReadFixture(t *testing.T, path string) []byte {
t.Helper()
data, err := os.ReadFile(filepath.Join(contractSchemaRoot, filepath.FromSlash(path)))
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return data
}
func mustReadObject(t *testing.T, path string) map[string]any {
t.Helper()
var obj map[string]any
if err := json.Unmarshal(mustReadFixture(t, path), &obj); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
return obj
}
func schemaStrings(t *testing.T, root any, path ...string) []string {
t.Helper()
value := schemaValue(t, root, path...)
items, ok := value.([]any)
if !ok {
t.Fatalf("%s is %T, want array", strings.Join(path, "."), value)
}
out := make([]string, 0, len(items))
for _, item := range items {
s, ok := item.(string)
if !ok {
t.Fatalf("%s item is %T, want string", strings.Join(path, "."), item)
}
out = append(out, s)
}
return out
}
func schemaValue(t *testing.T, root any, path ...string) any {
t.Helper()
current := root
for _, key := range path {
obj, ok := current.(map[string]any)
if !ok {
t.Fatalf("%s parent is %T, want object", key, current)
}
next, ok := obj[key]
if !ok {
t.Fatalf("missing schema path %s", strings.Join(path, "."))
}
current = next
}
return current
}
func assertConstInt(t *testing.T, label string, got any, want int) {
t.Helper()
gotFloat, ok := got.(float64)
if !ok {
t.Fatalf("%s = %T, want JSON number", label, got)
}
if int(gotFloat) != want || gotFloat != float64(want) {
t.Fatalf("%s = %v, want %d", label, got, want)
}
}
func assertStringsEqual(t *testing.T, label string, got, want []string) {
t.Helper()
got = append([]string(nil), got...)
want = append([]string(nil), want...)
sort.Strings(got)
sort.Strings(want)
if !slices.Equal(got, want) {
t.Fatalf("%s mismatch\ngot: %v\nwant: %v", label, got, want)
}
}
func mapsEqual(got, want map[string]map[string]string) bool {
if len(got) != len(want) {
return false
}
for category, gotKeys := range got {
wantKeys, ok := want[category]
if !ok || len(gotKeys) != len(wantKeys) {
return false
}
for key, gotType := range gotKeys {
if wantKeys[key] != gotType {
return false
}
}
}
return true
}
+672
View File
@@ -0,0 +1,672 @@
package diagnostics
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
diagnosticQuotaLockNamespace = 173
defaultShortIDRetries = 8
defaultAdminListLimit = 50
maxAdminListLimit = 200
)
type PostgresRepository struct {
pool *pgxpool.Pool
}
func NewPostgresRepository(pool *pgxpool.Pool) *PostgresRepository {
return &PostgresRepository{pool: pool}
}
func (r *PostgresRepository) InsertReceiving(ctx context.Context, input InsertReceivingInput) (InsertReceivingResult, error) {
if err := validateInsertReceivingInput(input); err != nil {
return InsertReceivingResult{}, err
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return InsertReceivingResult{}, fmt.Errorf("begin diagnostic report insert: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1::int4, $2::int4)`,
diagnosticQuotaLockNamespace, input.UserID); err != nil {
return InsertReceivingResult{}, fmt.Errorf("acquire diagnostic quota lock: %w", err)
}
now := input.Now
if now.IsZero() {
now = time.Now().UTC()
}
if err := r.checkQuotas(ctx, tx, input, now); err != nil {
return InsertReceivingResult{}, err
}
shortIDGenerator := input.ShortIDGenerator
if shortIDGenerator == nil {
shortIDGenerator = NewShortID
}
reportIDGenerator := input.ReportIDGenerator
if reportIDGenerator == nil {
reportIDGenerator = uuid.NewString
}
retries := input.ShortIDCollisionRetries
if retries <= 0 {
retries = defaultShortIDRetries
}
crashSummary := truncateCrashSummary(input.CrashSummary)
sessionIDs := input.PlaybackSessionIDs
if sessionIDs == nil {
// pgx binds a nil slice as SQL NULL, which bypasses the column's
// '{}' default and violates its NOT NULL constraint.
sessionIDs = []string{}
}
for attempt := 0; attempt < retries; attempt++ {
id := reportIDGenerator()
if _, err := uuid.Parse(id); err != nil {
return InsertReceivingResult{}, fmt.Errorf("%w: report id must be a UUID", ErrInvalidReportInput)
}
shortID, err := shortIDGenerator()
if err != nil {
return InsertReceivingResult{}, fmt.Errorf("generate diagnostic short id: %w", err)
}
shortID, err = ParseShortID(shortID)
if err != nil {
return InsertReceivingResult{}, fmt.Errorf("%w: generated short id is invalid", err)
}
var insertedID, insertedShortID string
err = tx.QueryRow(ctx, `
INSERT INTO client_diagnostic_reports (
id, short_id, user_id, profile_id, state, captured_at, report_type,
platform, app_version, crash_summary, manifest, playback_session_ids,
blob_bytes
)
VALUES ($1::uuid, $2, $3, $4, 'receiving', $5, $6, $7, $8, $9, $10::jsonb, $11, $12)
ON CONFLICT DO NOTHING
RETURNING id::text, short_id
`, id, shortID, input.UserID, nullableString(input.ProfileID), input.CapturedAt,
strings.TrimSpace(input.ReportType), strings.TrimSpace(input.Platform),
strings.TrimSpace(input.AppVersion), nullableString(crashSummary),
string(input.Manifest), sessionIDs, input.ExpectedBlobBytes).Scan(&insertedID, &insertedShortID)
if errors.Is(err, pgx.ErrNoRows) {
continue
}
if err != nil {
return InsertReceivingResult{}, fmt.Errorf("insert diagnostic report: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return InsertReceivingResult{}, fmt.Errorf("commit diagnostic report insert: %w", err)
}
return InsertReceivingResult{ID: insertedID, ShortID: insertedShortID}, nil
}
return InsertReceivingResult{}, ErrShortIDExhausted
}
func (r *PostgresRepository) checkQuotas(ctx context.Context, tx pgx.Tx, input InsertReceivingInput, now time.Time) error {
if input.MaxReportsPerUserDay > 0 {
var count int
if err := tx.QueryRow(ctx, `
SELECT COUNT(*)
FROM client_diagnostic_reports
WHERE user_id = $1
AND state IN ('receiving', 'ready')
AND received_at >= date_trunc('day', $2::timestamptz)
`, input.UserID, now).Scan(&count); err != nil {
return fmt.Errorf("count diagnostic reports for quota: %w", err)
}
if count >= input.MaxReportsPerUserDay {
return &QuotaError{Kind: QuotaKindReportsPerDay, Limit: int64(input.MaxReportsPerUserDay)}
}
}
if input.MaxBytesPerUser > 0 {
// Count 'receiving' rows too: a new upload reserves its expected size in
// blob_bytes before the bundle streams in, so concurrent or multi-node
// uploads can't each pass a near-limit check and both mark ready. Ready
// rows overwrite the reservation with their actual size on completion.
var used int64
if err := tx.QueryRow(ctx, `
SELECT COALESCE(SUM(blob_bytes), 0)
FROM client_diagnostic_reports
WHERE user_id = $1
AND state IN ('receiving', 'ready')
`, input.UserID).Scan(&used); err != nil {
return fmt.Errorf("sum diagnostic bytes for quota: %w", err)
}
if input.ExpectedBlobBytes > input.MaxBytesPerUser || used+input.ExpectedBlobBytes > input.MaxBytesPerUser {
return &QuotaError{Kind: QuotaKindBytesPerUser, Limit: input.MaxBytesPerUser}
}
}
return nil
}
func (r *PostgresRepository) MarkReady(ctx context.Context, id string, blob BlobInfo) error {
tag, err := r.pool.Exec(ctx, `
UPDATE client_diagnostic_reports
SET state = 'ready',
blob_bucket = $2,
blob_key = $3,
blob_bytes = $4,
uncompressed_bytes = $5,
blob_sha256 = $6
WHERE id = $1::uuid
`, id, strings.TrimSpace(blob.Bucket), strings.TrimSpace(blob.Key), blob.Bytes, blob.UncompressedBytes, strings.TrimSpace(blob.SHA256))
if err != nil {
return fmt.Errorf("mark diagnostic report ready: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (r *PostgresRepository) MarkFailed(ctx context.Context, id string) error {
tag, err := r.pool.Exec(ctx, `
UPDATE client_diagnostic_reports
SET state = 'failed'
WHERE id = $1::uuid
`, id)
if err != nil {
return fmt.Errorf("mark diagnostic report failed: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (r *PostgresRepository) GetByID(ctx context.Context, id string) (*Report, error) {
row := r.pool.QueryRow(ctx, reportSelectSQL()+`
WHERE id = $1::uuid
`, id)
report, err := scanReport(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return report, nil
}
func (r *PostgresRepository) ListForAdmin(ctx context.Context, filters ListFilters) (ListResult, error) {
limit := filters.Limit
if limit <= 0 || limit > maxAdminListLimit {
limit = defaultAdminListLimit
}
conditions := []string{"1=1"}
args := make([]any, 0, 10)
argIdx := 1
if filters.UserID != nil {
conditions = append(conditions, fmt.Sprintf("user_id = $%d", argIdx))
args = append(args, *filters.UserID)
argIdx++
}
if strings.TrimSpace(filters.Platform) != "" {
conditions = append(conditions, fmt.Sprintf("platform = $%d", argIdx))
args = append(args, strings.TrimSpace(filters.Platform))
argIdx++
}
if strings.TrimSpace(filters.ReportType) != "" {
conditions = append(conditions, fmt.Sprintf("report_type = $%d", argIdx))
args = append(args, strings.TrimSpace(filters.ReportType))
argIdx++
}
if filters.From != nil {
conditions = append(conditions, fmt.Sprintf("received_at >= $%d", argIdx))
args = append(args, *filters.From)
argIdx++
}
if filters.To != nil {
conditions = append(conditions, fmt.Sprintf("received_at <= $%d", argIdx))
args = append(args, *filters.To)
argIdx++
}
if strings.TrimSpace(filters.ShortID) != "" {
shortID, err := ParseShortID(filters.ShortID)
if err != nil {
return ListResult{}, err
}
conditions = append(conditions, fmt.Sprintf("lower(short_id) = lower($%d)", argIdx))
args = append(args, shortID)
argIdx++
}
if strings.TrimSpace(filters.Cursor) != "" {
cursorReceivedAt, cursorID, err := decodeReportCursor(filters.Cursor)
if err != nil {
return ListResult{}, err
}
conditions = append(conditions, fmt.Sprintf("(received_at, id) < ($%d, $%d::uuid)", argIdx, argIdx+1))
args = append(args, cursorReceivedAt, cursorID)
argIdx += 2
}
query := fmt.Sprintf("%s\n\t\tWHERE %s\n\t\tORDER BY received_at DESC, id DESC\n\t\tLIMIT $%d",
reportListSelectSQL(), strings.Join(conditions, " AND "), argIdx)
args = append(args, limit+1)
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return ListResult{}, fmt.Errorf("list diagnostic reports: %w", err)
}
defer rows.Close()
reports := make([]Report, 0, limit+1)
for rows.Next() {
report, err := scanReportSummary(rows)
if err != nil {
return ListResult{}, err
}
reports = append(reports, *report)
}
if err := rows.Err(); err != nil {
return ListResult{}, fmt.Errorf("iterate diagnostic reports: %w", err)
}
result := ListResult{}
if len(reports) > limit {
last := reports[limit-1]
result.NextCursor = encodeReportCursor(last.ReceivedAt, last.ID)
reports = reports[:limit]
}
result.Reports = reports
return result, nil
}
func (r *PostgresRepository) DeleteByID(ctx context.Context, id string) (*Report, error) {
row := r.pool.QueryRow(ctx, `
DELETE FROM client_diagnostic_reports
WHERE id = $1::uuid
RETURNING id::text, short_id, user_id, profile_id, state, captured_at, received_at,
report_type, platform, app_version, crash_summary, manifest, playback_session_ids,
blob_bucket, blob_key, blob_bytes, uncompressed_bytes, blob_sha256,
COALESCE(manifest->'report'->>'app_build', '')
`, id)
report, err := scanReport(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return report, nil
}
func (r *PostgresRepository) RetentionCandidates(ctx context.Context, olderThan time.Time, perUserByteCap int64) ([]Report, error) {
if olderThan.IsZero() && perUserByteCap <= 0 {
return nil, nil
}
query := reportCleanupSelectSQL() + `
WHERE received_at < $1
ORDER BY received_at ASC, id ASC
`
args := []any{olderThan}
if olderThan.IsZero() {
query = reportCleanupSelectSQL() + `
WHERE false
`
args = nil
}
if perUserByteCap > 0 {
byteCapQuery := `
WITH quota_candidates AS (
SELECT id
FROM (
SELECT id,
SUM(COALESCE(blob_bytes, 0)) OVER (
PARTITION BY user_id
ORDER BY received_at DESC, id DESC
) AS retained_bytes
FROM client_diagnostic_reports
WHERE state = 'ready'
) ranked
WHERE retained_bytes > $1
)
` + reportCleanupSelectSQL() + `
WHERE id IN (SELECT id FROM quota_candidates)
ORDER BY received_at ASC, id ASC
`
if olderThan.IsZero() {
return r.queryReportCleanup(ctx, byteCapQuery, perUserByteCap)
}
query = `
WITH quota_candidates AS (
SELECT id
FROM (
SELECT id,
SUM(COALESCE(blob_bytes, 0)) OVER (
PARTITION BY user_id
ORDER BY received_at DESC, id DESC
) AS retained_bytes
FROM client_diagnostic_reports
WHERE state = 'ready'
) ranked
WHERE retained_bytes > $2
)
` + reportCleanupSelectSQL() + `
WHERE received_at < $1 OR id IN (SELECT id FROM quota_candidates)
ORDER BY received_at ASC, id ASC
`
args = []any{olderThan, perUserByteCap}
}
return r.queryReportCleanup(ctx, query, args...)
}
func (r *PostgresRepository) StaleReceiving(ctx context.Context, grace time.Duration) ([]Report, error) {
if grace <= 0 {
grace = time.Hour
}
cutoff := time.Now().UTC().Add(-grace)
return r.queryReportCleanup(ctx, reportCleanupSelectSQL()+`
WHERE state IN ('receiving', 'failed')
AND received_at < $1
ORDER BY received_at ASC, id ASC
`, cutoff)
}
func (r *PostgresRepository) LiveBlobKeys(ctx context.Context, keys []string) (map[string]ReportState, error) {
live := make(map[string]ReportState, len(keys))
if len(keys) == 0 {
return live, nil
}
rows, err := r.pool.Query(ctx, `
SELECT candidate.key, report.state
FROM unnest($1::text[]) AS candidate(key)
JOIN client_diagnostic_reports report
ON report.blob_key = candidate.key
OR ($2 || report.user_id::text || '/' || report.id::text || '.tar.gz') = candidate.key
WHERE report.state IN ('ready', 'receiving')
`, keys, ObjectPrefix)
if err != nil {
return nil, fmt.Errorf("load live diagnostic blob keys: %w", err)
}
defer rows.Close()
for rows.Next() {
var key string
var state ReportState
if err := rows.Scan(&key, &state); err != nil {
return nil, fmt.Errorf("scan live diagnostic blob key: %w", err)
}
live[key] = state
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate live diagnostic blob keys: %w", err)
}
return live, nil
}
// queryReportCleanup runs a reportCleanupSelectSQL-shaped query; the returned
// reports carry no manifest and no app_build. Only the retention and
// stale-cleanup batches use it.
func (r *PostgresRepository) queryReportCleanup(ctx context.Context, query string, args ...any) ([]Report, error) {
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query diagnostic reports: %w", err)
}
defer rows.Close()
reports := []Report{}
for rows.Next() {
report, err := scanReportCleanup(rows)
if err != nil {
return nil, err
}
reports = append(reports, *report)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate diagnostic reports: %w", err)
}
return reports, nil
}
func validateInsertReceivingInput(input InsertReceivingInput) error {
if input.UserID <= 0 {
return fmt.Errorf("%w: user id is required", ErrInvalidReportInput)
}
if input.CapturedAt.IsZero() {
return fmt.Errorf("%w: captured_at is required", ErrInvalidReportInput)
}
if strings.TrimSpace(input.ReportType) == "" {
return fmt.Errorf("%w: report type is required", ErrInvalidReportInput)
}
if strings.TrimSpace(input.Platform) == "" {
return fmt.Errorf("%w: platform is required", ErrInvalidReportInput)
}
if strings.TrimSpace(input.AppVersion) == "" {
return fmt.Errorf("%w: app version is required", ErrInvalidReportInput)
}
if len(input.Manifest) == 0 || !json.Valid(input.Manifest) {
return fmt.Errorf("%w: manifest must be valid JSON", ErrInvalidReportInput)
}
if input.ExpectedBlobBytes < 0 {
return fmt.Errorf("%w: expected blob bytes cannot be negative", ErrInvalidReportInput)
}
return nil
}
// reportSelectSQL is the full projection including the manifest JSONB (up to
// MaxManifestBytes per row). Reserve it for single-report detail/download paths;
// the admin list uses reportListSelectSQL and retention/stale batches use
// reportCleanupSelectSQL so browsing a page or running a cleanup pass doesn't
// drag every report's manifest through the DB.
func reportSelectSQL() string {
return `SELECT id::text, short_id, user_id, profile_id, state, captured_at, received_at,
report_type, platform, app_version, crash_summary, manifest, playback_session_ids,
blob_bucket, blob_key, blob_bytes, uncompressed_bytes, blob_sha256,
COALESCE(manifest->'report'->>'app_build', '')
FROM client_diagnostic_reports`
}
// reportListSelectSQL is the admin-list projection: it omits the full manifest
// column but still extracts app_build from the manifest JSONB (a cheap text
// extraction) so list rows can show the build number without shipping the whole
// manifest. Reports scanned with scanReportSummary therefore have a nil
// Manifest. Cleanup batches must not pay for the JSONB extraction, so they use
// reportCleanupSelectSQL instead.
func reportListSelectSQL() string {
return `SELECT id::text, short_id, user_id, profile_id, state, captured_at, received_at,
report_type, platform, app_version, crash_summary, playback_session_ids,
blob_bucket, blob_key, blob_bytes, uncompressed_bytes, blob_sha256,
COALESCE(manifest->'report'->>'app_build', '')
FROM client_diagnostic_reports`
}
// reportCleanupSelectSQL is the retention/stale-cleanup projection. It omits
// both the manifest column and the app_build extraction because cleanup only
// needs id/state/blob fields to delete a row and its object; touching the JSONB
// per candidate would turn a scheduled pass over many large manifests into
// needless manifest I/O. Reports scanned with scanReportCleanup therefore have
// a nil Manifest and an empty AppBuild.
func reportCleanupSelectSQL() string {
return `SELECT id::text, short_id, user_id, profile_id, state, captured_at, received_at,
report_type, platform, app_version, crash_summary, playback_session_ids,
blob_bucket, blob_key, blob_bytes, uncompressed_bytes, blob_sha256
FROM client_diagnostic_reports`
}
type reportScanner interface {
Scan(dest ...any) error
}
// reportNulls holds the nullable columns shared by the full and summary
// projections so both scanners apply them the same way.
type reportNulls struct {
profileID sql.NullString
crashSummary sql.NullString
blobBucket sql.NullString
blobKey sql.NullString
blobSHA256 sql.NullString
blobBytes sql.NullInt64
uncompressedBytes sql.NullInt64
}
func (n *reportNulls) apply(report *Report) {
if n.profileID.Valid {
report.ProfileID = &n.profileID.String
}
if n.crashSummary.Valid {
report.CrashSummary = &n.crashSummary.String
}
if report.PlaybackSessionIDs == nil {
report.PlaybackSessionIDs = []string{}
}
if n.blobBucket.Valid {
report.BlobBucket = &n.blobBucket.String
}
if n.blobKey.Valid {
report.BlobKey = &n.blobKey.String
}
if n.blobBytes.Valid {
report.BlobBytes = &n.blobBytes.Int64
}
if n.uncompressedBytes.Valid {
report.UncompressedBytes = &n.uncompressedBytes.Int64
}
if n.blobSHA256.Valid {
report.BlobSHA256 = &n.blobSHA256.String
}
}
// scanReport scans the full reportSelectSQL projection, including the manifest.
func scanReport(row reportScanner) (*Report, error) {
var report Report
var nulls reportNulls
var manifest []byte
if err := row.Scan(
&report.ID,
&report.ShortID,
&report.UserID,
&nulls.profileID,
&report.State,
&report.CapturedAt,
&report.ReceivedAt,
&report.ReportType,
&report.Platform,
&report.AppVersion,
&nulls.crashSummary,
&manifest,
&report.PlaybackSessionIDs,
&nulls.blobBucket,
&nulls.blobKey,
&nulls.blobBytes,
&nulls.uncompressedBytes,
&nulls.blobSHA256,
&report.AppBuild,
); err != nil {
return nil, fmt.Errorf("scan diagnostic report: %w", err)
}
if len(manifest) > 0 {
report.Manifest = append(json.RawMessage(nil), manifest...)
}
nulls.apply(&report)
return &report, nil
}
// summaryScanTargets returns the scan destinations shared by the admin-list and
// cleanup projections, in column order. The two scanners differ only in whether
// app_build is appended, so keeping the common prefix in one place stops the
// long Scan lists from drifting apart.
func summaryScanTargets(report *Report, nulls *reportNulls) []any {
return []any{
&report.ID,
&report.ShortID,
&report.UserID,
&nulls.profileID,
&report.State,
&report.CapturedAt,
&report.ReceivedAt,
&report.ReportType,
&report.Platform,
&report.AppVersion,
&nulls.crashSummary,
&report.PlaybackSessionIDs,
&nulls.blobBucket,
&nulls.blobKey,
&nulls.blobBytes,
&nulls.uncompressedBytes,
&nulls.blobSHA256,
}
}
// scanReportSummary scans the reportListSelectSQL projection, which omits the
// manifest but includes app_build; the returned report's Manifest is left nil.
func scanReportSummary(row reportScanner) (*Report, error) {
var report Report
var nulls reportNulls
targets := append(summaryScanTargets(&report, &nulls), &report.AppBuild)
if err := row.Scan(targets...); err != nil {
return nil, fmt.Errorf("scan diagnostic report: %w", err)
}
nulls.apply(&report)
return &report, nil
}
// scanReportCleanup scans the reportCleanupSelectSQL projection, which omits
// both the manifest and app_build; the returned report's Manifest is nil and
// AppBuild is empty. Cleanup only reads id/state/blob fields.
func scanReportCleanup(row reportScanner) (*Report, error) {
var report Report
var nulls reportNulls
if err := row.Scan(summaryScanTargets(&report, &nulls)...); err != nil {
return nil, fmt.Errorf("scan diagnostic report: %w", err)
}
nulls.apply(&report)
return &report, nil
}
func nullableString(s *string) any {
if s == nil {
return nil
}
if strings.TrimSpace(*s) == "" {
return nil
}
return *s
}
func encodeReportCursor(receivedAt time.Time, id string) string {
return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%d|%s", receivedAt.UnixNano(), id)))
}
func decodeReportCursor(cursor string) (time.Time, string, error) {
raw, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return time.Time{}, "", fmt.Errorf("%w: decode: %v", ErrInvalidCursor, err)
}
parts := strings.Split(string(raw), "|")
if len(parts) != 2 {
return time.Time{}, "", ErrInvalidCursor
}
nanos, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return time.Time{}, "", fmt.Errorf("%w: timestamp: %v", ErrInvalidCursor, err)
}
id := parts[1]
if _, err := uuid.Parse(id); err != nil {
return time.Time{}, "", fmt.Errorf("%w: id: %v", ErrInvalidCursor, err)
}
return time.Unix(0, nanos).UTC(), id, nil
}
+572
View File
@@ -0,0 +1,572 @@
package diagnostics
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"reflect"
"slices"
"strconv"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/diagnostics/contract"
)
const (
MaxManifestBytes = int64(64 * 1024)
BundleContentType = "application/gzip"
diagnosticObjectPrefix = "diagnostics"
failedUploadCompensationTimeout = 10 * time.Second
)
var (
ErrDisabled = errors.New("diagnostics disabled")
ErrStorageUnavailable = errors.New("diagnostics storage unavailable")
ErrTooLarge = errors.New("diagnostics upload too large")
ErrUnsupportedSchema = errors.New("diagnostics schema version unsupported")
ErrDestinationMismatch = errors.New("diagnostics destination mismatch")
ErrStaleConsent = errors.New("diagnostics consent notice is stale")
ErrArchiveMismatch = errors.New("diagnostics archive metadata mismatch")
ErrReportStoreUnavailable = errors.New("diagnostics report store unavailable")
ErrProfileMismatch = errors.New("diagnostics profile id mismatch")
ErrChildProfileForbidden = errors.New("diagnostics child profile attribution forbidden")
)
type AvailabilityStatus string
const (
StatusAvailable AvailabilityStatus = "available"
StatusDisabled AvailabilityStatus = "disabled"
StatusStorageUnavailable AvailabilityStatus = "storage_unavailable"
)
type Status struct {
Status AvailabilityStatus `json:"status"`
ServerInstanceID string `json:"server_instance_id"`
AcceptedSchemaVersions []int `json:"accepted_schema_versions"`
MaxBundleBytes int64 `json:"max_bundle_bytes"`
MaxManifestBytes int64 `json:"max_manifest_bytes"`
RetentionDays int `json:"retention_days"`
ConsentNoticeVersion int `json:"consent_notice_version"`
}
type IngestResult struct {
ReportID string `json:"report_id"`
ShortID string `json:"short_id"`
}
type Service struct {
reports ReportStore
settings SettingsStore
store ObjectStore
profileValidator ProfileAttributionValidator
logger *slog.Logger
now func() time.Time
}
func NewService(reports ReportStore, settings SettingsStore, store ObjectStore, logger *slog.Logger) *Service {
if logger == nil {
logger = slog.Default()
}
return &Service{
reports: reports,
settings: settings,
store: store,
logger: logger,
now: func() time.Time { return time.Now().UTC() },
}
}
type ProfileAttributionValidator interface {
ProfileBelongsToUser(ctx context.Context, userID int, profileID string) (bool, error)
}
type ProfileAttributionValidatorFunc func(ctx context.Context, userID int, profileID string) (bool, error)
func (f ProfileAttributionValidatorFunc) ProfileBelongsToUser(ctx context.Context, userID int, profileID string) (bool, error) {
return f(ctx, userID, profileID)
}
// ProfileLookup resolves whether profileID exists for userID and whether it is
// a child profile. It backs NewProfileAttributionValidator.
type ProfileLookup func(ctx context.Context, userID int, profileID string) (found bool, isChild bool, err error)
// NewProfileAttributionValidator builds a ProfileAttributionValidator that
// attributes a report to a profile only when it belongs to the user and is not
// a child profile. The client diagnostics design forbids child profiles from
// performing diagnostics actions, so a nonconforming client that sends a child
// profile's ID (via X-Profile-Id or a manifest profile_id) has the upload
// rejected with ErrChildProfileForbidden rather than silently losing the
// attribution. A profile that is simply not the user's (found == false) is not
// an error: attribution is dropped and the upload proceeds unattributed.
func NewProfileAttributionValidator(lookup ProfileLookup) ProfileAttributionValidator {
return ProfileAttributionValidatorFunc(func(ctx context.Context, userID int, profileID string) (bool, error) {
found, isChild, err := lookup(ctx, userID, profileID)
if err != nil {
return false, err
}
if found && isChild {
return false, ErrChildProfileForbidden
}
return found, nil
})
}
func (s *Service) SetProfileAttributionValidator(validator ProfileAttributionValidator) {
s.profileValidator = validator
}
func (s *Service) Status(ctx context.Context, userID int) (Status, error) {
settings, err := s.currentSettings(ctx)
if err != nil {
return Status{}, err
}
return s.statusFromSettings(settings), nil
}
func (s *Service) Ingest(ctx context.Context, userID int, profileID *string, manifestJSON []byte, bundle io.Reader) (IngestResult, error) {
settings, err := s.currentSettings(ctx)
if err != nil {
s.logRejected(ctx, "", userID, "", "", 0, "settings_error")
return IngestResult{}, err
}
status := s.statusFromSettings(settings).Status
switch status {
case StatusDisabled:
s.logRejected(ctx, "", userID, "", "", 0, "disabled")
return IngestResult{}, ErrDisabled
case StatusStorageUnavailable:
s.logRejected(ctx, "", userID, "", "", 0, "storage_unavailable")
return IngestResult{}, ErrStorageUnavailable
}
if s.reports == nil {
s.logRejected(ctx, "", userID, "", "", 0, "report_store_unavailable")
return IngestResult{}, ErrReportStoreUnavailable
}
if len(manifestJSON) > int(MaxManifestBytes) {
s.logRejected(ctx, "", userID, "", "", int64(len(manifestJSON)), "too_large")
return IngestResult{}, ErrTooLarge
}
if manifestHasUnsupportedSchema(manifestJSON) {
s.logRejected(ctx, "", userID, "", "", int64(len(manifestJSON)), "unsupported_schema")
return IngestResult{}, ErrUnsupportedSchema
}
manifest, err := contract.ValidateManifest(manifestJSON)
if err != nil {
s.logRejected(ctx, "", userID, "", "", int64(len(manifestJSON)), "invalid_manifest")
return IngestResult{}, fmt.Errorf("%w: invalid manifest: %v", ErrInvalidBundle, err)
}
if manifest.Archive.Bytes > settings.MaxBundleBytes {
s.logRejected(ctx, "", userID, manifest.Report.Platform, manifest.Report.Type, manifest.Archive.Bytes, "too_large")
return IngestResult{}, ErrTooLarge
}
if manifest.Destination.ServerInstanceID != settings.ServerInstanceID {
s.logRejected(ctx, "", userID, manifest.Report.Platform, manifest.Report.Type, manifest.Archive.Bytes, "destination_mismatch")
return IngestResult{}, ErrDestinationMismatch
}
if manifest.Consent.NoticeVersion != settings.ConsentNoticeVersion {
s.logRejected(ctx, "", userID, manifest.Report.Platform, manifest.Report.Type, manifest.Archive.Bytes, "stale_consent")
return IngestResult{}, ErrStaleConsent
}
reportProfileID, err := s.validatedProfileID(ctx, userID, profileID, manifest.Report.ProfileID)
if err != nil {
s.logRejected(ctx, "", userID, manifest.Report.Platform, manifest.Report.Type, manifest.Archive.Bytes, "profile_validation_error")
return IngestResult{}, err
}
crashSummary := crashSummaryFromManifest(manifest)
// ExpectedBlobBytes reserves the client-claimed archive size against the
// per-user byte quota before the bundle streams in. Trusting the claim here is
// safe: a report only reaches MarkReady (which rewrites blob_bytes to the
// measured size) after archiveMatches confirms the claimed bytes/sha/entries
// exactly equal the streamed bundle, so no stored report can ever occupy more
// bytes than it reserved. A client that under-claims to under-reserve then
// uploads a larger bundle fails archiveMatches and is compensated (blob
// deleted, row marked failed) before it counts, gaining no storage. The
// residual cost of such rejected churn is bandwidth, not storage — bounded by
// the per-account single in-flight upload cap, not by max_bytes_per_user,
// which is a storage cap enforced oldest-first by retention.
reserved, err := s.reports.InsertReceiving(ctx, InsertReceivingInput{
UserID: userID,
ProfileID: reportProfileID,
CapturedAt: manifest.Report.CapturedAt,
ReportType: manifest.Report.Type,
Platform: manifest.Report.Platform,
AppVersion: manifest.Report.AppVersion,
CrashSummary: crashSummary,
Manifest: append(json.RawMessage(nil), manifestJSON...),
PlaybackSessionIDs: manifest.PlaybackSessionIDs,
ExpectedBlobBytes: manifest.Archive.Bytes,
MaxReportsPerUserDay: settings.MaxReportsPerUserDay,
MaxBytesPerUser: settings.MaxBytesPerUser,
Now: s.now(),
})
if err != nil {
reason := "insert_failed"
if errors.Is(err, ErrQuotaExceeded) {
reason = "quota_exceeded"
} else {
s.logger.ErrorContext(ctx, "diagnostic report insert failed",
"component", "diagnostics",
"user_id", userID,
"error", err,
)
}
s.logRejected(ctx, "", userID, manifest.Report.Platform, manifest.Report.Type, manifest.Archive.Bytes, reason)
return IngestResult{}, err
}
key := reportObjectKey(userID, reserved.ID)
info, err := s.putValidatedBundle(ctx, key, bundle, settings)
if err != nil {
s.compensateFailedUpload(ctx, reserved.ID, key)
rejectErr := classifyBundleUploadError(err)
s.logger.ErrorContext(ctx, "diagnostic bundle validation failed",
"component", "diagnostics",
"report_id", reserved.ID,
"user_id", userID,
"error", err,
)
s.logRejected(ctx, reserved.ID, userID, manifest.Report.Platform, manifest.Report.Type, manifest.Archive.Bytes, rejectReason(rejectErr))
return IngestResult{}, rejectErr
}
if !archiveMatches(manifest.Archive, info) {
s.compensateFailedUpload(ctx, reserved.ID, key)
s.logRejected(ctx, reserved.ID, userID, manifest.Report.Platform, manifest.Report.Type, info.CompressedBytes, "archive_mismatch")
return IngestResult{}, ErrArchiveMismatch
}
if !embeddedManifestMatches(manifestJSON, info.EmbeddedManifest) {
s.compensateFailedUpload(ctx, reserved.ID, key)
s.logRejected(ctx, reserved.ID, userID, manifest.Report.Platform, manifest.Report.Type, info.CompressedBytes, "manifest_mismatch")
return IngestResult{}, ErrArchiveMismatch
}
if err := s.reports.MarkReady(ctx, reserved.ID, BlobInfo{
Bucket: s.store.Bucket(),
Key: key,
Bytes: info.CompressedBytes,
UncompressedBytes: info.UncompressedBytes,
SHA256: info.SHA256,
}); err != nil {
s.compensateFailedUpload(ctx, reserved.ID, key)
s.logRejected(ctx, reserved.ID, userID, manifest.Report.Platform, manifest.Report.Type, info.CompressedBytes, "mark_ready_failed")
return IngestResult{}, err
}
s.logger.InfoContext(ctx, "diagnostic report accepted",
"component", "diagnostics",
"report_id", reserved.ID,
"user_id", userID,
"platform", manifest.Report.Platform,
"type", manifest.Report.Type,
"bytes", info.CompressedBytes,
"result", "accepted",
)
return IngestResult{ReportID: reserved.ID, ShortID: reserved.ShortID}, nil
}
func (s *Service) currentSettings(ctx context.Context) (Settings, error) {
settings, err := LoadSettings(ctx, s.settings)
if err != nil {
return Settings{}, err
}
if strings.TrimSpace(settings.ServerInstanceID) != "" || s.settings == nil {
return settings, nil
}
instanceID, err := ensureServerInstanceID(ctx, s.settings)
if err != nil {
return Settings{}, err
}
settings.ServerInstanceID = instanceID
return settings, nil
}
func (s *Service) statusFromSettings(settings Settings) Status {
availability := StatusDisabled
if settings.UploadsEnabled {
availability = StatusAvailable
if s.store == nil || strings.TrimSpace(s.store.Bucket()) == "" {
availability = StatusStorageUnavailable
}
}
return Status{
Status: availability,
ServerInstanceID: settings.ServerInstanceID,
AcceptedSchemaVersions: []int{contract.SchemaVersion},
MaxBundleBytes: settings.MaxBundleBytes,
MaxManifestBytes: MaxManifestBytes,
RetentionDays: settings.RetentionDays,
ConsentNoticeVersion: settings.ConsentNoticeVersion,
}
}
func (s *Service) putValidatedBundle(ctx context.Context, key string, bundle io.Reader, settings Settings) (BundleInfo, error) {
pr, pw := io.Pipe()
resultCh := make(chan bundleValidationResult, 1)
go func() {
info, err := ValidateBundle(pr, BundleLimits{
MaxCompressedBytes: settings.MaxBundleBytes,
MaxUncompressedBytes: settings.MaxUncompressedBytes,
})
if err != nil {
_ = pr.CloseWithError(err)
}
resultCh <- bundleValidationResult{info: info, err: err}
}()
reader := &validatingUploadReader{src: bundle, pipe: pw}
putErr := s.store.PutStream(ctx, s.store.Bucket(), key, reader, BundleContentType)
if putErr != nil {
reader.abort(&bundleUploadAbortError{err: putErr})
}
result := <-resultCh
if putErr != nil {
if result.err != nil && !isBundleUploadAbortError(result.err) {
return result.info, result.err
}
return BundleInfo{}, fmt.Errorf("%w: store diagnostic bundle: %w", ErrStorageUnavailable, putErr)
}
if result.err != nil {
return result.info, result.err
}
return result.info, nil
}
func (s *Service) compensateFailedUpload(ctx context.Context, reportID, key string) {
compensationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), failedUploadCompensationTimeout)
defer cancel()
if s.store != nil && key != "" {
if err := s.store.DeleteObject(compensationCtx, s.store.Bucket(), key); err != nil {
s.logger.WarnContext(compensationCtx, "diagnostic report blob compensation failed",
"component", "diagnostics",
"report_id", reportID,
"key", key,
"error", err,
)
}
}
if s.reports != nil && reportID != "" {
if err := s.reports.MarkFailed(compensationCtx, reportID); err != nil {
s.logger.WarnContext(compensationCtx, "diagnostic report failed-state compensation failed",
"component", "diagnostics",
"report_id", reportID,
"error", err,
)
}
}
}
func (s *Service) logRejected(ctx context.Context, reportID string, userID int, platform, reportType string, bytes int64, reason string) {
s.logger.InfoContext(ctx, "diagnostic report rejected",
"component", "diagnostics",
"report_id", reportID,
"user_id", userID,
"platform", platform,
"type", reportType,
"bytes", bytes,
"result", "rejected",
"reason", reason,
)
}
type bundleValidationResult struct {
info BundleInfo
err error
}
type validatingUploadReader struct {
src io.Reader
pipe *io.PipeWriter
closed bool
}
func (r *validatingUploadReader) Read(p []byte) (int, error) {
n, readErr := r.src.Read(p)
if n > 0 {
if _, err := r.pipe.Write(p[:n]); err != nil {
return n, err
}
}
if readErr != nil {
r.close(readErr)
}
return n, readErr
}
func (r *validatingUploadReader) abort(err error) {
r.close(err)
}
func (r *validatingUploadReader) close(err error) {
if r.closed {
return
}
r.closed = true
if errors.Is(err, io.EOF) {
_ = r.pipe.Close()
return
}
_ = r.pipe.CloseWithError(err)
}
func classifyBundleUploadError(err error) error {
switch {
case errors.Is(err, ErrCompressedTooLarge),
errors.Is(err, ErrUncompressedTooLarge),
errors.Is(err, ErrEntryTooLarge):
return ErrTooLarge
case errors.Is(err, ErrInvalidBundle),
errors.Is(err, ErrTooManyEntries),
errors.Is(err, ErrCompressionRatio):
return fmt.Errorf("%w: %v", ErrInvalidBundle, err)
default:
return err
}
}
func rejectReason(err error) string {
switch {
case errors.Is(err, ErrTooLarge):
return "too_large"
case errors.Is(err, ErrUnsupportedSchema):
return "unsupported_schema"
case errors.Is(err, ErrInvalidBundle):
return "invalid_bundle"
default:
return "storage_error"
}
}
func manifestHasUnsupportedSchema(data []byte) bool {
var header struct {
SchemaVersion *int `json:"schema_version"`
}
if err := json.Unmarshal(data, &header); err != nil {
return false
}
return header.SchemaVersion != nil && *header.SchemaVersion != contract.SchemaVersion
}
// resolveProfileID chooses the profile a report is attributed to from the
// X-Profile-Id header and the manifest's report.profile_id fallback. When both
// are present they must agree: a header captured under a different profile than
// the manifest means the client switched profiles between capture and upload,
// and silently preferring either source would attribute the DB row to one
// profile while the stored manifest/download still names the other. Reject that
// as ErrProfileMismatch. Single-source cases (only one present, or both equal)
// resolve to that value; when neither is present the report is unattributed.
func resolveProfileID(profileID *string, fallback string) (*string, error) {
header := ""
if profileID != nil {
header = strings.TrimSpace(*profileID)
}
manifestID := strings.TrimSpace(fallback)
switch {
case header != "" && manifestID != "" && header != manifestID:
return nil, fmt.Errorf("%w: header %q, manifest %q", ErrProfileMismatch, header, manifestID)
case header != "":
return &header, nil
case manifestID != "":
return &manifestID, nil
default:
return nil, nil
}
}
func (s *Service) validatedProfileID(ctx context.Context, userID int, profileID *string, fallback string) (*string, error) {
candidate, err := resolveProfileID(profileID, fallback)
if err != nil {
return nil, err
}
if candidate == nil {
return nil, nil
}
if s.profileValidator == nil {
return nil, nil
}
found, err := s.profileValidator.ProfileBelongsToUser(ctx, userID, *candidate)
if err != nil {
// A child-profile attribution is a client-facing rejection, not an
// internal failure: surface it verbatim so the handler can return a
// clear error instead of a generic 500.
if errors.Is(err, ErrChildProfileForbidden) {
return nil, err
}
return nil, fmt.Errorf("validate diagnostic profile attribution: %w", err)
}
if !found {
return nil, nil
}
return candidate, nil
}
func crashSummaryFromManifest(manifest contract.Manifest) *string {
if manifest.Crash == nil {
return nil
}
summary := manifest.Crash.Summary
return &summary
}
func reportObjectKey(userID int, reportID string) string {
return diagnosticObjectPrefix + "/" + strconv.Itoa(userID) + "/" + reportID + ".tar.gz"
}
func archiveMatches(archive contract.Archive, info BundleInfo) bool {
return archive.Bytes == info.CompressedBytes &&
archive.UncompressedBytes == info.UncompressedBytes &&
strings.EqualFold(archive.SHA256, info.SHA256) &&
slices.Equal(archive.Entries, info.Entries)
}
// embeddedManifestMatches reports whether the manifest.json embedded as the
// archive's first entry equals the received part-1 manifest with its `archive`
// object removed, per the bundle contract. Both are decoded to JSON before
// comparison so formatting or key-ordering differences don't matter.
func embeddedManifestMatches(received, embedded []byte) bool {
receivedMap, err := decodeJSONObject(received)
if err != nil {
return false
}
delete(receivedMap, "archive")
embeddedMap, err := decodeJSONObject(embedded)
if err != nil {
return false
}
return reflect.DeepEqual(receivedMap, embeddedMap)
}
func decodeJSONObject(data []byte) (map[string]any, error) {
dec := json.NewDecoder(bytes.NewReader(data))
// UseNumber keeps every JSON number as its exact decimal text (json.Number)
// instead of float64. Without it, two manifests that differ only in a large
// integer above 2^53 (e.g. log_summary.lines) both round to the same float
// and DeepEqual reports a false match, letting a tampered bundle through.
dec.UseNumber()
var obj map[string]any
if err := dec.Decode(&obj); err != nil {
return nil, err
}
// json.Unmarshal rejects trailing bytes after the value; Decoder.Decode does
// not, and Decoder.More() misses a stray closing delimiter (e.g. `{...}}`)
// because it only reports iteration inside an array/object. Re-assert that
// strictness by requiring the stream to be exhausted: a second Decode must
// hit io.EOF, so a manifest with any trailing token — junk or an extra
// `}`/`]` — is rejected and cannot compare equal. Trailing whitespace is
// fine; the decoder skips it and still returns io.EOF.
if err := dec.Decode(new(json.RawMessage)); err != io.EOF {
return nil, errors.New("unexpected trailing data after JSON object")
}
return obj, nil
}
+782
View File
@@ -0,0 +1,782 @@
package diagnostics
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"errors"
"io"
"log/slog"
"strconv"
"strings"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/diagnostics/contract"
)
func TestServiceIngestStoresReadyReport(t *testing.T) {
bundle, manifest, info := testDiagnosticsUpload(t, "server-1", DefaultConsentNoticeVer, "prof_1")
repo := &fakeDiagnosticReportStore{}
store := &fakeDiagnosticObjectStore{bucket: "private"}
svc := newTestDiagnosticsService(repo, store)
result, err := svc.Ingest(context.Background(), 42, nil, manifest, bytes.NewReader(bundle))
if err != nil {
t.Fatalf("Ingest: %v", err)
}
if result.ReportID != "11111111-1111-1111-1111-111111111111" {
t.Fatalf("ReportID = %q, want reserved report ID", result.ReportID)
}
if result.ShortID != "SILO-ABCDEF123456" {
t.Fatalf("ShortID = %q, want SILO-ABCDEF123456", result.ShortID)
}
if len(repo.ready) != 1 || repo.ready[0] != result.ReportID {
t.Fatalf("ready reports = %v, want [%s]", repo.ready, result.ReportID)
}
if len(repo.readyBlobs) != 1 {
t.Fatalf("ready blobs = %v, want one", repo.readyBlobs)
}
wantKey := "diagnostics/42/11111111-1111-1111-1111-111111111111.tar.gz"
readyBlob := repo.readyBlobs[0]
if readyBlob.Bucket != "private" || readyBlob.Key != wantKey {
t.Fatalf("ready blob location = %s/%s, want private/%s", readyBlob.Bucket, readyBlob.Key, wantKey)
}
if readyBlob.SHA256 != info.SHA256 {
t.Fatalf("ready blob sha256 = %q, want %q", readyBlob.SHA256, info.SHA256)
}
if len(store.puts) != 1 || store.puts[0] != wantKey {
t.Fatalf("stored keys = %v, want [%s]", store.puts, wantKey)
}
if !bytes.Equal(store.data, bundle) {
t.Fatal("stored bundle does not match uploaded bundle")
}
if len(repo.failed) != 0 || len(store.deleted) != 0 {
t.Fatalf("unexpected compensation: failed=%v deleted=%v", repo.failed, store.deleted)
}
}
func TestServiceIngestRejectsDestinationMismatch(t *testing.T) {
bundle, info := testDiagnosticsBundle(t)
repo := &fakeDiagnosticReportStore{}
store := &fakeDiagnosticObjectStore{bucket: "private"}
svc := newTestDiagnosticsService(repo, store)
manifest := testManifestJSON(t, "other-server", DefaultConsentNoticeVer, info)
_, err := svc.Ingest(context.Background(), 42, nil, manifest, bytes.NewReader(bundle))
if !errors.Is(err, ErrDestinationMismatch) {
t.Fatalf("Ingest error = %v, want ErrDestinationMismatch", err)
}
if repo.insertCalls != 0 {
t.Fatalf("InsertReceiving calls = %d, want 0", repo.insertCalls)
}
if len(store.puts) != 0 {
t.Fatalf("PutStream calls = %d, want 0", len(store.puts))
}
}
func TestServiceIngestRejectsStaleConsent(t *testing.T) {
bundle, info := testDiagnosticsBundle(t)
repo := &fakeDiagnosticReportStore{}
store := &fakeDiagnosticObjectStore{bucket: "private"}
svc := newTestDiagnosticsService(repo, store)
manifest := testManifestJSON(t, "server-1", DefaultConsentNoticeVer+1, info)
_, err := svc.Ingest(context.Background(), 42, nil, manifest, bytes.NewReader(bundle))
if !errors.Is(err, ErrStaleConsent) {
t.Fatalf("Ingest error = %v, want ErrStaleConsent", err)
}
if repo.insertCalls != 0 {
t.Fatalf("InsertReceiving calls = %d, want 0", repo.insertCalls)
}
if len(store.puts) != 0 {
t.Fatalf("PutStream calls = %d, want 0", len(store.puts))
}
}
func TestServiceIngestArchiveMismatchCompensates(t *testing.T) {
bundle, info := testDiagnosticsBundle(t)
info.SHA256 = strings.Repeat("0", 64)
repo := &fakeDiagnosticReportStore{}
store := &fakeDiagnosticObjectStore{bucket: "private"}
svc := newTestDiagnosticsService(repo, store)
manifest := testManifestJSON(t, "server-1", DefaultConsentNoticeVer, info)
_, err := svc.Ingest(context.Background(), 42, nil, manifest, bytes.NewReader(bundle))
if !errors.Is(err, ErrArchiveMismatch) {
t.Fatalf("Ingest error = %v, want ErrArchiveMismatch", err)
}
if repo.insertCalls != 1 {
t.Fatalf("InsertReceiving calls = %d, want 1", repo.insertCalls)
}
if len(store.puts) != 1 {
t.Fatalf("PutStream calls = %d, want 1", len(store.puts))
}
wantKey := "diagnostics/42/11111111-1111-1111-1111-111111111111.tar.gz"
if len(store.deleted) != 1 || store.deleted[0] != wantKey {
t.Fatalf("deleted keys = %v, want [%s]", store.deleted, wantKey)
}
if len(repo.failed) != 1 || repo.failed[0] != "11111111-1111-1111-1111-111111111111" {
t.Fatalf("failed reports = %v, want reserved report id", repo.failed)
}
if len(repo.ready) != 0 {
t.Fatalf("ready reports = %v, want none", repo.ready)
}
}
func TestServiceIngestRejectsEmbeddedManifestMismatch(t *testing.T) {
// The tar embeds a manifest.json that disagrees with the part-1 manifest
// (different os_version) while the archive size/sha fields still match the
// stored bundle, so this is caught only by the embedded-manifest check.
embedded := diagnosticsManifestBody("server-1", DefaultConsentNoticeVer, "prof_1", "15", nil)
bundle := buildManifestBundle(t, embedded)
info, err := ValidateBundle(bytes.NewReader(bundle), BundleLimits{})
if err != nil {
t.Fatalf("ValidateBundle: %v", err)
}
manifest := diagnosticsManifestBody("server-1", DefaultConsentNoticeVer, "prof_1", "99", &info)
if _, err := contract.ValidateManifest(manifest); err != nil {
t.Fatalf("test manifest invalid: %v\n%s", err, manifest)
}
repo := &fakeDiagnosticReportStore{}
store := &fakeDiagnosticObjectStore{bucket: "private"}
svc := newTestDiagnosticsService(repo, store)
_, err = svc.Ingest(context.Background(), 42, nil, manifest, bytes.NewReader(bundle))
if !errors.Is(err, ErrArchiveMismatch) {
t.Fatalf("Ingest error = %v, want ErrArchiveMismatch", err)
}
wantReportID := "11111111-1111-1111-1111-111111111111"
if len(repo.failed) != 1 || repo.failed[0] != wantReportID {
t.Fatalf("failed reports = %v, want [%s]", repo.failed, wantReportID)
}
if len(store.deleted) != 1 {
t.Fatalf("deleted keys = %v, want compensation delete", store.deleted)
}
if len(repo.ready) != 0 {
t.Fatalf("ready reports = %v, want none", repo.ready)
}
}
func TestEmbeddedManifestMatchesPreservesLargeIntegers(t *testing.T) {
// The received (part-1) and embedded manifests differ only in
// log_summary.lines by 1, at a magnitude above 2^53 where float64 cannot
// represent adjacent integers. Decoding with UseNumber must keep them distinct
// so a bundle whose embedded manifest was tampered is rejected, not accepted.
received := []byte(`{"log_summary":{"lines":9007199254740993},"archive":{"bytes":1}}`)
embedded := []byte(`{"log_summary":{"lines":9007199254740992}}`)
if embeddedManifestMatches(received, embedded) {
t.Fatal("embeddedManifestMatches = true for manifests differing by a large integer, want false")
}
// Sanity: with the archive object stripped from the received manifest, an
// otherwise identical large integer still compares equal.
sameReceived := []byte(`{"log_summary":{"lines":9007199254740993},"archive":{"bytes":1}}`)
sameEmbedded := []byte(`{"log_summary":{"lines":9007199254740993}}`)
if !embeddedManifestMatches(sameReceived, sameEmbedded) {
t.Fatal("embeddedManifestMatches = false for identical manifests, want true")
}
}
func TestEmbeddedManifestMatchesRejectsTrailingData(t *testing.T) {
// A stray closing delimiter after an otherwise-matching object must be
// rejected. Decoder.More() returns false here (it only tracks array/object
// iteration), so the decode-to-EOF check is what catches it; without it a
// bundle whose embedded manifest is not a single clean JSON object would
// still compare equal to the accepted part-1 manifest.
received := []byte(`{"log_summary":{"lines":1},"archive":{"bytes":1}}`)
for _, trailer := range []string{"}", "]", "garbage", `{"extra":1}`} {
embedded := []byte(`{"log_summary":{"lines":1}}` + trailer)
if embeddedManifestMatches(received, embedded) {
t.Fatalf("embeddedManifestMatches = true for embedded manifest with trailing %q, want false", trailer)
}
}
// Trailing whitespace after the object is not trailing data; it must still
// compare equal.
embeddedWithWhitespace := []byte("{\"log_summary\":{\"lines\":1}}\n ")
if !embeddedManifestMatches(received, embeddedWithWhitespace) {
t.Fatal("embeddedManifestMatches = false for embedded manifest with trailing whitespace, want true")
}
}
func TestServiceIngestReturnsStorageErrorForMidStreamPutFailure(t *testing.T) {
bundle, info := testDiagnosticsBundle(t)
putErr := errors.New("s3: connection reset by peer")
repo := &fakeDiagnosticReportStore{}
store := &fakeDiagnosticObjectStore{
bucket: "private",
putErr: putErr,
putReadBytes: 8,
}
svc := newTestDiagnosticsService(repo, store)
manifest := testManifestJSON(t, "server-1", DefaultConsentNoticeVer, info)
_, err := svc.Ingest(context.Background(), 42, nil, manifest, bytes.NewReader(bundle))
if !errors.Is(err, ErrStorageUnavailable) || !errors.Is(err, putErr) {
t.Fatalf("Ingest error = %v, want retryable storage error wrapping put failure", err)
}
if errors.Is(err, ErrInvalidBundle) {
t.Fatalf("Ingest error = %v, must not be classified as ErrInvalidBundle", err)
}
wantReportID := "11111111-1111-1111-1111-111111111111"
if len(repo.failed) != 1 || repo.failed[0] != wantReportID {
t.Fatalf("failed reports = %v, want [%s]", repo.failed, wantReportID)
}
wantKey := "diagnostics/42/" + wantReportID + ".tar.gz"
if len(store.deleted) != 1 || store.deleted[0] != wantKey {
t.Fatalf("deleted keys = %v, want [%s]", store.deleted, wantKey)
}
if len(repo.ready) != 0 {
t.Fatalf("ready reports = %v, want none", repo.ready)
}
}
func TestServiceIngestCompensatesWithDetachedContext(t *testing.T) {
bundle, info := testDiagnosticsBundle(t)
repo := &fakeDiagnosticReportStore{}
store := &fakeDiagnosticObjectStore{
bucket: "private",
putErr: errors.New("s3: connection reset by peer"),
putReadBytes: 8,
}
svc := newTestDiagnosticsService(repo, store)
ctx, cancel := context.WithCancel(context.Background())
cancel()
manifest := testManifestJSON(t, "server-1", DefaultConsentNoticeVer, info)
_, err := svc.Ingest(ctx, 42, nil, manifest, bytes.NewReader(bundle))
if err == nil {
t.Fatal("Ingest error = nil, want storage error")
}
if len(store.deleteCtxErrs) != 1 || store.deleteCtxErrs[0] != nil {
t.Fatalf("delete ctx errors = %v, want [nil]", store.deleteCtxErrs)
}
if len(repo.markFailedCtxErrs) != 1 || repo.markFailedCtxErrs[0] != nil {
t.Fatalf("mark failed ctx errors = %v, want [nil]", repo.markFailedCtxErrs)
}
}
func TestServiceIngestStoresOnlyValidatedProfileAttribution(t *testing.T) {
// Header-only attribution (manifest carries no profile_id): the header is
// the single source, so it is validated and stored.
bundle, manifest, _ := testDiagnosticsUpload(t, "server-1", DefaultConsentNoticeVer, "")
repo := &fakeDiagnosticReportStore{}
store := &fakeDiagnosticObjectStore{bucket: "private"}
svc := newTestDiagnosticsService(repo, store)
svc.SetProfileAttributionValidator(ProfileAttributionValidatorFunc(func(_ context.Context, userID int, profileID string) (bool, error) {
return userID == 42 && profileID == "prof_header", nil
}))
headerProfileID := "prof_header"
if _, err := svc.Ingest(context.Background(), 42, &headerProfileID, manifest, bytes.NewReader(bundle)); err != nil {
t.Fatalf("Ingest: %v", err)
}
if repo.insertInput.ProfileID == nil || *repo.insertInput.ProfileID != "prof_header" {
t.Fatalf("ProfileID = %v, want prof_header", repo.insertInput.ProfileID)
}
// Manifest-only attribution that fails validation is dropped, not stored.
bundle, manifest, _ = testDiagnosticsUpload(t, "server-1", DefaultConsentNoticeVer, "prof_1")
repo = &fakeDiagnosticReportStore{}
store = &fakeDiagnosticObjectStore{bucket: "private"}
svc = newTestDiagnosticsService(repo, store)
svc.SetProfileAttributionValidator(ProfileAttributionValidatorFunc(func(context.Context, int, string) (bool, error) {
return false, nil
}))
if _, err := svc.Ingest(context.Background(), 42, nil, manifest, bytes.NewReader(bundle)); err != nil {
t.Fatalf("Ingest with unvalidated profile: %v", err)
}
if repo.insertInput.ProfileID != nil {
t.Fatalf("ProfileID = %v, want nil for unvalidated attribution", *repo.insertInput.ProfileID)
}
}
func TestServiceIngestRejectsMismatchedProfileID(t *testing.T) {
// The manifest was captured under prof_manifest but the request carries a
// different X-Profile-Id (prof_header) because the client switched profiles
// between capture and upload. The two sources must not be silently
// reconciled; the upload is rejected and no report row is created.
bundle, manifest, _ := testDiagnosticsUpload(t, "server-1", DefaultConsentNoticeVer, "prof_manifest")
repo := &fakeDiagnosticReportStore{}
store := &fakeDiagnosticObjectStore{bucket: "private"}
svc := newTestDiagnosticsService(repo, store)
// Even a validator that would accept any profile must not paper over the
// mismatch: resolution fails before attribution validation runs.
svc.SetProfileAttributionValidator(ProfileAttributionValidatorFunc(func(context.Context, int, string) (bool, error) {
return true, nil
}))
headerProfileID := "prof_header"
_, err := svc.Ingest(context.Background(), 42, &headerProfileID, manifest, bytes.NewReader(bundle))
if !errors.Is(err, ErrProfileMismatch) {
t.Fatalf("Ingest error = %v, want ErrProfileMismatch", err)
}
if repo.insertInput.ProfileID != nil {
t.Fatalf("ProfileID = %v, want no insert on profile mismatch", *repo.insertInput.ProfileID)
}
if len(repo.ready) != 0 || len(store.puts) != 0 {
t.Fatalf("no report should be stored on mismatch: ready=%v puts=%v", repo.ready, store.puts)
}
}
func TestServiceIngestAcceptsMatchingProfileID(t *testing.T) {
// Header and manifest agree: attribution resolves to that single value.
bundle, manifest, _ := testDiagnosticsUpload(t, "server-1", DefaultConsentNoticeVer, "prof_1")
repo := &fakeDiagnosticReportStore{}
store := &fakeDiagnosticObjectStore{bucket: "private"}
svc := newTestDiagnosticsService(repo, store)
svc.SetProfileAttributionValidator(ProfileAttributionValidatorFunc(func(_ context.Context, userID int, profileID string) (bool, error) {
return userID == 42 && profileID == "prof_1", nil
}))
headerProfileID := "prof_1"
if _, err := svc.Ingest(context.Background(), 42, &headerProfileID, manifest, bytes.NewReader(bundle)); err != nil {
t.Fatalf("Ingest: %v", err)
}
if repo.insertInput.ProfileID == nil || *repo.insertInput.ProfileID != "prof_1" {
t.Fatalf("ProfileID = %v, want prof_1", repo.insertInput.ProfileID)
}
}
func TestNewProfileAttributionValidatorRejectsChildProfiles(t *testing.T) {
tests := []struct {
name string
found bool
isChild bool
lookErr error
want bool
wantErr bool
wantChild bool
}{
{name: "non-child profile attributed", found: true, isChild: false, want: true},
{name: "child profile forbidden", found: true, isChild: true, wantErr: true, wantChild: true},
{name: "missing profile dropped", found: false, isChild: false, want: false},
{name: "lookup error propagated", lookErr: errors.New("boom"), wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
validator := NewProfileAttributionValidator(func(_ context.Context, userID int, profileID string) (bool, bool, error) {
if userID != 42 || profileID != "prof_1" {
t.Fatalf("lookup called with userID=%d profileID=%q", userID, profileID)
}
return tc.found, tc.isChild, tc.lookErr
})
got, err := validator.ProfileBelongsToUser(context.Background(), 42, "prof_1")
if tc.wantErr {
if err == nil {
t.Fatal("ProfileBelongsToUser error = nil, want error")
}
if tc.wantChild && !errors.Is(err, ErrChildProfileForbidden) {
t.Fatalf("ProfileBelongsToUser error = %v, want ErrChildProfileForbidden", err)
}
return
}
if err != nil {
t.Fatalf("ProfileBelongsToUser: %v", err)
}
if got != tc.want {
t.Fatalf("ProfileBelongsToUser = %v, want %v", got, tc.want)
}
})
}
}
func TestServiceIngestRejectsChildProfileAttribution(t *testing.T) {
// A nonconforming client attributes the report to a child profile. The
// validator rejects it with ErrChildProfileForbidden, so the upload is
// refused outright rather than silently stored without attribution.
bundle, manifest, _ := testDiagnosticsUpload(t, "server-1", DefaultConsentNoticeVer, "prof_child")
repo := &fakeDiagnosticReportStore{}
store := &fakeDiagnosticObjectStore{bucket: "private"}
svc := newTestDiagnosticsService(repo, store)
svc.SetProfileAttributionValidator(ProfileAttributionValidatorFunc(func(context.Context, int, string) (bool, error) {
return false, ErrChildProfileForbidden
}))
_, err := svc.Ingest(context.Background(), 42, nil, manifest, bytes.NewReader(bundle))
if !errors.Is(err, ErrChildProfileForbidden) {
t.Fatalf("Ingest error = %v, want ErrChildProfileForbidden", err)
}
if repo.insertInput.ProfileID != nil {
t.Fatalf("ProfileID = %v, want no insert on child profile", *repo.insertInput.ProfileID)
}
if len(repo.ready) != 0 || len(store.puts) != 0 {
t.Fatalf("no report should be stored on child rejection: ready=%v puts=%v", repo.ready, store.puts)
}
}
func TestServiceIngestAcceptsManifestWithoutProfileID(t *testing.T) {
bundle, manifest, _ := testDiagnosticsUpload(t, "server-1", DefaultConsentNoticeVer, "")
repo := &fakeDiagnosticReportStore{}
store := &fakeDiagnosticObjectStore{bucket: "private"}
svc := newTestDiagnosticsService(repo, store)
if _, err := svc.Ingest(context.Background(), 42, nil, manifest, bytes.NewReader(bundle)); err != nil {
t.Fatalf("Ingest: %v", err)
}
if repo.insertInput.ProfileID != nil {
t.Fatalf("ProfileID = %v, want nil", *repo.insertInput.ProfileID)
}
}
func TestServiceDeleteReportDeletesRowWhenStorageUnavailable(t *testing.T) {
report := testReadyDiagnosticReport("22222222-2222-2222-2222-222222222222", 42)
repo := &fakeDiagnosticReportStore{
getReport: &report,
deleteReport: &report,
}
svc := newTestDiagnosticsService(repo, nil)
deleted, err := svc.DeleteReport(context.Background(), report.ID)
if err != nil {
t.Fatalf("DeleteReport: %v", err)
}
if deleted == nil || deleted.ID != report.ID {
t.Fatalf("deleted report = %#v, want %s", deleted, report.ID)
}
if len(repo.deleted) != 1 || repo.deleted[0] != report.ID {
t.Fatalf("deleted rows = %v, want [%s]", repo.deleted, report.ID)
}
}
func TestServiceDeleteReportKeepsBlobWhenRowDeletionFails(t *testing.T) {
report := testReadyDiagnosticReport("22222222-2222-2222-2222-222222222222", 42)
repo := &fakeDiagnosticReportStore{
getReport: &report,
deleteErr: errors.New("db down"),
}
store := &fakeDiagnosticObjectStore{bucket: "private"}
svc := newTestDiagnosticsService(repo, store)
if _, err := svc.DeleteReport(context.Background(), report.ID); err == nil {
t.Fatal("DeleteReport error = nil, want row deletion failure")
}
// The blob must survive a failed row deletion so a retry can still recover
// the original artifact.
if len(store.deleted) != 0 {
t.Fatalf("blob deleted despite row deletion failure: %v", store.deleted)
}
}
func TestServiceDeleteReportSucceedsWhenBlobDeletionFails(t *testing.T) {
report := testReadyDiagnosticReport("22222222-2222-2222-2222-222222222222", 42)
repo := &fakeDiagnosticReportStore{
getReport: &report,
deleteReport: &report,
}
store := &fakeDiagnosticObjectStore{bucket: "private", deleteErr: errors.New("s3 down")}
svc := newTestDiagnosticsService(repo, store)
deleted, err := svc.DeleteReport(context.Background(), report.ID)
if err != nil {
t.Fatalf("DeleteReport error = %v, want success after row deletion", err)
}
if deleted == nil || deleted.ID != report.ID {
t.Fatalf("deleted report = %#v, want %s", deleted, report.ID)
}
if len(repo.deleted) != 1 || repo.deleted[0] != report.ID {
t.Fatalf("deleted rows = %v, want [%s]", repo.deleted, report.ID)
}
// The row is gone; the orphaned blob delete was attempted (and failed), and
// the reconciler will reap it later.
if len(store.deleted) == 0 {
t.Fatal("blob deletion was not attempted after row removal")
}
}
func newTestDiagnosticsService(repo ReportStore, store ObjectStore) *Service {
settings := newMemorySettingsStore(map[string]string{
KeyUploadsEnabled: "true",
KeyMaxBundleBytes: "10485760",
KeyMaxUncompressedBytes: "67108864",
KeyMaxReportsPerUserDay: "20",
KeyRetentionDays: "30",
KeyMaxBytesPerUser: "209715200",
KeyConsentNoticeVersion: "1",
KeyServerInstanceID: "server-1",
})
svc := NewService(repo, settings, store, slog.New(slog.NewTextHandler(io.Discard, nil)))
svc.now = func() time.Time { return time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) }
return svc
}
func testDiagnosticsBundle(t *testing.T) ([]byte, BundleInfo) {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
payload := []byte(`{}`)
if err := tw.WriteHeader(&tar.Header{
Name: "manifest.json",
Mode: 0o600,
Size: int64(len(payload)),
}); err != nil {
t.Fatalf("write tar header: %v", err)
}
if _, err := tw.Write(payload); err != nil {
t.Fatalf("write tar payload: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatalf("close tar: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
data := buf.Bytes()
info, err := ValidateBundle(bytes.NewReader(data), BundleLimits{})
if err != nil {
t.Fatalf("ValidateBundle: %v", err)
}
return data, info
}
func testManifestJSON(t *testing.T, serverID string, noticeVersion int, archive BundleInfo) []byte {
t.Helper()
return testManifestJSONWithProfile(t, serverID, noticeVersion, archive, "prof_1")
}
func testManifestJSONWithProfile(t *testing.T, serverID string, noticeVersion int, archive BundleInfo, profileID string) []byte {
t.Helper()
data := diagnosticsManifestBody(serverID, noticeVersion, profileID, "15", &archive)
if _, err := contract.ValidateManifest(data); err != nil {
t.Fatalf("test manifest invalid: %v\n%s", err, string(data))
}
return data
}
// diagnosticsManifestBody builds a manifest JSON body. When info is nil the
// `archive` object is omitted, producing the embedded manifest.json contract
// form (part-1 manifest minus `archive`).
func diagnosticsManifestBody(serverID string, noticeVersion int, profileID, osVersion string, info *BundleInfo) []byte {
profileField := ""
if profileID != "" {
profileField = `,
"profile_id": "` + profileID + `"`
}
archiveField := ""
if info != nil {
archiveField = `,
"archive": {
"entries": ["manifest.json"],
"bytes": ` + strconv.FormatInt(info.CompressedBytes, 10) + `,
"uncompressed_bytes": ` + strconv.FormatInt(info.UncompressedBytes, 10) + `,
"sha256": "` + info.SHA256 + `"
}`
}
return []byte(`{
"schema_version": 1,
"report": {
"type": "manual",
"captured_at": "2026-07-20T12:00:00Z",
"capture_session_id": "run_test",
"app_version": "1.0.0",
"app_build": "100",
"platform": "android",
"os_version": "` + osVersion + `"` + profileField + `
},
"destination": { "server_instance_id": "` + serverID + `" },
"consent": { "mode": "manual", "notice_version": ` + strconv.Itoa(noticeVersion) + ` },
"device_summary": {
"manufacturer": "Google",
"model": "Pixel",
"os": "15",
"form_factor": "phone"
},
"playback_session_ids": ["ps_1"],
"log_summary": {
"lines": 1,
"bytes_gz": 1,
"dropped_lines": 0,
"categories": ["crash"],
"debug_logging": true
}` + archiveField + `
}`)
}
// testDiagnosticsUpload builds a coordinated (bundle, manifest) pair: the tar's
// embedded manifest.json is the part-1 manifest minus `archive`, and the part-1
// manifest's archive fields match the built bundle — the shape Ingest accepts.
func testDiagnosticsUpload(t *testing.T, serverID string, noticeVersion int, profileID string) ([]byte, []byte, BundleInfo) {
t.Helper()
embedded := diagnosticsManifestBody(serverID, noticeVersion, profileID, "15", nil)
bundle := buildManifestBundle(t, embedded)
info, err := ValidateBundle(bytes.NewReader(bundle), BundleLimits{})
if err != nil {
t.Fatalf("ValidateBundle: %v", err)
}
manifest := diagnosticsManifestBody(serverID, noticeVersion, profileID, "15", &info)
if _, err := contract.ValidateManifest(manifest); err != nil {
t.Fatalf("test manifest invalid: %v\n%s", err, manifest)
}
return bundle, manifest, info
}
func buildManifestBundle(t *testing.T, manifestBody []byte) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
if err := tw.WriteHeader(&tar.Header{
Name: "manifest.json",
Mode: 0o600,
Size: int64(len(manifestBody)),
}); err != nil {
t.Fatalf("write tar header: %v", err)
}
if _, err := tw.Write(manifestBody); err != nil {
t.Fatalf("write tar payload: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatalf("close tar: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
return buf.Bytes()
}
type fakeDiagnosticReportStore struct {
insertCalls int
insertInput InsertReceivingInput
insertErr error
ready []string
readyBlobs []BlobInfo
failed []string
markFailedCtxErrs []error
getReport *Report
getErr error
deleteReport *Report
deleteErr error
deleted []string
}
func (f *fakeDiagnosticReportStore) InsertReceiving(_ context.Context, input InsertReceivingInput) (InsertReceivingResult, error) {
f.insertCalls++
f.insertInput = input
if f.insertErr != nil {
return InsertReceivingResult{}, f.insertErr
}
return InsertReceivingResult{
ID: "11111111-1111-1111-1111-111111111111",
ShortID: "SILO-ABCDEF123456",
}, nil
}
func (f *fakeDiagnosticReportStore) MarkReady(_ context.Context, id string, blob BlobInfo) error {
f.ready = append(f.ready, id)
f.readyBlobs = append(f.readyBlobs, blob)
return nil
}
func (f *fakeDiagnosticReportStore) MarkFailed(ctx context.Context, id string) error {
f.failed = append(f.failed, id)
f.markFailedCtxErrs = append(f.markFailedCtxErrs, ctx.Err())
return nil
}
func (f *fakeDiagnosticReportStore) GetByID(context.Context, string) (*Report, error) {
if f.getErr != nil {
return nil, f.getErr
}
if f.getReport != nil {
report := *f.getReport
return &report, nil
}
return nil, ErrNotFound
}
func (f *fakeDiagnosticReportStore) ListForAdmin(context.Context, ListFilters) (ListResult, error) {
return ListResult{}, nil
}
func (f *fakeDiagnosticReportStore) DeleteByID(_ context.Context, id string) (*Report, error) {
f.deleted = append(f.deleted, id)
if f.deleteErr != nil {
return nil, f.deleteErr
}
if f.deleteReport != nil {
report := *f.deleteReport
return &report, nil
}
return nil, ErrNotFound
}
func (f *fakeDiagnosticReportStore) RetentionCandidates(context.Context, time.Time, int64) ([]Report, error) {
return nil, nil
}
func (f *fakeDiagnosticReportStore) StaleReceiving(context.Context, time.Duration) ([]Report, error) {
return nil, nil
}
type fakeDiagnosticObjectStore struct {
bucket string
puts []string
deleted []string
deleteCtxErrs []error
deleteErr error
data []byte
putErr error
putReadBytes int64
}
func (f *fakeDiagnosticObjectStore) PutStream(_ context.Context, _ string, key string, r io.Reader, _ string) error {
if f.putErr != nil {
if f.putReadBytes > 0 {
_, _ = io.CopyN(io.Discard, r, f.putReadBytes)
}
f.puts = append(f.puts, key)
return f.putErr
}
data, err := io.ReadAll(r)
if err != nil {
return err
}
f.puts = append(f.puts, key)
f.data = append(f.data[:0], data...)
return nil
}
func (f *fakeDiagnosticObjectStore) GetObject(context.Context, string, string) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(f.data)), nil
}
func (f *fakeDiagnosticObjectStore) DeleteObject(ctx context.Context, _ string, key string) error {
f.deleted = append(f.deleted, key)
f.deleteCtxErrs = append(f.deleteCtxErrs, ctx.Err())
return f.deleteErr
}
func (f *fakeDiagnosticObjectStore) ListObjects(context.Context, string) ([]string, error) {
return nil, nil
}
func (f *fakeDiagnosticObjectStore) PresignGetURL(context.Context, string, string, time.Duration) (string, error) {
return "", nil
}
func (f *fakeDiagnosticObjectStore) Bucket() string {
return f.bucket
}
func testReadyDiagnosticReport(id string, userID int) Report {
bucket := "private"
key := reportObjectKey(userID, id)
bytes := int64(128)
uncompressed := int64(256)
sha := strings.Repeat("a", 64)
return Report{
ID: id,
ShortID: "SILO-ABCDEF123456",
UserID: userID,
State: StateReady,
BlobBucket: &bucket,
BlobKey: &key,
BlobBytes: &bytes,
UncompressedBytes: &uncompressed,
BlobSHA256: &sha,
}
}
+256
View File
@@ -0,0 +1,256 @@
package diagnostics
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
const (
KeyUploadsEnabled = "diagnostics.uploads_enabled"
KeyMaxBundleBytes = "diagnostics.max_bundle_bytes"
KeyMaxUncompressedBytes = "diagnostics.max_uncompressed_bytes"
KeyMaxReportsPerUserDay = "diagnostics.max_reports_per_user_per_day"
KeyRetentionDays = "diagnostics.retention_days"
KeyMaxBytesPerUser = "diagnostics.max_bytes_per_user"
KeyConsentNoticeVersion = "diagnostics.consent_notice_version"
KeyCleanupIntervalMinutes = "diagnostics.cleanup_interval_minutes"
KeyServerInstanceID = "diagnostics.server_instance_id"
DefaultUploadsEnabled = false
DefaultMaxBundleBytes = int64(10 * 1024 * 1024)
DefaultMaxUncompressed = int64(64 * 1024 * 1024)
DefaultMaxReportsPerDay = 20
DefaultRetentionDays = 30
DefaultMaxBytesPerUser = int64(200 * 1024 * 1024)
DefaultConsentNoticeVer = 1
// DefaultCleanupIntervalMinutes matches the prior opslog-shared cadence so
// splitting diagnostics onto its own key preserves current behavior.
DefaultCleanupIntervalMinutes = 15
// maxCleanupIntervalMinutes caps the parsed interval well below the point
// where minutes * time.Minute overflows int64 nanoseconds (~1.5e8 minutes,
// which would wrap a huge configured value into a tiny — or negative —
// duration). 7 days is far longer than any sane cleanup cadence.
maxCleanupIntervalMinutes = 7 * 24 * 60
defaultUploadsEnabledStr = "false"
defaultMaxBundleStr = "10485760"
defaultMaxUncompressed = "67108864"
defaultMaxReportsStr = "20"
defaultRetentionDaysStr = "30"
defaultMaxBytesUserStr = "209715200"
defaultConsentNoticeStr = "1"
defaultCleanupIntervalStr = "15"
)
// SettingsStore is the read/write surface over server_settings used by the
// diagnostics feature gate. catalog.ServerSettingsRepo satisfies it.
type SettingsStore interface {
Get(ctx context.Context, key string) (string, error)
Set(ctx context.Context, key, value string) error
}
// conditionalSettingsStore is the optional insert-if-absent surface used to seed
// generated singletons (the server instance ID) without racing concurrent
// nodes. catalog.ServerSettingsRepo satisfies it.
type conditionalSettingsStore interface {
SetIfAbsent(ctx context.Context, key, value string) (bool, error)
}
type Settings struct {
UploadsEnabled bool
MaxBundleBytes int64
MaxUncompressedBytes int64
MaxReportsPerUserDay int
RetentionDays int
MaxBytesPerUser int64
ConsentNoticeVersion int
ServerInstanceID string
}
func SeedDefaults(ctx context.Context, store SettingsStore) error {
defaults := map[string]string{
KeyUploadsEnabled: defaultUploadsEnabledStr,
KeyMaxBundleBytes: defaultMaxBundleStr,
KeyMaxUncompressedBytes: defaultMaxUncompressed,
KeyMaxReportsPerUserDay: defaultMaxReportsStr,
KeyRetentionDays: defaultRetentionDaysStr,
KeyMaxBytesPerUser: defaultMaxBytesUserStr,
KeyConsentNoticeVersion: defaultConsentNoticeStr,
KeyCleanupIntervalMinutes: defaultCleanupIntervalStr,
}
for key, value := range defaults {
existing, err := store.Get(ctx, key)
if err != nil {
return fmt.Errorf("seed diagnostics defaults for %s: %w", key, err)
}
if existing != "" {
continue
}
if err := store.Set(ctx, key, value); err != nil {
return fmt.Errorf("seed diagnostics default %s: %w", key, err)
}
}
if _, err := ensureServerInstanceID(ctx, store); err != nil {
return err
}
return nil
}
// ensureServerInstanceID returns the diagnostics server instance ID, generating
// and persisting one when absent. Seeding is atomic when the store supports
// insert-if-absent: concurrent nodes converge on the single winning value
// instead of overwriting each other (which would reject already-issued
// destination IDs as destination_mismatch).
func ensureServerInstanceID(ctx context.Context, store SettingsStore) (string, error) {
existing, err := store.Get(ctx, KeyServerInstanceID)
if err != nil {
return "", fmt.Errorf("seed diagnostics server instance id: %w", err)
}
if id := strings.TrimSpace(existing); id != "" {
return id, nil
}
instanceID, err := newServerInstanceID()
if err != nil {
return "", err
}
conditional, ok := store.(conditionalSettingsStore)
if !ok {
if err := store.Set(ctx, KeyServerInstanceID, instanceID); err != nil {
return "", fmt.Errorf("seed diagnostics server instance id: %w", err)
}
return instanceID, nil
}
if _, err := conditional.SetIfAbsent(ctx, KeyServerInstanceID, instanceID); err != nil {
return "", fmt.Errorf("seed diagnostics server instance id: %w", err)
}
// Re-read so a node that lost the insert race adopts the winning value.
winner, err := store.Get(ctx, KeyServerInstanceID)
if err != nil {
return "", fmt.Errorf("seed diagnostics server instance id: %w", err)
}
if id := strings.TrimSpace(winner); id != "" {
return id, nil
}
return instanceID, nil
}
// LoadCleanupInterval returns the configured diagnostics cleanup interval. It is
// intentionally independent of the opslog cleanup cadence so tuning one does not
// silently move the other.
func LoadCleanupInterval(ctx context.Context, store SettingsStore) time.Duration {
minutes := DefaultCleanupIntervalMinutes
if store == nil {
return time.Duration(minutes) * time.Minute
}
if raw, err := store.Get(ctx, KeyCleanupIntervalMinutes); err == nil && strings.TrimSpace(raw) != "" {
if parsed := parseInt(raw); parsed > 0 {
minutes = parsed
if minutes > maxCleanupIntervalMinutes {
minutes = maxCleanupIntervalMinutes
}
}
}
return time.Duration(minutes) * time.Minute
}
func DefaultSettings() Settings {
return Settings{
UploadsEnabled: DefaultUploadsEnabled,
MaxBundleBytes: DefaultMaxBundleBytes,
MaxUncompressedBytes: DefaultMaxUncompressed,
MaxReportsPerUserDay: DefaultMaxReportsPerDay,
RetentionDays: DefaultRetentionDays,
MaxBytesPerUser: DefaultMaxBytesPerUser,
ConsentNoticeVersion: DefaultConsentNoticeVer,
}
}
func LoadSettings(ctx context.Context, store SettingsStore) (Settings, error) {
settings := DefaultSettings()
if store == nil {
return settings, nil
}
// get distinguishes a genuine read failure — propagated so callers can
// surface a retryable error — from a missing or empty value, which falls
// back to the default. Silently defaulting on a transient DB error could
// report uploads disabled or the wrong quota limits to clients making
// decisions from /diagnostics/status.
get := func(key string) (string, error) {
raw, err := store.Get(ctx, key)
if err != nil {
return "", fmt.Errorf("load diagnostics setting %s: %w", key, err)
}
return strings.TrimSpace(raw), nil
}
if raw, err := get(KeyUploadsEnabled); err != nil {
return Settings{}, err
} else if raw != "" {
if parsed, parseErr := strconv.ParseBool(raw); parseErr == nil {
settings.UploadsEnabled = parsed
}
}
if raw, err := get(KeyMaxBundleBytes); err != nil {
return Settings{}, err
} else if parsed := parseInt64(raw); parsed > 0 {
settings.MaxBundleBytes = parsed
}
if raw, err := get(KeyMaxUncompressedBytes); err != nil {
return Settings{}, err
} else if parsed := parseInt64(raw); parsed > 0 {
settings.MaxUncompressedBytes = parsed
}
if raw, err := get(KeyMaxReportsPerUserDay); err != nil {
return Settings{}, err
} else if parsed := parseInt(raw); parsed > 0 {
settings.MaxReportsPerUserDay = parsed
}
if raw, err := get(KeyRetentionDays); err != nil {
return Settings{}, err
} else if parsed := parseInt(raw); parsed > 0 {
settings.RetentionDays = parsed
}
if raw, err := get(KeyMaxBytesPerUser); err != nil {
return Settings{}, err
} else if parsed := parseInt64(raw); parsed > 0 {
settings.MaxBytesPerUser = parsed
}
if raw, err := get(KeyConsentNoticeVersion); err != nil {
return Settings{}, err
} else if parsed := parseInt(raw); parsed > 0 {
settings.ConsentNoticeVersion = parsed
}
if raw, err := get(KeyServerInstanceID); err != nil {
return Settings{}, err
} else {
settings.ServerInstanceID = raw
}
return settings, nil
}
func newServerInstanceID() (string, error) {
return uuid.NewString(), nil
}
func parseInt(s string) int {
v, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return 0
}
return v
}
func parseInt64(s string) int64 {
v, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
if err != nil {
return 0
}
return v
}
+234
View File
@@ -0,0 +1,234 @@
package diagnostics
import (
"context"
"errors"
"strconv"
"testing"
"time"
)
func TestSeedDefaultsPreservesExistingServerInstanceID(t *testing.T) {
store := newMemorySettingsStore(map[string]string{
KeyServerInstanceID: "existing-instance",
})
if err := SeedDefaults(context.Background(), store); err != nil {
t.Fatalf("SeedDefaults: %v", err)
}
if got := store.values[KeyServerInstanceID]; got != "existing-instance" {
t.Fatalf("server instance id = %q, want existing-instance", got)
}
if got := store.values[KeyUploadsEnabled]; got != defaultUploadsEnabledStr {
t.Fatalf("uploads enabled default = %q, want %q", got, defaultUploadsEnabledStr)
}
}
func TestSeedDefaultsGeneratesDedicatedServerInstanceIDWhenMissing(t *testing.T) {
const jellyfinServerIDKey = "jellyfin_compat.server_id"
store := newMemorySettingsStore(map[string]string{
jellyfinServerIDKey: "constant-jellyfin-id",
})
if err := SeedDefaults(context.Background(), store); err != nil {
t.Fatalf("SeedDefaults: %v", err)
}
got := store.values[KeyServerInstanceID]
if len(got) != 36 {
t.Fatalf("server instance id length = %d, want UUID string", len(got))
}
if got == store.values[jellyfinServerIDKey] {
t.Fatal("diagnostics server instance id reused jellyfin_compat.server_id")
}
if store.values[jellyfinServerIDKey] != "constant-jellyfin-id" {
t.Fatalf("jellyfin server id changed to %q", store.values[jellyfinServerIDKey])
}
}
func TestSeedDefaultsAdoptsConcurrentInstanceIDWinner(t *testing.T) {
// A store that supports insert-if-absent seeds the instance ID atomically:
// when another node wins the race, this node adopts the winning value
// instead of overwriting it.
store := &racingSettingsStore{
memorySettingsStore: memorySettingsStore{values: map[string]string{}},
concurrentWinner: "winner-instance",
}
if err := SeedDefaults(context.Background(), store); err != nil {
t.Fatalf("SeedDefaults: %v", err)
}
if got := store.values[KeyServerInstanceID]; got != "winner-instance" {
t.Fatalf("server instance id = %q, want winner-instance", got)
}
if store.setIfAbsentCalls != 1 {
t.Fatalf("SetIfAbsent calls = %d, want 1", store.setIfAbsentCalls)
}
}
func TestLoadCleanupIntervalUsesDiagnosticsKey(t *testing.T) {
store := newMemorySettingsStore(map[string]string{
KeyCleanupIntervalMinutes: "45",
})
if got := LoadCleanupInterval(context.Background(), store); got != 45*time.Minute {
t.Fatalf("LoadCleanupInterval = %v, want 45m", got)
}
if got := LoadCleanupInterval(context.Background(), newMemorySettingsStore(nil)); got != DefaultCleanupIntervalMinutes*time.Minute {
t.Fatalf("LoadCleanupInterval default = %v, want %d minutes", got, DefaultCleanupIntervalMinutes)
}
}
func TestLoadCleanupIntervalCapsHugeValues(t *testing.T) {
// The boundary: the largest value that still fits under the cap is accepted
// verbatim, while anything larger (including values that would overflow
// int64 nanoseconds when multiplied by time.Minute) clamps to the cap
// instead of wrapping into a tiny or negative duration.
atCap := newMemorySettingsStore(map[string]string{
KeyCleanupIntervalMinutes: strconv.Itoa(maxCleanupIntervalMinutes),
})
if got := LoadCleanupInterval(context.Background(), atCap); got != maxCleanupIntervalMinutes*time.Minute {
t.Fatalf("LoadCleanupInterval at cap = %v, want %v", got, maxCleanupIntervalMinutes*time.Minute)
}
for _, raw := range []string{
strconv.Itoa(maxCleanupIntervalMinutes + 1),
"153722868", // wraps int64 nanoseconds without the cap
"9223372036854775807", // math.MaxInt64 minutes
} {
store := newMemorySettingsStore(map[string]string{KeyCleanupIntervalMinutes: raw})
got := LoadCleanupInterval(context.Background(), store)
if got != maxCleanupIntervalMinutes*time.Minute {
t.Fatalf("LoadCleanupInterval(%s) = %v, want capped %v", raw, got, maxCleanupIntervalMinutes*time.Minute)
}
if got <= 0 {
t.Fatalf("LoadCleanupInterval(%s) = %v, want a positive duration", raw, got)
}
}
}
func TestLoadSettingsPropagatesReadErrors(t *testing.T) {
// A genuine read failure must surface as an error so callers can return a
// retryable failure instead of silently reporting defaults (e.g. uploads
// disabled) to clients.
store := &failingSettingsStore{
memorySettingsStore: memorySettingsStore{values: map[string]string{KeyUploadsEnabled: "true"}},
failKey: KeyUploadsEnabled,
}
if _, err := LoadSettings(context.Background(), store); err == nil {
t.Fatal("LoadSettings did not propagate a store read error")
}
// A later key failing must also propagate, not be swallowed as a default.
store = &failingSettingsStore{
memorySettingsStore: memorySettingsStore{values: map[string]string{KeyUploadsEnabled: "true"}},
failKey: KeyMaxBytesPerUser,
}
if _, err := LoadSettings(context.Background(), store); err == nil {
t.Fatal("LoadSettings did not propagate a late store read error")
}
}
func TestLoadSettingsParsesTypedValues(t *testing.T) {
store := newMemorySettingsStore(map[string]string{
KeyUploadsEnabled: "true",
KeyMaxBundleBytes: "123",
KeyMaxUncompressedBytes: "456",
KeyMaxReportsPerUserDay: "7",
KeyRetentionDays: "8",
KeyMaxBytesPerUser: "900",
KeyConsentNoticeVersion: "2",
KeyServerInstanceID: "server-1",
})
got, err := LoadSettings(context.Background(), store)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
if !got.UploadsEnabled ||
got.MaxBundleBytes != 123 ||
got.MaxUncompressedBytes != 456 ||
got.MaxReportsPerUserDay != 7 ||
got.RetentionDays != 8 ||
got.MaxBytesPerUser != 900 ||
got.ConsentNoticeVersion != 2 ||
got.ServerInstanceID != "server-1" {
t.Fatalf("LoadSettings parsed unexpected values: %+v", got)
}
}
func TestLoadSettingsFallsBackForInvalidNumbers(t *testing.T) {
store := newMemorySettingsStore(map[string]string{
KeyMaxBundleBytes: "-1",
KeyMaxUncompressedBytes: "not-a-number",
KeyMaxReportsPerUserDay: "0",
KeyRetentionDays: "0",
KeyMaxBytesPerUser: "-2",
KeyConsentNoticeVersion: "0",
})
got, err := LoadSettings(context.Background(), store)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
want := DefaultSettings()
if got.MaxBundleBytes != want.MaxBundleBytes ||
got.MaxUncompressedBytes != want.MaxUncompressedBytes ||
got.MaxReportsPerUserDay != want.MaxReportsPerUserDay ||
got.RetentionDays != want.RetentionDays ||
got.MaxBytesPerUser != want.MaxBytesPerUser ||
got.ConsentNoticeVersion != want.ConsentNoticeVersion {
t.Fatalf("LoadSettings = %+v, want defaults %+v", got, want)
}
}
type memorySettingsStore struct {
values map[string]string
}
func newMemorySettingsStore(values map[string]string) *memorySettingsStore {
store := &memorySettingsStore{values: map[string]string{}}
for k, v := range values {
store.values[k] = v
}
return store
}
func (s *memorySettingsStore) Get(_ context.Context, key string) (string, error) {
return s.values[key], nil
}
func (s *memorySettingsStore) Set(_ context.Context, key, value string) error {
s.values[key] = value
return nil
}
// failingSettingsStore returns an error when Get is called for failKey,
// simulating a transient database read failure for one setting.
type failingSettingsStore struct {
memorySettingsStore
failKey string
}
func (s *failingSettingsStore) Get(ctx context.Context, key string) (string, error) {
if key == s.failKey {
return "", errors.New("transient read failure")
}
return s.memorySettingsStore.Get(ctx, key)
}
// racingSettingsStore adds insert-if-absent semantics and simulates another node
// winning the instance-ID seed race via concurrentWinner.
type racingSettingsStore struct {
memorySettingsStore
concurrentWinner string
setIfAbsentCalls int
}
func (s *racingSettingsStore) SetIfAbsent(_ context.Context, key, value string) (bool, error) {
s.setIfAbsentCalls++
if existing := s.values[key]; existing != "" {
return false, nil
}
if s.concurrentWinner != "" {
s.values[key] = s.concurrentWinner
return false, nil
}
s.values[key] = value
return true, nil
}
+50
View File
@@ -0,0 +1,50 @@
package diagnostics
import (
"crypto/rand"
"errors"
"strings"
)
const (
shortIDPrefix = "SILO-"
shortIDPayloadLength = 12
shortIDChars = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
)
var ErrInvalidShortID = errors.New("invalid diagnostics short id")
func NewShortID() (string, error) {
var random [shortIDPayloadLength]byte
if _, err := rand.Read(random[:]); err != nil {
return "", err
}
var out [shortIDPayloadLength]byte
for i, b := range random {
out[i] = shortIDChars[int(b)&31]
}
return shortIDPrefix + string(out[:]), nil
}
func ParseShortID(raw string) (string, error) {
id := strings.ToUpper(strings.TrimSpace(raw))
payload := strings.TrimPrefix(id, shortIDPrefix)
if len(payload) != shortIDPayloadLength {
return "", ErrInvalidShortID
}
for i := 0; i < len(payload); i++ {
if !isShortIDChar(payload[i]) {
return "", ErrInvalidShortID
}
}
return shortIDPrefix + payload, nil
}
func isShortIDChar(ch byte) bool {
for i := 0; i < len(shortIDChars); i++ {
if shortIDChars[i] == ch {
return true
}
}
return false
}
+43
View File
@@ -0,0 +1,43 @@
package diagnostics
import (
"errors"
"strings"
"testing"
)
func TestNewShortIDIsParseable(t *testing.T) {
id, err := NewShortID()
if err != nil {
t.Fatalf("NewShortID: %v", err)
}
if len(id) != len(shortIDPrefix)+shortIDPayloadLength {
t.Fatalf("length = %d, want %d", len(id), len(shortIDPrefix)+shortIDPayloadLength)
}
if !strings.HasPrefix(id, shortIDPrefix) {
t.Fatalf("id = %q, want %s prefix", id, shortIDPrefix)
}
if _, err := ParseShortID(id); err != nil {
t.Fatalf("ParseShortID(%q): %v", id, err)
}
}
func TestParseShortIDNormalizesCaseAndOptionalPrefix(t *testing.T) {
for _, raw := range []string{"abcdef123456", "silo-abcdef123456", " SILO-ABCDEF123456 "} {
got, err := ParseShortID(raw)
if err != nil {
t.Fatalf("ParseShortID(%q): %v", raw, err)
}
if got != "SILO-ABCDEF123456" {
t.Fatalf("ParseShortID(%q) = %q, want SILO-ABCDEF123456", raw, got)
}
}
}
func TestParseShortIDRejectsAmbiguousCharacters(t *testing.T) {
for _, raw := range []string{"ABCDEF12345I", "ABCDEF12345L", "ABCDEF12345O", "ABCDEF12345U"} {
if _, err := ParseShortID(raw); !errors.Is(err, ErrInvalidShortID) {
t.Fatalf("ParseShortID(%q) error = %v, want ErrInvalidShortID", raw, err)
}
}
}
+91
View File
@@ -0,0 +1,91 @@
package diagnostics
import (
"context"
"errors"
"io"
"time"
"github.com/Silo-Server/silo-server/internal/s3client"
)
var ErrObjectNotFound = errors.New("diagnostics object not found")
// ObjectStore is the diagnostics-owned object storage surface. It is narrow
// enough for fake-backed ingest/admin tests and wraps the private S3 bucket in
// production.
type ObjectStore interface {
PutStream(ctx context.Context, bucket, key string, r io.Reader, contentType string) error
GetObject(ctx context.Context, bucket, key string) (io.ReadCloser, error)
DeleteObject(ctx context.Context, bucket, key string) error
ListObjects(ctx context.Context, prefix string) ([]string, error)
PresignGetURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error)
Bucket() string
}
type s3ObjectStore struct {
client *s3client.Client
}
// NewS3ObjectStore adapts the private S3 client to the diagnostics store
// interface. A nil client returns nil so interface-nil checks remain reliable.
func NewS3ObjectStore(client *s3client.Client) ObjectStore {
if client == nil {
return nil
}
return &s3ObjectStore{client: client}
}
func (s *s3ObjectStore) PutStream(ctx context.Context, bucket, key string, r io.Reader, contentType string) error {
return normalizeObjectStoreError(s.client.PutObjectStream(ctx, bucket, key, r, contentType))
}
func (s *s3ObjectStore) GetObject(ctx context.Context, bucket, key string) (io.ReadCloser, error) {
body, err := s.client.GetObjectStream(ctx, bucket, key)
if err != nil {
return nil, normalizeObjectStoreError(err)
}
return body, nil
}
func (s *s3ObjectStore) DeleteObject(ctx context.Context, bucket, key string) error {
return normalizeObjectStoreError(s.client.DeleteObject(ctx, bucket, key))
}
func (s *s3ObjectStore) ListObjects(ctx context.Context, prefix string) ([]string, error) {
keys, err := s.client.ListObjects(ctx, s.client.Bucket(), prefix)
if err != nil {
return nil, normalizeObjectStoreError(err)
}
return keys, nil
}
func (s *s3ObjectStore) PresignGetURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error) {
url, err := s.client.PresignGetURL(ctx, bucket, key, expiry)
if err != nil {
return "", normalizeObjectStoreError(err)
}
return url, nil
}
func (s *s3ObjectStore) EffectivePresignTTL(requested time.Duration) time.Duration {
return s.client.EffectivePresignTTL(requested)
}
func (s *s3ObjectStore) Bucket() string {
return s.client.Bucket()
}
func normalizeObjectStoreError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, s3client.ErrNotFound) {
return ErrObjectNotFound
}
return err
}
func IsObjectNotFound(err error) bool {
return errors.Is(err, ErrObjectNotFound) || errors.Is(err, s3client.ErrNotFound)
}

Some files were not shown because too many files have changed in this diff Show More