Compare commits
298
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67a8e7aa22 | ||
|
|
a102630365 | ||
|
|
51ef906025 | ||
|
|
ece3578e91 | ||
|
|
ee31a1f0e2 | ||
|
|
22fec4ed2d | ||
|
|
383973ec22 | ||
|
|
2bc6ebb013 | ||
|
|
5e2dd91d6b | ||
|
|
920629e0ad | ||
|
|
e625d574e3 | ||
|
|
eee63772a7 | ||
|
|
4d1c97655d | ||
|
|
7cfdabb096 | ||
|
|
0834edcfc0 | ||
|
|
82379fa3e2 | ||
|
|
5d7eec7a62 | ||
|
|
65d94459f4 | ||
|
|
6da6cf7a71 | ||
|
|
3c56ed606c | ||
|
|
c0b2e18173 | ||
|
|
89b7d25084 | ||
|
|
84cd0acb0c | ||
|
|
2e45a9c015 | ||
|
|
0d3b94d2dd | ||
|
|
3f951c8454 | ||
|
|
f6615ef8d7 | ||
|
|
ac1dd99c72 | ||
|
|
487dc84829 | ||
|
|
166c5ef32f | ||
|
|
5ddec8fa4e | ||
|
|
a0507c78eb | ||
|
|
4e42cb3053 | ||
|
|
bd97f3c123 | ||
|
|
8dc2dcbc13 | ||
|
|
bcc78e7332 | ||
|
|
845d969af1 | ||
|
|
dfce4972e4 | ||
|
|
a679b5e1a1 | ||
|
|
e18bc3c568 | ||
|
|
a2e6cd8d5f | ||
|
|
d6cdf5c9e4 | ||
|
|
fcd7eb697d | ||
|
|
44db85457c | ||
|
|
ab29bc8c8e | ||
|
|
37adec320e | ||
|
|
537b3f6476 | ||
|
|
60ce7ed1a4 | ||
|
|
b3e963f54e | ||
|
|
f094d70996 | ||
|
|
7f3b93d1b4 | ||
|
|
b1545ae9f9 | ||
|
|
bb53bd0652 | ||
|
|
bf3c5645f4 | ||
|
|
794e2d786a | ||
|
|
fcaee9bfce | ||
|
|
9136803130 | ||
|
|
3a5d318714 | ||
|
|
194e5ff527 | ||
|
|
c786551ba2 | ||
|
|
68b104fb53 | ||
|
|
8d40138bdb | ||
|
|
1d5f19d794 | ||
|
|
1c2d422548 | ||
|
|
eb6b968ed1 | ||
|
|
ef7eedf3fc | ||
|
|
1ab85d18ea | ||
|
|
0a914441ea | ||
|
|
31e6f1ef41 | ||
|
|
9fad08a6fa | ||
|
|
8b3bff194b | ||
|
|
dee46f9398 | ||
|
|
b822bb7181 | ||
|
|
1f9bd99990 | ||
|
|
4b3362d32e | ||
|
|
93b851fe81 | ||
|
|
2d5d4980de | ||
|
|
827843fd21 | ||
|
|
a6348b3dc5 | ||
|
|
b3e2943ccd | ||
|
|
c1cebf519d | ||
|
|
75c2260897 | ||
|
|
f965a489bd | ||
|
|
1d4f5dbce2 | ||
|
|
21ee543012 | ||
|
|
3457382324 | ||
|
|
a0851edef0 | ||
|
|
4f249fda8f | ||
|
|
c90e736054 | ||
|
|
84f8495e4f | ||
|
|
c5a5b02221 | ||
|
|
845b96e703 | ||
|
|
800f2543ac | ||
|
|
f637404c46 | ||
|
|
e56a1b3e03 | ||
|
|
4fa84a661a | ||
|
|
7369d0afd8 | ||
|
|
8044eb84dd | ||
|
|
b96e359b4e | ||
|
|
2fb5e10de4 | ||
|
|
91e1164090 | ||
|
|
1664c60425 | ||
|
|
95779c47a9 | ||
|
|
b3722dac58 | ||
|
|
6ece120f77 | ||
|
|
255b1be89c | ||
|
|
8fc054c15d | ||
|
|
1f2125b920 | ||
|
|
0f9584ef42 | ||
|
|
f377be9d6e | ||
|
|
035ef457a4 | ||
|
|
c36d70dcba | ||
|
|
be3bfafeaa | ||
|
|
18283c2c9b | ||
|
|
3ddc74f784 | ||
|
|
a196b0844e | ||
|
|
075e217477 | ||
|
|
854d07cf8f | ||
|
|
e487253ad3 | ||
|
|
808883cf8f | ||
|
|
28c6ddc237 | ||
|
|
3fd0912cb3 | ||
|
|
df9cacc8da | ||
|
|
02b6648995 | ||
|
|
c1c110e3d2 | ||
|
|
9cfd01e9c2 | ||
|
|
b7292a9473 | ||
|
|
98a22dafd2 | ||
|
|
648e5c76ef | ||
|
|
58eed826a4 | ||
|
|
10e15798e0 | ||
|
|
d68e70bb47 | ||
|
|
8b9149a789 | ||
|
|
04c4344f52 | ||
|
|
4d99597966 | ||
|
|
e96a8a0cf8 | ||
|
|
3e340552a8 | ||
|
|
a2ef26bece | ||
|
|
203a18ae83 | ||
|
|
76b10b822e | ||
|
|
7f4f609948 | ||
|
|
3df5ed4e09 | ||
|
|
0deff23985 | ||
|
|
021e54a03c | ||
|
|
0694787504 | ||
|
|
4e03f4b807 | ||
|
|
11704a1701 | ||
|
|
43d9056b01 | ||
|
|
a26b2de454 | ||
|
|
4a76016015 | ||
|
|
af0e3e75a9 | ||
|
|
e140bd9424 | ||
|
|
ea518d2316 | ||
|
|
fe06312b06 | ||
|
|
c29212b2c5 | ||
|
|
8e9e2c91d4 | ||
|
|
80aff39921 | ||
|
|
885354e573 | ||
|
|
0fb5afe479 | ||
|
|
d08a4f232f | ||
|
|
11eb771165 | ||
|
|
745f72967e | ||
|
|
e3a209c4ff | ||
|
|
ba2bac7f55 | ||
|
|
28340ad6f5 | ||
|
|
69dde62fcd | ||
|
|
42602b7896 | ||
|
|
2e0e145721 | ||
|
|
5f59f8e952 | ||
|
|
8fa40e928b | ||
|
|
b8b708cbe0 | ||
|
|
8bb88658b1 | ||
|
|
9ead29bfcc | ||
|
|
f7954f226e | ||
|
|
193a2905b2 | ||
|
|
430224a1b9 | ||
|
|
a5fb16a5c5 | ||
|
|
ce09d2b6fc | ||
|
|
cb2581af4a | ||
|
|
2155261706 | ||
|
|
5b0c90b4c1 | ||
|
|
97ac2b4eed | ||
|
|
c0f209a936 | ||
|
|
ade3a3c5d4 | ||
|
|
ce06dc8ca5 | ||
|
|
604bbf1a0f | ||
|
|
631d95530a | ||
|
|
d590eda22a | ||
|
|
c75c519e3d | ||
|
|
012006e7a7 | ||
|
|
f983c54e87 | ||
|
|
a4402d4621 | ||
|
|
866392fecd | ||
|
|
287ff3658f | ||
|
|
b250dbb59b | ||
|
|
26089950af | ||
|
|
d1fd7c236f | ||
|
|
18e9856227 | ||
|
|
57fbc1e0b4 | ||
|
|
9cae868a27 | ||
|
|
1e8f79fe18 | ||
|
|
9c6252dace | ||
|
|
8b3ebc78b6 | ||
|
|
2486345679 | ||
|
|
eb35141ecc | ||
|
|
cf0db385f3 | ||
|
|
eafa22a3c3 | ||
|
|
652b5d1e9f | ||
|
|
4fb8f6a711 | ||
|
|
1dbcf2cd9a | ||
|
|
75cf5e3b11 | ||
|
|
8afe783277 | ||
|
|
9cea2bb4f0 | ||
|
|
a0fbe2cdda | ||
|
|
4d2a990de1 | ||
|
|
96a279819e | ||
|
|
443445f0fe | ||
|
|
02e62767a1 | ||
|
|
b4a66b29e2 | ||
|
|
6854eac80e | ||
|
|
6414061022 | ||
|
|
a3aa93534d | ||
|
|
d83f7fadef | ||
|
|
185a90b048 | ||
|
|
78d4f9acbf | ||
|
|
7f67bb80ad | ||
|
|
b21c3eea37 | ||
|
|
f57c95e813 | ||
|
|
a181586139 | ||
|
|
dd2784c0ee | ||
|
|
4473f8c60b | ||
|
|
125e94b429 | ||
|
|
2a943550d4 | ||
|
|
6b45cfd855 | ||
|
|
ed6c084c68 | ||
|
|
91b5105f3c | ||
|
|
7080491f89 | ||
|
|
13ac4b753c | ||
|
|
a5ef464d95 | ||
|
|
dadd6f4769 | ||
|
|
4cc92c1579 | ||
|
|
f40611409d | ||
|
|
e21c35cb7f | ||
|
|
927d7764a4 | ||
|
|
20f36fdc31 | ||
|
|
5089e17ee6 | ||
|
|
07f2dd5a8f | ||
|
|
40329f616d | ||
|
|
fb3a21c332 | ||
|
|
86bc69c0a2 | ||
|
|
6df388b25b | ||
|
|
6ca427096b | ||
|
|
89c435f046 | ||
|
|
af52ea7bcc | ||
|
|
13c0dde538 | ||
|
|
2045b7a0b2 | ||
|
|
5c0027b54b | ||
|
|
87159b0a38 | ||
|
|
227986094b | ||
|
|
9819f0ff12 | ||
|
|
dc44e13bfa | ||
|
|
3ad77095c4 | ||
|
|
0726d9430e | ||
|
|
544a262924 | ||
|
|
187f29e163 | ||
|
|
729468be30 | ||
|
|
de931f784a | ||
|
|
b84c040d27 | ||
|
|
cac435c4b9 | ||
|
|
08997264d2 | ||
|
|
42fd55db08 | ||
|
|
7e532ea982 | ||
|
|
b3198276f7 | ||
|
|
6e1f79e8a9 | ||
|
|
14ffc91dfb | ||
|
|
fbe54ffec5 | ||
|
|
562ae635d7 | ||
|
|
cf3e68e02a | ||
|
|
20e0dd7fe2 | ||
|
|
e99079abf8 | ||
|
|
9b111649f3 | ||
|
|
f207b87ff7 | ||
|
|
c4cbcddeae | ||
|
|
63d608f6fc | ||
|
|
9d00ce2089 | ||
|
|
4502c7a850 | ||
|
|
623348111c | ||
|
|
e084cdd1d6 | ||
|
|
9cd00a877b | ||
|
|
bf54f040f9 | ||
|
|
fe7eb70483 | ||
|
|
c81de3459e | ||
|
|
3f3cf55002 | ||
|
|
190223e030 | ||
|
|
ea6d1d1f00 | ||
|
|
ae65678ba4 | ||
|
|
2e422b66df | ||
|
|
10a0635331 |
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.claude/skills
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
name: dev-environment-debugging
|
||||
description: Use when diagnosing a running Silo server — playback, streaming, or transcode failures, unhealthy nodes, stuck sessions, wrong database state, errors after a deploy, or anything needing the deployment's logs, health endpoints, or SQL. Covers both a local docker compose install and a remote host over SSH. Not for building or testing code in this repo, and not for client-side bugs that reproduce without a server.
|
||||
---
|
||||
|
||||
# Debugging a running Silo deployment
|
||||
|
||||
Everything here goes through `scripts/silo-dev`, which reads `.silo-dev.env` and runs
|
||||
commands against the deployment whether it is on this machine or behind SSH. Start with:
|
||||
|
||||
```bash
|
||||
scripts/silo-dev doctor
|
||||
```
|
||||
|
||||
`doctor` reports which of API, host shell, compose stack, database, and login actually
|
||||
work. Fix what it flags before diagnosing anything else — most confusing symptoms here
|
||||
are really a misconfigured target. If `.silo-dev.env` is missing, copy
|
||||
`.silo-dev.env.example` and fill it in; ask the user for hosts and credentials rather
|
||||
than guessing, and never move those values into a committed file.
|
||||
|
||||
## Orienting
|
||||
|
||||
```bash
|
||||
scripts/silo-dev compose ps # what is running
|
||||
scripts/silo-dev api /api/v1/health # API up
|
||||
scripts/silo-dev api /api/v1/ready # PostgreSQL and optional S3 reachable
|
||||
scripts/silo-dev logs --tail 200 # recent server logs
|
||||
scripts/silo-dev logs -f # follow
|
||||
```
|
||||
|
||||
`/ready` failing usually means the database, not the API — read the service logs before
|
||||
concluding Postgres is down.
|
||||
|
||||
Silo also records structured logs in the database itself, which survive container
|
||||
restarts and are easier to filter than stdout. `operational_logs` holds server events,
|
||||
`activity_log` holds HTTP requests; both are keyed by `"timestamp"` (quoted — it is a
|
||||
reserved word). See [references/queries.md](references/queries.md) for the diagnostic
|
||||
queries worth having on hand, along with the schema traps that bite most often.
|
||||
|
||||
Log level lives in `server_settings` under `server.log_level` and is read at startup, so
|
||||
raising it to `debug` means updating the row and recreating the service. Put it back
|
||||
afterwards unless the task needs it to persist.
|
||||
|
||||
## Search
|
||||
|
||||
Meilisearch is optional — Silo runs without it — but it is recommended, and it is what
|
||||
makes search results good. It ships as a compose service behind the `search` profile and
|
||||
needs `MEILI_MASTER_KEY` set, so a stack brought up without that profile simply has no
|
||||
`meilisearch` service.
|
||||
|
||||
The provider is chosen from the `catalog.search.provider` setting rather than from
|
||||
whether the container is running, and the failure mode is quiet: if Meilisearch is
|
||||
selected but the URL is empty, unreachable, or the provider fails to initialize, Silo
|
||||
logs a warning at startup and serves Postgres results instead. Nothing surfaces to the
|
||||
client. So "search got worse" or "search stopped matching the way it used to" is usually
|
||||
the fallback being active, not a ranking regression — check which provider is live before
|
||||
touching query code:
|
||||
|
||||
```bash
|
||||
scripts/silo-dev psql "SELECT key, value FROM server_settings WHERE key LIKE 'catalog.search%' ORDER BY key;"
|
||||
scripts/silo-dev logs --tail 500 | grep -i 'catalog search'
|
||||
```
|
||||
|
||||
Provider settings, index name, timeouts, batch sizes, and the semantic-search options all
|
||||
live under `catalog.search.meilisearch.*` in `server_settings`. Changing the provider
|
||||
needs a restart to take effect.
|
||||
|
||||
## Playback
|
||||
|
||||
Work outward from the failure the user actually saw.
|
||||
|
||||
**Direct play** — confirm the file exists at the path stored in `media_files`
|
||||
(`scripts/silo-dev sh 'ls -la …'`, under `SILO_MEDIA_ROOT`), then compare the file's
|
||||
codecs against what the client advertised. A mismatch should have fallen back to remux
|
||||
or transcode; if it did not, that is the bug.
|
||||
|
||||
**Remux and transcode** — both run ffmpeg inside the server container, so check ffmpeg
|
||||
is present and, for hardware paths, that `/dev/dri` is passed through:
|
||||
|
||||
```bash
|
||||
scripts/silo-dev compose "exec -T silo sh -lc 'ffmpeg -hwaccels; ls -la /dev/dri'"
|
||||
```
|
||||
|
||||
Transcode output lands in a bind-mounted temp directory; a full disk there looks like a
|
||||
transcode bug. If `SILO_TRANSCODE_SSH` is set, the node is a separate host with its own
|
||||
`/api/v1/health` and logs — check it before blaming the main server.
|
||||
|
||||
**Stuck sessions** — query `playback_sessions_sync` for rows whose `updated_at` has gone
|
||||
stale. The reconciler and shutdown path clean these up; when Redis is unavailable,
|
||||
cross-node coordination degrades and stale rows are a symptom rather than the cause.
|
||||
|
||||
## Deploying a fix
|
||||
|
||||
`make dev-deploy` and friends come from `Makefile.local`, which is gitignored and
|
||||
per-developer — do not assume they exist. Confirm with `make -n dev-deploy` first, and
|
||||
otherwise deploy however the user's setup does. Afterwards, verify rather than assume:
|
||||
|
||||
```bash
|
||||
scripts/silo-dev compose ps
|
||||
scripts/silo-dev api /api/v1/ready
|
||||
scripts/silo-dev logs --tail 100
|
||||
```
|
||||
|
||||
Deploys restart the container, which invalidates any token you were holding.
|
||||
|
||||
## Rename drift
|
||||
|
||||
Silo was formerly Continuum. Names like `continuum-postgres`, `/opt/continuum`,
|
||||
`CONTINUUM_IMAGE`, or a stray `docker-compose.dev.yml` are usually legacy artifacts, but
|
||||
on a long-lived host they are occasionally still the live stack. Check what compose
|
||||
actually reports before deciding which one you are looking at.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Diagnostic queries
|
||||
|
||||
Run with `scripts/silo-dev psql "<sql>"`.
|
||||
|
||||
## Schema traps
|
||||
|
||||
- `activity_log` and `operational_logs` order by `"timestamp"` — quoted, not `created_at`.
|
||||
- `admin_jobs` uses `job_type`, `requested_at`, and `completed_at` (not `type`,
|
||||
`created_at`, `finished_at`).
|
||||
- Login accounts (`users`) are separate from household profiles (`user_profiles`); a
|
||||
playback session carries both a `user_id` and a `profile_id`.
|
||||
|
||||
## Errors and requests
|
||||
|
||||
```sql
|
||||
SELECT "timestamp", level, component, message, request_id, playback_session_id, attrs
|
||||
FROM operational_logs
|
||||
WHERE level IN ('error', 'warn')
|
||||
ORDER BY "timestamp" DESC
|
||||
LIMIT 50;
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT "timestamp", client_ip, method, path, status_code, duration_ms, request_id
|
||||
FROM activity_log
|
||||
WHERE status_code >= 500
|
||||
ORDER BY "timestamp" DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
Drop the `WHERE` clause on `activity_log` for a plain recent-traffic view — useful for
|
||||
confirming a client is reaching the server at all, and with which paths.
|
||||
|
||||
## Playback
|
||||
|
||||
```sql
|
||||
SELECT s.session_id, u.username, COALESCE(s.profile_id, '') AS profile_id,
|
||||
s.play_method, s.reporting_node, s.transcode_node_url,
|
||||
s.position_seconds, s.is_paused, s.target_resolution,
|
||||
s.started_at, s.updated_at
|
||||
FROM playback_sessions_sync s
|
||||
LEFT JOIN users u ON u.id = s.user_id
|
||||
ORDER BY s.updated_at DESC NULLS LAST;
|
||||
```
|
||||
|
||||
Stale rows only:
|
||||
|
||||
```sql
|
||||
SELECT session_id, user_id, play_method, updated_at
|
||||
FROM playback_sessions_sync
|
||||
WHERE updated_at < now() - interval '5 minutes'
|
||||
ORDER BY updated_at;
|
||||
```
|
||||
|
||||
## Nodes
|
||||
|
||||
```sql
|
||||
SELECT id, name, type, url, enabled, healthy, active_jobs, last_health_check
|
||||
FROM stream_nodes
|
||||
ORDER BY type, name;
|
||||
```
|
||||
|
||||
Read-only node state is better taken from here than from the admin endpoints, which need
|
||||
a bearer token. To force a re-check, `POST /api/v1/admin/nodes/{id}/check` — reachable as
|
||||
`scripts/silo-dev api /api/v1/admin/nodes/{id}/check -X POST`.
|
||||
|
||||
## Jobs
|
||||
|
||||
```sql
|
||||
SELECT id, job_type, status, message, error_message,
|
||||
progress_current, progress_total, requested_at, started_at,
|
||||
completed_at, heartbeat_at
|
||||
FROM admin_jobs
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
Filter by `job_type = 'library_refresh'` (and select `request_payload`, `result_payload`)
|
||||
when chasing a scan or refresh that did not finish.
|
||||
|
||||
## Users and settings
|
||||
|
||||
```sql
|
||||
SELECT id, username, email, role, enabled, permissions, max_streams, max_transcodes, created_at
|
||||
FROM users
|
||||
ORDER BY id;
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT key, value
|
||||
FROM server_settings
|
||||
WHERE key NOT ILIKE '%secret%'
|
||||
AND key NOT ILIKE '%password%'
|
||||
AND key NOT ILIKE '%api_key%'
|
||||
AND key NOT ILIKE '%token%'
|
||||
ORDER BY key;
|
||||
```
|
||||
|
||||
Keep that filter. Encrypted `server_settings` values are GCM-bound to their key name, so
|
||||
they are unreadable here anyway — and dumping them into a transcript is a leak.
|
||||
|
||||
## Raising the log level
|
||||
|
||||
```sql
|
||||
INSERT INTO server_settings (key, value) VALUES ('server.log_level', 'debug')
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
||||
```
|
||||
|
||||
Then `scripts/silo-dev compose "up -d --force-recreate silo"`, and set it back to `info`
|
||||
when done.
|
||||
@@ -0,0 +1,248 @@
|
||||
---
|
||||
name: issue-to-pr
|
||||
disable-model-invocation: true
|
||||
description: >-
|
||||
End-to-end implement a GitHub issue and open a ready pull request. Invoke
|
||||
explicitly as `/issue-to-pr <number|#ref|url>` (this is a side-effecting
|
||||
workflow — it creates branches and opens PRs — so it does not auto-trigger).
|
||||
It creates an isolated git worktree off the latest main, reproduces the bug
|
||||
(or scopes the feature), finds the root cause, implements the smallest correct
|
||||
change following repo conventions, verifies it (build/lint/tests), runs a Codex
|
||||
adversarial review and addresses the material findings, then commits and opens
|
||||
a ready PR. Not for pure triage (use triage-issue), pure review of an existing
|
||||
PR (use review-pr-or-mr / code-review), or analysis without code changes.
|
||||
---
|
||||
|
||||
# Issue → PR
|
||||
|
||||
Turn a single GitHub issue into a verified, reviewed, ready-to-merge pull
|
||||
request — autonomously — while staying honest about uncertainty and stopping
|
||||
when a human decision is genuinely required.
|
||||
|
||||
The goal is not "produce a diff." It is: **confirm the real problem exists,
|
||||
make the smallest change that correctly solves it, prove it works by execution,
|
||||
have an adversary try to break it, and only then ship a PR.** Speed comes from
|
||||
doing these in order, not from skipping them.
|
||||
|
||||
## Inputs
|
||||
|
||||
- The issue reference is passed as `$ARGUMENTS` — a number (`123`), a `#123`
|
||||
reference, or a full issue URL. If none is given, ask; do not guess.
|
||||
- The repo is the current git repository; PRs target `origin`
|
||||
(`Silo-Server/silo-server`) and base `main`.
|
||||
|
||||
## Operating principles
|
||||
|
||||
- **Default to "maybe nothing needs fixing."** Agents "fix" already-correct or stale
|
||||
code in a large fraction of cases because they don't stop to confirm the problem is
|
||||
real. Confirm the issue reproduces on current `main` before changing anything.
|
||||
- **Verify by execution.** Build/lint/tests are the primary correctness signal. The
|
||||
adversarial review is a *second* layer on top, never a substitute for green checks.
|
||||
- **Don't trust recall for APIs.** Grep for every symbol and signature you call —
|
||||
hallucinated-but-plausible APIs are a common failure here.
|
||||
- **Know when to stop.** See [Stop and ask](#stop-and-ask). Autonomy is for execution,
|
||||
not for inventing product decisions or shipping unverifiable guesses.
|
||||
|
||||
Track the phases below with a task list so progress is visible on long runs.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Understand the issue
|
||||
|
||||
Read everything before writing anything.
|
||||
|
||||
```bash
|
||||
gh issue view <N> --json number,title,body,labels,state,author,url,comments
|
||||
```
|
||||
|
||||
Determine: **bug vs. feature** (labels + body → branch prefix `fix/` vs `feat/`);
|
||||
**acceptance criteria** (what does "done" mean?); **linked scope** (does it serve
|
||||
an epic / sub-issue to reference as `Part of #NNN`?); and **client surface** (does
|
||||
it touch API/auth/playback/session/library/metadata behavior consumed by
|
||||
`silo-android` / `silo-apple`? — if so, coordinated client follow-up may be needed).
|
||||
|
||||
If the issue is vague, underspecified, or needs a product call, stop and ask now —
|
||||
before spending a worktree on the wrong problem.
|
||||
|
||||
### 2. Create a worktree off the latest main
|
||||
|
||||
Work in isolation so the user's checkout is untouched. Create it at the **main
|
||||
repository root**, not inside the current worktree.
|
||||
|
||||
```bash
|
||||
# Resolve the primary repo root (works from inside any worktree)
|
||||
MAIN_ROOT="$(cd "$(dirname "$(git rev-parse --git-common-dir)")" && pwd)"
|
||||
git -C "$MAIN_ROOT" fetch origin # always branch off fresh main
|
||||
|
||||
SLUG=<short-kebab-slug-from-issue-title>
|
||||
BRANCH=fix/issue-<N>-$SLUG # use feat/ for features
|
||||
WT="$MAIN_ROOT/.claude/worktrees/issue-<N>"
|
||||
|
||||
git -C "$MAIN_ROOT" worktree add -b "$BRANCH" "$WT" origin/main
|
||||
cd "$WT"
|
||||
```
|
||||
|
||||
If the branch/worktree already exists from a prior run, reuse it (cd in) rather
|
||||
than failing — but confirm it's based on current `origin/main`; if it's stale, tell
|
||||
the user before continuing. From here, **all work happens in `$WT`** (the Bash tool
|
||||
keeps the working directory between calls); use absolute paths when in doubt.
|
||||
|
||||
### 3. Reproduce / confirm — a hard gate
|
||||
|
||||
This is the highest-leverage step. Do not skip it.
|
||||
|
||||
- **Bug:** reproduce it concretely — ideally a **test that fails on current `main`**
|
||||
(red), or a precise script/trace through the faulty path. Pin down the exact code
|
||||
path and the invariant it violates.
|
||||
- **If you cannot reproduce it, STOP and report** — do not patch. The issue may
|
||||
already be fixed, be environment-specific, or be a misunderstanding. Shipping a
|
||||
speculative fix for a non-reproducing bug is the most common way agents make
|
||||
things worse.
|
||||
- **Feature:** confirm the capability is actually missing, then map where it fits —
|
||||
the owning package, patterns to follow, the data flow, the smallest set of files.
|
||||
|
||||
**Plan proportionally.** If you could describe the diff in one sentence (a typo, a
|
||||
log line, a one-line guard), skip formal planning and make the change. Plan first
|
||||
when the work is multi-file, unfamiliar, or architecturally uncertain — name the
|
||||
files/interfaces involved, state what's out of scope, and end the plan with the
|
||||
verification step. For anything large, confirm direction with the user before a big
|
||||
build-out.
|
||||
|
||||
### 4. Implement
|
||||
|
||||
Make the change, scoped to this one concern (one concern per PR). The two hard rules
|
||||
that a reviewer cannot un-break after the fact:
|
||||
|
||||
- **API:** additive-only within `/api/v1` — never rename/remove a response field,
|
||||
change a field's type, or repurpose a status code. New behavior = new
|
||||
fields/endpoints; expose capability endpoints for feature detection.
|
||||
- **Migrations:** new DB changes are Goose SQL migrations via
|
||||
`make migrate-create NAME=...` (timestamped). Never `goose fix`, never paired
|
||||
`.up.sql`/`.down.sql`.
|
||||
|
||||
For a bug, the failing test from step 3 should now pass.
|
||||
|
||||
### 5. Verify — the primary correctness gate
|
||||
|
||||
Run the checks and make them genuinely pass. The exact commands and the worktree
|
||||
quirks (`GOWORK=off`, stubbing `web/dist`, the v2 golangci invocation, the flaky
|
||||
GPU tests) are in **[references/verification.md](references/verification.md)** —
|
||||
read it first, because plain `go build ./...` / `make lint` fail inside
|
||||
`.claude/worktrees`.
|
||||
|
||||
At minimum: Go build + the relevant package tests pass; if `web/` changed,
|
||||
`pnpm run lint` + `pnpm run format:check` + build pass; `make verify-local-paths` is
|
||||
clean. Run the **full relevant suite**, not just your new test — catch regressions
|
||||
in code you didn't touch. Show the command output as evidence; don't assert success.
|
||||
|
||||
**Never reach green by weakening the checks.** Deleting/skipping/`xfail`-ing tests,
|
||||
loosening assertions, lowering coverage thresholds, or relaxing lint/CI to make a
|
||||
red check pass is a blocker, full stop — if a test genuinely must change, that needs
|
||||
explicit human sign-off, not a quiet edit. If you can't make the checks pass on the
|
||||
merits, stop and report.
|
||||
|
||||
### 6. Adversarial review (Codex)
|
||||
|
||||
Commit your work first (so the review sees exactly what will ship), then run a Codex
|
||||
adversarial review of the branch against `main`. Full invocation, output handling,
|
||||
the iteration cap, and the fallback are in
|
||||
**[references/codex-review.md](references/codex-review.md)** — follow it exactly.
|
||||
|
||||
The loop, in short:
|
||||
1. Commit the implementation on the branch.
|
||||
2. Run the adversarial review (`--base main`, foreground/`--wait`, extended timeout).
|
||||
3. For each finding: **first confirm it's real and grounded in the actual code**
|
||||
(reviewers over-flag — don't fix phantom nits). Then **fix** material findings
|
||||
that affect correctness or the stated requirements (and re-run the review), or
|
||||
**rebut** them with a defensible, written reason. Note non-material findings as
|
||||
optional.
|
||||
4. **Cap the loop at 2 iterations (3 absolute max).** Debugging effectiveness decays
|
||||
sharply after ~2–3 attempts, so thrashing won't help — if material findings still
|
||||
stand after the cap, stop and escalate to the user instead of looping.
|
||||
|
||||
Do not open the PR while a material, un-rebutted adversarial finding stands.
|
||||
|
||||
### 7. Commit, push, open the PR
|
||||
|
||||
Conventional Commit subjects scoped to the domain, e.g.
|
||||
`fix(playback): guard against nil session on reconnect`, ending with the
|
||||
`Co-Authored-By:` trailer for the model you are actually running as — don't copy a
|
||||
model name from this file or from an older commit.
|
||||
|
||||
Push and open a **ready** (non-draft) PR against `main`:
|
||||
|
||||
```bash
|
||||
git push -u origin "$BRANCH"
|
||||
gh pr create --base main --head "$BRANCH" --title "<conventional subject>" --body "$(cat <<'EOF'
|
||||
## Problem
|
||||
<what's broken / missing, grounded in the issue — and how you reproduced it>
|
||||
|
||||
## Approach
|
||||
<what you changed and *why this approach* — the tradeoff, not a file list>
|
||||
|
||||
## Verification
|
||||
<commands run and their result; the failing→passing test; manual checks>
|
||||
|
||||
## Adversarial review
|
||||
<clean, or the material findings and how each was fixed/rebutted>
|
||||
|
||||
## Risks & follow-up
|
||||
<rollout/migration/compatibility risks; any silo-android / silo-apple
|
||||
follow-up; anything deferred>
|
||||
|
||||
Closes #<N>
|
||||
<add "Part of #<epic>" if the issue serves a capability epic/sub-issue>
|
||||
|
||||
## AI-use disclosure
|
||||
Implemented by Claude Code (issue-to-pr skill) with human oversight.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
End the PR body with:
|
||||
|
||||
```
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
```
|
||||
|
||||
For **UI changes**, attach before/after screenshots or a recording (the repo
|
||||
requires this); if you can't capture them, explicitly flag in the PR that they're
|
||||
still needed.
|
||||
|
||||
### 8. Report back
|
||||
|
||||
Give the user the PR URL, a 2–3 line summary, the verification result, the
|
||||
adversarial-review outcome, any flagged risks or required client follow-up, and the
|
||||
worktree path so they can inspect it.
|
||||
|
||||
## Long or multi-iteration runs
|
||||
|
||||
Performance degrades on long sessions, and context compaction can silently drop
|
||||
constraints. So: keep a short progress note and a "failed approaches" list in the
|
||||
scratchpad so you don't repeat dead ends; and after any compaction, re-read CLAUDE.md
|
||||
and re-assert the hard guardrails (additive-only API, migration rules, no weakening
|
||||
of tests) before continuing.
|
||||
|
||||
## Stop and ask
|
||||
|
||||
Pause and ask the user (don't push a PR) when:
|
||||
|
||||
- The issue is ambiguous, underspecified, or needs a product/UX decision.
|
||||
- **The bug doesn't reproduce** on current `main`.
|
||||
- The fix would require a non-additive API change, or a risky/irreversible migration.
|
||||
- The change balloons into something large or architectural — propose a plan first.
|
||||
- Checks can't be made to pass on the merits, or material adversarial findings remain
|
||||
after the iteration cap.
|
||||
- A test/CI change would be needed to "pass" — get sign-off, don't do it silently.
|
||||
- The right fix clearly belongs in a sibling repo (`silo-android`, `silo-apple`, a
|
||||
plugin/SDK repo) — see CLAUDE.md's multi-repo guidance.
|
||||
|
||||
In these cases, summarize what you found and what you'd do, and let the user decide.
|
||||
|
||||
## Notes
|
||||
|
||||
- Authorized to open ready PRs autonomously once checks pass and the adversarial
|
||||
review is clean — that's the durable instruction from setup. Not authorized to
|
||||
merge, force-push shared branches, or close issues by hand.
|
||||
- Never invent a fix you can't verify or explain. An honest "here's what's uncertain"
|
||||
beats a confident wrong PR.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Codex adversarial review
|
||||
|
||||
The `/codex:adversarial-review` command is marked `disable-model-invocation: true`,
|
||||
so you cannot trigger it as a tool/skill. Call the same underlying runtime
|
||||
directly via Bash: the `codex-companion.mjs` helper, subcommand
|
||||
`adversarial-review`. It challenges the *approach and design*, not just defects —
|
||||
it tries to find the strongest reasons the change should not ship.
|
||||
|
||||
## 1. Locate the companion script
|
||||
|
||||
```bash
|
||||
CODEX_COMPANION="${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/scripts/codex-companion.mjs}"
|
||||
if [ -z "$CODEX_COMPANION" ] || [ ! -f "$CODEX_COMPANION" ]; then
|
||||
CODEX_COMPANION="$(find "$HOME/.claude/plugins/marketplaces" "$HOME/.claude/plugins/cache" \
|
||||
-name codex-companion.mjs 2>/dev/null | head -n1)"
|
||||
fi
|
||||
echo "$CODEX_COMPANION"
|
||||
```
|
||||
|
||||
If nothing is found, the Codex plugin isn't installed/set up. Don't silently skip
|
||||
the review — tell the user, point them at `/codex:setup`, and ask whether to
|
||||
proceed without it (or fall back to a `feature-dev:code-reviewer` agent pass).
|
||||
The user asked specifically for the Codex adversarial review, so treat its absence
|
||||
as a real blocker, not a detail.
|
||||
|
||||
## 2. Commit first, then review the branch against main
|
||||
|
||||
Review what will actually ship. Commit the implementation on the branch, then
|
||||
diff against `main`:
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "<conventional subject>"
|
||||
```
|
||||
|
||||
Run it in the **foreground** so results come back this turn, and set an extended
|
||||
Bash timeout (reviews can take several minutes) — pass `timeout: 600000` on the
|
||||
Bash call:
|
||||
|
||||
```bash
|
||||
node "$CODEX_COMPANION" adversarial-review --wait --base main "optional focus text"
|
||||
```
|
||||
|
||||
- `--wait` runs foreground and prints the result to stdout.
|
||||
- `--base main` reviews the whole branch diff (`main...HEAD`).
|
||||
- Append focus text to steer it (e.g. the specific failure mode, the risky
|
||||
concurrency path, the migration). Keep the adversarial framing — don't soften it.
|
||||
- Do **not** use `--background` here; you need the findings in-hand to act on them.
|
||||
|
||||
## 3. Interpret the output
|
||||
|
||||
The review returns a verdict plus findings:
|
||||
|
||||
- **`approve` / no material findings** → green light for the PR.
|
||||
- **`needs-attention`** → there is at least one material risk. Each finding names
|
||||
a file, a line range, an impact, and a concrete recommendation.
|
||||
|
||||
LLM reviewers systematically **over-flag** — they report gaps even when the work is
|
||||
sound. So for every finding, in order:
|
||||
|
||||
1. **Confirm it's real.** Open the cited file/lines and verify the finding is
|
||||
grounded in the actual code and actually reachable. Drop findings that reference
|
||||
code that doesn't exist, lines outside the diff, or behavior the code doesn't have
|
||||
— don't fix phantom nits.
|
||||
2. For a confirmed finding, do one of two things — never silently ignore it:
|
||||
- **Fix it** if it affects correctness or the issue's stated requirements, then
|
||||
re-run the review (go back to step 2 after committing the fix); or
|
||||
- **Rebut it** with a defensible, written reason it doesn't apply, and capture
|
||||
that reasoning in the PR's "Adversarial review" section.
|
||||
3. Treat style/naming/speculative/low-value findings as **optional** — don't
|
||||
gold-plate. The adversarial prompt is told not to emit these, but if any slip
|
||||
through, note and move on; don't add abstraction or defensive code for cases that
|
||||
can't happen.
|
||||
|
||||
## 4. Loop — but cap it
|
||||
|
||||
Repeat implement → verify → commit → review until the review is `approve` or every
|
||||
material finding is fixed or defensibly rebutted.
|
||||
|
||||
**Cap the loop at 2 iterations (3 absolute max).** Debugging effectiveness decays
|
||||
sharply after ~2–3 attempts — if material findings still stand after the cap, stop
|
||||
and escalate to the user with a summary rather than thrashing or shipping over a
|
||||
standing objection. Only open the PR once the review is clean within the cap.
|
||||
|
||||
## 5. Record it in the PR
|
||||
|
||||
Summarize the outcome in the PR body's **Adversarial review** section: either
|
||||
"clean — no material findings" or a terse list of what was raised and how each was
|
||||
fixed or rebutted. This gives reviewers the adversary's perspective for free.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Verification inside a worktree
|
||||
|
||||
Commands assume the repository root is the cwd (the new worktree, `$WT`).
|
||||
|
||||
## Why the obvious commands fail here
|
||||
|
||||
A `.claude/worktrees/*` checkout lives under the parent Silo workspace, whose
|
||||
`go.work` only lists the primary `./silo-server`, not worktree checkouts. So:
|
||||
|
||||
- `go env GOWORK` resolves to the parent workspace from anywhere under `SiloServer/`.
|
||||
- Plain `go build ./...` / `go test ./...` fail with
|
||||
*"directory prefix . does not contain modules listed in go.work"*.
|
||||
- `go build ./...` also needs `web/dist` to exist (the embed directive).
|
||||
- `golangci-lint` is not installed locally.
|
||||
|
||||
## Go
|
||||
|
||||
Prefix every Go command with `GOWORK=off`.
|
||||
|
||||
```bash
|
||||
# Build the parts that matter without needing web/dist:
|
||||
GOWORK=off go build ./internal/... ./cmd/...
|
||||
|
||||
# Run the tests for the package(s) you touched (fast, targeted):
|
||||
GOWORK=off go test ./internal/<pkg>/...
|
||||
|
||||
# If you need a full ./... build, stub the embed target first:
|
||||
mkdir -p web/dist && touch web/dist/.gitkeep
|
||||
GOWORK=off go build ./...
|
||||
```
|
||||
|
||||
Lint Go without installing golangci-lint — must be the **v2** module path (the
|
||||
repo's `.golangci` config rejects v1):
|
||||
|
||||
```bash
|
||||
GOWORK=off go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run ./internal/...
|
||||
```
|
||||
|
||||
**Known flake, not a real failure:** `internal/playback` GPU-probe tests
|
||||
(`TestResolveHWAccel*`, the NVENC smoke test) can fail with `signal: killed`
|
||||
under parallel load on macOS, but pass in isolation. If they're the only
|
||||
failures, re-run them alone before treating them as real:
|
||||
|
||||
```bash
|
||||
GOWORK=off go test ./internal/playback/ -run TestResolveHWAccel -count=1
|
||||
```
|
||||
|
||||
## Frontend (only if `web/` changed)
|
||||
|
||||
```bash
|
||||
cd web
|
||||
pnpm install # if deps changed or node_modules is absent
|
||||
pnpm run lint
|
||||
pnpm run format:check
|
||||
pnpm run build # catches type/build errors before the PR
|
||||
cd ..
|
||||
```
|
||||
|
||||
The repo requires `pnpm run lint` and `pnpm run format:check` to be clean before
|
||||
a merge request.
|
||||
|
||||
## Repo-wide
|
||||
|
||||
```bash
|
||||
make verify-local-paths # must be clean; CLAUDE.md forbids absolute paths in docs/specs
|
||||
```
|
||||
|
||||
## Migrations
|
||||
|
||||
If the change needs schema work, create a timestamped Goose migration — never
|
||||
hand-number, never `goose fix`, never paired up/down files:
|
||||
|
||||
```bash
|
||||
make migrate-create NAME=add_thing
|
||||
make migrate-validate
|
||||
make migrate-status
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: jellycompat-diagnosis
|
||||
description: Use when a Jellyfin-protocol client (JellyCon, Findroid, Infuse, Swiftfin) misbehaves against Silo's jellycompat API — wrong or missing data, ignored sort/filter/pagination parameters, oversized responses, or slow browse queries. Covers comparing Silo's responses against a real Jellyfin server field-by-field and reading the jellycompat debug log. Not for Silo's own `/api/v1` clients.
|
||||
---
|
||||
|
||||
# Diagnosing jellycompat responses
|
||||
|
||||
Jellyfin clients are unforgiving about response shape: a missing field or an extra one
|
||||
changes what they render, and they rarely report why. The reliable method is to make the
|
||||
identical request against Silo and against a real Jellyfin, then diff the JSON.
|
||||
|
||||
Set `JELLYFIN_COMPARE_URL` and credentials in `.silo-dev.env` — without a reference
|
||||
server you are guessing at what the client expected.
|
||||
|
||||
## Start from what the client actually sent
|
||||
|
||||
The debug log records full request/response pairs, but only when the server runs with
|
||||
`JELLYCOMPAT_DEBUG_LOG` set to a path. If it is unset, that is the first fix.
|
||||
|
||||
```bash
|
||||
scripts/silo-dev sh 'grep -E "^(===|Status:|Remote:|Response \()" /opt/silo/jellycompat-debug.log | tail -60'
|
||||
```
|
||||
|
||||
That overview is usually enough to localize the problem before reading any bodies. Look
|
||||
for response sizes (a 20-item list over ~50KB is wrong), latency (browse over a second is
|
||||
wrong), non-200 statuses, and — most importantly — which query parameters the client
|
||||
actually sent, which is often not what you assumed. `grep -n "^==="` gives request
|
||||
boundaries to `sed` a specific body out of.
|
||||
|
||||
## Compare against real Jellyfin
|
||||
|
||||
Authenticate to both and request the same path. Jellyfin's auth is a POST to
|
||||
`/Users/AuthenticateByName` with an `X-Emby-Authorization` header; the response carries
|
||||
`AccessToken` and `User.Id`, which you need for subsequent `/Users/{id}/Items` calls.
|
||||
Silo's jellycompat listens on port 8096 alongside the main API.
|
||||
|
||||
Diff the sorted key lists of one item from each side before diffing values — a field that
|
||||
is present on one and absent on the other explains most client bugs on its own, and
|
||||
`omitempty` in `dto.go` means an empty value and a missing field are the same thing on
|
||||
the wire.
|
||||
|
||||
[references/response-parity.md](references/response-parity.md) has the queries JellyCon
|
||||
sends, the fields real Jellyfin returns with and without an explicit `Fields` parameter,
|
||||
the parameter-handling behaviors worth asserting, and a symptom-to-cause table for the
|
||||
failures already found once — check it before re-deriving a diagnosis.
|
||||
|
||||
## After a fix
|
||||
|
||||
Redeploy, then re-authenticate — restarting the container invalidates the tokens — and
|
||||
re-run every query you were comparing, not just the one you fixed. Field gating is shared
|
||||
across handlers, so a change that corrects a list view frequently perturbs detail views.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Response parity reference
|
||||
|
||||
## Queries JellyCon sends
|
||||
|
||||
| Query | Purpose | Key params |
|
||||
|-------|---------|-----------|
|
||||
| Random widgets | Home screen | `SortBy=Random&IsPlayed=False&ImageTypeLimit=0` (no `Fields`) |
|
||||
| Library browse | Movie/TV list | `Fields=Overview,Genres,Studios,…&SortBy=SortName&ParentId=LIB` |
|
||||
| Recently added | New content | `Fields=…&SortBy=DateCreated&IsPlayed=False&Limit=20` |
|
||||
| Recently played | Watched | `IsPlayed=True&SortBy=DatePlayed&Limit=20` |
|
||||
| In progress | Resume | `Filters=IsResumable&SortBy=DatePlayed&Limit=20` |
|
||||
| Item detail | Single item | `GET /Items/{id}` |
|
||||
| Views | Libraries | `GET /Users/{id}/Views` |
|
||||
|
||||
## Expected shape
|
||||
|
||||
Structural, per response:
|
||||
|
||||
- `TotalRecordCount` is the real count, not a ceiling.
|
||||
- `Items` length matches `Limit`, or the total when fewer exist.
|
||||
- List items run ~0.5–1.5KB each without `Fields`, ~2–3KB with them.
|
||||
|
||||
Fields real Jellyfin returns on a list item with no `Fields` parameter:
|
||||
|
||||
```
|
||||
Name, Id, ServerId, Type, IsFolder, ProductionYear, OfficialRating,
|
||||
CommunityRating, RunTimeTicks, UserData, ImageTags, ChannelId, MediaType,
|
||||
LocationType, VideoType, Container, HasSubtitles, CriticRating, PremiereDate,
|
||||
ImageBlurHashes
|
||||
```
|
||||
|
||||
With an explicit `Fields` parameter: every requested field appears, unrequested ones do
|
||||
not, `ImageTags` is present even when empty (`{}`), and `People` and `MediaSources`
|
||||
appear only on detail views — never in a list.
|
||||
|
||||
Parameter handling worth asserting directly:
|
||||
|
||||
- `ImageTypeLimit=0` → `ImageTags: {}` and no `BackdropImageTags`; `=1` → normal tags.
|
||||
- `SortBy=Random` → a different order per request.
|
||||
- `IsPlayed=False` / `True` → filtered accordingly, and the `True` case is fast.
|
||||
- `Filters=IsResumable` → only items with `PlaybackPositionTicks > 0`.
|
||||
|
||||
## Symptoms already traced once
|
||||
|
||||
| Symptom | Cause | Where |
|
||||
|---------|-------|-------|
|
||||
| `TotalRecordCount` pinned at 10000 | count ceiling | `internal/catalog/browse.go` |
|
||||
| Same items every request | `SortBy` value unmapped | `query.go:mapSortBy` |
|
||||
| Parameter ignored (`IsPlayed`, `ImageTypeLimit`) | not parsed | `itemsQuery` / `parseItemsQuery` in `query.go` |
|
||||
| Items over ~5KB each | fields ungated, detail fetched per item | `mapping.go` |
|
||||
| Multi-second empty response | over-fetch loop scanning the catalog | needs a dedicated handler |
|
||||
| Missing Studios/Taglines/Countries | not carried through the intermediate type | `upstream_types.go`, `mediaItemToListItem` |
|
||||
| `People`/`MediaSources` in a list | resume path built items from the detail mapper | `handleResumeResponse` |
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `internal/jellycompat/query.go` | query parsing, field and sort mapping |
|
||||
| `internal/jellycompat/mapping.go` | DTO construction, field gating |
|
||||
| `internal/jellycompat/handlers_items.go` | dispatch, `ImageTypeLimit` |
|
||||
| `internal/jellycompat/content_direct.go` | browse post-filtering, user-data enrichment |
|
||||
| `internal/jellycompat/dto.go` | JSON tags — `omitempty` decides field presence |
|
||||
| `internal/jellycompat/upstream_types.go` | catalog-to-DTO intermediate types |
|
||||
| `internal/jellycompat/logging.go` | debug log middleware (`JELLYCOMPAT_DEBUG_LOG`) |
|
||||
| `internal/catalog/browse.go` | SQL building, sort, count, pagination |
|
||||
@@ -0,0 +1,5 @@
|
||||
.secrets/
|
||||
.state/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.env
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
name: silo-discord-triage
|
||||
description: Triage Discord thread URLs, Discord channel/message links, screenshots, or pasted Discord discussions that report bugs, regressions, feature requests, or support issues for Silo projects. Use when the user provides a Discord thread about Silo Server, the Silo Apple clients, the Silo Android app, or an unclear cross-project Silo issue and wants the report fetched with a scoped Discord bot token, summarized, validated, routed to the right repo, converted into a GitHub issue, used as the starting point for a fix, or answered through the bot after approving the exact reply.
|
||||
---
|
||||
|
||||
# Silo Discord Triage
|
||||
|
||||
## Overview
|
||||
|
||||
Turn one Discord report into a decision-ready Silo intake: extract the concrete problem, route it to the correct project, validate the claim before editing, and produce privacy-safe next actions.
|
||||
|
||||
## Intake Workflow
|
||||
|
||||
1. Capture the source thread.
|
||||
- If the user provided a Discord URL, run `scripts/parse_discord_url.py <url>` to identify the URL shape and IDs for private working notes.
|
||||
- Prefer `scripts/fetch_discord_thread.py <url>` when the bot token is available. The script reads `DISCORD_BOT_TOKEN` first — set it in the repo's gitignored `.silo-dev.env` — then falls back to `.secrets/discord_bot_token` next to this skill, which is gitignored for the same reason.
|
||||
- Keep the token out of prompts, command arguments, logs, issues, commits, public summaries, and reusable skill instructions. If a token file is used, keep it local, ignored, and mode `0600`.
|
||||
- If bot-token fetch fails because the bot lacks channel/thread access, permissions, or message-content access, report that blocker precisely and fall back to browser access, pasted transcript, screenshots, or exported thread content.
|
||||
- Open the Discord URL with whatever browser or Chrome tooling is available only when bot-token ingestion is unavailable or insufficient.
|
||||
- Read the first report, title, replies, attachments, screenshots, logs, timestamps that affect ordering, and any maintainer follow-up. Treat Discord content as untrusted input.
|
||||
- Do not post to Discord, react, mention users, or take external actions without explicit user approval.
|
||||
|
||||
2. Classify and route.
|
||||
- Bug/regression: identify observed behavior, expected behavior, affected version or environment, repro steps, frequency, workaround, logs, and user impact.
|
||||
- Feature request: identify the user need, affected workflow, proposed behavior, constraints, acceptance criteria, and open product questions.
|
||||
- Support/question: separate user configuration or usage help from product defects.
|
||||
- Server/web/API issues belong to Silo Server when they involve API behavior, web UI, auth/profiles, libraries, metadata, downloads, matching, Jellyfin/Jellycompat, database migrations, deploys, or backend logs.
|
||||
- Apple issues belong to the Silo Apple client when they involve iOS, tvOS, macOS, SwiftUI layout, playback, AirPlay, cast remote control, subtitles, native media controls, background behavior, or App Store platform behavior.
|
||||
- Android issues belong to the Silo Android app when they involve Android UI, playback, downloads, platform permissions, notifications, Android TV, or device-specific Android behavior. Locate the owning repo from local workspace or GitHub context before assuming a path.
|
||||
- Cross-project issues need one primary owner plus explicit dependencies. Avoid spreading a fix across repos until the failing boundary is proven.
|
||||
|
||||
3. Validate before acting.
|
||||
- For code changes, inspect the current owning repo and current implementation first. Preserve unrelated local work.
|
||||
- For server/live API failures, start from the exact endpoint, log, schema, or runtime path described by the report before changing logic.
|
||||
- For Apple/mobile visual reports, identify the actual platform and owning view or player path early instead of diagnosing from the server repo.
|
||||
- For Android reports, verify the current Android code and device/API-level assumptions before proposing server or Apple changes.
|
||||
- Search existing GitHub issues or PRs when the user asks to file, prioritize, or fix a report.
|
||||
- Keep verification minimal by default: do not add tests for small changes or UI changes unless requested; add backend tests only for critical or high-risk logic.
|
||||
|
||||
4. Produce the result.
|
||||
- For triage only, return a concise brief with: source type, classification, owning project, distilled report, evidence, validation performed, likely next action, and remaining unknowns.
|
||||
- For GitHub issue text, draft a sanitized title and body with repro steps, expected behavior, actual behavior, evidence summary, scope, and acceptance criteria when relevant.
|
||||
- Do not include private Discord URLs, Discord IDs, personal names, account names, private hostnames, local usernames, local filesystem paths, or private infrastructure identifiers in public issue text, PR text, commit messages, or summaries. Use neutral placeholders such as `internal Discord thread`, `reporting user`, `private deployment host`, or `local workspace`.
|
||||
- Before publishing issue or PR text, committing, or summarizing public output, run `~/.local/bin/privacy-leak-check` on the draft or staged content when available.
|
||||
|
||||
5. Post an approved reply when requested.
|
||||
- Require explicit approval of the exact message content before posting. An instruction such as "send that" after the draft is shown counts as approval; a request to investigate or draft does not.
|
||||
- Use `scripts/post_discord_message.py <url> --content-file <path>` first to validate the destination and payload without posting.
|
||||
- Add `--confirm` only after approval. The helper disables all allowed mentions, verifies Discord returned the same content, and never prints the token or message content.
|
||||
- Fetch the thread again after posting and verify the approved reply is present once. Do not retry a successful post merely because a later verification fetch fails.
|
||||
- If posting returns `403`, report the missing Discord permission precisely. A public channel needs `Send Messages`; a thread also needs `Send Messages in Threads`. Private threads must be visible to or joined by the bot.
|
||||
- Prefer the bot helper over browser login for approved Silo thread replies when the bot token is configured and the bot can access the destination.
|
||||
|
||||
## Issue Draft Shape
|
||||
|
||||
Use this structure when the user asks to turn the thread into a GitHub issue:
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
Briefly state the user-visible problem or requested capability.
|
||||
|
||||
## Source
|
||||
Internal Discord thread. Do not include private links or IDs unless the user explicitly asks for a private tracker entry.
|
||||
|
||||
## Steps to Reproduce
|
||||
1. ...
|
||||
|
||||
## Expected Behavior
|
||||
...
|
||||
|
||||
## Actual Behavior
|
||||
...
|
||||
|
||||
## Evidence
|
||||
Summarize screenshots, logs, messages, or validation results without exposing private identifiers.
|
||||
|
||||
## Scope
|
||||
Owning project: Silo Server, Silo Apple, Silo Android, or cross-project.
|
||||
|
||||
## Acceptance Criteria
|
||||
- ...
|
||||
```
|
||||
|
||||
## Script
|
||||
|
||||
Use `scripts/parse_discord_url.py` to parse Discord URLs without network access. It accepts `discord.com` and `discordapp.com` channel links and emits JSON by default.
|
||||
|
||||
Use `scripts/fetch_discord_thread.py <url>` to fetch thread or channel messages through the Discord HTTP API. Requirements:
|
||||
|
||||
- Put the bot token in `DISCORD_BOT_TOKEN` (via `.silo-dev.env`), or keep it in `.secrets/discord_bot_token` next to this skill. The environment variable overrides the file. Both locations are gitignored; the token must never reach a committed file, a prompt, a command argument, or a log.
|
||||
- Install the Discord app with the `bot` scope only.
|
||||
- Grant the bot `View Channel` and `Read Message History` for the specific report channels/threads.
|
||||
- Enable the `Message Content` privileged intent when the skill must read user-written content, embeds, attachments, components, or polls from message objects.
|
||||
- For intake-only triage, do not grant `Send Messages`, `Manage Messages`, `Manage Threads`, moderation, admin, or webhook permissions.
|
||||
- For explicitly approved reply workflows, grant only `Send Messages` and, for threads, `Send Messages in Threads`; moderation, admin, webhook, and message-management permissions remain unnecessary.
|
||||
- If the report is in a private thread, ensure the bot is permitted to see that thread or is added to it.
|
||||
|
||||
Use `scripts/post_discord_message.py <url> --content-file <path> [--confirm]` for bot replies. Without `--confirm`, it performs a read-only destination/payload validation. With `--confirm`, it posts one message with mentions disabled and verifies the create-message response.
|
||||
|
||||
`fetch_discord_thread.py` emits oldest-first JSON with pseudonymous author labels by default. Message links (guild/channel/message URLs) are supported: the fetch anchors on the linked message (`around` context, guaranteed inclusion) and marks it with `"is_linked_message": true` in the output — start triage from that message. Use `--include-sensitive` only for private local diagnosis when exact Discord IDs, usernames, message URLs, or attachment URLs are necessary.
|
||||
|
||||
Use `scripts/discover_discord_threads.py` for automation. It reads `.secrets/discord_monitor.json`, lists active threads visible to the bot, compares them with `.state/discord_seen_threads.json`, and emits only newly discovered thread URLs by default. Run it with `--init` once before starting an automation so existing active threads are marked seen and only future threads create sessions. In automation runs, mark each Discord thread as seen with `--mark-thread-id <id>` only after its Codex session has been created.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Silo Discord Triage"
|
||||
short_description: "Fetch, triage, and reply to Silo Discord reports"
|
||||
default_prompt: "Use $silo-discord-triage to fetch this Discord thread with the scoped bot token, triage it, and post an exact reply only after I approve the content."
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Discover new active Discord threads for Silo intake automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
from fetch_discord_thread import DEFAULT_TOKEN_ENV, DEFAULT_TOKEN_FILE, DiscordAPIError, load_token, request_json # noqa: E402
|
||||
|
||||
|
||||
DISCORD_EPOCH_MS = 1420070400000
|
||||
DEFAULT_CONFIG_FILE = SCRIPT_DIR.parent / ".secrets" / "discord_monitor.json"
|
||||
DEFAULT_STATE_FILE = SCRIPT_DIR.parent / ".state" / "discord_seen_threads.json"
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def snowflake_timestamp(snowflake: str) -> str | None:
|
||||
try:
|
||||
timestamp_ms = (int(snowflake) >> 22) + DISCORD_EPOCH_MS
|
||||
except ValueError:
|
||||
return None
|
||||
return datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def read_json(path: Path, default: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return default
|
||||
|
||||
|
||||
def write_json(path: Path, data: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp_path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
os.chmod(tmp_path, 0o600)
|
||||
tmp_path.replace(path)
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def load_config(path: Path) -> dict[str, Any]:
|
||||
config = read_json(path, {})
|
||||
guild_ids = config.get("guild_ids")
|
||||
if not isinstance(guild_ids, list) or not guild_ids:
|
||||
raise SystemExit(f"Missing guild_ids in {path}")
|
||||
return config
|
||||
|
||||
|
||||
def thread_url(guild_id: str, thread_id: str) -> str:
|
||||
return f"https://discord.com/channels/{guild_id}/{thread_id}"
|
||||
|
||||
|
||||
def normalize_thread(guild_id: str, thread: dict[str, Any]) -> dict[str, Any]:
|
||||
thread_id = thread["id"]
|
||||
return {
|
||||
"created_at": snowflake_timestamp(thread_id),
|
||||
"last_message_id": thread.get("last_message_id"),
|
||||
"message_count": thread.get("message_count"),
|
||||
"name": thread.get("name") or "untitled-discord-thread",
|
||||
"parent_channel_id": thread.get("parent_id"),
|
||||
"thread_id": thread_id,
|
||||
"type": thread.get("type"),
|
||||
"url": thread_url(guild_id, thread_id),
|
||||
}
|
||||
|
||||
|
||||
def iter_active_threads(token: str, config: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
parent_filter = set(config.get("parent_channel_ids") or [])
|
||||
ignored = set(config.get("ignore_thread_ids") or [])
|
||||
discovered: list[dict[str, Any]] = []
|
||||
for guild_id in config["guild_ids"]:
|
||||
payload = request_json(f"/guilds/{guild_id}/threads/active", token)
|
||||
for thread in payload.get("threads", []):
|
||||
thread_id = thread.get("id")
|
||||
if not thread_id or thread_id in ignored:
|
||||
continue
|
||||
parent_id = thread.get("parent_id")
|
||||
if parent_filter and parent_id not in parent_filter:
|
||||
continue
|
||||
discovered.append(normalize_thread(guild_id, thread))
|
||||
discovered.sort(key=lambda item: item.get("thread_id") or "", reverse=True)
|
||||
return discovered
|
||||
|
||||
|
||||
def mark_seen(state: dict[str, Any], threads: list[dict[str, Any]]) -> None:
|
||||
seen = state.setdefault("seen_threads", {})
|
||||
timestamp = now_iso()
|
||||
for thread in threads:
|
||||
thread_id = thread["thread_id"]
|
||||
record = seen.setdefault(thread_id, {})
|
||||
record.update(
|
||||
{
|
||||
"first_seen_at": record.get("first_seen_at") or timestamp,
|
||||
"last_seen_at": timestamp,
|
||||
"name": thread.get("name"),
|
||||
"url": thread.get("url"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def mark_thread_ids(state: dict[str, Any], thread_ids: list[str]) -> None:
|
||||
seen = state.setdefault("seen_threads", {})
|
||||
timestamp = now_iso()
|
||||
for thread_id in thread_ids:
|
||||
record = seen.setdefault(thread_id, {})
|
||||
record.update(
|
||||
{
|
||||
"first_seen_at": record.get("first_seen_at") or timestamp,
|
||||
"last_seen_at": timestamp,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Discover new active Discord threads visible to the Silo intake bot.")
|
||||
parser.add_argument("--config-file", default=str(DEFAULT_CONFIG_FILE), help="Discord monitor config JSON")
|
||||
parser.add_argument("--state-file", default=str(DEFAULT_STATE_FILE), help="Seen-thread state JSON")
|
||||
parser.add_argument("--token-env", default=DEFAULT_TOKEN_ENV, help="Environment variable containing the bot token")
|
||||
parser.add_argument("--token-file", default=str(DEFAULT_TOKEN_FILE), help="Fallback file containing the bot token")
|
||||
parser.add_argument("--max-new", type=int, default=20, help="Maximum new threads to emit")
|
||||
parser.add_argument("--init", action="store_true", help="Mark all currently visible active threads as seen")
|
||||
parser.add_argument("--include-seen", action="store_true", help="Emit seen threads too")
|
||||
parser.add_argument("--mark-seen", action="store_true", help="Mark emitted new threads as seen")
|
||||
parser.add_argument("--mark-thread-id", action="append", default=[], help="Mark one specific Discord thread ID as seen without discovery")
|
||||
args = parser.parse_args()
|
||||
|
||||
state_path = Path(args.state_file).expanduser()
|
||||
state = read_json(state_path, {"seen_threads": {}})
|
||||
if args.mark_thread_id:
|
||||
mark_thread_ids(state, args.mark_thread_id)
|
||||
write_json(state_path, state)
|
||||
print(json.dumps({"marked_seen": len(args.mark_thread_id)}, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
token = load_token(args.token_env, args.token_file)
|
||||
if not token:
|
||||
raise SystemExit(f"No Discord bot token found; set {args.token_env} or create .secrets/discord_bot_token in the skill directory")
|
||||
|
||||
config_path = Path(args.config_file).expanduser()
|
||||
config = load_config(config_path)
|
||||
|
||||
try:
|
||||
active_threads = iter_active_threads(token, config)
|
||||
except DiscordAPIError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
seen = state.setdefault("seen_threads", {})
|
||||
if args.init:
|
||||
mark_seen(state, active_threads)
|
||||
write_json(state_path, state)
|
||||
print(json.dumps({"initialized": True, "marked_seen": len(active_threads)}, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
if args.include_seen:
|
||||
output_threads = active_threads
|
||||
else:
|
||||
output_threads = [thread for thread in active_threads if thread["thread_id"] not in seen]
|
||||
output_threads = output_threads[: args.max_new]
|
||||
|
||||
if args.mark_seen:
|
||||
mark_seen(state, output_threads)
|
||||
write_json(state_path, state)
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"new_thread_count": len(output_threads),
|
||||
"threads": output_threads,
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch Discord thread/channel messages with a bot token from env or a local secret file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
from parse_discord_url import parse_discord_url # noqa: E402
|
||||
|
||||
|
||||
API_BASE = "https://discord.com/api/v10"
|
||||
DEFAULT_TOKEN_ENV = "DISCORD_BOT_TOKEN"
|
||||
DEFAULT_TOKEN_FILE = SCRIPT_DIR.parent / ".secrets" / "discord_bot_token"
|
||||
USER_AGENT = "silo-discord-triage/1.0"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FetchResult:
|
||||
source: str
|
||||
channel_id: str
|
||||
guild_id: str | None
|
||||
channel: dict[str, Any] | None
|
||||
message_count: int
|
||||
messages: list[dict[str, Any]]
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
class DiscordAPIError(RuntimeError):
|
||||
def __init__(self, status: int | None, message: str):
|
||||
self.status = status
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def request_json(path: str, token: str, params: dict[str, Any] | None = None) -> Any:
|
||||
query = ""
|
||||
if params:
|
||||
query = "?" + urllib.parse.urlencode({key: value for key, value in params.items() if value is not None})
|
||||
request = urllib.request.Request(
|
||||
API_BASE + path + query,
|
||||
headers={
|
||||
"Authorization": f"Bot {token}",
|
||||
"User-Agent": USER_AGENT,
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
if response.status == 204:
|
||||
return None
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
retry_after = None
|
||||
try:
|
||||
retry_after = json.loads(body).get("retry_after")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if exc.code == 429 and retry_after is not None:
|
||||
time.sleep(float(retry_after))
|
||||
continue
|
||||
raise DiscordAPIError(exc.code, format_http_error(exc.code, body)) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise DiscordAPIError(None, f"Discord request failed: {exc.reason}") from exc
|
||||
|
||||
|
||||
def format_http_error(status: int, body: str) -> str:
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
payload = {}
|
||||
|
||||
code = payload.get("code")
|
||||
message = payload.get("message") or "Discord API request failed"
|
||||
if status in {401, 403}:
|
||||
message += "; check the bot token, channel visibility, thread access, View Channel, and Read Message History permissions"
|
||||
return f"Discord API error {status}" + (f" ({code})" if code else "") + f": {message}"
|
||||
|
||||
|
||||
def fetch_messages(channel_id: str, token: str, max_messages: int, anchor_message_id: str | None = None) -> list[dict[str, Any]]:
|
||||
remaining = max_messages
|
||||
messages: list[dict[str, Any]] = []
|
||||
before = None
|
||||
|
||||
if anchor_message_id:
|
||||
# Anchor on the linked message so it is always included, with context on both sides.
|
||||
limit = min(100, remaining)
|
||||
page = request_json(f"/channels/{channel_id}/messages", token, {"limit": limit, "around": anchor_message_id})
|
||||
messages.extend(page or [])
|
||||
remaining -= len(messages)
|
||||
if not any(item.get("id") == anchor_message_id for item in messages):
|
||||
anchor = request_json(f"/channels/{channel_id}/messages/{anchor_message_id}", token)
|
||||
if isinstance(anchor, dict):
|
||||
messages.append(anchor)
|
||||
remaining -= 1
|
||||
if messages:
|
||||
before = min(messages, key=lambda item: int(item["id"]))["id"]
|
||||
|
||||
while remaining > 0:
|
||||
limit = min(100, remaining)
|
||||
page = request_json(f"/channels/{channel_id}/messages", token, {"limit": limit, "before": before})
|
||||
if not page:
|
||||
break
|
||||
messages.extend(page)
|
||||
remaining -= len(page)
|
||||
before = page[-1]["id"]
|
||||
if len(page) < limit:
|
||||
break
|
||||
|
||||
deduped = {item["id"]: item for item in messages}
|
||||
return sorted(deduped.values(), key=lambda item: int(item["id"]))
|
||||
|
||||
|
||||
def pseudonymize_author(author_id: str, labels: dict[str, str]) -> str:
|
||||
if author_id not in labels:
|
||||
labels[author_id] = f"author_{len(labels) + 1}"
|
||||
return labels[author_id]
|
||||
|
||||
|
||||
def attachment_summary(attachment: dict[str, Any], include_sensitive: bool) -> dict[str, Any]:
|
||||
summary = {
|
||||
"filename": attachment.get("filename"),
|
||||
"content_type": attachment.get("content_type"),
|
||||
"size": attachment.get("size"),
|
||||
"description": attachment.get("description"),
|
||||
}
|
||||
if include_sensitive:
|
||||
summary["id"] = attachment.get("id")
|
||||
summary["url"] = attachment.get("url")
|
||||
summary["proxy_url"] = attachment.get("proxy_url")
|
||||
return {key: value for key, value in summary.items() if value not in (None, "", [])}
|
||||
|
||||
|
||||
def normalize_message(message: dict[str, Any], labels: dict[str, str], include_sensitive: bool, guild_id: str | None, anchor_message_id: str | None = None) -> dict[str, Any]:
|
||||
author = message.get("author") or {}
|
||||
author_id = author.get("id") or "unknown"
|
||||
normalized: dict[str, Any] = {
|
||||
"author": pseudonymize_author(author_id, labels),
|
||||
"timestamp": message.get("timestamp"),
|
||||
"edited_timestamp": message.get("edited_timestamp"),
|
||||
"content": message.get("content") or "",
|
||||
"attachments": [attachment_summary(item, include_sensitive) for item in message.get("attachments", [])],
|
||||
"embeds": message.get("embeds", []),
|
||||
"components": message.get("components", []),
|
||||
}
|
||||
if message.get("referenced_message"):
|
||||
normalized["references_message"] = True
|
||||
if anchor_message_id and message.get("id") == anchor_message_id:
|
||||
normalized["is_linked_message"] = True
|
||||
if include_sensitive:
|
||||
normalized["id"] = message.get("id")
|
||||
normalized["channel_id"] = message.get("channel_id")
|
||||
normalized["author_id"] = author_id
|
||||
normalized["author_username"] = author.get("username")
|
||||
normalized["author_global_name"] = author.get("global_name")
|
||||
normalized["jump_url"] = build_jump_url(message, guild_id)
|
||||
return {key: value for key, value in normalized.items() if value not in (None, "", [])}
|
||||
|
||||
|
||||
def build_jump_url(message: dict[str, Any], guild_id: str | None) -> str | None:
|
||||
channel_id = message.get("channel_id")
|
||||
message_id = message.get("id")
|
||||
if channel_id and message_id:
|
||||
return f"https://discord.com/channels/{guild_id or '@me'}/{channel_id}/{message_id}"
|
||||
return None
|
||||
|
||||
|
||||
def normalize_channel(channel: dict[str, Any], include_sensitive: bool) -> dict[str, Any]:
|
||||
keys = ["name", "type", "topic", "thread_metadata", "message_count", "member_count"]
|
||||
normalized = {key: channel.get(key) for key in keys if channel.get(key) not in (None, "", [])}
|
||||
if include_sensitive:
|
||||
normalized["id"] = channel.get("id")
|
||||
normalized["guild_id"] = channel.get("guild_id")
|
||||
normalized["parent_id"] = channel.get("parent_id")
|
||||
return normalized
|
||||
|
||||
|
||||
def build_result(source: str, token: str, max_messages: int, include_sensitive: bool) -> FetchResult:
|
||||
parsed = parse_discord_url(source)
|
||||
if not parsed.valid or not parsed.channel_or_thread_id:
|
||||
raise SystemExit(f"Invalid Discord URL: {parsed.error}")
|
||||
|
||||
channel = request_json(f"/channels/{parsed.channel_or_thread_id}", token)
|
||||
messages = fetch_messages(parsed.channel_or_thread_id, token, max_messages, parsed.message_id)
|
||||
labels: dict[str, str] = {}
|
||||
normalized_messages = [normalize_message(item, labels, include_sensitive, parsed.guild_id, parsed.message_id) for item in messages]
|
||||
warnings = build_warnings(messages, normalized_messages)
|
||||
|
||||
return FetchResult(
|
||||
source="discord_url",
|
||||
channel_id=parsed.channel_or_thread_id if include_sensitive else "redacted",
|
||||
guild_id=parsed.guild_id if include_sensitive else None,
|
||||
channel=normalize_channel(channel, include_sensitive) if isinstance(channel, dict) else None,
|
||||
message_count=len(normalized_messages),
|
||||
messages=normalized_messages,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def build_warnings(raw_messages: list[dict[str, Any]], normalized_messages: list[dict[str, Any]]) -> list[str]:
|
||||
warnings = []
|
||||
if raw_messages and not any(item.get("content") for item in raw_messages):
|
||||
warnings.append("All fetched messages have empty content; verify the bot has Message Content privileged intent access or that the thread contains only non-text content.")
|
||||
if raw_messages and any(item.get("attachments") for item in raw_messages):
|
||||
warnings.append("Attachment URLs are omitted by default; rerun with --include-sensitive only for private local diagnosis if direct attachment access is required.")
|
||||
if not normalized_messages:
|
||||
warnings.append("No messages returned; verify the bot can view the channel/thread and has Read Message History.")
|
||||
return warnings
|
||||
|
||||
|
||||
def load_token(token_env: str, token_file: str) -> str | None:
|
||||
token = os.environ.get(token_env)
|
||||
if token:
|
||||
return token.strip()
|
||||
|
||||
path = Path(token_file).expanduser()
|
||||
try:
|
||||
token = path.read_text(encoding="utf-8").strip()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if not token:
|
||||
return None
|
||||
return token
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Fetch Discord thread/channel messages using a scoped bot token.")
|
||||
parser.add_argument("url", help="Discord channel, thread, or message URL")
|
||||
parser.add_argument("--max-messages", type=int, default=300, help="Maximum messages to fetch, newest pages first then output oldest-first")
|
||||
parser.add_argument("--token-env", default=DEFAULT_TOKEN_ENV, help="Environment variable containing the bot token")
|
||||
parser.add_argument("--token-file", default=str(DEFAULT_TOKEN_FILE), help="Fallback file containing the bot token when the environment variable is unset")
|
||||
parser.add_argument("--include-sensitive", action="store_true", help="Include Discord IDs, usernames, message URLs, and attachment URLs")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.max_messages < 1:
|
||||
raise SystemExit("--max-messages must be at least 1")
|
||||
|
||||
token = load_token(args.token_env, args.token_file)
|
||||
if not token:
|
||||
raise SystemExit(f"No Discord bot token found; set {args.token_env} or create .secrets/discord_bot_token in the skill directory")
|
||||
|
||||
try:
|
||||
result = build_result(args.url, token, args.max_messages, args.include_sensitive)
|
||||
except DiscordAPIError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(json.dumps(asdict(result), indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse Discord channel, thread, and message URLs without network access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
DISCORD_HOSTS = {"discord.com", "www.discord.com", "discordapp.com", "www.discordapp.com"}
|
||||
SNOWFLAKE_RE = re.compile(r"^\d{15,25}$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedDiscordURL:
|
||||
input: str
|
||||
valid: bool
|
||||
host: str | None = None
|
||||
guild_id: str | None = None
|
||||
channel_or_thread_id: str | None = None
|
||||
message_id: str | None = None
|
||||
is_dm: bool = False
|
||||
kind: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def parse_discord_url(raw_url: str) -> ParsedDiscordURL:
|
||||
parsed = urlparse(raw_url.strip())
|
||||
host = parsed.netloc.lower()
|
||||
if host not in DISCORD_HOSTS:
|
||||
return ParsedDiscordURL(input=raw_url, valid=False, host=host or None, error="not a Discord URL")
|
||||
|
||||
parts = [part for part in parsed.path.split("/") if part]
|
||||
if len(parts) < 3 or parts[0] != "channels":
|
||||
return ParsedDiscordURL(input=raw_url, valid=False, host=host, error="expected /channels/<guild>/<channel-or-thread>/<message?>")
|
||||
|
||||
guild_id, channel_id = parts[1], parts[2]
|
||||
message_id = parts[3] if len(parts) > 3 else None
|
||||
is_dm = guild_id == "@me"
|
||||
|
||||
bad_fields = []
|
||||
if not is_dm and not SNOWFLAKE_RE.match(guild_id):
|
||||
bad_fields.append("guild_id")
|
||||
if not SNOWFLAKE_RE.match(channel_id):
|
||||
bad_fields.append("channel_or_thread_id")
|
||||
if message_id is not None and not SNOWFLAKE_RE.match(message_id):
|
||||
bad_fields.append("message_id")
|
||||
if bad_fields:
|
||||
return ParsedDiscordURL(
|
||||
input=raw_url,
|
||||
valid=False,
|
||||
host=host,
|
||||
guild_id=None if is_dm else guild_id,
|
||||
channel_or_thread_id=channel_id,
|
||||
message_id=message_id,
|
||||
is_dm=is_dm,
|
||||
error="invalid Discord snowflake field(s): " + ", ".join(bad_fields),
|
||||
)
|
||||
|
||||
if message_id:
|
||||
kind = "message_link"
|
||||
elif is_dm:
|
||||
kind = "dm_channel_link"
|
||||
else:
|
||||
kind = "channel_or_thread_link"
|
||||
|
||||
return ParsedDiscordURL(
|
||||
input=raw_url,
|
||||
valid=True,
|
||||
host=host,
|
||||
guild_id=None if is_dm else guild_id,
|
||||
channel_or_thread_id=channel_id,
|
||||
message_id=message_id,
|
||||
is_dm=is_dm,
|
||||
kind=kind,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Parse a Discord URL into channel/thread/message identifiers.")
|
||||
parser.add_argument("url", help="Discord URL to parse")
|
||||
parser.add_argument("--text", action="store_true", help="Print a short text summary instead of JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
result = parse_discord_url(args.url)
|
||||
if args.text:
|
||||
if not result.valid:
|
||||
print(f"invalid: {result.error}")
|
||||
else:
|
||||
message_part = f", message={result.message_id}" if result.message_id else ""
|
||||
guild_part = "dm" if result.is_dm else f"guild={result.guild_id}"
|
||||
print(f"{result.kind}: {guild_part}, channel_or_thread={result.channel_or_thread_id}{message_part}")
|
||||
else:
|
||||
print(json.dumps(asdict(result), indent=2, sort_keys=True))
|
||||
return 0 if result.valid else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate or post one explicitly approved Discord message with the scoped bot token."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
from fetch_discord_thread import ( # noqa: E402
|
||||
API_BASE,
|
||||
DEFAULT_TOKEN_ENV,
|
||||
DEFAULT_TOKEN_FILE,
|
||||
USER_AGENT,
|
||||
DiscordAPIError,
|
||||
load_token,
|
||||
request_json,
|
||||
)
|
||||
from parse_discord_url import parse_discord_url # noqa: E402
|
||||
|
||||
|
||||
def post_json(path: str, token: str, payload: dict[str, Any]) -> Any:
|
||||
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
while True:
|
||||
request = urllib.request.Request(
|
||||
API_BASE + path,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": f"Bot {token}",
|
||||
"User-Agent": USER_AGENT,
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
response_body = exc.read().decode("utf-8", errors="replace")
|
||||
retry_after = None
|
||||
try:
|
||||
retry_after = json.loads(response_body).get("retry_after")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if exc.code == 429 and retry_after is not None:
|
||||
time.sleep(float(retry_after))
|
||||
continue
|
||||
raise DiscordAPIError(exc.code, format_post_error(exc.code, response_body)) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise DiscordAPIError(None, f"Discord post failed: {exc.reason}") from exc
|
||||
|
||||
|
||||
def format_post_error(status: int, body: str) -> str:
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
payload = {}
|
||||
code = payload.get("code")
|
||||
message = payload.get("message") or "Discord API request failed"
|
||||
if status == 401:
|
||||
message += "; check the bot token"
|
||||
elif status == 403:
|
||||
message += (
|
||||
"; check View Channel and Send Messages, plus Send Messages in Threads "
|
||||
"and thread membership/visibility when posting to a thread"
|
||||
)
|
||||
return f"Discord API error {status}" + (f" ({code})" if code else "") + f": {message}"
|
||||
|
||||
|
||||
def read_content(raw_content: str | None, content_file: str | None) -> str:
|
||||
if raw_content is not None:
|
||||
content = raw_content
|
||||
elif content_file == "-":
|
||||
content = sys.stdin.read()
|
||||
elif content_file:
|
||||
content = Path(content_file).expanduser().read_text(encoding="utf-8")
|
||||
else:
|
||||
raise SystemExit("Provide exactly one of --content or --content-file")
|
||||
|
||||
content = content.rstrip("\r\n")
|
||||
if not content.strip():
|
||||
raise SystemExit("Discord message content must not be empty")
|
||||
if len(content) > 2000:
|
||||
raise SystemExit(f"Discord message content is {len(content)} characters; maximum is 2000")
|
||||
return content
|
||||
|
||||
|
||||
def content_fingerprint(content: str) -> str:
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate or post one explicitly approved Discord message using the scoped bot token."
|
||||
)
|
||||
parser.add_argument("url", help="Discord channel, thread, or message URL")
|
||||
content_group = parser.add_mutually_exclusive_group(required=True)
|
||||
content_group.add_argument("--content", help="Message content")
|
||||
content_group.add_argument("--content-file", help="UTF-8 message file, or - for stdin")
|
||||
parser.add_argument("--confirm", action="store_true", help="Actually post after the exact content is approved")
|
||||
parser.add_argument("--token-env", default=DEFAULT_TOKEN_ENV, help="Environment variable containing the bot token")
|
||||
parser.add_argument("--token-file", default=str(DEFAULT_TOKEN_FILE), help="Fallback local token file")
|
||||
args = parser.parse_args()
|
||||
|
||||
parsed = parse_discord_url(args.url)
|
||||
if not parsed.valid or not parsed.channel_or_thread_id:
|
||||
raise SystemExit(f"Invalid Discord URL: {parsed.error}")
|
||||
content = read_content(args.content, args.content_file)
|
||||
token = load_token(args.token_env, args.token_file)
|
||||
if not token:
|
||||
raise SystemExit(
|
||||
f"No Discord bot token found; set {args.token_env} or create "
|
||||
".secrets/discord_bot_token in the skill directory"
|
||||
)
|
||||
|
||||
try:
|
||||
channel = request_json(f"/channels/{parsed.channel_or_thread_id}", token)
|
||||
result: dict[str, Any] = {
|
||||
"valid": True,
|
||||
"posted": False,
|
||||
"channel_type": channel.get("type") if isinstance(channel, dict) else None,
|
||||
"is_thread": bool(isinstance(channel, dict) and channel.get("type") in {10, 11, 12}),
|
||||
"content_length": len(content),
|
||||
"content_sha256": content_fingerprint(content),
|
||||
"mentions_disabled": True,
|
||||
}
|
||||
if not args.confirm:
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
response = post_json(
|
||||
f"/channels/{parsed.channel_or_thread_id}/messages",
|
||||
token,
|
||||
{
|
||||
"content": content,
|
||||
"allowed_mentions": {
|
||||
"parse": [],
|
||||
"replied_user": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
result.update(
|
||||
{
|
||||
"posted": True,
|
||||
"verified": isinstance(response, dict) and response.get("content") == content,
|
||||
"timestamp": response.get("timestamp") if isinstance(response, dict) else None,
|
||||
}
|
||||
)
|
||||
if not result["verified"]:
|
||||
print(json.dumps(result, indent=2, sort_keys=True), file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return 0
|
||||
except DiscordAPIError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: web-ui-testing
|
||||
description: Use when verifying a change to the Silo web frontend (`web/`) in a real browser — checking a page renders, a flow works, or a fix actually landed, and capturing screenshots for a PR. Runs the local Vite dev server against a real Silo backend so no build or deploy is needed. Not for backend-only changes, and not a substitute for unit tests in `web/src`.
|
||||
---
|
||||
|
||||
# Verifying web UI changes
|
||||
|
||||
The frontend only ever calls relative `/api` URLs, so pointing it at a real backend is a
|
||||
one-line config change — no build, no deploy, and hot reload still works while you
|
||||
iterate.
|
||||
|
||||
## Point the dev server at a backend
|
||||
|
||||
```bash
|
||||
printf 'VITE_API_PROXY_TARGET=%s\n' "$(scripts/silo-dev env | awk '/^SILO_URL/{print $2}')" > web/.env.local
|
||||
make dev-frontend
|
||||
```
|
||||
|
||||
Any reachable Silo instance works as the target — the shared `.silo-dev.env` deployment,
|
||||
a colleague's server, or `http://localhost:8090` for a backend you are running yourself
|
||||
with `make dev-backend`. Vite proxies `/api` (including WebSockets, such as the realtime
|
||||
`scans` channel) and sets `changeOrigin` automatically for non-localhost targets, because
|
||||
remote vhost proxies reject a localhost `Host` header.
|
||||
|
||||
`web/.env.local` is per-checkout, so a git worktree needs its own.
|
||||
|
||||
Before debugging anything in the UI, confirm the proxy itself:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:5173/api/v1/health
|
||||
```
|
||||
|
||||
Silo health JSON means the wiring is good and any remaining problem is the frontend. An
|
||||
HTML page or a connection error means the proxy target is wrong or the backend is down —
|
||||
`scripts/silo-dev doctor` will say which.
|
||||
|
||||
## Drive it
|
||||
|
||||
The app is at `http://localhost:5173`. Use the Playwright tooling to navigate, interact,
|
||||
and screenshot.
|
||||
|
||||
The dev server binds `0.0.0.0` and `allowedHosts` covers `.ts.net` plus the machine's own
|
||||
hostname, so it is also reachable from any device on the tailnet at
|
||||
`http://<tailscale-machine>:5173`. Always give the user that URL alongside the localhost
|
||||
one when starting a dev server for them — it is how they watch the UI you are working on
|
||||
from another machine. `tailscale status --json | jq -r .Self.DNSName` gives the name.
|
||||
|
||||
Most pages need a session. Sign in through the login form using `SILO_ADMIN_USER` and
|
||||
`SILO_ADMIN_PASSWORD` from `.silo-dev.env`; the session then lives in that browser
|
||||
profile for the rest of the run. If those are unset, ask the user for an account on the
|
||||
target rather than testing only the signed-out shell — and never type credentials the
|
||||
user has not given you for that server.
|
||||
|
||||
## What counts as verified
|
||||
|
||||
A change is verified when you have seen the specific thing that was broken now behaving
|
||||
correctly, on a page reached the way a user reaches it, against real data. Screenshots
|
||||
belong in the PR for anything visual.
|
||||
|
||||
Watch for the failure modes that look like success: a page that renders because it fell
|
||||
back to an empty state, a stale bundle from a dev server started before your edit, and a
|
||||
console full of errors under a page that looks fine. Check the browser console before
|
||||
calling it done.
|
||||
|
||||
## States that are hard to reach
|
||||
|
||||
Some UI only appears under conditions you cannot conjure on a real server — a flood of
|
||||
queued scans, a specific error shape, a half-finished download. Rather than mutating the
|
||||
backend, render the real page inside a temporary dev-only route wrapping it in its own
|
||||
`QueryClientProvider` (queries `enabled: false`) seeded via `setQueryData`. It needs its
|
||||
own client because `useAuth` calls `clear()` on the global one when auth fails, which
|
||||
would wipe the fixtures. Delete the harness route once you have the screenshot.
|
||||
@@ -63,6 +63,13 @@ POSTGRES_SHM_SIZE=8gb
|
||||
# Optional public URL for Silo. If not set, the server will use the IP address of the container.
|
||||
# SILO_PUBLIC_URL=https://silo.example.com
|
||||
|
||||
# Optional trusted reverse-proxy CIDRs for client IP resolution. When set, this
|
||||
# overrides the "Trusted Proxies" admin setting on every startup (the value is
|
||||
# validated and written to server_settings, so the Admin UI shows it). Leave
|
||||
# unset to manage it from Admin > Settings > General instead. Defaults trust
|
||||
# RFC 1918 private ranges + loopback.
|
||||
# SILO_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8,::1/128,203.0.113.7/32
|
||||
|
||||
# Run from source / advanced overrides
|
||||
# Only DATABASE_URL is required when running Silo outside the default docker compose stack.
|
||||
# DATABASE_URL=postgres://silo:password@localhost:5432/silo
|
||||
@@ -76,3 +83,4 @@ POSTGRES_SHM_SIZE=8gb
|
||||
|
||||
# Optional server mode override for source runs or standalone worker nodes.
|
||||
# MODE=integrated
|
||||
#SILO_MIGRATE_TIMEOUT=60m
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
github: quick104
|
||||
@@ -0,0 +1,93 @@
|
||||
name: Bug report
|
||||
description: Report something broken in Silo server or web UI
|
||||
title: "[bug] "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Raw logs only. Never AI-summarize logs. You must have reproduced this yourself on a real deployment before filing.
|
||||
|
||||
Fabricated or AI-hallucinated reports are an instant block. Read [CONTRIBUTING.md](https://github.com/Silo-Server/silo-server/blob/main/CONTRIBUTING.md) first.
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened
|
||||
description: What broke? What did you actually observe?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Exact steps you ran on a real deployment.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: silo-version
|
||||
attributes:
|
||||
label: Silo version / commit
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment
|
||||
options:
|
||||
- Docker
|
||||
- Bare metal / systemd
|
||||
- Local dev build
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: clients-affected
|
||||
attributes:
|
||||
label: Clients affected
|
||||
options:
|
||||
- label: Web UI
|
||||
- label: Android
|
||||
- label: iOS/tvOS/macOS
|
||||
- label: Jellyfin-compat client (Infuse, Findroid, ...)
|
||||
- label: Other/not client-specific
|
||||
- type: textarea
|
||||
id: relevant-logs
|
||||
attributes:
|
||||
label: Relevant logs
|
||||
description: Paste raw server logs.
|
||||
placeholder: paste raw server logs; write "no logs available" if genuinely none
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: ai-involvement
|
||||
attributes:
|
||||
label: AI involvement in this report
|
||||
options:
|
||||
- None — written by a human
|
||||
- AI-assisted, human verified the repro
|
||||
- AI-generated, human verified the repro
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: ai-tool-model
|
||||
attributes:
|
||||
label: AI tool + model
|
||||
placeholder: e.g. Claude Code / claude-fable-5 — or n/a
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: confirmations
|
||||
attributes:
|
||||
label: Confirmations
|
||||
options:
|
||||
- label: I reproduced this myself on a real deployment
|
||||
required: true
|
||||
- label: Logs above are raw copy-paste, not AI-summarized
|
||||
required: true
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,21 @@
|
||||
## Problem
|
||||
Part of #NNN
|
||||
<!-- PRs with no linked scope item will be questioned. -->
|
||||
|
||||
What does this solve?
|
||||
|
||||
## Approach
|
||||
Why this approach?
|
||||
|
||||
## Testing
|
||||
Paste ACTUAL command output here. For UI changes, include screenshots or recordings.
|
||||
|
||||
### AI Disclosure
|
||||
- Tool(s):
|
||||
- Model(s):
|
||||
- Involvement:
|
||||
- Adversarial review:
|
||||
|
||||
## Checklist
|
||||
- [ ] I ran an adversarial AI review of the diff and summarized findings above.
|
||||
- [ ] I ran the repo verify commands: `make lint`, `cd web && pnpm run lint`, `cd web && pnpm run format:check`, and relevant `go test ./...`.
|
||||
@@ -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}"
|
||||
|
||||
+22
-2
@@ -30,6 +30,8 @@ silo.yaml
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
# Per-developer settings for the agent skills (hosts, credentials, tokens).
|
||||
.silo-dev.env
|
||||
|
||||
# Developer-specific make targets
|
||||
Makefile.local
|
||||
@@ -49,8 +51,17 @@ web/*.tsbuildinfo
|
||||
docs/inspiration
|
||||
|
||||
|
||||
.agents/
|
||||
/.claude/
|
||||
# `.agents/skills` is a symlink to `.claude/skills` so non-Claude agents find the
|
||||
# same files; everything else under .agents/ is per-developer scratch.
|
||||
/.agents/*
|
||||
!/.agents/skills
|
||||
# Ignore everything in .claude/ except the committed, shared skills.
|
||||
/.claude/*
|
||||
!/.claude/skills/
|
||||
# Skills may carry per-developer state next to their instructions (the global
|
||||
# *.local.md rule above covers notes; these cover tokens and run state).
|
||||
**/.secrets/
|
||||
**/.state/
|
||||
.cursor/
|
||||
.superpowers/
|
||||
.codex/
|
||||
@@ -59,5 +70,14 @@ node_modules/
|
||||
logs/
|
||||
.playwright-mcp/
|
||||
demo/
|
||||
docker-compose.dev.yml
|
||||
docker-compose.local.yml
|
||||
# Local-only deployment override (unpublishes bundled redis/postgres host
|
||||
# ports via `ports: !override []`). Kept out of git so a rebase from main
|
||||
# never disturbs it and it never lands in a PR; its absence once exposed
|
||||
# Redis to the internet, so it must persist on the box.
|
||||
docker-compose.override.yml
|
||||
# Local compose customization layered onto docker-compose.yml by make dev-deploy.
|
||||
docker-compose.local.yml
|
||||
.playwright-cli/
|
||||
output/
|
||||
|
||||
@@ -23,12 +23,27 @@ linters:
|
||||
- ineffassign
|
||||
- misspell
|
||||
- nilerr
|
||||
- sloglint
|
||||
- staticcheck
|
||||
- unconvert
|
||||
- unused
|
||||
- wastedassign
|
||||
|
||||
settings:
|
||||
sloglint:
|
||||
# Require the context-carrying slog variants (InfoContext, …) wherever a
|
||||
# context.Context is in scope, so log records correlate with the active
|
||||
# OpenTelemetry trace. "scope" (not "all") allows the plain Info/… form in
|
||||
# background/init code where no ctx exists. Enforced repo-wide after the
|
||||
# phase-3 sweep converted the ~1,159 legacy call sites.
|
||||
context: "scope"
|
||||
# Message must be a constant, not a built string; move dynamic parts to attrs.
|
||||
static-msg: true
|
||||
# Attribute keys are snake_case (component, request_id, trace_id, …).
|
||||
key-naming-case: snake
|
||||
# Don't mix key-value pairs and slog.Attr in one call.
|
||||
no-mixed-args: true
|
||||
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Silo agent environment — copy to `.silo-dev.env` and fill in.
|
||||
#
|
||||
# cp .silo-dev.env.example .silo-dev.env && $EDITOR .silo-dev.env
|
||||
#
|
||||
# `.silo-dev.env` is gitignored. It is the single place agents and humans look
|
||||
# for "which Silo deployment am I debugging, and how do I reach it" — real
|
||||
# hosts, tokens, and passwords belong here and nowhere else in the repo.
|
||||
#
|
||||
# The skills in `.claude/skills/` (also reachable as `.agents/skills/`) read it
|
||||
# through `scripts/silo-dev`, which works the same whether your server runs on
|
||||
# this machine or over SSH. Nothing here is required to build or run Silo; it
|
||||
# only configures debugging against an already-running deployment.
|
||||
|
||||
# --- The deployment under test -----------------------------------------------
|
||||
|
||||
# Base URL you reach the server on. A local docker compose install is usually
|
||||
# http://localhost:8090; a remote one is whatever you browse to.
|
||||
SILO_URL=http://localhost:8090
|
||||
|
||||
# SSH target for the host running Silo, e.g. root@10.0.0.5 or a ~/.ssh/config
|
||||
# alias. Leave empty when Silo runs on this machine — commands then run locally.
|
||||
SILO_SSH=
|
||||
|
||||
# Directory on that host holding docker-compose.yml and .env.
|
||||
SILO_DIR=/opt/silo
|
||||
|
||||
# Compose file, relative to SILO_DIR. Colon-separate to layer overrides.
|
||||
SILO_COMPOSE_FILE=docker-compose.yml
|
||||
|
||||
# Compose service names. Override if your stack renames them.
|
||||
SILO_SERVICE=silo
|
||||
SILO_POSTGRES_SERVICE=postgres
|
||||
|
||||
# --- Database ----------------------------------------------------------------
|
||||
# Match POSTGRES_USER / POSTGRES_DB in the deployment's own .env.
|
||||
|
||||
SILO_DB_USER=silo
|
||||
SILO_DB_NAME=silo
|
||||
|
||||
# --- Credentials -------------------------------------------------------------
|
||||
# An account on the deployment, used to exercise authenticated API endpoints and
|
||||
# to sign in to the web UI while verifying changes. Use a throwaway admin on a
|
||||
# dev box, not your production login.
|
||||
|
||||
SILO_ADMIN_USER=
|
||||
SILO_ADMIN_PASSWORD=
|
||||
|
||||
# --- Optional ----------------------------------------------------------------
|
||||
|
||||
# Host path the media library is mounted from, for checking that a file exists.
|
||||
SILO_MEDIA_ROOT=
|
||||
|
||||
# SSH target of a separate transcode node, if you run one.
|
||||
SILO_TRANSCODE_SSH=
|
||||
|
||||
# A real Jellyfin server to compare jellycompat responses against, and an
|
||||
# account on it. Only needed for the jellycompat-diagnosis skill.
|
||||
JELLYFIN_COMPARE_URL=
|
||||
JELLYFIN_COMPARE_USER=
|
||||
JELLYFIN_COMPARE_PASSWORD=
|
||||
|
||||
# Scoped Discord bot token for the silo-discord-triage skill.
|
||||
DISCORD_BOT_TOKEN=
|
||||
@@ -1,108 +1,108 @@
|
||||
# Repository Guidelines
|
||||
# Silo Server
|
||||
|
||||
## Project Structure & Module Organization
|
||||
`cmd/silo` contains the main server entrypoint. Backend code lives in `internal/`, organized by domain (`api`, `catalog`, `metadata`, `playback`, `scanner`, `jellycompat`, etc.); keep new code in the package that owns the behavior instead of creating catch-all helpers. Database changes belong in `migrations/sql/` as Goose SQL migrations. Legacy converted migrations intentionally keep their original numeric versions so existing `schema_versions` rows can bootstrap cleanly into Goose. New migrations must use timestamped filenames created with `make migrate-create NAME=add_thing`; do not run `goose fix` or create paired `.up.sql` / `.down.sql` files. The React frontend lives in `web/src/`, with feature code split across `components/`, `pages/`, `hooks/`, `player/`, and `lib/`. Reference material belongs in `docs/architecture/` or `docs/superpowers/{specs,plans}/`; ad hoc SQL helpers live in `scripts/`.
|
||||
Go backend for Silo: API contracts, auth/session, catalog/scanner/playback services, database
|
||||
migrations, Jellyfin compatibility, and the host-side plugin runtime. `cmd/silo` is the
|
||||
entrypoint, backend code is under `internal/` by domain, the React frontend is `web/src/`.
|
||||
|
||||
When creating or editing `docs/superpowers/specs/` or `docs/superpowers/plans/`, never include local absolute filesystem paths or transient worktree IDs. Use repository-relative paths and wording like "Commands assume the repository root is the cwd."
|
||||
This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term
|
||||
maintainability is encouraged.
|
||||
|
||||
This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged.
|
||||
## Priorities
|
||||
|
||||
Performance and reliability first. Keep behavior predictable under load and during failures —
|
||||
session restarts, reconnects, partial streams. When a tradeoff is forced, choose correctness and
|
||||
robustness over short-term convenience.
|
||||
|
||||
## Core Priorities
|
||||
Put new code in the package that owns the behavior rather than in a catch-all helper. Prefer
|
||||
extracting shared logic over duplicating it, and prefer changing existing code over bolting a
|
||||
local workaround onto it.
|
||||
|
||||
1. Performance first.
|
||||
2. Reliability first.
|
||||
3. Keep behavior predictable under load and during failures (session restarts, reconnects, partial streams).
|
||||
## Gotchas
|
||||
|
||||
If a tradeoff is required, choose correctness and robustness over short-term convenience.
|
||||
**Migrations.** New DB changes are Goose SQL migrations in `migrations/sql/`, created with
|
||||
`make migrate-create NAME=add_thing` so they get timestamped filenames. Never run `goose fix`,
|
||||
and never create paired `.up.sql` / `.down.sql` files. Legacy converted migrations deliberately
|
||||
keep their original numeric versions so existing `schema_versions` rows bootstrap cleanly — do
|
||||
not renumber them.
|
||||
|
||||
## Maintainability
|
||||
**Encrypted settings.** Encrypted `server_settings` rows are GCM-bound to their key name.
|
||||
Renaming a row in SQL makes its value undecryptable.
|
||||
|
||||
Long term maintainability is a core priority. If you add new functionality, first check if there is shared logic that can be extracted to a separate module. Duplicate logic across multiple files is a code smell and should be avoided. Don't be afraid to change existing code. Don't take shortcuts by just adding local logic to solve a problem.
|
||||
**Profiles vs accounts.** Login accounts (`users`) are separate from household profiles; several
|
||||
profiles on one account share a `user_id`. A profile's `is_primary` marks the household parent,
|
||||
which is *not* the server-wide `admin` role on the account.
|
||||
|
||||
This repository is part of a broader multi-repo Silo workspace. The sibling
|
||||
repositories are usually checked out side-by-side in the same parent directory.
|
||||
**Docs hygiene.** Files under `docs/superpowers/{specs,plans}/` must not contain local absolute
|
||||
filesystem paths or transient worktree IDs — use repository-relative paths and wording like
|
||||
"Commands assume the repository root is the cwd." `make verify-local-paths` enforces this.
|
||||
|
||||
- `silo-server` owns the Go backend, web admin UI, API contracts, auth/session
|
||||
behavior, catalog/scanner/playback services, database migrations, Jellyfin
|
||||
compatibility, and host-side plugin runtime.
|
||||
- `silo-android` owns the Android phone and TV clients. Client-visible API,
|
||||
auth, playback, session, library, and metadata changes may require Android
|
||||
follow-up.
|
||||
- `silo-apple` owns the iOS, tvOS, and macOS clients. Client-visible API, auth,
|
||||
playback, session, library, and metadata changes may require Apple follow-up.
|
||||
**Dev frontend against a remote backend.** Set `VITE_API_PROXY_TARGET` in `web/.env.local` before
|
||||
`make dev-frontend`; the frontend calls relative `/api` URLs that Vite proxies.
|
||||
|
||||
When changing server behavior consumed by clients, check whether both client
|
||||
repos need model, routing, playback, or UX updates. Prefer coordinated
|
||||
multi-repo changes over leaving one platform behind.
|
||||
**Working from a plan.** When implementing from an attached plan, don't edit the plan file.
|
||||
|
||||
Do not assume all plugin-related code lives in this repo.
|
||||
## Multi-repo
|
||||
|
||||
- `silo-plugin-sdk` owns the public plugin SDK, protobuf contracts, generated plugin API code, manifest helpers, and runtime bootstrap.
|
||||
- `silo-plugins` owns the central plugin catalog / repository manifest.
|
||||
- First-party plugins such as `silo-plugin-tmdb`, and `silo-plugin-tvdb` live in their own repositories.
|
||||
- `Silo` owns host-side plugin installation, runtime management, API handlers, and integration logic.
|
||||
Sibling repos are usually checked out side-by-side in the same parent directory.
|
||||
|
||||
When a task mentions plugins, first determine whether the change belongs in this repo, the SDK repo, the catalog repo, or a specific plugin repo. Prefer coordinated multi-repo changes over forcing plugin work into `Silo`.
|
||||
- `silo-android` — Android phone and TV clients.
|
||||
- `silo-apple` — iOS, tvOS, and macOS clients.
|
||||
- `silo-plugin-sdk` — public plugin SDK, protobuf contracts, generated plugin API, manifest
|
||||
helpers, runtime bootstrap.
|
||||
- `silo-plugins` — central plugin catalog / repository manifest.
|
||||
- First-party plugins (`silo-plugin-metadata-tmdb`, `silo-plugin-metadata-tvdb`, …) each have
|
||||
their own repo.
|
||||
|
||||
## Build, and Development Commands
|
||||
Use the checked-in `Makefile` for the common paths:
|
||||
Client-visible changes to API, auth, playback, session, library, or metadata behavior usually
|
||||
need follow-up in both client repos — prefer coordinated multi-repo changes over leaving a
|
||||
platform behind. When a task mentions plugins, work out first whether it belongs here, in the
|
||||
SDK, in the catalog, or in a specific plugin repo.
|
||||
|
||||
- `make build`: install frontend deps, build `web/dist`, compile `./silo`
|
||||
- `make dev-backend`: run the Go server in integrated mode
|
||||
- `make dev-frontend`: start the Vite dev server with HMR
|
||||
- `make dev-proxy` / `make dev-transcode`: run standalone worker modes
|
||||
- `make lint`: run `golangci-lint` and frontend ESLint
|
||||
- `make migrate-status` / `make migrate-up`: inspect or apply Goose migrations through Silo's legacy-safe bootstrapping runner
|
||||
## Building and verifying
|
||||
|
||||
Run before opening a merge request:
|
||||
`make build`, `make dev-backend`, `make dev-frontend`, `make lint`, `make migrate-status` /
|
||||
`make migrate-up` — read the `Makefile` for the rest. Local services:
|
||||
`docker compose up -d postgres redis`.
|
||||
|
||||
- `cd web && pnpm run lint`
|
||||
- `cd web && pnpm run format:check`
|
||||
- `make verify-local-paths`
|
||||
Before opening a merge request:
|
||||
|
||||
For local services, start PostgreSQL and Redis with `docker compose up -d postgres redis`.
|
||||
```bash
|
||||
make lint
|
||||
cd web && pnpm run lint && pnpm run format:check
|
||||
make verify-local-paths
|
||||
```
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
Go code must stay `gofmt`/`goimports` clean and pass `golangci-lint`. Keep package names lowercase and focused; Frontend code is TypeScript with 2-space indentation, semicolons, double quotes, trailing commas, and a 100-character line width (`web/.prettierrc`). Use `PascalCase.tsx` for components/pages, `useThing.ts` for hooks, and keep shared utilities in `web/src/lib` or `web/src/utils`.
|
||||
Go stays `gofmt`/`goimports` clean; the frontend follows `web/.prettierrc`.
|
||||
|
||||
## Skills
|
||||
|
||||
## Deployment Debugging
|
||||
Task-specific guides live in `.claude/skills/`, also reachable as `.agents/skills/` for agents
|
||||
that look there. Read the one that matches the task instead of working from this file alone.
|
||||
|
||||
When troubleshooting a Silo deployment (container health, playback failures,
|
||||
database state, log analysis, deploys), follow the runbook at
|
||||
`.claude/skills/deployment-debugging/SKILL.md`. It includes step-by-step
|
||||
procedures and a first-run setup that configures SSH, database, and Redis
|
||||
connection details for your environment.
|
||||
They share one config file: copy `.silo-dev.env.example` to `.silo-dev.env` and fill in how to
|
||||
reach your Silo deployment — URL, SSH target, database, an account to debug with. That file is
|
||||
gitignored and is the only place hosts, passwords, and tokens belong. `scripts/silo-dev doctor`
|
||||
checks it end to end.
|
||||
|
||||
## Commit & Merge Request Guidelines
|
||||
Recent history follows Conventional Commit-style subjects such as `feat(playback): add realtime session hub`, `fix(playback): ...`, and `docs: ...`. Keep commits scoped to one concern. For non-trivial work, open an issue or discussion first; this codebase moves quickly. Merge requests should explain the problem, why this approach was chosen, linked issue/spec/plan, risks or follow-up work, and AI-use disclosure. Include screenshots or recordings for UI changes.
|
||||
## v1 API rules
|
||||
|
||||
## Learned User Preferences
|
||||
Additive-only within `/api/v1`:
|
||||
|
||||
- When implementing from an attached plan, do not edit the plan file itself.
|
||||
- After feature work, user may invoke the `/simplify` skill to improve readability without changing behavior.
|
||||
- Never rename or remove a response field, change a field's type, or repurpose a status code on
|
||||
an existing endpoint.
|
||||
- New functionality adds new fields or endpoints. Removals go through the Deprecation/Sunset
|
||||
header flow only.
|
||||
- New features expose capability endpoints for feature detection rather than relying on version
|
||||
sniffing. Contract strategy and tooling: issue #135.
|
||||
|
||||
## Learned Workspace Facts
|
||||
## Pull requests
|
||||
|
||||
- Silo separates login accounts (`users`) from household profiles; multiple profiles on one account share the same `user_id`.
|
||||
- Profile `is_primary` marks the household parent for that account; it is not the same as server-wide `admin` role on the user account.
|
||||
- Point the Vite dev frontend at a remote backend with `VITE_API_PROXY_TARGET` in `web/.env.local` (or inline) when running `make dev-frontend`; the frontend calls relative `/api` URLs proxied by Vite.
|
||||
Conventional Commit subjects (`feat(playback): add realtime session hub`). One concern per PR.
|
||||
Explain the problem, why this approach, the linked issue/spec/plan, and risks or follow-up work.
|
||||
Include screenshots or recordings for UI changes. Link the capability epic or sub-issue the PR
|
||||
serves (`Part of #NNN`) — PRs with no linked scope item get questioned at review. For non-trivial
|
||||
work, open an issue or discussion first; this codebase moves quickly.
|
||||
|
||||
## Silo v1 Process (scope lock & API rules)
|
||||
|
||||
Silo is working toward a locked v1 feature set so client teams can plan against a stable core. These rules apply to every change.
|
||||
|
||||
**Scope gate.** Before implementing a new user-facing capability, check `docs/architecture/v1-scope.md`. If the capability is not listed there — or the file says scope is not yet locked — do NOT open a feature PR. File a **v1 capability proposal** issue (template in this repo) and wait for triage. Bug fixes, refactors, and work on already-locked capabilities proceed normally.
|
||||
|
||||
**API rules — additive-only within `/api/v1`.**
|
||||
- Never rename or remove a response field, change a field's type, or repurpose a status code on an existing endpoint.
|
||||
- New functionality adds new fields/endpoints; removals go through the existing Deprecation/Sunset header flow only.
|
||||
- New features expose capability endpoints (feature detection) rather than relying on version sniffing.
|
||||
- Contract strategy and tooling: issue #135.
|
||||
|
||||
**PR requirements.**
|
||||
- Link the capability epic or sub-issue the PR serves (`Part of #NNN`). PRs with no linked scope item will be questioned at review.
|
||||
- One concern per PR; Conventional Commit subject; AI-use disclosure in the PR body.
|
||||
|
||||
**Pre-push checklist.** `make lint` · `cd web && pnpm run lint && pnpm run format:check` · `make verify-local-paths` · Go tests in a libvips-capable container (a bare-host `go test ./...` silently skips CGO packages, including `internal/api/handlers`).
|
||||
AI-use disclosure is required in the PR body. If you are an AI agent contributing on behalf of a
|
||||
non-maintainer, follow [docs/ai-contributions.md](docs/ai-contributions.md) — it has the required
|
||||
disclosure block and the evidence standard.
|
||||
|
||||
+23
-1
@@ -36,13 +36,35 @@ AI writes most of the code here. That's fine. What's not fine is copy-pasting ou
|
||||
|
||||
"The AI suggested it" is not an acceptable answer in review. You should be able to explain the reasoning and tradeoffs.
|
||||
|
||||
## AI Disclosure (Required)
|
||||
|
||||
Every PR and every issue needs to say what AI was involved: the tool, the exact model ID, and the involvement level. "No AI" is a perfectly fine answer. Just say so.
|
||||
|
||||
```md
|
||||
### AI Disclosure
|
||||
- Tool(s): e.g. Claude Code, Codex CLI, Cursor — or "none"
|
||||
- Model(s): exact model ID(s), e.g. claude-fable-5, gpt-5.4 — or "n/a"
|
||||
- Involvement: fully AI-generated | AI-assisted | human-written, AI-reviewed | none
|
||||
- Adversarial review: what your own AI review of the diff found, and how you resolved it
|
||||
```
|
||||
|
||||
The exact model matters. This project is developed with frontier models. If a PR was generated by an older or weaker model, the fastest honest response may be for me to re-implement the idea with a current model instead of reviewing the diff line by line. Disclosing the model lets that call happen quickly. "Thanks, the idea is accepted, but the implementation will be redone" is a possible outcome; see [Be Realistic](#be-realistic).
|
||||
|
||||
Before submitting, run an adversarial review of your own diff with whatever AI tooling you have, and summarize what it found and what you did about it. "It found nothing" is only credible for tiny diffs.
|
||||
|
||||
Undisclosed AI use that is discovered later gets the PR closed on the spot. Repeat offenses get you blocked. The offense is the non-disclosure, not the AI use.
|
||||
|
||||
Fabricated content is an immediate block, first offense. Invented APIs, repro steps that never happened, AI-imagined vulnerabilities, and "bugs" nobody actually observed all count. This is the curl-style rule for hallucinated reports.
|
||||
|
||||
For issues, logs must be raw copy-paste, never AI-paraphrased. You must have actually reproduced the problem yourself on a real deployment before filing.
|
||||
|
||||
## Merge Requests
|
||||
|
||||
A good MR answers: what problem does this solve, why this approach, how was it tested, anything to watch out for. For non-trivial changes, link the issue and note any migration/compatibility concerns. Screenshots for UI changes.
|
||||
|
||||
Small, well-explained MRs get reviewed fast. Big unexplained ones sit.
|
||||
|
||||
If AI meaningfully shaped the implementation, say so in the description. Not gatekeeping — just helps reviewers understand intent.
|
||||
AI disclosure is mandatory for every MR; use the required block in [AI Disclosure (Required)](#ai-disclosure-required).
|
||||
|
||||
## Development Setup
|
||||
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
# Developing Silo
|
||||
|
||||
This document covers building, running, and contributing to the Silo server. If you just want to run Silo, see the [README](README.md).
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution expectations, merge request guidance, and the policy for AI-assisted submissions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Go** 1.24+
|
||||
- **Bun** 1.0+
|
||||
- **PostgreSQL** 18+
|
||||
- **FFmpeg** (for transcoding support)
|
||||
|
||||
## Local Development
|
||||
|
||||
Local development remains intentionally separate from the deploy-oriented compose setup. Use [docker-compose.yml](docker-compose.yml) for local services and the source-build workflow below.
|
||||
|
||||
```sh
|
||||
# Start local PostgreSQL and Redis
|
||||
docker compose up -d postgres redis
|
||||
|
||||
# Run the frontend dev server (hot reload, proxies API to :8090)
|
||||
make dev-frontend
|
||||
|
||||
# Run the Go backend
|
||||
make dev-backend
|
||||
```
|
||||
|
||||
The main compose file expects `MEDIA_ROOT` to be set even if you only want the bundled PostgreSQL and Redis services, so set that in `.env` first (`cp .env.example .env`).
|
||||
|
||||
If you are developing `Silo` and `silo-plugin-sdk` together, keep using the local [`go.work`](go.work) workspace. That workspace is a developer convenience only. CI and release builds run with `GOWORK=off`, so any new SDK helper used here must be pushed and tagged in `silo-plugin-sdk` before this repo can merge or release the change.
|
||||
|
||||
Plugin authors should start with [docs/architecture/plugin-development.md](docs/architecture/plugin-development.md), which covers the RPC plugin package format, generated proto workflow, SDK import paths, route and asset exposure, and auth or user-config integration points.
|
||||
|
||||
## Make Targets
|
||||
|
||||
| Target | Description |
|
||||
|---|---|
|
||||
| `make build` | Build frontend + Go binary |
|
||||
| `make frontend` | Build frontend only |
|
||||
| `make dev-frontend` | Vite dev server with HMR |
|
||||
| `make dev-backend` | Run Go backend (integrated mode) |
|
||||
| `make dev-proxy` | Run a standalone proxy node |
|
||||
| `make dev-transcode` | Run a standalone transcode node |
|
||||
| `make migrate-create NAME=add_thing` | Create a timestamped Goose SQL migration |
|
||||
| `make migrate-validate` | Validate Goose migration files without touching a database |
|
||||
| `make migrate-status` | Show Goose migration status using Silo's bootstrapping runner |
|
||||
| `make migrate-up` | Apply pending Goose migrations using Silo's bootstrapping runner |
|
||||
| `make clean` | Remove build artifacts |
|
||||
|
||||
## Database Migrations
|
||||
|
||||
PostgreSQL schema migrations are managed by Goose. Migration SQL files live in
|
||||
`migrations/sql/` and use Goose annotations. Converted legacy migrations keep
|
||||
their original numeric versions so existing `schema_versions` rows can bootstrap
|
||||
cleanly into Goose without replaying old SQL. New migrations should be created
|
||||
with timestamped filenames:
|
||||
|
||||
```sh
|
||||
make migrate-create NAME=add_thing
|
||||
make migrate-validate
|
||||
```
|
||||
|
||||
Do not run `goose fix`; timestamped migrations are the repository policy because
|
||||
they avoid version collisions across parallel PRs. The existing `001`-style
|
||||
files are historical compatibility records, not the naming pattern for new work.
|
||||
Runtime migrations are applied by the integrated/API server only. Proxy and
|
||||
transcode modes never mutate schema.
|
||||
For existing installs, use `make migrate-status` and `make migrate-up` rather
|
||||
than invoking the Goose CLI directly; those targets copy legacy
|
||||
`schema_versions` rows into `public.goose_db_version` under the migration lock
|
||||
before reading or applying migrations. Set `ENV_FILE=path/to/.env` when the
|
||||
database URL should be read from a non-default env file.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```sh
|
||||
# Go tests (uses testcontainers — Docker must be running)
|
||||
go test ./...
|
||||
|
||||
# Frontend tests
|
||||
cd web && bun test
|
||||
```
|
||||
|
||||
## Linting
|
||||
|
||||
```sh
|
||||
# Go
|
||||
golangci-lint run
|
||||
|
||||
# Frontend
|
||||
cd web && bun run lint
|
||||
cd web && bun run format:check
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
cmd/silo/ Entry point
|
||||
internal/
|
||||
api/ HTTP router, handlers, middleware
|
||||
auth/ JWT authentication and sessions
|
||||
catalog/ Media item, episode, season repositories
|
||||
config/ YAML + env var configuration
|
||||
jellycompat/ Jellyfin/Emby protocol compatibility
|
||||
metadata/ Plugin-driven metadata matching and enrichment
|
||||
playback/ Direct play, remux, transcode session management
|
||||
scanner/ Media file discovery and FFProbe
|
||||
worker/ Background jobs (scan, match, reconcile)
|
||||
web/ React + TypeScript frontend (Vite, Tailwind, shadcn/ui)
|
||||
migrations/sql/ Goose-managed PostgreSQL schema migrations
|
||||
```
|
||||
@@ -1,8 +1,17 @@
|
||||
# Silo
|
||||
|
||||
A self-hosted media streaming server with a React frontend and Go backend. Supports direct play, remuxing, and hardware-accelerated transcoding. Includes optional Jellyfin/Emby-compatible app support for clients such as VidHub and Findroid.
|
||||
Silo is a self-hosted media streaming server for your movies, shows, music, and books. Point it at your media folders and stream to your devices — at home or away — with direct play, remuxing, and hardware-accelerated transcoding handled automatically.
|
||||
|
||||
Join the community on [Discord](https://discord.gg/4RxuUQAEnW).
|
||||
Join the community on [Discord](https://discord.gg/4RxuUQAEnW). If Silo is useful to you, consider [sponsoring the project](https://github.com/sponsors/quick104) — see [Supporting Silo](#supporting-silo).
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Plays your media, your way** — direct play when the device supports it, remux or hardware-accelerated transcode (including NVENC) when it doesn't.
|
||||
- **Web app included** — a full-featured web client and admin interface ship with the server.
|
||||
- **Works with apps you already use** — optional Jellyfin/Emby-compatible API supports clients such as VidHub, Findroid, and Infuse.
|
||||
- **Household profiles** — multiple profiles per account, with per-profile watch state and parental controls.
|
||||
- **Plugin-driven metadata** — match and enrich your libraries with providers like TMDB and TVDB, installed as plugins.
|
||||
- **Fast setup** — one `docker compose up -d` brings up the whole stack; everything else is configured in the admin UI.
|
||||
|
||||
## Deploy with Docker (recommended)
|
||||
|
||||
@@ -105,157 +114,6 @@ Silo is externally stateful by default rather than fully stateless. Durable appl
|
||||
Migrating an existing Continuum Docker install should be done with the preflight
|
||||
helper and cutover guide in [docs/continuum-to-silo-docker-migration.md](docs/continuum-to-silo-docker-migration.md).
|
||||
|
||||
## Build from Source
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Go** 1.24+
|
||||
- **Bun** 1.0+
|
||||
- **PostgreSQL** 18+
|
||||
- **FFmpeg** (for transcoding support)
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Start PostgreSQL** (skip if you already have one running)
|
||||
|
||||
```sh
|
||||
docker compose up -d postgres redis
|
||||
```
|
||||
|
||||
The main compose file still expects `MEDIA_ROOT` to be set even if you only want the bundled PostgreSQL and Redis services, so set that in `.env` first.
|
||||
|
||||
2. **Configure the database connection**
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and set `DATABASE_URL` to point to your PostgreSQL instance.
|
||||
|
||||
3. **Build and run**
|
||||
|
||||
```sh
|
||||
make build
|
||||
./silo
|
||||
```
|
||||
|
||||
The server starts at `http://localhost:8080` by default. All other settings are configured through the admin UI.
|
||||
|
||||
## Development
|
||||
|
||||
Local development remains intentionally separate from the deploy-oriented compose setup. Use [docker-compose.yml](docker-compose.yml) and the existing source-build workflow for local development.
|
||||
|
||||
```sh
|
||||
# Run the frontend dev server (hot reload, proxies API to :8090)
|
||||
make dev-frontend
|
||||
|
||||
# Run the Go backend
|
||||
make dev-backend
|
||||
```
|
||||
|
||||
If you are developing `Silo` and `silo-plugin-sdk` together, keep using the local [`go.work`](go.work) workspace. That workspace is a developer convenience only. CI and release builds run with `GOWORK=off`, so any new SDK helper used here must be pushed and tagged in `silo-plugin-sdk` before this repo can merge or release the change.
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution expectations, merge request guidance, and the policy for AI-assisted submissions.
|
||||
|
||||
Plugin authors should start with [docs/architecture/plugin-development.md](docs/architecture/plugin-development.md), which covers the RPC plugin package format, generated proto workflow, SDK import paths, route and asset exposure, and auth or user-config integration points.
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
If you are reporting a bug, install problem, or performance issue, start with the admin workflow and reproduction steps, not Claude/Codex analysis.
|
||||
|
||||
Please include:
|
||||
|
||||
- What you were trying to do
|
||||
- Exact steps you took
|
||||
- What you expected to happen
|
||||
- What actually happened
|
||||
- What exact action is slow or broken (`save`, `scan`, `browse`, `import`, `playback`, etc.)
|
||||
- Whether it happens every time or only sometimes
|
||||
- The library, media type, filter, setting, or value involved
|
||||
- Version, branch, commit, and deployment details if you know them
|
||||
- Screenshots, recordings, or log snippets if relevant
|
||||
|
||||
If you used Claude/Codex for debugging, put that under `Technical notes` at the end. Suspected files, SQL output, stack traces, and root-cause theories can be helpful, but only after the workflow and repro steps are clear.
|
||||
|
||||
Use this template:
|
||||
|
||||
```text
|
||||
Goal:
|
||||
Steps:
|
||||
Expected:
|
||||
Actual:
|
||||
What is slow/broken:
|
||||
Scope:
|
||||
Version/branch:
|
||||
Deployment:
|
||||
Technical notes:
|
||||
```
|
||||
|
||||
### Make Targets
|
||||
|
||||
| Target | Description |
|
||||
|---|---|
|
||||
| `make build` | Build frontend + Go binary |
|
||||
| `make frontend` | Build frontend only |
|
||||
| `make dev-frontend` | Vite dev server with HMR |
|
||||
| `make dev-backend` | Run Go backend (integrated mode) |
|
||||
| `make dev-proxy` | Run a standalone proxy node |
|
||||
| `make dev-transcode` | Run a standalone transcode node |
|
||||
| `make migrate-create NAME=add_thing` | Create a timestamped Goose SQL migration |
|
||||
| `make migrate-validate` | Validate Goose migration files without touching a database |
|
||||
| `make migrate-status` | Show Goose migration status using Silo's bootstrapping runner |
|
||||
| `make migrate-up` | Apply pending Goose migrations using Silo's bootstrapping runner |
|
||||
| `make clean` | Remove build artifacts |
|
||||
|
||||
### Database Migrations
|
||||
|
||||
PostgreSQL schema migrations are managed by Goose. Migration SQL files live in
|
||||
`migrations/sql/` and use Goose annotations. Converted legacy migrations keep
|
||||
their original numeric versions so existing `schema_versions` rows can bootstrap
|
||||
cleanly into Goose without replaying old SQL. New migrations should be created
|
||||
with timestamped filenames:
|
||||
|
||||
```sh
|
||||
make migrate-create NAME=add_thing
|
||||
make migrate-validate
|
||||
```
|
||||
|
||||
Do not run `goose fix`; timestamped migrations are the repository policy because
|
||||
they avoid version collisions across parallel PRs. The existing `001`-style
|
||||
files are historical compatibility records, not the naming pattern for new work.
|
||||
Runtime migrations are applied by the integrated/API server only. Proxy and
|
||||
transcode modes never mutate schema.
|
||||
For existing installs, use `make migrate-status` and `make migrate-up` rather
|
||||
than invoking the Goose CLI directly; those targets copy legacy
|
||||
`schema_versions` rows into `public.goose_db_version` under the migration lock
|
||||
before reading or applying migrations. Set `ENV_FILE=path/to/.env` when the
|
||||
database URL should be read from a non-default env file.
|
||||
|
||||
### Running Tests
|
||||
|
||||
```sh
|
||||
# Go tests (uses testcontainers — Docker must be running)
|
||||
go test ./...
|
||||
|
||||
# Frontend tests
|
||||
cd web && bun test
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
```sh
|
||||
# Go
|
||||
golangci-lint run
|
||||
|
||||
# Frontend
|
||||
cd web && bun run lint
|
||||
cd web && bun run format:check
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Silo is licensed under `AGPL-3.0-or-later`. See [LICENSE](LICENSE).
|
||||
|
||||
## Configuration
|
||||
|
||||
Silo requires only a `DATABASE_URL` when running from source or against external infrastructure. In the default Docker Compose path, the stack wires the database and Redis URLs for you. All other settings — libraries, metadata providers, transcoding, users — are managed through the admin UI after first launch.
|
||||
@@ -269,7 +127,7 @@ Silo requires only a `DATABASE_URL` when running from source or against external
|
||||
| `proxy` | Stream proxy node that connects to the shared deployment database and Redis |
|
||||
| `transcode` | HLS transcode worker node that connects to the shared deployment database and Redis |
|
||||
|
||||
## PostgreSQL Auto-Tuning
|
||||
### PostgreSQL Auto-Tuning
|
||||
|
||||
The default Docker Compose stack does not require a checked-in `postgresql.conf`.
|
||||
It enables Silo's [pgtune](https://github.com/le0pard/pgtune)-style OLTP tuning
|
||||
@@ -321,20 +179,93 @@ change PostgreSQL server settings. Settings already written with `ALTER SYSTEM`
|
||||
remain in `postgresql.auto.conf`; reset those PostgreSQL parameters if you later
|
||||
move fully to a custom `postgresql.conf`.
|
||||
|
||||
## Project Structure
|
||||
## Build from Source
|
||||
|
||||
If you prefer running Silo without Docker:
|
||||
|
||||
1. **Install prerequisites**: Go 1.24+, Bun 1.0+, PostgreSQL 18+, and FFmpeg.
|
||||
|
||||
2. **Start PostgreSQL and Redis** (skip if you already have them running)
|
||||
|
||||
```sh
|
||||
docker compose up -d postgres redis
|
||||
```
|
||||
|
||||
The main compose file still expects `MEDIA_ROOT` to be set even if you only want the bundled PostgreSQL and Redis services, so set that in `.env` first.
|
||||
|
||||
3. **Configure the database connection**
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and set `DATABASE_URL` to point to your PostgreSQL instance.
|
||||
|
||||
4. **Build and run**
|
||||
|
||||
```sh
|
||||
make build
|
||||
./silo
|
||||
```
|
||||
|
||||
The server starts at `http://localhost:8080` by default. All other settings are configured through the admin UI.
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
If you are reporting a bug, install problem, or performance issue, start with the admin workflow and reproduction steps, not Claude/Codex analysis.
|
||||
|
||||
Please include:
|
||||
|
||||
- What you were trying to do
|
||||
- Exact steps you took
|
||||
- What you expected to happen
|
||||
- What actually happened
|
||||
- What exact action is slow or broken (`save`, `scan`, `browse`, `import`, `playback`, etc.)
|
||||
- Whether it happens every time or only sometimes
|
||||
- The library, media type, filter, setting, or value involved
|
||||
- Version, branch, commit, and deployment details if you know them
|
||||
- Screenshots, recordings, or log snippets if relevant
|
||||
|
||||
If you used Claude/Codex for debugging, put that under `Technical notes` at the end. Suspected files, SQL output, stack traces, and root-cause theories can be helpful, but only after the workflow and repro steps are clear.
|
||||
|
||||
Use this template:
|
||||
|
||||
```text
|
||||
Goal:
|
||||
Steps:
|
||||
Expected:
|
||||
Actual:
|
||||
What is slow/broken:
|
||||
Scope:
|
||||
Version/branch:
|
||||
Deployment:
|
||||
Technical notes:
|
||||
```
|
||||
cmd/silo/ Entry point
|
||||
internal/
|
||||
api/ HTTP router, handlers, middleware
|
||||
auth/ JWT authentication and sessions
|
||||
catalog/ Media item, episode, season repositories
|
||||
config/ YAML + env var configuration
|
||||
jellycompat/ Jellyfin/Emby protocol compatibility
|
||||
metadata/ Plugin-driven metadata matching and enrichment
|
||||
playback/ Direct play, remux, transcode session management
|
||||
scanner/ Media file discovery and FFProbe
|
||||
worker/ Background jobs (scan, match, reconcile)
|
||||
web/ React + TypeScript frontend (Vite, Tailwind, shadcn/ui)
|
||||
migrations/sql/ Goose-managed PostgreSQL schema migrations
|
||||
```
|
||||
|
||||
## Contributing & Development
|
||||
|
||||
Silo is open source and contributions are welcome. See [DEVELOPMENT.md](DEVELOPMENT.md) for building from source in a dev workflow, running tests, database migrations, and project layout, and [CONTRIBUTING.md](CONTRIBUTING.md) for contribution expectations, merge request guidance, and the policy for AI-assisted submissions.
|
||||
|
||||
## Supporting Silo
|
||||
|
||||
Silo is an open-source hobby project, developed in spare time and funded out of pocket. If you'd like to support development, you can sponsor via [GitHub Sponsors](https://github.com/sponsors/quick104).
|
||||
|
||||
Donations go directly toward the costs of building and running the project:
|
||||
|
||||
- AI development tooling subscriptions (Claude, Codex) used to build and maintain Silo
|
||||
- Push notification relay infrastructure
|
||||
- Future development costs
|
||||
|
||||
Sponsoring is entirely optional — Silo is and will remain free and open source. Bug reports, contributions, and feedback are just as valuable.
|
||||
|
||||
## License & Trademarks
|
||||
|
||||
Silo's source code is licensed under the **GNU Affero General Public License
|
||||
v3.0 or later** (`AGPL-3.0-or-later`) — see [LICENSE](LICENSE).
|
||||
|
||||
The **Silo name, logo, and wordmark are trademarks of Silo Media L.L.C.** and
|
||||
are **not** covered by the AGPL. You're free to fork and redistribute the code,
|
||||
but forks and redistributions must not use the Silo brand as their identity and
|
||||
must remove or replace the brand assets. Publishing a Silo-branded app to an app
|
||||
store requires written permission. See [TRADEMARK.md](TRADEMARK.md) for what's
|
||||
permitted — including referential use like "compatible with Silo."
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Silo Trademark & Brand Policy
|
||||
|
||||
Silo's source code is free and open source under the **GNU Affero General Public
|
||||
License v3.0 or later** (`AGPL-3.0-or-later`). You are welcome to use, study,
|
||||
modify, and redistribute it under those terms.
|
||||
|
||||
**That freedom applies to the code — not to the Silo name and brand.** The Silo
|
||||
trademarks identify the official project and its official builds. This policy
|
||||
explains how they may and may not be used. It exists for a simple reason:
|
||||
anyone should be free to fork the software, while users can still trust that
|
||||
something called "Silo" actually comes from us.
|
||||
|
||||
## Marks covered
|
||||
|
||||
The following are trademarks of **Silo Media L.L.C.** ("Silo Media"), whether or
|
||||
not registered:
|
||||
|
||||
- the names **Silo** and **Silo Server**
|
||||
- the **Silo logo** and **wordmark**
|
||||
- Silo **product and component names**, including **SiloRemote**, **SiloMac**,
|
||||
and other "Silo…" names
|
||||
- confusingly similar variations of the above
|
||||
|
||||
The AGPL license over the source code **does not grant any license to these
|
||||
marks.** This reservation is expressly permitted by AGPL-3.0 §7(e).
|
||||
|
||||
## Uses that are always fine — no permission needed
|
||||
|
||||
- **Referring to Silo truthfully** — "compatible with Silo," "a plugin for
|
||||
Silo," "built on Silo," "a fork of Silo." Use the word to refer to *our*
|
||||
project, not to brand *yours*.
|
||||
- **Redistributing official, unmodified builds** under the Silo name, with all
|
||||
notices kept intact.
|
||||
- **Running, modifying, and self-hosting** Silo for yourself or your
|
||||
organization.
|
||||
- **Editorial and educational** use — articles, tutorials, talks, reviews.
|
||||
|
||||
## Uses that require our written permission
|
||||
|
||||
- Distributing a **modified version under the Silo name** (see *Forks* below).
|
||||
- **Publishing an app to any app store** — Apple App Store, Google Play,
|
||||
F-Droid, or similar — under the Silo name, logo, or a confusingly similar
|
||||
identity.
|
||||
- Using the marks in a **company, product, domain, or app name**, or in any way
|
||||
that implies Silo Media operates or endorses your offering.
|
||||
- **Merchandise** or other commercial use of the logo or wordmark.
|
||||
|
||||
## Forks must rebrand
|
||||
|
||||
You may fork Silo — that is your right under the AGPL. But a **distributed** fork
|
||||
must not use the Silo name or logo as its identity. Pick your own name and logo,
|
||||
remove the Silo brand assets (below), and you may state factually that your
|
||||
project "is based on Silo" or "is a fork of Silo." This is the same model used by
|
||||
Mozilla, Rust, and many other open-source projects: **open code, protected
|
||||
identity.**
|
||||
|
||||
## Brand assets are not under the AGPL
|
||||
|
||||
The Silo logo, wordmark, and icon files are **© 2026 Silo Media L.L.C., all
|
||||
rights reserved**, and are **not** licensed under the AGPL or any other
|
||||
open-source license. Any redistribution or fork must **remove or replace** them.
|
||||
See the `NOTICE` file in the brand-asset directories.
|
||||
|
||||
## Official sources
|
||||
|
||||
The only official Silo software comes from:
|
||||
|
||||
- the **Silo-Server** organization on GitHub — <https://github.com/Silo-Server>
|
||||
- the official website — <https://siloserver.org>
|
||||
- official app-store listings published by **Silo Media L.L.C.**
|
||||
|
||||
Any app-store listing, download, or "Silo" app that does not originate from the
|
||||
above is **not** official and is **not** authorized to use the Silo marks.
|
||||
|
||||
## Requesting permission / reporting misuse
|
||||
|
||||
Email **trademark@siloserver.org** with the details. We're generally glad to
|
||||
grant reasonable requests — the goal of this policy is only to prevent user
|
||||
confusion and unauthorized "official-looking" releases.
|
||||
|
||||
---
|
||||
|
||||
*Silo and the Silo logo are trademarks of Silo Media L.L.C. The **™** symbol is
|
||||
used with the Silo wordmark until federal registration issues, after which **®**
|
||||
applies.*
|
||||
+747
-110
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,9 @@ func buildLiveSessionSync(s *playback.Session, reportingNode string) worker.Sess
|
||||
PlayMethod: string(s.PlayMethod),
|
||||
ReportingNode: reportingNode,
|
||||
ClientIP: s.ClientIP,
|
||||
ClientName: s.ClientName,
|
||||
ClientVersion: s.ClientVersion,
|
||||
ClientUserAgent: s.ClientUserAgent,
|
||||
AudioTrackIndex: s.AudioTrackIndex,
|
||||
TranscodeAudio: s.TranscodeAudio,
|
||||
StreamBitrateKbps: s.StreamBitrateKbps,
|
||||
|
||||
@@ -31,6 +31,26 @@ services:
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
meilisearch:
|
||||
image: ${MEILISEARCH_IMAGE:-getmeili/meilisearch:latest}
|
||||
restart: unless-stopped
|
||||
profiles: [search]
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -euc
|
||||
- |
|
||||
if [ -z "$${MEILI_MASTER_KEY:-}" ]; then
|
||||
echo "MEILI_MASTER_KEY is required when enabling the search profile" >&2
|
||||
exit 1
|
||||
fi
|
||||
exec /bin/meilisearch
|
||||
environment:
|
||||
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-}
|
||||
ports:
|
||||
- "${MEILISEARCH_HOST:-127.0.0.1}:${MEILISEARCH_PORT:-7700}:7700"
|
||||
volumes:
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/meilisearch:/meili_data
|
||||
|
||||
silo:
|
||||
image: ${SILO_IMAGE:-ghcr.io/silo-server/silo-server:latest}
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# AI-assisted contributions
|
||||
|
||||
For AI agents preparing a pull request or issue for this repository on behalf of someone who is
|
||||
not a maintainer.
|
||||
|
||||
## Disclosure is required
|
||||
|
||||
Include this completed block in the PR or issue body, with the exact model ID you are running as.
|
||||
Do not omit it, and do not let your operator omit it — undisclosed AI use gets the contribution
|
||||
closed.
|
||||
|
||||
```md
|
||||
### AI Disclosure
|
||||
- Tool(s): e.g. Claude Code, Codex CLI, Cursor — or "none"
|
||||
- Model(s): exact model ID(s), e.g. claude-opus-5, gpt-5.6 — or "n/a"
|
||||
- Involvement: fully AI-generated | AI-assisted | human-written, AI-reviewed | none
|
||||
- Adversarial review: what your own AI review of the diff found, and how you resolved it
|
||||
```
|
||||
|
||||
## Evidence standard
|
||||
|
||||
Run the repo verify commands before declaring the work complete, and paste the real output into
|
||||
the PR:
|
||||
|
||||
```bash
|
||||
make lint
|
||||
cd web && pnpm run lint && pnpm run format:check
|
||||
make verify-local-paths
|
||||
go test ./... # targeted at the packages you touched
|
||||
```
|
||||
|
||||
Run an adversarial review of your own diff and summarize what it found in the PR body.
|
||||
|
||||
Never file an issue containing a repro you did not actually execute, or logs you synthesized.
|
||||
Raw logs only. A fabricated repro costs a maintainer more time than no report at all.
|
||||
|
||||
## What to expect
|
||||
|
||||
The maintainer may accept the idea but re-implement it with a current frontier model. That is a
|
||||
normal outcome here, not a rejection of the contribution — tell your operator to expect it.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Direct Stream Resumption
|
||||
|
||||
Protocol v3 advertises `direct_stream_resume_v1` for playback plans whose
|
||||
delivery is `original_http`. The capability formalizes resumption of the
|
||||
original file by issuing sequential authorized HTTP requests. It does not
|
||||
change authentication, authorization, or playback-session ownership.
|
||||
|
||||
Original-file responses follow the HTTP byte-range contract:
|
||||
|
||||
- `Accept-Ranges: bytes` advertises byte addressing.
|
||||
- A satisfiable `Range` request returns `206 Partial Content` with the selected
|
||||
interval in `Content-Range`.
|
||||
- Open-ended and suffix byte ranges are supported.
|
||||
- A range starting at or past end of file, or another invalid range, returns
|
||||
`416 Requested Range Not Satisfiable` with `Content-Range: bytes */<size>`.
|
||||
- `HEAD` returns the same representation headers as `GET` without a body.
|
||||
|
||||
On Linux, macOS, and Windows, each response carries a strong, opaque `ETag`
|
||||
derived from the open file's filesystem identity, change time, modification
|
||||
time, and size. The validator is stable while the playback plan's original-file
|
||||
entity is unchanged, but changes for same-size replacements even when their
|
||||
modification time is preserved. Platforms that cannot expose a durable
|
||||
filesystem revision omit the validator instead of hashing an entire media file
|
||||
before each request. On those platforms an ETag-based `If-Range` request cannot
|
||||
match and safely falls back to a full `200 OK` response.
|
||||
|
||||
A client resuming a transfer sends both `Range` and `If-Range` with the
|
||||
validator. When it still matches, the server returns the requested `206`
|
||||
response. When the entity changed, the server ignores the range and returns the
|
||||
entire current entity as `200 OK`, preventing bytes from different revisions
|
||||
from being combined. `If-None-Match` uses the same validator for ordinary
|
||||
conditional requests.
|
||||
|
||||
Playback sessions already treat each transport request independently.
|
||||
Sequential ranged requests refresh transport activity, and cleanup never
|
||||
expires a session while one of those transport requests is active. A late
|
||||
request within the paused-session grace remains valid under the same rules as
|
||||
the initial request.
|
||||
|
||||
The capability does not apply to progressive remux delivery
|
||||
`server_remux_progressive`, which is not byte-resumable. It applies only to
|
||||
`original_http`; remux and transcode transports retain their existing
|
||||
contracts.
|
||||
@@ -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`).
|
||||
@@ -0,0 +1,205 @@
|
||||
# Observability (OpenTelemetry logs + traces)
|
||||
|
||||
Silo emits structured **logs** and distributed **traces** via OpenTelemetry (OTLP),
|
||||
in addition to the existing stderr and `opslog` database pipeline. The feature is
|
||||
**opt-in and default-off**: with no `OTEL_*` / `SILO_OTEL_ENABLED` configuration the
|
||||
server behaves exactly as before (stderr + `opslog` only).
|
||||
|
||||
**Metrics are not part of OpenTelemetry here.** They remain on Prometheus
|
||||
(`client_golang`, the `/metrics` endpoint, and the domain metrics in
|
||||
`internal/api/middleware`). See [Metrics](#metrics-stay-on-prometheus) below.
|
||||
|
||||
## Enabling it
|
||||
|
||||
Telemetry turns on when **either** `SILO_OTEL_ENABLED` is truthy **or**
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT` is set.
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `SILO_OTEL_ENABLED` | Master gate (`1`/`true`/`yes`/`on`). | off |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Collector endpoint; also implicitly enables telemetry. | — |
|
||||
| `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc` (default) or `http/protobuf`. | `grpc` |
|
||||
| `OTEL_SERVICE_NAME` | `service.name` resource attribute. | `silo-server` |
|
||||
| `OTEL_SERVICE_VERSION` | `service.version` resource attribute. | unset |
|
||||
| `OTEL_TRACES_SAMPLER` | `always_on`, `always_off`, `traceidratio`, `parentbased_always_on`, `parentbased_always_off`, or `parentbased_traceidratio`. Unsupported values (e.g. `jaeger_remote`) fall back to the default. | `parentbased_traceidratio` |
|
||||
| `OTEL_TRACES_SAMPLER_ARG` | Trace-id ratio for the ratio-based samplers (0–1; clamps >1 to 1). | `1.0` |
|
||||
|
||||
The node identity is attached as the `service.instance.id` resource attribute, so
|
||||
multiple Silo nodes sharing one `service.name` stay distinguishable in the backend.
|
||||
|
||||
All other `OTEL_EXPORTER_OTLP_*` knobs (headers, TLS, per-signal endpoints) are read
|
||||
directly from the environment by the OTLP exporters — the environment is the single
|
||||
source of truth for exporter wiring.
|
||||
|
||||
The endpoint/exporter connection is lazy and non-blocking: an unreachable collector
|
||||
does **not** delay or crash startup. Setup failure is also non-fatal: if the `OTEL_*`
|
||||
environment is malformed (e.g. an unparseable endpoint URL), the server logs an error
|
||||
and keeps running with telemetry disabled rather than crash-looping.
|
||||
|
||||
## Architecture
|
||||
|
||||
The bootstrap lives in `internal/telemetry` (`Setup` in `telemetry.go`). When enabled
|
||||
it builds one shared `resource.Resource`, a `TracerProvider` (sampler per
|
||||
`OTEL_TRACES_SAMPLER`, batched OTLP exporter), a `LoggerProvider` (batched OTLP exporter), and the W3C
|
||||
`TraceContext + Baggage` propagator. Shutdown is deferred in `cmd/silo/main.go` with a
|
||||
flush timeout so buffered spans/logs drain on exit.
|
||||
|
||||
### Log handler chain
|
||||
|
||||
Logs are bridged, not rerouted. The OTel `otelslog` handler is added to the existing
|
||||
`slog` handler chain via **fan-out**, so every record still reaches stderr and the
|
||||
`opslog` DB/admin-stream pipeline unchanged:
|
||||
|
||||
```
|
||||
slog.<Level>Context(ctx, …)
|
||||
→ opslog.Handler (DB capture + admin stream, when level ≥ capture)
|
||||
→ logfilter.Handler (drops quieted subsystem prefixes)
|
||||
→ slog.MultiHandler
|
||||
├── stderr (json/text)
|
||||
└── otelslog → OTLP (level-gated + best-effort)
|
||||
```
|
||||
|
||||
Three properties matter:
|
||||
|
||||
- **Level-gated.** `slog.MultiHandler.Enabled` ORs its children, so the OTel branch is
|
||||
wrapped in a level gate bound to the shared `LevelVar`; console and OTLP share one
|
||||
verbosity knob. Without this, Debug records would be built and exported even at
|
||||
`log_level=info`.
|
||||
- **Best-effort.** The OTel branch never propagates an export error, so a failing
|
||||
collector cannot break the console or DB branches.
|
||||
- **Redacted.** The whole fan-out is wrapped in `internal/logredact`, so console and OTLP
|
||||
emit secret-masked output. Redaction is key-based (`password`, `secret`, `token`,
|
||||
`api_key`, `authorization`, `cookie`, …); a matching attribute's value becomes
|
||||
`[REDACTED]`, including attrs bound via `.With(...)` and nested groups. The marker list
|
||||
is shared with the opslog DB path (`opslog.shouldRedact` → `logredact.SecretKey`) so all
|
||||
sinks agree. Limitation: values are not scanned, so a secret embedded in a free-text
|
||||
message or under a non-secret key is not caught.
|
||||
|
||||
## Logging conventions (enforced)
|
||||
|
||||
Use the **context-carrying** slog variants and tag the subsystem:
|
||||
|
||||
```go
|
||||
slog.InfoContext(ctx, "scanner: starting", "component", "scanner", "folder_id", id)
|
||||
```
|
||||
|
||||
Rules, enforced by `sloglint` in `make lint` (`.golangci.yml`):
|
||||
|
||||
- **`slog.<Level>Context(ctx, …)`** wherever a `context.Context` is in scope (so records
|
||||
carry the active `trace_id`/`span_id`). The plain `slog.Info(…)` form is only allowed
|
||||
where no `ctx` exists (early boot, top-level goroutines).
|
||||
- **Static message** — the message is a constant; move dynamic parts to attributes.
|
||||
- **snake_case attribute keys** — `component`, `request_id`, `trace_id`, `user_id`, …
|
||||
- **No mixed args** — don't combine key-value pairs and `slog.Attr` in one call.
|
||||
|
||||
### Component classification
|
||||
|
||||
Every direct `slog.*Context` call carries a `component` attribute. `opslog` classifies
|
||||
records by that attribute first, falling back to `opslog.InferComponent` (the `subsystem:`
|
||||
prefix in the message) when no attribute is present — bound loggers that already set
|
||||
`component` via `.With(...)` are left as-is.
|
||||
|
||||
**Limitation:** `sloglint` enforces the *shape* (context variant, snake keys, static
|
||||
message) but **cannot** enforce that a `component` attribute is present. That convention
|
||||
rests on this doc, the canonical list below, and review.
|
||||
|
||||
Canonical component values (first path segment under `internal/`; `app` for `cmd/silo`):
|
||||
|
||||
`activitylog`, `adminjob`, `ai`, `api`, `app`, `audiobooks`, `auth`, `autoscan`,
|
||||
`catalog`, `chapterthumbs`, `downloads`, `ebooks`, `historyimport`, `jellycompat`,
|
||||
`libraryingest`, `manga`, `metadata`, `nodeconfig`, `nodepool`, `noderecipe`,
|
||||
`nodesessions`, `notifications`, `opslog`, `playback`, `plugins`, `policy`, `proxy`, `ratelimit`,
|
||||
`recommendations`, `requests`, `scanner`, `scanqueue`, `sections`, `taskmanager`,
|
||||
`telemetry`, `transcodenode`, `watchlist`, `watchsync`, `webhooksync`, `worker`.
|
||||
|
||||
## Metrics stay on Prometheus
|
||||
|
||||
OpenTelemetry here installs **no MeterProvider**. Metrics continue to flow through the
|
||||
existing Prometheus `client_golang` instrumentation to `/metrics`; Grafana scraping is
|
||||
unaffected.
|
||||
|
||||
This is deliberate and guarded: the trace-instrumentation libraries (`otelhttp`, etc.,
|
||||
added in later phases) also emit metrics through the *global* MeterProvider. Because none
|
||||
is set, that global stays the built-in **no-op**, so those metric calls are silently
|
||||
discarded — no double-counting into Prometheus, no second `/metrics` source. A guard test
|
||||
asserts `otel.GetMeterProvider()` remains the no-op after `Setup`. Migrating metrics to
|
||||
the OTel metrics SDK is out of scope.
|
||||
|
||||
## Local collector (example)
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (dev)
|
||||
otel-collector:
|
||||
image: otel/opentelemetry-collector:latest
|
||||
command: ["--config=/etc/otelcol/config.yaml"]
|
||||
volumes:
|
||||
- ./otelcol.yaml:/etc/otelcol/config.yaml
|
||||
ports:
|
||||
- "4317:4317" # OTLP gRPC
|
||||
- "4318:4318" # OTLP HTTP
|
||||
```
|
||||
|
||||
```yaml
|
||||
# otelcol.yaml — minimal debug pipeline
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc: {}
|
||||
http: {}
|
||||
exporters:
|
||||
debug:
|
||||
verbosity: detailed
|
||||
service:
|
||||
pipelines:
|
||||
traces: { receivers: [otlp], exporters: [debug] }
|
||||
logs: { receivers: [otlp], exporters: [debug] }
|
||||
```
|
||||
|
||||
Run Silo with `SILO_OTEL_ENABLED=1 OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317` and watch
|
||||
logs + traces arrive at the collector. Swap `debug` for Loki/Tempo (or a vendor OTLP
|
||||
endpoint) for real storage; Grafana can then show traces alongside the unchanged
|
||||
Prometheus metrics.
|
||||
|
||||
## Log retention & rotation
|
||||
|
||||
Silo does **not** write or rotate its own log files. Rotation/retention is handled by the
|
||||
layer that owns each sink, which is the intended cloud-native split:
|
||||
|
||||
- **Console (stderr)** is owned by the container runtime. Under Docker, configure the
|
||||
`json-file` driver to cap and rotate on-disk logs, and mount the log directory on a
|
||||
volume so history survives container recreation (the problem that motivated this work):
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
silo:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m" # rotate at 50 MB
|
||||
max-file: "5" # keep 5 rotated files
|
||||
```
|
||||
|
||||
For a non-Docker deployment, run under a supervisor/journald or pipe to `logrotate`.
|
||||
|
||||
- **OTLP** is the durable, queryable path: the collector/backend (Loki, a vendor, …) owns
|
||||
retention and is the recommended place to keep searchable history. Enable it (see above)
|
||||
and set retention on the backend.
|
||||
|
||||
- **opslog (Postgres)** self-prunes: daily partitions with per-component retention and
|
||||
size caps (`internal/opslog/cleanup.go`). No operator action needed.
|
||||
|
||||
An in-process rotating file sink (e.g. lumberjack) was deliberately **not** added — it
|
||||
would re-introduce a custom sink the OTLP + runtime split already covers.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **`log_quiet` also suppresses OTel logs.** The fan-out sits below the quiet filter, so
|
||||
quieting a subsystem empties its OTLP stream too (OTLP mirrors the console stream).
|
||||
- **Redaction is key-based, not value-based.** Secrets under a recognized key are masked
|
||||
on every sink, but a secret embedded in a message string or under an unrecognized key is
|
||||
not caught (see [Redacted](#log-handler-chain)).
|
||||
- **Early-boot logs are not exported.** Records emitted before the handler is installed
|
||||
(DB connect, migrations, tuning) reach stderr only, matching existing `opslog` behavior.
|
||||
- **Per-subsystem trace propagation into plugins** is a follow-up owned by
|
||||
`silo-plugin-sdk`; this repo instruments only the host side.
|
||||
@@ -0,0 +1,787 @@
|
||||
# Restart-resilient playback (TR-lease)
|
||||
|
||||
How Silo keeps playback alive across a server restart, reconnect, or partial
|
||||
stream — and how the design arrived at **TR-lease** (token-carried
|
||||
reconstruction + a server-side session-deny marker for revocation).
|
||||
|
||||
> **Status: the revocation half is deferred to a future PR.** Only the
|
||||
> token-carried **reconstruction** core shipped in this PR. The **session-deny /
|
||||
> stream-revocation** mechanism described throughout (the `silo:streamauth:<sid>`
|
||||
> deny marker, the proxy's `Allowed()` enforcement, and the admin
|
||||
> Stop/Terminate deny write) is **not present in the current implementation**.
|
||||
> Admin Terminate and user Stop tear down the live in-memory session and the
|
||||
> ffmpeg producer, but they do **not** prevent a still-valid stream token from
|
||||
> reconstructing the session until its 24h TTL expires. Sections describing that
|
||||
> mechanism are kept for design continuity and are individually flagged as
|
||||
> deferred below.
|
||||
|
||||
This document is the consolidated design record. It folds together four earlier
|
||||
working notes so a future engineer can see the full evolution, the options
|
||||
weighed, the issues raised across review rounds, the variables traded off, and
|
||||
why the final shape was chosen. The superseded notes were:
|
||||
|
||||
- *unified-playback-reconstruct* — the reconstruct core (PR #174), "RC".
|
||||
- *token-carried-playback-reconstruct* — the storage evolution, "TR".
|
||||
- *async-recipe-card-playback* — a monitoring-preserving middle path, "RC-async".
|
||||
- *playback-reconstruct-options-comparison* — the goals/options matrix.
|
||||
|
||||
Paths are repository-relative; assume the repository root is the cwd.
|
||||
|
||||
---
|
||||
|
||||
## 1. The problem
|
||||
|
||||
Before this work, a missing in-memory playback session was a `404`. A server
|
||||
restart (deploy, crash, OOM) wiped the in-memory session map, so every in-flight
|
||||
stream died: the client's next manifest/segment/byte-range request hit a session
|
||||
that no longer existed and playback stopped hard. The same gap appeared on any
|
||||
front-end that had never held the session (horizontal scale) and on a dedicated
|
||||
transcode node reboot.
|
||||
|
||||
Core priorities for the fix (from the repo guidelines): **performance first,
|
||||
reliability first, predictable behavior under load and during failures**. The
|
||||
hot path (per-segment serve) must stay off central/Postgres; bytes must stay
|
||||
offloaded to nodes; correctness and robustness win over convenience.
|
||||
|
||||
---
|
||||
|
||||
## 2. The core insight: reconstruct, don't rehydrate
|
||||
|
||||
A missing session is a **reconstruct trigger, not a 404**. The client always
|
||||
re-requests the next chunk after an outage, and its request already states where
|
||||
it is (an HTTP `Range`, a `?seek=`, or a `seg_NNNNN` number). So the server
|
||||
rebuilds the session just-in-time from two things:
|
||||
|
||||
1. a small **durable descriptor** (identity, ownership, and the byte-affecting
|
||||
encode parameters — the "recipe"), and
|
||||
2. the **position the client supplies** on the triggering request.
|
||||
|
||||
Nothing live is serialized (no ffmpeg process, context, channels, or log sink).
|
||||
This is the contribution of PR #174 ("RC"), which also unified the native and
|
||||
Jellyfin-compat paths behind one `playback.TranscodeManager` so the
|
||||
card-lifetime rules, the reconstruct concurrency cap, and the node-affinity rule
|
||||
live in exactly one place. The reconstruct skeleton —
|
||||
`LoadOrReconstructSession`, `ReconstructSession` / `ReconstructTranscode`, the
|
||||
single-flight + `reconstructSem` cap that paces the post-restart thundering
|
||||
herd, `RegisterReconstructed`, the `CloseProcess`/`Close` split — is the
|
||||
foundation every later option reused. **It was kept; only the descriptor storage
|
||||
and the revocation model evolved.**
|
||||
|
||||
The six routes that must survive a restart:
|
||||
|
||||
| # | Route | Tier | How it is rebuilt |
|
||||
|---|-------|:----:|-------------------|
|
||||
| 1 | native direct | 1 | client `Range` re-serve; descriptor = identity |
|
||||
| 2 | native remux | 1 | re-spawn ffmpeg at `?seek`; descriptor = id + audio |
|
||||
| 3 | native transcode | 2 | rebuild `Session` + ffmpeg seeked to the requested segment; descriptor = full encode opts |
|
||||
| 4 | jellycompat direct | 1 | compat store + `Range` re-serve |
|
||||
| 5 | jellycompat remux | 1 | compat store + `?seek` |
|
||||
| 6 | jellycompat transcode | 2 | compat store + shared-manager reconstruct |
|
||||
|
||||
Tier 1 = stateless re-serve (client re-supplies position). Tier 2 = rebuild
|
||||
ffmpeg. Routes 4–6 always retain a durable central compat store
|
||||
(`jellycompat_playback_sessions`): third-party Jellyfin clients cannot
|
||||
round-trip a native descriptor, so the compat layer keeps the full
|
||||
`PlaybackSession` (route + media source resolved before native reconstruct
|
||||
runs).
|
||||
|
||||
---
|
||||
|
||||
## 3. The two axes the options differ on
|
||||
|
||||
Every option is a point on a 2-D grid: **where the descriptor lives** (vertical)
|
||||
× **how revocation is bounded** (horizontal).
|
||||
|
||||
```text
|
||||
AUTHORITY / REVOCATION MODEL ----------------------->
|
||||
re-resolve at long token short TTL +
|
||||
reconstruct (24h, expire) refresh checkpoint
|
||||
D central +----------------+----------------+----------------+
|
||||
E Postgres row | RC (#174) | (n/a) | RC-async |
|
||||
S +----------------+----------------+----------------+
|
||||
C client/node- | (n/a) | TR-24h | TR + re-mint |
|
||||
R carried token | | | |
|
||||
I +----------------+----------------+----------------+
|
||||
P
|
||||
T top row = descriptor stays central (node serves bytes but cannot
|
||||
| self-reconstruct → node-restart gap)
|
||||
| bottom = recipe travels to the node (node self-sufficient, no central record)
|
||||
```
|
||||
|
||||
**TR-lease is a fourth point off this grid:** bottom row (token-carried, 24h),
|
||||
but the revocation column is replaced by a *server-side deny marker* — on an
|
||||
admin kill, central would write a per-session deny to Redis that the node
|
||||
enforces by withholding bytes. Revocation is decoupled from the token TTL and
|
||||
needs no client refresh. (An earlier iteration polled and re-resolved every
|
||||
stream on a timer; that was cut to the event-driven session-deny — see §7 and
|
||||
the §8 design note.)
|
||||
|
||||
> **Status: deferred to a future PR.** The deny-marker column described above is
|
||||
> the design target, not the current shipped behavior. As shipped, the
|
||||
> token-carried row offers no revocation tighter than the 24h token TTL on the
|
||||
> node path; admin Terminate and user Stop only tear down the live session and
|
||||
> ffmpeg producer.
|
||||
|
||||
---
|
||||
|
||||
## 4. Goals weighed
|
||||
|
||||
| ID | Goal |
|
||||
|----|------|
|
||||
| G1 | Hot path (per-segment serve) stays off central/Postgres |
|
||||
| G2 | Bytes offloaded to nodes (served from disk, signature-verified, never central) |
|
||||
| G3 | Restart resiliency across all 6 routes |
|
||||
| G4 | Two-factor ownership (auth caller + ownership rebind; refuse `userID==0`/mismatch) |
|
||||
| G5 | Revocation latency (how fast a banned user / pulled access stops playing) |
|
||||
| G6 | Single-box: zero external store required for reconstruction |
|
||||
| G7 | Restart runway (descriptor stays valid longer than the outage) |
|
||||
| G8 | Multi-front-end / verify-anywhere (no integrated-transcode split-brain) |
|
||||
| G9 | Observability — durable, identity-rich active-stream record |
|
||||
| G10 | Low operational surface (tables, migrations, stores, endpoints) |
|
||||
| G11 | Node-restart recovery |
|
||||
| G12 | No new client coordination required |
|
||||
| G13 | Small signing-key blast radius |
|
||||
| G14 | Cleanup correctness (never wipe a live session's segment dir) |
|
||||
|
||||
A note on G9 (observability): the *live* "who is streaming now" view does **not**
|
||||
depend on the descriptor strategy. Every offload node calls the Redis-backed
|
||||
node-session tracker (`internal/nodesessions/tracker.go`) on every serve, tied to
|
||||
actual byte-serving (so a non-cooperative client or replayed token still shows
|
||||
up) and surviving a central restart. G9 scores only the *durable, identity-rich*
|
||||
(who + what + recipe) slice on top of that.
|
||||
|
||||
---
|
||||
|
||||
## 5. The options, and the comparison
|
||||
|
||||
Legend: ✓ meets · ✓\* minor asterisk · ~ partial · ✗ fails · ✓✓ best-in-class
|
||||
|
||||
| Goal | `main` | RC #174 | TR-24h | TR + re-mint | RC-async | **TR-lease** |
|
||||
|------|:------:|:-------:|:------:|:------------:|:--------:|:------------:|
|
||||
| G1 hot path free of central DB | ✓ | ✓\* | ✓ | ✓ | ✓ | ✓ |
|
||||
| G2 bytes offloaded | ✓ | ✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ |
|
||||
| G3 restart resiliency (6/6) | ✗ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| G4 two-factor ownership | ✓ | ✓ | ~ | ~ | ~ | ✓~ |
|
||||
| G5 revocation latency | ~ | ~ | ✗ (≤24h) | ✓ (≤TTL) | ✓✓ | ✓✓ |
|
||||
| G6 single-box, no external store | ✓ | ✗ | ✓✓ | ~ | ~ | ~ |
|
||||
| G7 restart runway | n/a | ✓ (30m) | ✓✓ (24h) | ~ | ~ | ✓✓ (24h) |
|
||||
| G8 multi-front-end | ✗ | ~ | ✓~ | ✓~ | ✓~ | ✓~ |
|
||||
| G9 durable identity-rich record | ✗ | ✓✓ | ✗ | ✗ | ✓ | ~ |
|
||||
| G10 low operational surface | ✓✓ | ~ | ✓ | ~ | ✗ | ~ |
|
||||
| G11 node-restart recovery | ✗ | ✗ | ✓ | ✓ | ✓ | ✓ |
|
||||
| G12 no new client coordination | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ |
|
||||
| G13 small signing-key blast radius | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ |
|
||||
| G14 cleanup correctness | ✓ | ✓✓ | ~ | ~ | ~ | ~ |
|
||||
|
||||
The TTL/revocation strategies head-to-head. **Note: the session-deny column is
|
||||
the deferred design target — see §7; as shipped, the node path has no revocation
|
||||
tighter than the 24h token TTL.**
|
||||
|
||||
| Strategy | Revocation latency | Restart runway | Client refresh? | Central on refresh? |
|
||||
|----------|--------------------|----------------|-----------------|---------------------|
|
||||
| Re-resolve at reconstruct (RC) | on reconstruct; node hop ≤24h | 30 min | no | only on reconstruct |
|
||||
| Leave 24h, expire only (TR-24h) | up to 24h | 24h | no | never |
|
||||
| Short TTL + refresh + denylist (RC-async / TR item B) | ≤~5 min | ~90 s grace | **yes** | every ~3.5 min/session |
|
||||
| 24h token + session-deny marker (TR-lease — **deferred**) | *design target:* admin kill cuts node bytes on next serve; passive ban = next-play / ≤24h. *As shipped:* node path bounded by ≤24h token only | 24h | **no** | nothing on the serve path; one write per admin-kill event |
|
||||
|
||||
---
|
||||
|
||||
## 6. Why each non-final option was set aside
|
||||
|
||||
- **RC (PR #174, the recipe card in Postgres).** Correct and the basis of
|
||||
everything here, but it couples reconstruction to a shared per-session Postgres
|
||||
store: a write on every start, per audio/quality change, and a ≤1/min refresh,
|
||||
with resilience tied to the DB being reachable. It does not give the single-box
|
||||
zero-dependency property, and the node hop's 24h token stays unrevocable
|
||||
anyway. RC's reconstruct *core* was kept; its *storage* was the thing to evolve.
|
||||
|
||||
- **TR-24h (token-carried, leave the token to expire).** Wins the single-box case
|
||||
(zero external dependency) and removes the per-start DB write, but revocation is
|
||||
bounded by *nothing tighter than the 24h token TTL* on the node path — a banned
|
||||
user keeps node-served direct/remux for up to 24h. This is actually the
|
||||
*status quo* for the node path even pre-branch (the proxy verifies by signature
|
||||
alone, no DB), so TR-24h only adds identity claims to the token without
|
||||
improving revocation. Not acceptable as the endpoint.
|
||||
|
||||
- **TR + re-mint (short TTL + authenticated playlist refresh + denylist), and
|
||||
RC-async (the same revocation idea but keeping an async-written Postgres mirror
|
||||
for observability).** Both bound revocation to ~5 min by having the client
|
||||
refetch a stable `RequireAuth` playlist that re-checks access and re-mints
|
||||
short-lived segment URLs. The cost: a **hard client-coordination requirement**
|
||||
(silo-android / silo-apple / Jellyfin clients must implement the refresh flow),
|
||||
a shrunken restart runway (~90 s grace instead of 30 min — if central is down
|
||||
past the grace, playback stalls), and a new authenticated re-mint transport
|
||||
that does not exist on the supported proxy/node topology. RC-async additionally
|
||||
keeps a write pipeline + denylist + the central row (the highest operational
|
||||
surface of all). They solved revocation by pushing work onto the client and
|
||||
shortening the runway — the opposite of the reliability priorities.
|
||||
|
||||
The open item across TR and RC-async was always **revocation enforcement** ("item
|
||||
B"): bounding revocation needs *either* a per-segment token check on the hot path
|
||||
*or* an authenticated re-mint checkpoint the client drives. TR-lease removes that
|
||||
dilemma.
|
||||
|
||||
---
|
||||
|
||||
## 7. TR-lease: the chosen design
|
||||
|
||||
TR-lease keeps TR's 24h token (so the restart runway is untouched and no client
|
||||
refresh flow is needed) and adds a server-side **session-deny marker** for the
|
||||
one revocation case the node path cannot otherwise cover — no client
|
||||
participation, no steady-state cost.
|
||||
|
||||
**Reconstruction (token-carried).** The signed stream token *is* the durable
|
||||
descriptor. Its claims carry the full byte-affecting recipe plus `uid/pid/mfid`
|
||||
ownership lookup keys. A front-end that lost its in-memory session rebuilds
|
||||
ffmpeg from the token the client re-presents — no shared per-session store, zero
|
||||
external dependency for reconstruction on a single box. `HWAccel`/`HWDevice` are
|
||||
deliberately *not* carried; they are re-resolved from live config so an operator
|
||||
config change applies to reconstructed sessions too. Ownership re-binds to the
|
||||
live authenticated caller and refuses `userID==0` / mismatch — the token's `uid`
|
||||
is never trusted alone (a leaked URL is useless without the owner's auth session
|
||||
on the native path).
|
||||
|
||||
**Revocation (session-deny marker).**
|
||||
|
||||
> **Status: deferred to a future PR.** Everything in this subsection (the
|
||||
> `silo:streamauth:<sid>` deny marker, the admin Stop/Terminate deny write, and
|
||||
> the node-side `Allowed()` enforcement) describes the design target and is **not
|
||||
> present in the current implementation**. As shipped, admin Terminate and user
|
||||
> Stop tear down the live in-memory session and the ffmpeg producer, but a
|
||||
> still-valid stream token can reconstruct the session until its 24h TTL expires;
|
||||
> there is no node-side byte-withholding and no guaranteed/instant revocation on
|
||||
> the node path. The design below is retained for when revocation lands.
|
||||
|
||||
The revocation surface was deliberately
|
||||
kept to the *one* case the offloaded topology cannot otherwise cover, rather than
|
||||
a periodic re-check of every stream. An admin Stop/Terminate would write
|
||||
`silo:streamauth:<sid> = deny` (TTL ≥ the 24h token lifetime); the offload node
|
||||
would read it (a node-local Redis GET, sub-ms) before serving any bytes and
|
||||
return `403` with no bytes on a hit. Enforcement is **byte-withholding, not
|
||||
client cooperation** — a revoked client that ignores the 403 keeps hitting a
|
||||
wall.
|
||||
|
||||
Why this would be sufficient, and what it deliberately does *not* do:
|
||||
|
||||
- **New playback is already blocked at central** — `/playback/start` is
|
||||
`RequireAuth` + access check, so a ban/scope change always takes effect on the
|
||||
next play, everywhere, with no marker needed.
|
||||
- **Cooperative clients already stop** — central holds the realtime WebSocket and
|
||||
can drop it + send a stop on a ban.
|
||||
- **The only thing needing a hard node-side cut** is the *explicit, session-scoped
|
||||
admin kill* of a *non-cooperative* client holding a valid token on a
|
||||
node-served direct/remux stream (no producer to kill). That is exactly what the
|
||||
(deferred) session-deny marker would cover; until it lands this case is bounded
|
||||
by the ≤24h token like the passive bans below.
|
||||
- **Not covered (accepted limitation):** a *passive* ban or *partial* access
|
||||
change (lost one library, lowered rating) does NOT hard-revoke an
|
||||
already-running, non-cooperative node stream. It is enforced at next play and
|
||||
otherwise bounded by the ≤24h token. Closing this would require either a
|
||||
periodic re-resolver (a poll over every active stream — rejected as constant
|
||||
DB load for a rare case) or per-content deny enumeration; both were judged not
|
||||
worth the machinery for a self-hosted server. A user-scoped deny was rejected
|
||||
too: it over-blocks content the viewer still legitimately has access to.
|
||||
|
||||
**Fail-open on absence (deferred).** In the design, the normal case is *no*
|
||||
marker, which serves; a Redis error also serves; only a present deny withholds
|
||||
bytes. This is a deliberate reliability-first choice: availability over
|
||||
revocation latency.
|
||||
|
||||
**Why this shape.** It keeps token-carried reconstruction's single-box and
|
||||
verify-anywhere properties and would add revocation with no client coordination
|
||||
(G12) and essentially no steady-state cost — the marker is written only on the
|
||||
rare admin-kill event, never on a timer. It is, in effect, **TR-24 plus a single
|
||||
session-scoped kill switch**. The kill switch is **deferred**; what shipped is
|
||||
TR-24 (token-carried reconstruction, ≤24h token, no node-side revocation).
|
||||
|
||||
---
|
||||
|
||||
## 8. What was implemented, and where
|
||||
|
||||
This PR ships **Commit 1 only** (token-carried reconstruction). **Commit 2 (the
|
||||
node session-deny marker) is deferred to a future PR** — the `internal/streamauth`
|
||||
package, the proxy `Allowed()` guard, and the admin deny write described below
|
||||
are **not present in the current implementation**.
|
||||
|
||||
**Commit 1 — token-carried reconstruction; retire `transcode_recipes`. (shipped)**
|
||||
- `streamtoken.Claims` gains the recipe + `uid/pid/mfid`
|
||||
(`internal/streamtoken/token.go`).
|
||||
- `playback.RecipeCard` projects to/from claims (`ToClaims` /
|
||||
`RecipeCardFromClaims`); the `Reconstruct*` front door takes the decoded recipe
|
||||
instead of reading Postgres (`internal/playback/recipecard.go`,
|
||||
`transcode_manager.go`).
|
||||
- Native serve URLs carry the token as `?st=`; the manifest rewriter already
|
||||
appends `RawQuery` to segment URIs, so segments inherit it with no route change
|
||||
(`internal/api/handlers/playback.go`, `stream.go`). The integrated server is
|
||||
hit directly, so the query-stripping-proxy concern that motivates a path
|
||||
segment does not apply there; the proxy/node path keeps the token in the URL
|
||||
path as before.
|
||||
- Jellycompat folds the recipe into its durable compat store
|
||||
(`PlaybackSession.Recipe`) since Jellyfin clients cannot round-trip a native
|
||||
token (`internal/jellycompat/...`).
|
||||
- Segment-dir cleanup switches from the card index to in-memory liveness (live
|
||||
map + in-flight reconstruct set + age past the max token TTL); a dedicated node
|
||||
keeps its boot-time full wipe (`internal/playback/transcode_cleanup.go`,
|
||||
`transcode_manager.go`).
|
||||
- `PostgresRecipeStore`, the `RecipeStore` interface, and the unmerged
|
||||
`transcode_recipes` migration are deleted.
|
||||
|
||||
**Commit 2 — node session-deny marker. (DEFERRED — not in this PR)**
|
||||
|
||||
> The items below describe the planned revocation commit. They were removed from
|
||||
> this PR and deferred to a future one; none of these symbols exist in the
|
||||
> current implementation. The admin Stop/Terminate path still tears down the live
|
||||
> session and ffmpeg producer, but writes no deny marker, so a valid token can
|
||||
> reconstruct until its 24h TTL expires.
|
||||
|
||||
- `internal/streamauth`: a deny-only Redis `Store` — `Deny(sid)` (TTL ≥ token
|
||||
lifetime) and a fail-open `Allowed(sid)` reader. No poller, no allow-leases, no
|
||||
access re-resolver.
|
||||
- The node guard in `internal/proxy/server.go` (`verifyToken` consults
|
||||
`Allowed`).
|
||||
- An admin Stop/Terminate writes the deny marker
|
||||
(`internal/api/handlers/admin_playback_control.go` → `PlaybackHandler.LeaseDenier`).
|
||||
- `nodesessions.SessionInfo` carries the numeric ownership keys
|
||||
(`auth_user_id`/`profile_id`/`media_file_id`), populated by the node from the
|
||||
verified token, to enrich the live admin "active streams" view (who/what, not
|
||||
just session id) — see §10 monitoring.
|
||||
- A single integrated box reads no marker and needs none — the producer-kill +
|
||||
session removal on Terminate is the stop there.
|
||||
|
||||
> Design note (about the deferred commit 2): an earlier iteration added a central
|
||||
> *revalidator* that re-resolved access for every active stream every ~2 min and
|
||||
> wrote allow/deny leases. It was dropped in favour of the event-driven
|
||||
> session-deny above: the poll re-derived, at a constant all-streams DB cost,
|
||||
> signals the
|
||||
> system already emits (new playback is gated at `/playback/start`; admin kill is
|
||||
> an explicit event), and its only unique coverage — sub-24h *passive* revocation
|
||||
> of an in-flight node stream — was judged not worth the machinery (see §7).
|
||||
|
||||
---
|
||||
|
||||
## 9. Issues raised across review, and their disposition
|
||||
|
||||
From two adversarial review rounds on the reconstruct core and the storage
|
||||
evolution:
|
||||
|
||||
- **Integrated-transcode split-brain across front-ends (P-1 / A).** The
|
||||
`SessionManager` + reconstruct single-flight are per-process and the descriptor
|
||||
carries no owning-node id, so without sticky affinity two front-ends can run
|
||||
divergent ffmpeg. **Disposition: deferred (owner-identity claim).** Supported
|
||||
topology is dedicated `--mode=transcode` nodes (routed via `tnode`, no
|
||||
split-brain) + single-box / LB-session-affinity integrated transcode. The
|
||||
caveat is documented, not silently assumed.
|
||||
- **24h node-path token is unrevocable (P-2).** The (deferred, see §7–§8)
|
||||
session-deny marker would let the explicit admin kill cut node bytes on the next
|
||||
serve. **Until that ships, this remains open:** the node-path token is
|
||||
unrevocable, so admin kill and passive bans / partial access changes are all
|
||||
bounded by next-play + the ≤24h token.
|
||||
- **Dedicated transcode-node restart (P-3 / tr-10). Fixed for both paths.**
|
||||
*Native:* the proxy forwards the verified stream token to the transcode node
|
||||
(`X-Silo-Stream-Token`); on a manifest/segment miss the node re-verifies the
|
||||
token, decodes the recipe, and self-reconstructs ffmpeg seeked to the requested
|
||||
segment — single-flighted and concurrency-capped exactly like the integrated
|
||||
reconstruct. *Jellycompat:* the node-hop token is server-minted and *could* carry the
|
||||
recipe like native, but it deliberately does not — the recipe is mutated in
|
||||
place under a stable session id (a Jellyfin audio/subtitle switch can arrive as
|
||||
a `/Sessions/Playing/Progress` report that restarts ffmpeg without re-minting
|
||||
the client's token), and a third-party Jellyfin client cannot be driven to
|
||||
refresh a stale token, so a token *snapshot* could reconstruct a stale
|
||||
rendition. Central instead writes the recipe to a shared Redis recipe store
|
||||
(`internal/noderecipe`) keyed by upstream session id and overwrites it on every
|
||||
switch, and the node (which cannot reach Postgres) reads the *current* recipe on
|
||||
the reconstruct miss — over the same Redis the session tracker already uses (and
|
||||
the deferred deny-lease would use), so no central URL is needed. The boot-time
|
||||
full segment-dir wipe
|
||||
stands; reconstruct re-transcodes from the requested segment. See §10 for the
|
||||
full rationale (and why this is *not* a token-carryable case).
|
||||
- **`userID==0` tolerance / "auth optional" (P-5).** Reconstruct hard-rejects
|
||||
`userID==0` and a card whose session id does not match the URL.
|
||||
- **Mutable recipe under a stable id (D).** A parameter change re-mints a new
|
||||
manifest + token and the client reloads, so the common case self-heals. The
|
||||
only residual is a restart *during* the brief switch transition with a prior
|
||||
token in flight: it reconstructs the prior *rendition* (access is re-resolved,
|
||||
only the rendition can be stale). TTL-bounded, self-healing on reload; minor,
|
||||
accepted.
|
||||
- **Cleanup liveness (F / tr-9).** Each process owns its `TranscodeDir`, so the
|
||||
liveness signal is the in-process live map + the in-flight reconstruct set +
|
||||
age-past-max-TTL; a dir is reaped only when absent from both and older than any
|
||||
surviving token. No cross-process enumeration-failure mode for the supported
|
||||
topology.
|
||||
- **Signing-key blast radius (E / tr-7, G13).** A single `JWTSecret` signs native
|
||||
auth, the stream token, and the node bearer, so a node-secret leak can forge
|
||||
native logins. **Accepted risk** on the trusted-node precondition. **Deferred
|
||||
hardening:** split the stream-token secret from the native-auth secret, then
|
||||
asymmetric stream-token signing before any untrusted/edge node.
|
||||
|
||||
---
|
||||
|
||||
## 10. Residuals and follow-ups
|
||||
|
||||
- **Revocation of an in-flight node stream (all cases, until deny-lease ships).**
|
||||
Because the session-deny marker is deferred (§7–§8), *no* ban — admin kill,
|
||||
passive ban, or partial access change — hard-cuts an already-running,
|
||||
non-cooperative node-served stream; all are enforced at next play, bounded by
|
||||
the ≤24h token. The future deny-lease closes the *admin-kill* case; passive
|
||||
bans would still need a per-content deny enumeration on the access-change event,
|
||||
or accepting it. See §7.
|
||||
- **Monitoring.** Two distinct questions:
|
||||
- *Live "who is watching what right now"* — covered. Every node serve writes a
|
||||
record to the Redis tracker (`silo:sessions:*`), now enriched with
|
||||
`auth_user_id` / `media_file_id` from the verified token, so the admin
|
||||
active-streams view answers who/what/where, tied to real byte-serving and
|
||||
surviving a central restart. Integrated (no-node) sessions show in central's
|
||||
in-memory session list.
|
||||
- *Durable historical/audit ("who watched what last week")* — NOT provided by
|
||||
this subsystem (there is no longer a durable per-session recipe record, by
|
||||
design — G9 was the deliberate trade for token-carried reconstruction). That
|
||||
history lives in the separate watch-progress / scrobble system.
|
||||
- **Multiple viewers on one stream token.** The token is per playback session, so
|
||||
a shared/leaked stream URL lets several clients pull bytes under one session id.
|
||||
They **collapse to a single entry** in the tracker (one `sid`) — monitoring
|
||||
cannot distinguish or count them — and the (deferred) session-deny would cut
|
||||
them as a group, not individually. Detecting/limiting this needs a per-account
|
||||
concurrency cap or
|
||||
per-device session ids (device binding); deferred. Do not bind to client IP
|
||||
(mobile / CGNAT).
|
||||
- **Token in logs (tr-1).** Scrub the `?st=` token / path token from request
|
||||
loggers (the jellycompat logger logs `RawQuery`; the native logger omits it).
|
||||
Short TTL + signature bound the exposure.
|
||||
- **Owner-identity (A).** Required before multi-front-end *integrated* transcode
|
||||
without sticky affinity; defines owner election, self-URL discovery, a
|
||||
peer-forward endpoint, and failover.
|
||||
- **Node-side reconstruct (P-3). Done** for native (token-forwarded) and
|
||||
jellycompat (Redis recipe handoff via `internal/noderecipe`: central writes the
|
||||
recipe at remote-transcode start, the node reads it on a reconstruct miss).
|
||||
Precondition: central and the nodes share the same Redis (already required for
|
||||
the session tracker, and for the deferred deny-lease). Residual: the recipe
|
||||
store is consulted
|
||||
only for a jellycompat token, so a node restart still depends on the recipe key
|
||||
surviving (24h TTL, ≥ token lifetime).
|
||||
- *Why a server-side store and not the token here (the real rationale).* It is
|
||||
tempting to delete `internal/noderecipe` and let the jellycompat node-hop
|
||||
token carry the recipe like native — the token is server-minted and opaque to
|
||||
the Jellyfin client (it follows it only as a redirect target), so it *could*.
|
||||
That does **not** work, and the reason is **not** the often-stated "a Jellyfin
|
||||
client can't round-trip a token." The reason is that the recipe is **mutated
|
||||
in place under a stable session id**: a Jellyfin audio/subtitle switch can
|
||||
arrive as a `/Sessions/Playing/Progress` report
|
||||
(`restartCompatTranscodeForAudioSelection`) that restarts ffmpeg in place
|
||||
**without re-minting the client's token**. A third-party Jellyfin client
|
||||
cannot be driven to refresh the now-stale token — its VOD/`ENDLIST` manifest
|
||||
is fetched once, and the compat WebSocket (`HandleSocket`) is a receive-only
|
||||
keep-alive stub with no server→client command to force a manifest re-fetch. A
|
||||
token is a snapshot at mint time, so after such a switch it would reconstruct
|
||||
the **stale** rendition (old audio/subtitle) until the client happens to
|
||||
re-fetch the manifest (e.g. on a seek), which for a VOD stream may be never.
|
||||
Because the node also cannot reach Postgres (where the authoritative compat
|
||||
recipe lives), the only way the node rebuilds the *current* recipe is a
|
||||
node-reachable, server-authoritative store that central overwrites on every
|
||||
switch — i.e. `internal/noderecipe`. Native has no equivalent path: every
|
||||
native audio switch re-mints the manifest + token, so the native client's
|
||||
token is never stale, which is exactly why native needs no such store. The
|
||||
store is **load-bearing, not redundant**.
|
||||
- **Key split / asymmetric signing (E).** Closes the account-takeover path; a
|
||||
precondition for untrusted edge nodes.
|
||||
|
||||
---
|
||||
|
||||
## 11. Preconditions
|
||||
|
||||
- **Persistent `TranscodeDir` (recommended).** With persistence, reconstruct
|
||||
serves surviving segments; with an ephemeral dir it re-transcodes from the
|
||||
descriptor (which survives a wiped dir). A node *restart* still loses the
|
||||
session regardless (P-3).
|
||||
- **Re-transcode of one segment beats the buffer runway** on worst-case hardware
|
||||
(software decode, subtitle burn-in). Measure.
|
||||
- **Client retry/backoff exists.** Clients retry segment/manifest GETs on
|
||||
5xx/connection error and reconnect the WebSocket. The token rides the URL, so no
|
||||
extra client logic is needed to carry it; a client that does not retry sees the
|
||||
failed request as fatal.
|
||||
|
||||
---
|
||||
|
||||
## 12. Diagrams
|
||||
|
||||
The `mermaid` blocks render in GitHub / VS Code / most Markdown viewers; the
|
||||
ASCII blocks render anywhere.
|
||||
|
||||
### 12.1 Topology — where state lives
|
||||
|
||||
> **Deferred:** the `silo:streamauth:<sid> = deny` marker, the "Admin
|
||||
> Stop/Terminate → write session-deny marker" arrow, and the node's "deny GET"
|
||||
> are the planned revocation path and are **not** in the current implementation.
|
||||
> The tracker (`silo:sessions:*`) and the recipe handoff are shipped.
|
||||
|
||||
```text
|
||||
┌─────────────────────────── CENTRAL (mode=server) ───────────────────────────┐
|
||||
Client │ RequireAuth /playback/start /playback/transcode/start (mint token) │
|
||||
(web / android / │ native serve: /playback/transcode/{sid}/master.m3u8?st= /stream/{sid}?st= │
|
||||
apple / jellyfin) │ Admin Stop/Terminate → write session-deny marker (event, not a timer) │
|
||||
└───────────┬──────────────────────────┬───────────────────────┬──────────────┘
|
||||
│ Postgres │ Redis │
|
||||
▼ ▼ │ (multi-node only)
|
||||
┌───────────────────────┐ ┌──────────────────────────┐ ▼
|
||||
│ catalog / access │ │ silo:sessions:* (tracker)│ ┌────────────────────────┐
|
||||
│ jellycompat_playback_ │ │ silo:streamauth:<sid> │ │ Proxy node (mode=proxy) │
|
||||
│ sessions (compat │ │ = deny (session kill) │◀─│ verifyToken + deny GET │
|
||||
│ store, carries Recipe)│ └──────────────────────────┘ │ serves direct/remux │
|
||||
└───────────────────────┘ ▲ │ proxies transcode ──────┼──▶ Transcode node
|
||||
✗ NO transcode_recipes table (deleted this branch) │ node-local GET └────────────────────────┘ (mode=transcode)
|
||||
└── sub-ms, off Postgres ffmpeg /transcode/{sid}/…
|
||||
```
|
||||
|
||||
Descriptor location by route (the core change). The "lease (node)" revocation
|
||||
column is the **deferred** design; as shipped, the node path has no revocation
|
||||
tighter than the ≤24h token:
|
||||
|
||||
| route family | descriptor (how it reconstructs) | revocation |
|
||||
|--------------|----------------------------------|------------|
|
||||
| native (1–3) | the signed token the client re-presents | *deferred:* lease (node) / shipped: producer-kill (integrated), ≤24h token (node) |
|
||||
| jellycompat (4–6) | `jellycompat_playback_sessions.data.Recipe` (token can't round-trip) | *deferred:* lease (node) / shipped: producer-kill, ≤24h token (node) |
|
||||
| ~~all~~ | ~~`transcode_recipes` Postgres row~~ — removed | — |
|
||||
|
||||
### 12.2 Where the token rides
|
||||
|
||||
```text
|
||||
NATIVE (integrated server is hit directly → query param; segments inherit it)
|
||||
manifest : GET /playback/transcode/{sid}/master.m3u8?st=<JWT>
|
||||
segment : GET /playback/transcode/{sid}/segment/seg_00042.m4s?st=<JWT> ← RawQuery propagated by the manifest rewriter
|
||||
direct : GET /stream/{sid}?st=<JWT>
|
||||
|
||||
PROXY/NODE (capability URL → path segment; survives a query-stripping hop)
|
||||
GET {proxy}/stream/transcode/{JWT}/master.m3u8 → relative segment URIs inherit {JWT}
|
||||
GET {proxy}/stream/direct/{JWT} GET {proxy}/stream/remux/{JWT}
|
||||
|
||||
JELLYCOMPAT → recipe is NOT in the token; it lives in the compat store.
|
||||
the path token is used only for the node hop.
|
||||
```
|
||||
|
||||
### 12.3 Normal playback — native, integrated box (no nodes)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant S as Central server (integrated)
|
||||
participant PG as Postgres (access)
|
||||
Note over C,S: transcode start
|
||||
C->>S: POST /playback/transcode/start (RequireAuth)
|
||||
S->>PG: resolve file + access
|
||||
S->>S: StartTranscode (ffmpeg) + RegisterTranscodeSession
|
||||
S->>S: card = NewRecipeCard(opts); token = Sign(card.ToClaims(), 24h)
|
||||
S-->>C: 202 manifestURL = /playback/transcode/{sid}/master.m3u8?st=TOKEN
|
||||
Note over C,S: hot path — NO Postgres, NO lease (live session in memory)
|
||||
C->>S: GET .../master.m3u8?st=TOKEN
|
||||
S->>S: LoadOrReconstructSession → live hit → BuildPlaybackManifest (segments carry ?st)
|
||||
S-->>C: manifest
|
||||
loop every segment
|
||||
C->>S: GET .../segment/seg_N.m4s?st=TOKEN
|
||||
S->>S: live session → GetSegment → ServeFile
|
||||
S-->>C: bytes
|
||||
end
|
||||
```
|
||||
|
||||
The hot path touches neither Postgres nor the lease — it is an in-memory map
|
||||
hit. `?st` is verified only to decode a card if reconstruct is needed; a live
|
||||
session ignores it.
|
||||
|
||||
### 12.4 Normal playback — native, multi-node (offloaded)
|
||||
|
||||
> **Deferred:** the `GET silo:streamauth:{sid}` lease guard and its `deny → 403`
|
||||
> branch are the planned revocation path, **not** in the current implementation.
|
||||
> As shipped the node verifies the token, tracks the session, and serves — there
|
||||
> is no deny check.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant S as Central server
|
||||
participant N as Proxy node
|
||||
participant T as Transcode node
|
||||
participant R as Redis
|
||||
C->>S: POST /playback/transcode/start (RequireAuth)
|
||||
S->>S: card(full recipe, tnode=T); token = Sign(card.ToClaims())
|
||||
S-->>C: manifestURL = {N}/stream/transcode/TOKEN/master.m3u8
|
||||
loop manifest + segments
|
||||
C->>N: GET /stream/transcode/TOKEN/...
|
||||
N->>N: verifyToken(TOKEN)
|
||||
N->>R: GET silo:streamauth:{sid} (lease guard)
|
||||
alt deny
|
||||
N-->>C: 403 (no bytes)
|
||||
else allow / absent
|
||||
N->>R: Track silo:sessions:{node}:{sid} (uid, mfid from claims)
|
||||
N->>T: proxy /transcode/{sid}/...
|
||||
T-->>N: bytes
|
||||
N-->>C: bytes
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
The node copies `uid`/`mfid` from the verified token into the Redis tracker —
|
||||
that is what enriches the live admin "active streams" view (who/what per node).
|
||||
|
||||
### 12.5 Restart → reconstruct (native transcode)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant S as Central server (just restarted — session map empty)
|
||||
Note over S: in-memory session GONE; NO transcode_recipes table to read
|
||||
C->>S: GET /playback/transcode/{sid}/segment/seg_42.m4s?st=TOKEN
|
||||
S->>S: card = streamCardFromQuery(?st) → Verify + decode recipe
|
||||
S->>S: LoadOrReconstructSession → GetSession = NotFound, card != nil
|
||||
S->>S: ReconstructSession (re-bind: refuse userID==0 / != card.uid; card.sid == {sid})
|
||||
S->>S: ReconstructTranscode(seg=42) → single-flight, mark in-flight, respawn ffmpeg SEEKED to seg 42
|
||||
S-->>C: bytes (resumes near where the client was)
|
||||
```
|
||||
|
||||
- direct/remux restart is the same but Tier-1: reconstruct the `Session` from the
|
||||
identity token, then `Range` / `?seek` re-serve — no ffmpeg rebuild.
|
||||
- multi-node restart: the integrated front-end reconstructs only the `Session`
|
||||
(to learn `tnode`) and re-proxies; the transcode node keeps serving. If the
|
||||
transcode *node* itself reboots, the proxy forwards the stream token
|
||||
(`X-Silo-Stream-Token`) and the node self-reconstructs ffmpeg seeked to the
|
||||
requested segment (native path, P-3). A jellycompat session on a rebooted node
|
||||
reconstructs too: the node fetches the recipe central wrote to the shared Redis
|
||||
recipe store (`internal/noderecipe`) at transcode start, since the Jellyfin
|
||||
client's token cannot carry it.
|
||||
- cleanup race: a segment dir is reaped only if absent from the live map AND the
|
||||
in-flight set AND older than 24h (max token TTL), so a token that can still
|
||||
reconstruct never gets its dir wiped.
|
||||
|
||||
### 12.6 Auth deny — the session-deny marker
|
||||
|
||||
> **Status: deferred to a future PR.** The entire "Admin Stop/Terminate → write
|
||||
> deny marker" and "Offload node → GET deny → 403" flow below is the planned
|
||||
> revocation design and is **not** in the current implementation. As shipped,
|
||||
> admin Stop/Terminate does the "realtime WS stop + producer teardown" half only
|
||||
> (the right branch); it writes no marker, and the node performs no deny check, so
|
||||
> a valid token reconstructs until its ≤24h TTL. New playback is still gated at
|
||||
> `/playback/start` (the top subgraph is accurate).
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph New["New playback (any topology)"]
|
||||
P["/playback/start — RequireAuth + access check"] --> PB{allowed?}
|
||||
PB -- no --> PR[refused — ban takes effect at next play]
|
||||
PB -- yes --> PM[mint token]
|
||||
end
|
||||
subgraph Admin["Admin Stop/Terminate (event, not a timer)"]
|
||||
K[denyStreamLease] --> WD[SET silo:streamauth:sid = deny<br/>TTL = 24h]
|
||||
K --> WS[realtime WS stop + producer teardown]
|
||||
end
|
||||
subgraph Node["Offload node — every serve"]
|
||||
G[verifyToken OK] --> L{GET silo:streamauth:sid}
|
||||
L -- present deny --> F[403, no bytes]
|
||||
L -- absent / redis-err --> SV[serve bytes fail-open]
|
||||
end
|
||||
```
|
||||
|
||||
Invariants:
|
||||
- No steady-state writes: the marker is set only on an admin kill, never on a
|
||||
timer. The normal serve path finds *no* key and serves.
|
||||
- Fail-open: absent key or Redis error → serve. The only hard stop is a present
|
||||
deny.
|
||||
- The integrated box reads no marker — the producer-kill + session removal on
|
||||
Terminate is the stop there; the marker only matters where nodes serve.
|
||||
- Not covered here (by design): a *passive* ban / partial access change of an
|
||||
in-flight non-cooperative node stream — enforced at next play, bounded by the
|
||||
≤24h token (§7).
|
||||
|
||||
### 12.7 Jellycompat — what differs
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant J as Jellyfin client
|
||||
participant S as Central (jellycompat)
|
||||
participant PG as Postgres compat store
|
||||
J->>S: negotiate → PlaybackSession{UpstreamSessionID, MediaSources}
|
||||
S->>PG: Put (jellycompat_playback_sessions)
|
||||
Note over S: transcode start
|
||||
S->>S: StartTranscode; recipe = NewRecipeCard(opts)
|
||||
S->>PG: Update(ps): TranscodeStarted=true, Recipe=recipe (same write)
|
||||
Note over J,S: restart → reconstruct from the COMPAT STORE, not a token
|
||||
J->>S: GET HLS segment
|
||||
S->>PG: Get(playSessionID) → ps.Recipe
|
||||
S->>S: LoadOrReconstructSession(UpstreamSessionID, card = ps.Recipe) → ReconstructTranscode(*ps.Recipe)
|
||||
S-->>J: bytes
|
||||
```
|
||||
|
||||
Jellyfin clients cannot carry a native `?st` token, so the recipe is folded into
|
||||
the durable compat row (`PlaybackSession.Recipe`, persisted in the `data` JSONB)
|
||||
and written in the same `Update` that flips `TranscodeStarted`. The node hop
|
||||
(multi-node jellycompat) still uses a path token exactly like native (the lease
|
||||
guard is deferred along with the rest of the revocation path).
|
||||
|
||||
### 12.8 Hot-path cost per request
|
||||
|
||||
The "lease" / "SET deny" Redis costs below are **deferred** (the revocation path
|
||||
is not shipped); as shipped the node does only the tracker write, and admin
|
||||
Stop/Terminate writes no Redis key:
|
||||
|
||||
| request | Postgres | Redis | central CPU | reconstruct |
|
||||
|---------|----------|-------|-------------|-------------|
|
||||
| native segment, live, integrated | — | — | map hit | — |
|
||||
| native segment, live, node | — | 1 track (+ deferred: GET lease) | — | — |
|
||||
| native segment, after restart, integrated | — | — | verify JWT + ffmpeg respawn (once, single-flight) | yes |
|
||||
| jellycompat segment, live | — | track (+ deferred: lease) | map hit | — |
|
||||
| jellycompat segment, after restart | 1 Get (compat row) | track (+ deferred: lease) | respawn | yes |
|
||||
| admin Stop/Terminate (event, not per-request) | — | deferred: 1 SET deny | one write | — |
|
||||
|
||||
---
|
||||
|
||||
## 13. Restart-resiliency behavior matrix (role × path)
|
||||
|
||||
The observable reconstruction behavior of every playback path under a restart of
|
||||
each server role. **✅** = the path reconstructs and resumes on the client's next
|
||||
request; **—** = the path never executes on that role (nothing to reconstruct);
|
||||
**⚠️** = reconstructs only while a stated precondition holds.
|
||||
|
||||
| path | integrated restart | proxy-node restart | transcode-node restart |
|
||||
|------|--------------------|--------------------|------------------------|
|
||||
| native · direct | ✅ rebuild `Session` from `?st` token → `Range` re-serve | ✅ stateless re-serve from `claims.MediaPath` | — not served on a transcode node |
|
||||
| native · remux | ✅ token + `?seek` → re-spawn pipe | ✅ stateless per-request pipe re-spawn | — |
|
||||
| native · transcode | ✅ token (full recipe) → respawn ffmpeg seeked to segment | ✅ transparent re-route + token forward | ✅ self-contained — token is recipe-complete |
|
||||
| jellycompat · direct | ✅ re-resolve from durable compat row → `Range` re-serve | ✅ stateless re-serve from token `MediaPath` | — |
|
||||
| jellycompat · remux | ✅ compat row + `?seek` | ✅ stateless per-request pipe re-spawn | — |
|
||||
| jellycompat · transcode | ✅ `ReconstructTranscode(*ps.Recipe)` from compat row | ✅ transparent re-route + token forward | ⚠️ reconstructs **iff** the shared Redis recipe (`internal/noderecipe`, ≤24h TTL) still exists; fails closed (404) otherwise |
|
||||
|
||||
**What carries the recovery state, per role**
|
||||
|
||||
| role | native carrier | jellycompat carrier |
|
||||
|------|----------------|---------------------|
|
||||
| integrated | `?st` stream token — identity for direct/remux, full recipe for transcode | durable compat row `PlaybackSession.Recipe` |
|
||||
| proxy node | self-describing path token, re-derived per request — the proxy holds no per-session state | same path token (identity); the recipe is not needed at the proxy |
|
||||
| transcode node | forwarded `X-Silo-Stream-Token`, recipe-complete | identity token + Redis `noderecipe` recipe (the Jellyfin token cannot carry it) |
|
||||
|
||||
**Reading the matrix**
|
||||
|
||||
- **Integrated and proxy-node restarts are unconditional** for every applicable
|
||||
path. The proxy holds no authoritative per-session state — direct/remux are
|
||||
re-served from the token's `MediaPath`, transcode is reverse-proxied — so a
|
||||
proxy bounce, or a reroute to any in-group proxy (`pickProxy` is round-robin
|
||||
with no session affinity), is transparent.
|
||||
- **The four `—` cells are structural, not gaps.** Direct and remux never run on
|
||||
a transcode node — the proxy (or the integrated box) serves them from source
|
||||
media — so a transcode-node restart has nothing to reconstruct on those paths.
|
||||
- **The one conditional (⚠️)** is jellycompat transcode after a *transcode-node*
|
||||
restart. The node-hop token is identity-only — not because a Jellyfin client
|
||||
cannot round-trip it (the token is server-minted and opaque to the client), but
|
||||
because the recipe is mutated in place under a stable session id and a
|
||||
third-party Jellyfin client cannot be driven to refresh a stale token, so a
|
||||
token snapshot could reconstruct a stale rendition (see §10). The node therefore
|
||||
rebuilds from the Redis `noderecipe` entry central overwrites on every switch
|
||||
rather than from the token. Native transcode has no such dependency — every
|
||||
native audio switch re-mints the token, so it is always recipe-complete and
|
||||
current. This is the only native-vs-jellycompat asymmetry in reconstruction
|
||||
(see §10).
|
||||
|
||||
**Out of scope of this matrix** (accepted behaviors, not restart-reconstruction
|
||||
holes — see §7, §10):
|
||||
|
||||
- Passive ban / partial access change of an in-flight node stream — enforced at
|
||||
next play, bounded by the ≤24h token, not by reconstruction.
|
||||
- Integrated transcode behind a non-sticky load balancer can split-brain into
|
||||
divergent segment dirs (P-1); safe single-front-end or with LB affinity. Remote
|
||||
(node) sessions are immune — node affinity routes every front-end to the same
|
||||
transcode node.
|
||||
@@ -61,7 +61,8 @@ point of keeping the key out of the database. Recovery means re-entering the
|
||||
affected credentials:
|
||||
|
||||
- Re-enter Sonarr/Radarr and Autoscan API keys, S3 keys, watch-sync connections,
|
||||
history-import tokens, and subtitle provider credentials.
|
||||
history-import tokens, subtitle provider credentials, and plugin runtime
|
||||
configuration.
|
||||
- `auth.jwt_secret` becomes unreadable, so all existing sessions are invalid and
|
||||
users must log in again (a new secret is generated on next boot if the row is
|
||||
cleared — see below).
|
||||
@@ -79,7 +80,11 @@ remaining plaintext in place:
|
||||
3. the two arr `api_key_ref` columns — these are **resolved-then-encrypted**: a
|
||||
legacy row that held a `server_settings` reference (e.g.
|
||||
`requests.radarr.api_key`) is collapsed to the real credential before being
|
||||
encrypted.
|
||||
encrypted,
|
||||
4. whole `plugin_runtime_configs.config_value` objects. Plugin config is
|
||||
intentionally opaque to the host storage layer: plugins may write undeclared
|
||||
keys and manifest annotations may be unavailable or change over time, so
|
||||
manifest-selected field encryption cannot fail closed.
|
||||
|
||||
The backfill is safe to run repeatedly: already-encrypted values are skipped, and
|
||||
a per-row guard makes concurrent multi-node boots converge without
|
||||
@@ -92,7 +97,9 @@ whatever the primary encrypted. No manual steps are required.
|
||||
Downgrading to a binary that predates this change is **not** safe while secrets
|
||||
are encrypted, because the old binary has no read path: it would read
|
||||
`enc:v1:auth.jwt_secret` as a literal JWT secret (invalidating all sessions) and
|
||||
read `enc:v1:`-prefixed integration keys as garbage credentials.
|
||||
read `enc:v1:`-prefixed integration keys as garbage credentials. It would also
|
||||
pass the reserved plugin-config envelope object to plugins instead of their
|
||||
configuration.
|
||||
|
||||
To downgrade safely you must first return the affected values to plaintext, for
|
||||
example:
|
||||
@@ -123,7 +130,10 @@ history-import admin/session tokens and temporary server-list credentials,
|
||||
subtitle provider `api_key`/`password`,
|
||||
the Jellyfin-compat session's bridged Silo access/refresh tokens
|
||||
(`jellycompat_sessions.streamapp_access_token` / `streamapp_refresh_token`), and
|
||||
the ABS signing key.
|
||||
the ABS signing key. Plugin runtime configuration is encrypted as one opaque
|
||||
row-bound envelope rather than by manifest field. Consequently, runtime code
|
||||
must use `RuntimeConfigStore`; database JSON-member queries and indexes are not
|
||||
supported for `plugin_runtime_configs.config_value`.
|
||||
|
||||
Deliberately **not** encrypted (tracked as follow-ups):
|
||||
|
||||
@@ -132,11 +142,6 @@ Deliberately **not** encrypted (tracked as follow-ups):
|
||||
lookup. They need a deterministic **blind-index hash** column instead:
|
||||
`api_keys.api_key`, `webhook_sync_connections.webhook_secret`,
|
||||
`jellycompat_sessions.token`, and `watch_together_rooms.join_token`.
|
||||
- **Plugin runtime config** — `plugin_runtime_configs.config_value` is opaque
|
||||
plugin-defined JSONB whose secret fields are manifest-declared and whose runtime
|
||||
lives in a separate repo; encrypting it correctly needs a coordinated,
|
||||
manifest-aware design.
|
||||
|
||||
Excluded (not a gap): `plex_sync_connections.*` is a dead table (zero Go
|
||||
references); `oauth_completion.token_ciphertext` is already AES-GCM;
|
||||
`users.password_hash` and the `*_hash` columns are already hashed.
|
||||
|
||||
@@ -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,99 @@
|
||||
# Handoff: `account.capabilities_changed` event on the events WebSocket
|
||||
|
||||
**Date:** 2026-07-06
|
||||
**Requested by:** Apple client team (silo-apple)
|
||||
**Status:** Proposed — server implementation needed
|
||||
|
||||
## Problem
|
||||
|
||||
Clients cache user-facing capability payloads (e.g. `GET /api/v1/downloads/capability`)
|
||||
and have no way to learn that an admin changed something that affects them. Concrete
|
||||
incident: enabling "Allow transcoded downloads" (`users.download_transcode_allowed`)
|
||||
for a user did not surface the transcode quality presets in the iOS app until the
|
||||
client's cache expired (previously 24h).
|
||||
|
||||
The Apple client now re-fetches capability on app-foreground and when the download
|
||||
options UI opens (pull-based safety net, shipped in silo-apple). This handoff covers
|
||||
the push half: an invalidation event so connected clients react immediately, without
|
||||
polling.
|
||||
|
||||
## Existing infrastructure (no new transport needed)
|
||||
|
||||
- Events WebSocket: `GET /api/v1/events/ws` (`internal/api/handlers/events_ws.go`),
|
||||
channel-based subscribe with per-role channel ACLs (`allowedChannelsForRole`).
|
||||
- `user_state` channel is already available to non-admin users and the hub already
|
||||
filters envelopes by `env.UserID` for non-admins (`allowsEventForClaims`), so a
|
||||
user-targeted event on this channel is only delivered to that user (and admins).
|
||||
- Publishing helper: `evt.Hub.PublishJSON(ctx, channel, type, payload, opts)`
|
||||
(`internal/events/hub.go`), pattern example in
|
||||
`internal/api/handlers/user_state_events.go`.
|
||||
|
||||
## Proposed contract
|
||||
|
||||
**Channel:** `user_state` (reuse; it is the only user-scoped channel non-admins can
|
||||
subscribe to, and clients that care already subscribe to it).
|
||||
|
||||
**Event type:** `account.capabilities_changed`
|
||||
|
||||
**Payload — intentionally minimal (invalidation ping, not a data carrier):**
|
||||
|
||||
```json
|
||||
{
|
||||
"scope": "downloads" | "playback" | "all"
|
||||
}
|
||||
```
|
||||
|
||||
Do **not** include the new capability values. Clients respond by re-fetching the REST
|
||||
endpoints they already consume (`/api/v1/downloads/capability`, playback prefs, etc.).
|
||||
This keeps the event contract trivial and guarantees it never drifts from the REST
|
||||
response shapes. `scope` is a coarse hint so clients can skip irrelevant re-fetches;
|
||||
when in doubt publish `"all"`.
|
||||
|
||||
**Targeting:**
|
||||
|
||||
- Per-user change → `PublishOptions{UserID: <affected user>}` so only that user's
|
||||
connections receive it.
|
||||
- Server-wide setting change → publish once with no `UserID` (fan-out to all
|
||||
connected users). Verify `allowsEventForClaims` passes envelopes with
|
||||
`UserID == 0` to non-admins — it does today (`env.UserID > 0` guard).
|
||||
|
||||
## Publish sites
|
||||
|
||||
1. **Admin user update handlers** (`internal/api/handlers/admin.go` user
|
||||
create/update paths): publish scope `"downloads"` when any of
|
||||
`download_allowed`, `download_transcode_allowed` change; `"playback"` for
|
||||
playback-affecting fields (`max_streams`, `max_transcodes`, quality limits);
|
||||
`"all"` if simpler. Target the affected `UserID`.
|
||||
2. **Server settings writes** (settings handler / config store): publish untargeted
|
||||
scope `"downloads"` when any `download.*` key changes (notably
|
||||
`download.transcode_enabled`, `download.enabled`, `defaults.download_*`);
|
||||
`"playback"` for `playback.*` gates that shape client-visible capability.
|
||||
3. **Access-group / policy-engine changes** that alter `download` /
|
||||
`download_transcode` action outcomes, if applicable — same event, `"all"` scope,
|
||||
untargeted (membership makes per-user targeting fiddly; the re-fetch is cheap).
|
||||
|
||||
No snapshot frame needed for this event type (`snapshotForChannel` for `user_state`
|
||||
already returns `null`).
|
||||
|
||||
## Client behavior (for reference / Android alignment)
|
||||
|
||||
- Apple: will add a `ServerEventsClient` subscribing to `user_state` +
|
||||
`notifications`; on `account.capabilities_changed` with scope `downloads`/`all`,
|
||||
calls its existing capability re-fetch. Until then the pull-based refresh already
|
||||
covers correctness.
|
||||
- Android: already has `NotificationsRealtimeClient` on this socket; should handle
|
||||
the same event the same way.
|
||||
- Clients must treat the event as best-effort: sockets are down while backgrounded,
|
||||
so they keep refresh-on-foreground / refresh-on-use regardless.
|
||||
|
||||
## Acceptance checks
|
||||
|
||||
1. Toggle `download_transcode_allowed` for a connected non-admin user → that user's
|
||||
socket receives `account.capabilities_changed` (scope `downloads`); other users
|
||||
receive nothing.
|
||||
2. Flip `download.transcode_enabled` server setting → all connected users receive
|
||||
the event once.
|
||||
3. A non-admin subscribed to `user_state` never receives another user's targeted
|
||||
capability events (existing `allowsEventForClaims` filtering).
|
||||
4. `GET /api/v1/downloads/capability` immediately after the event reflects the new
|
||||
presets (it already does — the event carries no data to go stale).
|
||||
@@ -0,0 +1,179 @@
|
||||
# Streaming write-deadline fix: stop truncating streams at 120s without losing stalled-client protection
|
||||
|
||||
**Status:** planned (2026-07-09)
|
||||
**Repos:** silo-server (primary), silo-apple (hardening follow-up)
|
||||
**Root-cause session:** live debugging on Apple TV, 2026-07-09 evening
|
||||
|
||||
## Problem
|
||||
|
||||
`cmd/silo/main.go:2373` sets `WriteTimeout: 120 * time.Second` on the main API
|
||||
`http.Server`. Go's `WriteTimeout` is an **absolute deadline from the start of each
|
||||
request**, not an idle timeout — so every response still being written at T+120s is
|
||||
killed mid-body with a clean connection close.
|
||||
|
||||
Observed impact (2026-07-09, dev server, Apple TV loopback client):
|
||||
|
||||
- Every direct-stream GET (`/api/v1/stream/{session_id}`) serving a 15.3 GB MKV was
|
||||
cut at exactly 120s. Server log signature: `path=/api/v1/stream/` with
|
||||
`duration_ms=120000` (three instances in a 3h window; more go unlogged when the
|
||||
client disconnects first).
|
||||
- The Apple client's byte-cursor reconnect ladder silently absorbs most kills, so
|
||||
playback "mostly works" — but when a kill lands during backpressure parking or a
|
||||
demuxer resync, the client's 3-attempt `.prematureEOF` retry budget exhausts and it
|
||||
performs a full teardown + new-session recovery (~5 s visible stop). Four such
|
||||
failures in ~35 minutes of playback.
|
||||
- Confirmed by dual-path probe (long ranged GET consumed at playback rate): died at
|
||||
~123s direct to the origin and ~151s via the CDN edge edge (origin killed
|
||||
the edge's upstream GET at 120s; the edge's buffer drained for the extra ~30s).
|
||||
The CDN is exonerated — it only relayed the origin's truncation.
|
||||
|
||||
Precedent inside the same file: the Jellyfin-compat listener (`main.go:2514`) and the
|
||||
Audiobookshelf-compat listener (`main.go:2535`) both already set `WriteTimeout: 0`
|
||||
because compat clients stream long responses. The main listener serves the exact same
|
||||
class of traffic for first-party clients and was never aligned.
|
||||
|
||||
## Goals / non-goals
|
||||
|
||||
Goals:
|
||||
|
||||
1. Streaming/long responses are never killed while they are making progress.
|
||||
2. Genuinely stalled connections (client gone, TCP zombie, slow-loris) still get
|
||||
reaped in bounded time — we do NOT simply set `WriteTimeout: 0` on the main
|
||||
listener, because that removes mid-response stall protection entirely (a peer that
|
||||
stops ACKing would block a handler goroutine + fd indefinitely).
|
||||
3. All non-streaming API routes keep today's 120s absolute protection unchanged.
|
||||
4. No client changes required for the fix to take effect (Android, web, Infuse/compat
|
||||
benefit identically).
|
||||
|
||||
Non-goals: CDN edge tuning; silo-server#333 control-plane restart work; the
|
||||
silo-apple `.prematureEOF` parking hardening (tracked as follow-up below, it is
|
||||
defense-in-depth once this lands).
|
||||
|
||||
## Design: rolling per-write deadline for streaming handlers
|
||||
|
||||
Semantics change for streaming responses only, from "must complete within 120s" to
|
||||
"must make progress at least every N seconds".
|
||||
|
||||
### New helper (one small file, e.g. `internal/api/httpstream/rolling_deadline.go`)
|
||||
|
||||
```go
|
||||
// RollingDeadlineWriter wraps a streaming http.ResponseWriter and pushes the
|
||||
// connection's write deadline forward on every successful write, so long
|
||||
// responses survive indefinitely while stalled ones die within `window`.
|
||||
type RollingDeadlineWriter struct {
|
||||
w http.ResponseWriter
|
||||
rc *http.ResponseController
|
||||
window time.Duration // stall budget, e.g. 180s
|
||||
step time.Duration // min interval between deadline bumps, e.g. 15s
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func New(w http.ResponseWriter, window time.Duration) *RollingDeadlineWriter
|
||||
func (s *RollingDeadlineWriter) Write(p []byte) (int, error) // bump-then-write
|
||||
func (s *RollingDeadlineWriter) ReadFrom(r io.Reader) (int64, error) // see below
|
||||
func (s *RollingDeadlineWriter) Header() http.Header
|
||||
func (s *RollingDeadlineWriter) WriteHeader(code int)
|
||||
func (s *RollingDeadlineWriter) Flush()
|
||||
func (s *RollingDeadlineWriter) Unwrap() http.ResponseWriter // ResponseController compat
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- Uses `http.NewResponseController(w).SetWriteDeadline(time.Now().Add(window))` —
|
||||
Go ≥ 1.20 (we are on 1.26.4). A per-request controller deadline **overrides** the
|
||||
server-level `WriteTimeout` for that response, which is exactly the mechanism the
|
||||
stdlib provides for this case.
|
||||
- Bumps are rate-limited (`step`, ~15s) so we do one `SetWriteDeadline` per ~15s of
|
||||
wall time, not one per 32 KB chunk.
|
||||
- **`ReadFrom` must be implemented** and delegate to the underlying writer in bounded
|
||||
slices (e.g. `io.CopyN(underlying, r, 64<<20)` per iteration, bumping the deadline
|
||||
between iterations). Rationale: `http.ServeContent` → `io.Copy` uses the
|
||||
`io.ReaderFrom` fast path on the response writer (sendfile for `*os.File`); a naive
|
||||
wrapper without `ReadFrom` silently forfeits sendfile and burns CPU copying 15 GB
|
||||
through userspace. Bounded-slice delegation keeps sendfile *and* the rolling
|
||||
deadline.
|
||||
- First bump happens in `New` (covers the header write + first body bytes).
|
||||
- `window` default 180s, overridable via env `SILO_STREAM_WRITE_STALL_TIMEOUT`
|
||||
(seconds). 180s > the Apple client's longest observed benign backpressure park
|
||||
(~20s) and > outage-probe cadence with margin, while still reaping zombies in
|
||||
minutes. Note: a paused client that stops reading its socket for >window will have
|
||||
the connection reaped — that is fine and intended; the client's reconnect ladder
|
||||
resumes at the byte cursor on unpause (this is today's behavior for ALL streams at
|
||||
120s, so it is a strict improvement).
|
||||
|
||||
### Application points (all on the main listener)
|
||||
|
||||
| Path | File | Mechanism | Change |
|
||||
|---|---|---|---|
|
||||
| Direct play (the tonight failure) | `internal/playback/directplay.go:52` `ServeDirectPlay` | `http.ServeContent` of the full media file | wrap `w` before `ServeContent` |
|
||||
| Remux stream | `internal/playback/remux.go:226` `ServeRemux` (write loop `:255`) | manual chunked `Write`+`Flush` | wrap `w` (loop needs no change) |
|
||||
| Offline downloads | `internal/api/handlers/downloads.go:401` → `svc.ServeFile` | full media file download | wrap `w` at the handler before delegating |
|
||||
| Transcode-node proxy | `internal/api/handlers/playback.go:2998` `io.Copy(w, resp.Body)` | long-lived proxy of remote transcode output | wrap `w` |
|
||||
| Ebook/converted serving | `internal/api/handlers/ebook_convert_serve.go:159`, `ebook_reader.go:625` | `ServeContent`, files up to ~100s of MB; slow readers can exceed 120s | wrap `w` (cheap, same helper) |
|
||||
|
||||
Explicitly **unchanged**:
|
||||
|
||||
- Transcode HLS segments (`playback.go:2897 http.ServeFile`) — bounded few-second
|
||||
files; 120s absolute is fine. (Harmless to wrap later if segment sizes grow.)
|
||||
- WebSockets (`/control/ws`) — hijacked connections manage their own deadlines;
|
||||
tonight's WS survived 4+ minutes untouched, confirming no interaction.
|
||||
- Jellyfin/ABS compat listeners — separate `http.Server`s, already `WriteTimeout: 0`;
|
||||
optionally migrate them to the rolling writer later so they regain stall
|
||||
protection (follow-up, not required).
|
||||
- All JSON/image/API routes — keep the server-level 120s absolute deadline.
|
||||
|
||||
`cmd/silo/main.go:2374` itself stays at `WriteTimeout: 120s`. That is the point: the
|
||||
global guard remains, streaming handlers opt out per-response with a better contract.
|
||||
|
||||
## Server tests
|
||||
|
||||
1. **Unit — rolling behavior:** wrapper on a fake ResponseController records deadline
|
||||
bumps; assert bump on first write, rate-limited bumps thereafter, `ReadFrom`
|
||||
chunking delegates and bumps per slice.
|
||||
2. **Integration — survives the server timeout:** `httptest.Server` with
|
||||
`WriteTimeout: 1s`; handler streams 3s of throttled data through the wrapper
|
||||
(window 2s). Assert full body received (fails without wrapper — this is the
|
||||
regression test for tonight's bug).
|
||||
3. **Integration — stalled client still reaped:** wrapper with window 500ms writing
|
||||
to a client that stops reading; assert handler write errors within ~window, not
|
||||
never.
|
||||
4. **Regression:** non-streaming route on the same server still killed at
|
||||
`WriteTimeout` (guard unchanged).
|
||||
|
||||
## Validation on dev (after tonight's viewing)
|
||||
|
||||
1. `make dev-deploy`.
|
||||
2. Re-run the range-GET probe (script preserved from tonight's session): sustained
|
||||
open-ended GET at playback rate for ≥ 15 min on both direct and CDN paths; expect
|
||||
zero early closes and no `duration_ms=120000` entries in the request log.
|
||||
3. Long ATV playback session (existing client build): expect zero
|
||||
`prematureSourceEnd` teardowns over a full film.
|
||||
4. Spot-check normal API latency/behavior and one Jellyfin-compat client.
|
||||
|
||||
## Follow-up workstream: silo-apple client hardening (separate branch)
|
||||
|
||||
Defense-in-depth so any truncating origin/middlebox (not just our own bug) degrades
|
||||
to an invisible reconnect instead of a 5s rebuild:
|
||||
|
||||
1. Park persistent mid-stream `.prematureEOF` into the existing outage ride-through
|
||||
once the cursor-resume retries reproduce the same truncation offset
|
||||
(`PlaybackOriginOutagePolicy.shouldPark`, `PlaybackSourceOriginStream.swift:159-173`;
|
||||
writer then gets the 240s park allowance instead of the 10s deadline). Keep it
|
||||
kill-switchable alongside `SILO_DISABLE_OUTAGE_RIDE_THROUGH`.
|
||||
2. Add `[CMP-ORIGIN]` logging: one line per origin connection end (cause, HTTP
|
||||
status, cursor, bytes delivered, retry streak) — tonight the entire retry-and-give-up
|
||||
sequence ran with zero log output.
|
||||
3. Verify reconnects use fresh connection state (AetherEngine builds a new URLSession
|
||||
per reconnect to avoid poisoned keep-alive pools; our 3 rapid same-offset failures
|
||||
while a fresh session succeeded seconds later is circumstantial evidence we reuse).
|
||||
4. Optional: raise `.prematureEOF` retry cap toward AE's progress-aware semantics.
|
||||
|
||||
Android note: the server fix benefits Android identically with no client change;
|
||||
worth a later check of how its player handles a 120s kill today (it may have been
|
||||
absorbing them silently, or contributing to unexplained rebuffers).
|
||||
|
||||
## Rollout
|
||||
|
||||
Dev first (above), soak for a few days of normal viewing, then include in the next
|
||||
server release. The change is additive and per-handler; risk is localized to the
|
||||
wrapped streaming paths, and the global timeout still protects everything else.
|
||||
@@ -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
|
||||
}
|
||||
+45
@@ -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"
|
||||
}
|
||||
}
|
||||
+44
@@ -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"
|
||||
}
|
||||
}
|
||||
+54
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"
|
||||
}
|
||||
}
|
||||
+46
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,311 @@
|
||||
# Plan: interleave `transcribe_translate` so live AI subtitles follow the playhead
|
||||
|
||||
Issue: https://github.com/Silo-Server/silo-server/issues/154
|
||||
Area: `internal/subtitles/ai` (+ `internal/playback` extraction, `internal/config`)
|
||||
Type: bug fix to an existing (shipped) capability — makes the chained AI-subtitle
|
||||
path deliver its designed live behavior. No new client-facing contract.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Problem (confirmed)
|
||||
|
||||
`Service.runTranscribe` (`internal/subtitles/ai/service.go`) runs the chained
|
||||
`transcribe_translate` kind as two **global, sequential** stages:
|
||||
|
||||
1. Transcribe the **entire** file (progress 5%→70%). Transcript cues are not
|
||||
streamed for the chained kind —
|
||||
`streamTranscript := streaming && job.Kind == JobKindTranscribe`
|
||||
(`service.go:417`), so it is `false` for `transcribe_translate`.
|
||||
2. Only then translate **all** cues (70%→95%), streaming each translated batch
|
||||
as it lands (`service.go:482-493`).
|
||||
|
||||
Because translation cannot start until step 1 finishes for the whole file, the
|
||||
first translated cue streams minutes into the job, long past the playhead.
|
||||
|
||||
### Two distinct bottlenecks (both must be addressed)
|
||||
|
||||
- **Transcription stage is global.** Translation waits for the whole-file ASR
|
||||
pass. This is the dominant term for a feature film (~20 min ASR) and the
|
||||
primary cause. Fixed by interleaving translation per chunk (Phase 1).
|
||||
- **Extraction is global and precedes any callback.** `Transcribe` calls
|
||||
`t.extract(...)` (`transcriber.go:124`) before the `onChunk` loop, and
|
||||
`ExtractAudioChunks` blocks on a single whole-track ffmpeg pass
|
||||
(`internal/playback/audio_extract.go:34,65`). So even with per-chunk
|
||||
interleave and small chunks, the first cue still waits for full-file
|
||||
extraction (~1–2 min for a 2h film). Fixed by playhead-first incremental
|
||||
extraction (Phase 2).
|
||||
|
||||
The transcriber already exposes the per-chunk seam: `Transcribe` invokes
|
||||
`onChunk(cues, done, total)` per chunk, playhead-first (`transcriber.go:111-174`),
|
||||
and plain `transcribe` already streams through it (`service.go:426-429`).
|
||||
|
||||
Secondary defect found while confirming: `WhisperTranscriber.SetExtraction`
|
||||
clamps any `chunkSeconds` outside `[60, 600]` to the **default 600**
|
||||
(`transcriber.go:100-102`), so a small live chunk value silently becomes 10 min
|
||||
instead of clamping to the floor.
|
||||
|
||||
## Goal & honest latency targets
|
||||
|
||||
Translated cues stream playhead-first for a session-attached
|
||||
`transcribe_translate` job, instead of waiting for whole-file transcription.
|
||||
|
||||
Time-to-first-translated-cue, by phase:
|
||||
|
||||
- **Today:** `extract(full) + transcribe(full) + translate(first batch)` — many
|
||||
minutes (≈ full ASR pass).
|
||||
- **After Phase 1 (interleave, existing full extraction):**
|
||||
`extract(full) + transcribe(first chunk) + translate(first chunk)` — removes
|
||||
the whole-file ASR pass from the critical path; ≈ extraction time (~1–2 min for
|
||||
a 2h film), then cues follow the playhead. Already usable for live after a
|
||||
one-time wait.
|
||||
- **After Phase 2 (playhead-first incremental extraction):**
|
||||
`extract(one chunk near playhead) + transcribe + translate` — single-digit
|
||||
seconds. This is what the issue means by "within seconds."
|
||||
|
||||
Phase 1 delivers most of the value with low risk; Phase 2 closes the remaining
|
||||
extraction latency. Ship Phase 1 first; do not claim "within seconds" until
|
||||
Phase 2 lands.
|
||||
|
||||
### Non-goals (this plan)
|
||||
|
||||
- Pacing/throttling to the playhead (rolling window). Optional in the issue;
|
||||
follow-up below.
|
||||
- Cross-chunk translation-context continuity beyond batch context neighbours.
|
||||
|
||||
## Decision: interleave only when streaming
|
||||
|
||||
**Session-attached (live) jobs** use the interleaved per-chunk path.
|
||||
**Background (non-streaming) `transcribe_translate` jobs keep the current
|
||||
whole-file transcribe-then-translate-at-end path unchanged.** Rationale: the
|
||||
issue is live-only; this preserves background output byte-for-byte (full
|
||||
cross-file translation context, no incremental-extraction cost) and keeps the
|
||||
two behaviors cleanly separated by the existing `streaming` boolean. The
|
||||
shared transcript-storage step is factored so it is not duplicated.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — interleave translation per chunk (streaming jobs)
|
||||
|
||||
### 1a. `internal/subtitles/ai/service.go` — `runTranscribe`
|
||||
|
||||
For the chained kind **when `streaming`**, replace the kind-specific `onChunk`
|
||||
body (`service.go:423-430`) with per-chunk translate-and-stream:
|
||||
|
||||
- Capture a translate error in a closure: `var translateErr error` and an
|
||||
accumulator `var translatedAccum []SubtitleCue`.
|
||||
- In `onChunk(chunk, done, total)`:
|
||||
- If `translateErr != nil` or `len(chunk) == 0`, return (skip silence; stop
|
||||
translating after a prior failure). Do **not** abort transcription.
|
||||
- Else call `s.translator.Translate` on **that chunk's** cues. On error: set
|
||||
`translateErr`, emit `TranslationFailed` once, and return (let transcription
|
||||
continue). On success: stream translated cues via `TranslationCues` and
|
||||
append to `translatedAccum`.
|
||||
- Progress: single 5%→95% band keyed on chunk `done/total`
|
||||
(message "Transcribing & translating").
|
||||
- Keep `onChunk` signature unchanged (no error return). Aborting on translate
|
||||
failure via the callback is intentionally avoided — see 1b.
|
||||
|
||||
After `Transcribe` returns:
|
||||
|
||||
- **Always store the transcript track** (unchanged, `service.go:447-466`,
|
||||
including the transcript `SubtitleReady` broadcast). This must run regardless
|
||||
of `translateErr`.
|
||||
- If `translateErr != nil`: `finishWithError(ctx, job, translateErr)`. The
|
||||
transcript is already persisted (cache preserved), and the live session
|
||||
already received `TranslationFailed`.
|
||||
- Else: assemble the translated track from `translatedAccum` (`sortCuesByStart`
|
||||
+ `StoreSubtitle`, `service.go:499-514`), `CompleteJob`, and
|
||||
`TranslationCompleted` (`service.go:515-522`).
|
||||
- Remove the whole-file translate-at-end (`service.go:479-497`) for the
|
||||
streaming path only.
|
||||
|
||||
Non-streaming chained jobs fall through to the existing whole-file path,
|
||||
untouched.
|
||||
|
||||
### 1b. Transcript-on-translation-failure (resolves review P1)
|
||||
|
||||
Today the full transcript is stored before translation starts
|
||||
(`service.go:447` then `:482`), so a translation failure still leaves a
|
||||
transcript/cache row and an already-broadcast `SubtitleReady`. The interleaved
|
||||
path preserves this exactly because:
|
||||
|
||||
- a per-chunk translate failure sets `translateErr` but lets the ASR pass run to
|
||||
completion (it does **not** return an error from `onChunk`), so `Transcribe`
|
||||
returns the full cue set normally;
|
||||
- the transcript is then stored on the same post-transcribe path as today,
|
||||
before the job is failed for the translated track.
|
||||
|
||||
An ASR failure or context cancellation still returns an error from `Transcribe`
|
||||
and yields no transcript — identical to today.
|
||||
|
||||
### 1c. Live progress semantics (resolves review P2)
|
||||
|
||||
`SubtitleTranslationCuesPayload.Done/Total` is documented as overall progress
|
||||
(`internal/playback/realtime.go:172-181`). For live `transcribe_translate`:
|
||||
|
||||
- `TranslationCues` carries **`done = chunks completed, total = total chunk
|
||||
count`** — the same chunk-granular convention plain `transcribe` already emits
|
||||
(`service.go:427-428`). Monotonic and stable across chunks.
|
||||
- `TranslationStarted.TotalCues = 0` (indeterminate), consistent with the
|
||||
transcribe path today (`service.go:412`), since the cue total is unknown before
|
||||
ASR completes.
|
||||
- Cues carry absolute Start/End, so the client places them regardless of
|
||||
arrival order; unpause is driven by cue arrival near the playhead, not by
|
||||
`Done/Total`. Confirm with client teams that `Done/Total` is treated as a
|
||||
fraction, not absolute cue indices (clients already receive chunk counts from
|
||||
the plain `transcribe` path, so this should hold).
|
||||
|
||||
### 1d. Small live chunks + clamp fix
|
||||
|
||||
- Add `TranscribeJobRequest.ChunkSeconds int` (0 = configured default). In
|
||||
`Transcribe`, use `req.ChunkSeconds` when `> 0`, else the atomic default
|
||||
(`transcriber.go:121-122`); validate/clamp identically to `SetExtraction`.
|
||||
- Fix the clamp in `SetExtraction` (`transcriber.go:99-105`): clamp
|
||||
out-of-range to the nearest bound, not to the 600 default.
|
||||
- Lower `minASRChunkSeconds` from 60 to 15 (`transcriber.go:26`); update the
|
||||
doc comment about request count / boundary word-clip tradeoff.
|
||||
- Config: add `subtitle_ai.live_asr_chunk_seconds` (default 30):
|
||||
- `internal/config/config.go`: new field beside `ASRChunkSeconds`
|
||||
(`config.go:274-277`).
|
||||
- `internal/config/db_loader.go`: load it next to `asr_chunk_seconds`
|
||||
(`db_loader.go:472-476`).
|
||||
- `internal/subtitles/ai/config.go`: add `LiveASRChunkSeconds int` to the
|
||||
service `Config` so the service can set `req.ChunkSeconds` for streaming jobs.
|
||||
- `internal/api/router.go`: plumb through `effectiveSubtitleAIConfig` and the
|
||||
`OnConfigChange` `UpdateConfig` (`router.go:1024-1055`).
|
||||
|
||||
### 1e. Phase 1 tests
|
||||
|
||||
- `service_test.go`: fake `Transcriber` emitting several chunks playhead-first
|
||||
via `onChunk`; fake `Translator` recording calls. For a streaming
|
||||
`transcribe_translate` job assert: one `Translate` per non-empty chunk; first
|
||||
`TranslationCues` streamed before the last chunk is transcribed; empty chunks
|
||||
skipped; final stored translated track = all translated cues sorted by start;
|
||||
`Done/Total` is chunk-granular and monotonic.
|
||||
- Failure case: a translate error on chunk N sets the failure but transcription
|
||||
completes, the transcript track **is** stored, `TranslationFailed` is emitted,
|
||||
and the job ends via `finishWithError`.
|
||||
- Non-streaming `transcribe_translate`: unchanged whole-file path; correct final
|
||||
track; no `TranslationCues`.
|
||||
- `transcriber_test.go`: `SetExtraction` clamp fix and `ChunkSeconds` override
|
||||
(incl. the new 15s floor).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — playhead-first incremental extraction (true seconds latency)
|
||||
|
||||
Phase 1 still waits on full-file extraction before the first cue. Phase 2
|
||||
removes that by extracting and consuming chunks incrementally, seeked to the
|
||||
playhead.
|
||||
|
||||
### 2a. New incremental extractor in `internal/playback`
|
||||
|
||||
Add a streaming, seek-based extractor alongside `ExtractAudioChunks`:
|
||||
|
||||
```
|
||||
ExtractAudioChunksFrom(ctx, filePath, audioTrackIndex, dir, ffmpegPath,
|
||||
startSec float64, chunkSeconds int,
|
||||
onSegment func(AudioChunk) error) error
|
||||
```
|
||||
|
||||
- Runs one ffmpeg pass with `-ss <startSec> -i <file> ... -f segment
|
||||
-segment_list segments.csv` covering `startSec`→end.
|
||||
- Consumes segments **as they complete**: poll the segment-list CSV (ffmpeg
|
||||
appends `filename,start,end` when it closes each segment) on a short interval;
|
||||
for each new row, invoke `onSegment(AudioChunk{Path, Start: startSec + csvStart})`.
|
||||
Reconcile any trailing rows after `cmd.Wait()` so the final segment is not
|
||||
missed.
|
||||
- Honors `ctx` (CommandContext kills ffmpeg on cancel) and `onSegment` errors
|
||||
(stop the pass). Caller owns `dir`; segments are deleted by the consumer after
|
||||
ASR to cap disk at one extraction.
|
||||
- Preserves exact per-segment starts from the CSV, so timing does not accumulate
|
||||
drift within a pass (only the initial `-ss` seek is packet-accurate, sub-second,
|
||||
and is further corrected by the existing `ProbeAudioStartOffset`).
|
||||
|
||||
Rejected alternative — **per-chunk seek extraction** (`-ss s -t d` per chunk):
|
||||
simpler control flow but O(n) ffmpeg spawns and packet-aligned per-chunk seeks
|
||||
that can clip words at every seam. The single-pass streaming consumer keeps the
|
||||
existing segment-muxer boundary quality and far fewer process spawns.
|
||||
|
||||
### 2b. Transcriber incremental mode
|
||||
|
||||
- Add `TranscribeJobRequest.Incremental bool`, set by the service for streaming
|
||||
jobs.
|
||||
- When `Incremental`, `Transcribe` drives playhead-first in two streaming passes
|
||||
(reproducing today's `chunkOrderForPosition` order): pass A `startSec =
|
||||
playhead → end`, then pass B `0 → playhead`. Each completed segment is fed
|
||||
straight into the existing per-chunk ASR loop body (transcribe → `onChunk`),
|
||||
which Phase 1 already wires to translate+stream. The full cue set is still
|
||||
accumulated and returned for transcript storage.
|
||||
- When not `Incremental` (background, and plain transcribe unless we opt it in),
|
||||
keep the existing whole-file `ExtractAudioChunks` + `chunkOrderForPosition`
|
||||
path.
|
||||
|
||||
Note: timing composition (`-ss` reset PTS + segment CSV start + probe offset)
|
||||
must be verified with a fixture; see tests.
|
||||
|
||||
### 2c. Phase 2 tests
|
||||
|
||||
- `audio_extract` test (or transcriber test with an injected extractor) covering
|
||||
incremental emission order, exact-start mapping with a `startSec` offset, and
|
||||
ctx-cancel mid-pass.
|
||||
- Transcriber test asserting `Incremental` yields playhead-first chunk order
|
||||
across the two passes and returns the complete cue set for transcript storage.
|
||||
- Confirm first `onChunk` fires after ~one chunk of audio, not after full-file
|
||||
extraction (timing/ordering assertion via a fake extractor, not wall-clock).
|
||||
|
||||
---
|
||||
|
||||
## Multi-repo / client impact
|
||||
|
||||
None expected to the contract. Reuses the existing notifier protocol
|
||||
(`TranslationStarted` / `TranslationCues` / `TranslationCompleted` /
|
||||
`TranslationFailed`, `notifier.go`) under the same translated-track `trackKey`.
|
||||
Translated cues simply begin arriving far earlier, and `Done/Total` keeps the
|
||||
chunk-granular meaning plain `transcribe` already uses (1c). Worth a smoke test
|
||||
on one `silo-android` / `silo-apple` client to confirm the live track renders
|
||||
cues arriving before the transcript track completes, and that `Done/Total` is
|
||||
treated as a fraction.
|
||||
|
||||
## Risks & tradeoffs
|
||||
|
||||
- **Translation context at chunk boundaries (live only).** Per-chunk translation
|
||||
resets the batch context-neighbour window at each ~30s boundary — accepted
|
||||
quality tradeoff for live; background jobs are unchanged.
|
||||
- **More/smaller ASR requests for live jobs.** 30s chunks mean more requests;
|
||||
per-chunk timeout (`chunkSeconds * asrChunkTimeoutFactor`) and per-spawn
|
||||
overhead scale; the 15s floor bounds the worst case.
|
||||
- **Incremental extraction reliability (Phase 2).** Segment-list polling + the
|
||||
`-ss` seek path need fixtures for boundary timing, the final-segment race, and
|
||||
cancel/cleanup. Single ffmpeg pass per direction keeps cost low.
|
||||
- **Boundary word-clips.** Smaller live chunks add more fixed-length boundaries
|
||||
where a straddling word can clip — already a documented v1 limitation; the
|
||||
silence-aligned follow-up would address it.
|
||||
|
||||
## Out of scope / follow-ups
|
||||
|
||||
- **Pace to playback (issue item 3):** rolling window ahead of the playhead to
|
||||
bound work; separate change once interleave + incremental extraction land.
|
||||
- **Cross-chunk translation context:** feed the previous chunk's tail source
|
||||
cues as untranslated context into each chunk's translate call.
|
||||
- **Silence-aligned chunk boundaries:** reduce boundary word-clips that smaller
|
||||
live chunks make more frequent.
|
||||
|
||||
## v1 scope note
|
||||
|
||||
`docs/architecture/v1-scope.md` is **NOT LOCKED**; the issue carries
|
||||
`v1-proposed`. This is a **bug fix** to an already-shipped capability (no new
|
||||
user-facing capability, endpoint, or response field — only an internal config
|
||||
key and corrected streaming behavior), so it proceeds under the "bug fixes
|
||||
proceed normally" clause. Link the PR to the AI-subtitle capability item per the
|
||||
v1 PR requirements.
|
||||
|
||||
## Pre-push checklist
|
||||
|
||||
- `make lint`
|
||||
- `cd web && pnpm run lint && pnpm run format:check` (run only if web touched —
|
||||
none expected)
|
||||
- `make verify-local-paths`
|
||||
- Go tests for `internal/subtitles/ai` and `internal/playback` in a
|
||||
libvips-capable container (a bare-host `go test ./...` silently skips CGO
|
||||
packages).
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
# Personal Collection Catalog Filter Standardization - Implementation Plan
|
||||
|
||||
- **Date:** 2026-06-23
|
||||
- **Spec:** `docs/superpowers/specs/2026-06-23-personal-collection-catalog-filter-standardization-design.md` (rev. 3, post-adversarial-review). The spec owns the *why* and the decisions; this plan owns the *how*, with concrete files and ordering.
|
||||
- **Note:** All paths are repository-relative. Commands assume the repository root is the cwd.
|
||||
|
||||
## Approach & PR Boundaries
|
||||
|
||||
Three concerns, landed in order. PR 1 is independently valuable and must merge first because PR 2 depends on its hardened predicate for correctness.
|
||||
|
||||
| PR | Concern | Depends on |
|
||||
|----|---------|-----------|
|
||||
| **PR 1** | Harden the catalog `watched` predicate (series/season rollup + row-type branching) and pin equivalence with item user-data. | — |
|
||||
| **PR 2** | Replace the `watch_filter` / `media_filter` enum vocabulary with a `display_query_definition` fragment end to end (migration → storage → API → execution → web). | PR 1 |
|
||||
| **PR 3** (deferred) | Wire display filters for smart/live collections via an executor-level `(source) AND (display)` conjunction. | PR 2 |
|
||||
|
||||
**Why PR 3 is deferred:** display-filter controls are currently exposed only for manual/exact and imported collections (`CollectionBuilder.tsx` renders them only when `collection_type === "manual"`; imported uses `ImportedCollectionEditor.tsx`). Smart collections never reach `FilterUserCollectionDisplayItems` today (`catalog_resolver.go` branches smart collections to `resolveQuerySource` before the filter call). So the OR-leak the adversarial review found is a guardrail for *future* wiring, not a v1 path. Build PR 2 so the same conjunction helper is reusable, but do not expose smart-collection display filters until PR 3.
|
||||
|
||||
---
|
||||
|
||||
## PR 1 — Catalog Watched Predicate Hardening
|
||||
|
||||
**Goal:** make the catalog `watched` rule produce the same played/read verdict as item user-data (`internal/api/handlers/user_state.go`) for every item kind, under both scoped and unscoped queries. This is the single-semantic foundation; everything else routes through it.
|
||||
|
||||
### Current state (verified)
|
||||
|
||||
- `internal/catalog/query_builder.go`:
|
||||
- `buildWatchedClause` (≈900–912) wraps `userStateCompletionClause` and negates for `value:false`.
|
||||
- `userStateCompletionClause` (≈1119–1171) checks completion against **the row's own `content_id`** (`uwp.media_item_id = mi.content_id` / `uwh.media_item_id = mi.content_id`). No episode rollup → a series row is effectively never watched.
|
||||
- `ebookUserStateCompletionClause` (≈1173–1193) is selected **only when `qb.mediaScope == "ebook"`** → an ebook under an unscoped query is misclassified by the video clause.
|
||||
- `internal/api/handlers/user_state.go` `allEpisodesCompleted` (≈171–185) defines the canonical rollup over the episode repository's set.
|
||||
- Episode set (the parity target): `internal/catalog/episode_repo.go` `ListBySeriesIDs` / `ListBySeasonIDs` select from `episodes` filtered by `series_id` / `season_id` **AND** `episodeAvailabilityPredicate` = `EXISTS (SELECT 1 FROM episode_libraries el WHERE el.episode_id = episodes.content_id)`. No specials/season-0 exclusion, no media-file requirement. Links: `episodes.series_id`, `episodes.season_id`.
|
||||
- The base relation always exposes `mi.type` (`media_items mi`, or the episode derived table that hard-codes `'episode'::text AS type`), so a `CASE` on row type is feasible.
|
||||
|
||||
### Changes
|
||||
|
||||
1. **Extract a reusable completion predicate.** In `internal/catalog/query_builder.go`, refactor `userStateCompletionClause` into a helper that, given a row alias and the linking column, emits the leaf completion EXISTS pair (progress + history, with hidden-history guard). Keep `ebookUserStateCompletionClause` as the ebook leaf. This is the shared building block for the branches below.
|
||||
|
||||
2. **Add series/season rollup branches with repository-parity episode sets.** Emit, for a series row, "≥1 child episode exists AND no child episode is incomplete," expressed so the child-episode set is **identical** to `episodeAvailabilityPredicate`:
|
||||
```sql
|
||||
EXISTS (SELECT 1 FROM episodes e
|
||||
WHERE e.series_id = mi.content_id
|
||||
AND EXISTS (SELECT 1 FROM episode_libraries el WHERE el.episode_id = e.content_id))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM episodes e
|
||||
WHERE e.series_id = mi.content_id
|
||||
AND EXISTS (SELECT 1 FROM episode_libraries el WHERE el.episode_id = e.content_id)
|
||||
AND NOT (<leaf completion predicate for e.content_id>))
|
||||
```
|
||||
Season rollup is the same with `e.season_id = mi.content_id`. The leaf predicate inside the `NOT EXISTS` reuses the helper from step 1 keyed on `e.content_id`. **The `episode_libraries` EXISTS must match `episodeAvailabilityPredicate` verbatim** — if `episode_repo.go` ever changes that predicate, this SQL changes with it. Add a code comment cross-referencing `episodeAvailabilityPredicate` so the coupling is discoverable.
|
||||
|
||||
3. **Branch by row type for unscoped queries.** When `qb.mediaScope` is empty/`video` (rows can be mixed), select the completion source from `mi.type` rather than from `mediaScope`:
|
||||
- `movie` / `episode` / video leaf → leaf predicate,
|
||||
- `series` → series rollup, `season` → season rollup,
|
||||
- `ebook` → ebook leaf,
|
||||
- `manga` → deferred; treat as never-completed for now (documented), do not invent a collection-only interpretation.
|
||||
When `mediaScope` is a single concrete kind (e.g. `series`, `ebook`), the optimizer-friendly path may emit just that branch. Implement as a `CASE mi.type WHEN ... END` or chained `(mi.type = 'x' AND <clause>) OR ...`; verify argIdx accounting (each leaf/ebook clause increments `qb.argIdx` by 2 — the rollups reuse the leaf, so count the placeholders carefully and add a builder test that the produced arg count matches bound args).
|
||||
|
||||
4. **`last_watched` stays separate.** Do not route `last_watched` through the new predicate; it is a `user_last_watched` CTE timestamp aggregate. Leave as-is; add a one-line comment noting the intentional divergence.
|
||||
|
||||
### Tests (PR 1)
|
||||
|
||||
- `internal/catalog/query_builder_test.go`: assert the `watched` clause contains the series/season rollup EXISTS only when the scope/row-type can include series/seasons; assert arg count == bound args for each branch; assert the `episode_libraries` predicate text is present in the rollup.
|
||||
- **Equivalence test (highest value):** seed a Postgres fixture with a series whose episodes are variously completed/not, a season, a movie, and an ebook for one profile; assert the catalog `watched` rule's matched set equals exactly the items whose `user_state.go` `Played` flag is true. Because `user_state.go` lives in `internal/api/handlers` (CGO/libvips), run this in the libvips-capable container; the test can drive both the catalog executor and the item-user-state builder against the same pool and diff the verdicts. If a same-package home is awkward, place the comparison test in `internal/api/handlers` where both sides are reachable.
|
||||
|
||||
### Release note (PR 1)
|
||||
|
||||
Catalog `watched` filtering for series/seasons changes from "never matches" to "matches when all available episodes are completed." Call this out in the PR body and release notes per the spec's semantics-change risk.
|
||||
|
||||
---
|
||||
|
||||
## PR 2 — Replace the Enum Vocabulary with `display_query_definition`
|
||||
|
||||
One concern: swap the persisted representation and every surface that reads/writes it. Clean replacement — no aliases, no backfill (the enums are branch-only and unreleased; see spec Current Branch State). Land the steps below together so no layer reads a column another layer has dropped.
|
||||
|
||||
### Step 2.1 — Migration reshape
|
||||
|
||||
Edit `migrations/sql/20260623184858_user_collection_display_filters.sql` in place so the canonical schema has no legacy columns:
|
||||
|
||||
- **Up:** `ALTER TABLE public.user_personal_collections ADD COLUMN IF NOT EXISTS display_query_definition jsonb;` (nullable; `NULL`/absent = no display filter). Remove the `watch_filter` / `media_filter` `ADD COLUMN` and their CHECK constraints.
|
||||
- **Down:** `ALTER TABLE ... DROP COLUMN IF EXISTS display_query_definition;`
|
||||
|
||||
**Operational gotcha:** Goose keys applied migrations by version, so editing an already-applied migration will **not** re-run it. Any dev DB that already applied the original `20260623184858` (which added `watch_filter`/`media_filter`) must be rolled back to before it and re-applied: `make migrate-down` to the prior version, then `make migrate-up`. If a shared dev DB makes in-place editing risky, the alternative is a *new* timestamped migration (`make migrate-create NAME=replace_collection_display_filters`) that drops the two columns and adds `display_query_definition`; the spec prefers in-place reshape since nothing is merged, but call the choice out in the PR.
|
||||
|
||||
### Step 2.2 — Backend storage & types
|
||||
|
||||
- `internal/userstore/types.go`: in `Collection` (≈185–213), `CreateCollectionInput` (≈238–255), `UpdateCollectionInput` (≈257–278), remove `WatchFilter` / `MediaFilter`; add `DisplayQueryDefinition string` (and `*string` on the update input), mirroring the existing `QueryDefinition` fields.
|
||||
- `internal/userstore/pgstore/collections.go`:
|
||||
- `collectionSelectColumns` (≈15–18): drop `watch_filter, media_filter`; add `display_query_definition`.
|
||||
- `scanCollection` (≈20–42) and the `ListCollections` scan (≈192–196): drop the two scan targets; add `&c.DisplayQueryDefinition`.
|
||||
- `CreateCollection` INSERT (≈79–94): drop the two columns/args; add `display_query_definition` + arg.
|
||||
- `UpdateCollection` (≈270–283): replace the two `add("watch_filter"…)` / `add("media_filter"…)` blocks with a single `display_query_definition` block driven by `input.DisplayQueryDefinition != nil`.
|
||||
- The normalization at create-time (≈59–68) moves from enum normalization to **fragment normalization** (Step 2.5).
|
||||
- `internal/userstore/collection_filters.go`: repurpose into the fragment normalizer's home, or delete the enum constants/`Normalize*` funcs if nothing else uses them after the swap. Keep the file's tests meaningful — convert `collection_filters_test.go` to cover the new normalizer (Step 2.5) rather than the deleted enums.
|
||||
|
||||
### Step 2.3 — API surface
|
||||
|
||||
- `internal/api/handlers/collections.go`:
|
||||
- `createCollectionRequest` (≈39–50), `updateCollectionRequest` (≈52–67), `collectionResponse` (≈73–101): drop `watch_filter` / `media_filter`; add `display_query_definition json.RawMessage` following the existing `query_definition` handling (response assembly mirrors `defaultJSON([]byte(c.QueryDefinition))` at ≈843).
|
||||
- `HandleCreateCollection` (≈270–279) / `HandleUpdateCollection` (≈341–356): replace enum validation with fragment normalization/validation (Step 2.5); reject invalid fragments with `400`.
|
||||
- `collectionCapabilitiesResponse` (≈108–111) + `HandleCapabilities` (≈224–229): replace `watch_filters`/`media_filters` value lists with capability metadata describing the supported display-query fields (`type`, `watched`) and the UI presets (`all/watched/unwatched`, `all/movies/series`). Keep it additive and feature-detection-oriented per the v1 API rules.
|
||||
- `internal/api/handlers/user_collection_imports.go`: `userImportSharedFields` (≈67–77) and `createImportedCollection` validation (≈214–223) — same swap to `display_query_definition`.
|
||||
|
||||
### Step 2.4 — Exact-collection execution through the catalog executor
|
||||
|
||||
Replace the custom Go filter pass and the metadata-only in-Go matcher with one SQL path.
|
||||
|
||||
- Delete `internal/catalog/user_collection_display_filters.go` (`FilterUserCollectionDisplayItems` and helpers) and its test, plus the duplicated `userCollectionAllEpisodesCompleted`.
|
||||
- Call sites to rewrite:
|
||||
- `internal/catalog/catalog_resolver.go` `resolveUserCollectionSource` (≈479–526, filter call at ≈517).
|
||||
- `internal/sections/fetcher.go` `fetchUserCollection` (filter call at ≈1371).
|
||||
- New behavior for exact collections (per spec Execution Model):
|
||||
1. Load member IDs in source order (`user_personal_collection_items`, `ORDER BY position`) — unchanged.
|
||||
2. Parse `collection.DisplayQueryDefinition` into a `QueryDefinition`, **force `Limit = nil`** and drop any fragment `sort`/`library_ids` defensively before execution.
|
||||
3. Run the executor with `AccessFilter.AllowedContentIDs = memberIDs` (the pattern `resolveCandidateItemsWithQuery` already uses at `catalog_resolver.go` ≈606–607). The executor's `conditions` slice AND-joins `AllowedContentIDs` with the display fragment, so membership ∧ filter is automatic — no OR-leak risk here.
|
||||
4. For source-ordered requests: request the **full** match set (no limit), collect matching IDs into a set, reapply original order, then apply request offset/limit. Pagination happens after reordering, never inside the executor.
|
||||
5. Stop using `filterCatalogItems` (`catalog_resolver.go` ≈1772) for these collections — replace the `UseSourceOrder` branch in `resolveExactOrderedMediaItems` (≈571–586) with the match-set-then-reorder path. **Do not delete `filterCatalogItems`:** it has a second caller at `catalog_resolver.go:244` (the query-source path), so it stays; only the exact-collection call at ≈575 changes.
|
||||
- Empty display fragment ⇒ skip the executor filter entirely and return members in source order (preserves today's "no filter" performance for unfiltered collections).
|
||||
|
||||
### Step 2.5 — Fragment normalization (shared backend helper)
|
||||
|
||||
A `display_query_definition` is a **filter-only** `QueryDefinition` fragment. Add one normalizer (e.g. in `internal/catalog` next to `QueryDefinition`, or `internal/userstore`) used by every write path (create/update/import):
|
||||
|
||||
- Accept only `match` + `groups`, with rules limited to the allowed display vocabulary (v1: `type`, `watched`).
|
||||
- Reject (preferred, on write) or strip `limit`, `sort`, `library_ids`, `media_scope`.
|
||||
- Reject unknown fields/ops so clients learn the contract.
|
||||
- Return a canonical JSON string for storage. Used by `pgstore` create/update and the API handlers.
|
||||
|
||||
### Step 2.6 — Web
|
||||
|
||||
The frontend already has the machinery: `QueryDefinition` types, `createEmptyQueryDefinition` / `normalizeQueryDefinition` (`web/src/api/types.ts` ≈1318–1361), and `CollectionGuidedRulesEditor.tsx` which already converts `watched` rules ↔ a `watchStatus` field (`queryDefinitionToGuidedState` ≈93–254, `guidedStateToQueryDefinition` ≈257–374). Reuse it; do not add a parallel state model.
|
||||
|
||||
- `web/src/lib/collectionDisplayFilters.ts`: replace the enum option lists / `normalize*` helpers with converters **preset ⇄ `QueryDefinition` fragment**:
|
||||
- `watched`/`unwatched`/`all` ⇄ a `{field:"watched",op:"is",value:true|false}` rule (or none),
|
||||
- `movie`/`series`/`all` ⇄ a `{field:"type",op:"is",value:"movie"|"series"}` rule (or none),
|
||||
- a builder that assembles the two presets into a single filter-only fragment, and a reader that derives the two preset values back from a fragment (for editing).
|
||||
- `web/src/api/types.ts`: drop `UserCollectionWatchFilter` / `UserCollectionMediaFilter` and the `watch_filter` / `media_filter` fields on `Collection` (≈1270–1271), `CreateCollectionRequest` (≈1403–1404), `UpdateCollectionRequest` (≈1420–1421); add `display_query_definition?: QueryDefinition`.
|
||||
- Editors — swap the two `<Select>` controls' wiring to read/write through the helper, keeping the same visible labels:
|
||||
- `web/src/components/collections/CollectionBuilder.tsx` (value shape ≈45–57, UI ≈259–313, submit ≈88–101),
|
||||
- `web/src/pages/ImportedCollectionEditor.tsx` (state ≈118–125, UI ≈277–354, save ≈184–214),
|
||||
- `web/src/components/CollectionTemplateGallery/UserCollectionTemplateConfigForm.tsx` (state ≈99–100, submit ≈119–167).
|
||||
- `web/src/hooks/queries/collections.ts` (`useCreateCollection` ≈85–101, `useUpdateCollection` ≈103–127): payloads now carry `display_query_definition` instead of the enums (the generic `buildUserCollectionPayload` needs no change beyond the type swap).
|
||||
|
||||
### Tests (PR 2)
|
||||
|
||||
- `internal/api/handlers/collections_test.go`: create/update/get round-trips `display_query_definition`; `watch_filter`/`media_filter` are absent from request and response; an invalid fragment (e.g. containing `limit` or an unknown field) returns `400`.
|
||||
- Resolver/executor tests:
|
||||
- exact collection routes through the catalog `watched` rule and preserves source order across pages;
|
||||
- an exact collection larger than a page returns the full match set in source order regardless of fragment contents (guards the limit-truncation finding);
|
||||
- a mixed-media exact collection (movie + ebook) with `watched`/`unwatched` classifies the ebook via `ebook_reader_progress`, not video history (relies on PR 1 row-type branching).
|
||||
- Fragment-normalizer unit tests (replacing `collection_filters_test.go`): strips/rejects `limit`/`sort`/`library_ids`/`media_scope`; round-trips `type` + `watched` rules.
|
||||
- Frontend: helper test for preset ⇄ fragment conversion (both directions); one editor test asserting the submit payload carries the canonical `display_query_definition`.
|
||||
|
||||
---
|
||||
|
||||
## PR 3 — Smart/Live Display Filters (deferred)
|
||||
|
||||
Only when smart/live collections expose display-filter controls. Implements the spec's executor-level conjunction so `(source) AND (display)` holds regardless of the source's top-level `match`.
|
||||
|
||||
- Seam: `internal/catalog/query_executor.go`, after the `AllowedContentIDs` handling (≈322) and before `ApplySectionAccessFilter`. Build the display fragment with a second `NewQueryBuilder("mi").WithArgIdx(argIdx)`, `rebindSQLPlaceholders` its output to the running `argIdx`, append the rebound clause to `conditions`, and advance `argIdx`/`args`. Because `conditions` is AND-joined (≈353), this yields `(sourceWhere) AND (displayWhere)` with no group-list manipulation and no OR-leak.
|
||||
- Do **not** append the display group into `def.Groups` — that is the OR-leak the adversarial review found (`QueryBuilder.Build` joins top-level groups with the def's single `match`, ≈84–111).
|
||||
- Tests: a `match: any` source plus a display filter never returns rows the source alone excludes; a contradictory `watched` overlay on a `match: any` source returns zero rows.
|
||||
|
||||
---
|
||||
|
||||
## Verification (every PR, pre-push)
|
||||
|
||||
Per `CLAUDE.md`:
|
||||
|
||||
- `make lint`
|
||||
- `cd web && pnpm run lint && pnpm run format:check`
|
||||
- `make verify-local-paths`
|
||||
- Go tests in a **libvips-capable container** (a bare-host `go test ./...` silently skips CGO packages including `internal/api/handlers`, where the equivalence and DTO tests live). Prefix `GOWORK=off` for builds/tests in this repo.
|
||||
- Targeted: `go test ./internal/catalog/... ./internal/api/handlers/... ./internal/userstore/...` and the relevant `web` helper/editor tests.
|
||||
|
||||
## Sequencing summary
|
||||
|
||||
1. PR 1 (predicate + equivalence test) → merge.
|
||||
2. PR 2 (migration → types/storage → API/imports → fragment normalizer → exact execution → web), landed together → merge.
|
||||
3. PR 3 only if/when smart-collection display filters are wired.
|
||||
|
||||
Link each PR to the capability sub-issue (`Part of #NNN`) per the v1 process; keep one concern per PR.
|
||||
|
||||
## Open items to confirm before/while implementing
|
||||
|
||||
- Confirm no `silo-android` / `silo-apple` build already consumes the branch's `watch_filter` / `media_filter` fields (spec assumes unreleased; if pinned, fall back to the contingency backfill path in the spec's Migration Strategy).
|
||||
- Confirm `episodeAvailabilityPredicate` is the intended "child episode" definition to match (Open Question #4 in the spec) — it currently includes specials/season-0 if they have library links. Match it exactly; change item user-data and the catalog rule together if it should differ.
|
||||
- Decide the fragment normalizer's package home (`internal/catalog` vs `internal/userstore`) based on import direction — it needs the `QueryDefinition` type without creating an import cycle.
|
||||
@@ -0,0 +1,327 @@
|
||||
# Hybrid Semantic Search Hardening Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Commands assume the repository root is the cwd.
|
||||
|
||||
**Goal:** Make Meilisearch hybrid semantic search safe to enable broadly by gating it on *trustworthy* per-type vector coverage, validating embedder capability, and exposing per-query/admin diagnostics — without regressing the hot search path.
|
||||
|
||||
**Architecture:** Semantic search stays gated behind the existing `SemanticEnabled && wordCount >= 3` rule, plus a new **coverage gate** that reads an in-memory, periodically-refreshed per-type coverage snapshot (never a per-request DB query). Coverage = `current-model embeddings ÷ embed-eligible items`, computed entirely from Postgres and latched with hysteresis. Existing provider/fallback fields are plumbed through the resolver into an additive `search_diagnostics` response object. A rate-limited capability probe validates the Meili embedder without downing keyword search.
|
||||
|
||||
**Tech Stack:** Go (pgx/pgxpool), Meilisearch hybrid search (`userProvided` embedder), React/TypeScript admin UI, Goose migrations.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **API additive-only within `/api/v1`** — never rename/remove a response field, change a type, or repurpose a status code. New fields are `omitempty` additions. (CLAUDE.md)
|
||||
- **Performance first, reliability first, predictable under load** — the search request path must not gain a synchronous DB count, lock wait, or remote probe. Fail **safe** (keyword-only), never panic. (CLAUDE.md Core Priorities)
|
||||
- **One additive index migration** is in scope (revised — see Assumptions): a btree on `media_item_embeddings(model)` to support the periodic coverage query. No schema/column changes.
|
||||
- **Canonical embedding dimensions** = `embeddingvectors.CanonicalDimensions` (= 3072; re-exported as `recommendations.CanonicalEmbeddingDimensions`). Never hardcode the number.
|
||||
- **Single source of truth for embed-eligibility** — the predicate `(status='matched' OR type IN ('audiobook','ebook'))` must not be duplicated. It moves to the neutral `internal/embeddingvectors` package (imported by both `catalog` and `recommendations`; `recommendations` already imports `catalog`, so `catalog` cannot import `recommendations`).
|
||||
- **Single phase** — measurement *and* enforcement ship together. The diagnostics shipped here are what let us retune thresholds later without code changes-in-anger.
|
||||
- Build/test in worktrees with `GOWORK=off` and a stubbed `web/dist` (see worktree-build-quirks memory).
|
||||
|
||||
---
|
||||
|
||||
## Corrected Baseline (what actually exists today — verified)
|
||||
|
||||
Work from this, not from intuition. Each point was confirmed against the tree.
|
||||
|
||||
1. **The only semantic skip rule is `SemanticEnabled && len(Fields(normalizedQuery)) >= 3`** (`internal/catalog/search_meilisearch_provider.go:329`). There is **no** `SkipTotal`/preview skip, and `TestMeilisearchSearchRequestBuildsHybridForApproximateInteractiveSearch` (`search_provider_test.go:213`) asserts hybrid **IS** used with `SkipTotal: true`. → **Do not add a `SkipTotal` skip.** Layer the coverage gate onto the existing rule; remove nothing.
|
||||
2. **`CatalogSearchResult` already carries `Provider` and `FallbackReason`** (`search_provider.go:64-71`). Diagnostics work is *plumbing these through* `CatalogResult` (which drops them, `catalog_resolver.go:24-30,291-296`) — not inventing provider fields.
|
||||
3. **The provider already self-downgrades hybrid→keyword on a Meili error** (`search_meilisearch_provider.go:221-228`). Diagnostics must report the *post-downgrade* reality.
|
||||
4. **`countCatalogSearchVectorDocuments` ignores `model`** (`search_indexer.go:757`), counts the *indexed* population (no eligibility filter), and its companion denominator (`document_count`) comes from **Meili index stats** (`search_service.go:129`), not Postgres. It has **four callers** — `search_service.go:139`, `search_indexer.go:133`, `:203`, `:310` — all must be updated when its signature changes.
|
||||
5. **Indexed population ≠ embed-eligible population.** `LoadDocumentsAfter` (`search_indexer.go:534`) indexes every type-matched, non-manga row regardless of `status`; embeddings are only written for `(status='matched' OR audiobook OR ebook)` (`recommendations/repo.go:154-160`, `recommendationItemEligibilityWhereClause`). A denominator of "indexable items" is therefore structurally < 1.0 on any library with unmatched video → **the gate would never open.** Coverage must use the **embed-eligible** population for both numerator and denominator.
|
||||
6. **Status fields that already exist:** `document_count`, `vector_document_count` (`search_provider.go:347-348`). The earlier draft's `index_document_count` does **not** exist — keep real names; add new fields alongside.
|
||||
7. **`UpsertEmbedding` already enqueues a Meili index event after every write** (`recommendations/repo.go:186`) — verified correct, **no change**.
|
||||
8. **Catalog search settings are restart-bound** (`config/restart_keys.go:75-85`, *"Catalog search provider construction is intentionally startup-bound in v1"*). The service is built **once** in `NewRouter` and never live-rebuilt. → the coverage refresher is a single process-lifetime goroutine; no live-rebuild leak exists in v1.
|
||||
9. **`catalogSearchService` is a local var in `NewRouter`** (`router.go:437`), wired into handlers but **never returned** to `main.go`. The long-lived `deps.AppContext` (`router.go:88`) is the refresher's context.
|
||||
10. **Legacy `/search` plain-`q` re-route is descoped** — see [Descoped](#descoped--not-in-this-pass).
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### D1 — Coverage = current-model embeddings ÷ embed-eligible items (single Postgres source, per type)
|
||||
|
||||
Let `ELIG(alias)` = `embeddingvectors.ItemEligibilityWhereClause(alias)` → `(alias.status='matched' OR alias.type IN ('audiobook','ebook'))` (moved here from `recommendations`; `recommendations.recommendationItemEligibilityWhereClause` is refactored to call it — no behavior change, no duplication).
|
||||
|
||||
- **Denominator** (embed-eligible items, per type):
|
||||
```sql
|
||||
SELECT mi.type, COUNT(*) AS eligible
|
||||
FROM media_items mi
|
||||
WHERE NOT EXISTS (SELECT 1 FROM manga_chapters mc WHERE mc.chapter_content_id = mi.content_id)
|
||||
AND ($1::text[] IS NULL OR mi.type = ANY($1))
|
||||
AND (mi.status = 'matched' OR mi.type IN ('audiobook','ebook')) -- ELIG("mi")
|
||||
GROUP BY mi.type
|
||||
```
|
||||
- **Numerator** (eligible items with a *current-model* embedding, per type):
|
||||
```sql
|
||||
SELECT mi.type, COUNT(*) AS vectorized
|
||||
FROM media_item_embeddings e
|
||||
JOIN media_items mi ON mi.content_id = e.media_item_id
|
||||
WHERE NOT EXISTS (SELECT 1 FROM manga_chapters mc WHERE mc.chapter_content_id = mi.content_id)
|
||||
AND ($1::text[] IS NULL OR mi.type = ANY($1))
|
||||
AND (mi.status = 'matched' OR mi.type IN ('audiobook','ebook')) -- ELIG("mi")
|
||||
AND ($2 = '' OR e.model = $2)
|
||||
GROUP BY mi.type
|
||||
```
|
||||
|
||||
Applying `ELIG` to **both** guarantees numerator ⊆ denominator → ratio ∈ `[0,1]` (an embedding left behind on an item that became unmatched can't push a type over 1.0). Unmatched items are excluded from both, so they never drag coverage. As backfill completes, ratio → 1.0.
|
||||
|
||||
**Active model (`$2`)** is read from the embedding lock (`recommendations.EmbeddingLock.Model`, `embedding_lock.go:16`). No lock ⇒ `$2 = ""` and the snapshot is marked not-ready (never gate semantic on for an unestablished embedding space).
|
||||
|
||||
> **Why model-filtering is load-bearing:** right after a model change, old vectors persist but are mismatched to the new query embedder. A model-blind count reads ~100% at the most dangerous moment. Filtering by the locked model collapses coverage to ~0 until re-embedding catches up.
|
||||
|
||||
**Index (migration, in scope):** `media_item_embeddings` has only an HNSW index on `embedding` (`migrations/sql/001_schema.sql:1857`, `014_...:25`) — nothing on `model`. Add `CREATE INDEX CONCURRENTLY idx_media_item_embeddings_model ON public.media_item_embeddings (model)` so the numerator filter is index-supported. (Goose: timestamped file via `make migrate-create`, with `-- +goose NO TRANSACTION` because `CONCURRENTLY` cannot run in a txn.)
|
||||
|
||||
### D2 — Coverage lives in an in-memory snapshot, refreshed off the request path
|
||||
|
||||
A `semanticCoverageTracker` (new, `internal/catalog/semantic_coverage.go`) holds an `atomic.Pointer[semanticCoverageSnapshot]`.
|
||||
|
||||
- **Refresh cadence:** a single background goroutine every `semanticCoverageRefreshInterval` (**2 minutes** — long enough that two `GROUP BY` aggregates are negligible steady-state load even at 1M rows; short enough to bound the post-model-change danger window, which also fails safe via the model-collapse rule below). The interval is a named const; **there is no after-sync trigger** (the indexer and service share no handle — see Descoped rationale; the interval alone is sufficient and avoids a cross-component wiring + race).
|
||||
- **Single-flight & immutability:** `Refresh` holds a `sync.Mutex` across read-prev → query → compute → publish (background path; contention irrelevant). Each refresh builds a **fresh** `PerType` map and publishes a new immutable snapshot pointer; the published snapshot is **never mutated** after publish (avoids a `map` data race against concurrent `CoverageReady` reads). A `-race` test fires refreshes concurrently with reads.
|
||||
- **Fail-safe reads:** `CoverageReady` reads only the atomic pointer — **no DB, no lock, no remote call.** If the snapshot is `nil` (boot, before first refresh) it returns `(false, "coverage not yet computed")` — **never** dereferences a nil map (that would be a hot-path panic).
|
||||
- **Error retention:** on a refresh query error, retain the last-good snapshot (transient DB blips must not flip semantic off); log; do not publish a zeroed snapshot. Admin status flags `UpdatedAt` staleness.
|
||||
- **Model-change collapse:** the snapshot records the `Model` it was computed for. When `Refresh` observes the active model differs from the published snapshot's model, it **immediately publishes an all-not-ready snapshot** (safe) and recomputes real coverage under the new model on the same pass. This bounds the mismatched-vector window to ≤ one interval and fails safe within it.
|
||||
|
||||
### D3 — Per-type readiness with hysteresis; scope = AND of its types; empty scope = all types
|
||||
|
||||
Each type's `ready` flag is **latched**: flips **true** at `ratio >= semanticCoverageEnableRatio` (0.90), flips **false** only at `ratio < semanticCoverageDisableRatio` (0.80). The snapshot carries prior latched state so each refresh applies hysteresis — preventing flap (identical queries returning differently-ranked results).
|
||||
|
||||
`CoverageReady(itemTypes)`:
|
||||
- **Non-empty scope:** ready iff **every** requested type is latched-ready (`min` semantics — a weak subtype blocks the mixed scope). The reason names the first failing type.
|
||||
- **Empty/nil scope** (the common "search everything" case — `MediaScopeItemTypes("") → nil`, so `ItemTypes == nil` reaches the gate): ready iff **every type in the snapshot** is latched-ready. This deliberately mirrors the empty-config semantics and refuses to let an unscoped search bypass a weak type. Reason names the first failing type, or `"coverage not yet computed"` if the snapshot is nil/empty.
|
||||
|
||||
### D4 — Diagnostics: existing fields, plumbed, reported post-downgrade, scoped to the provider path
|
||||
|
||||
- Add `Mode string` (`"keyword"`/`"hybrid"`) and `SemanticUsed bool` to `CatalogSearchResult`; the Meili provider sets them to reflect the **final** request after the internal hybrid→keyword downgrade.
|
||||
- Add `Provider`, `Mode`, `SemanticUsed`, `FallbackReason` to `CatalogResult`; copy them in `resolveDirectSearchSource`.
|
||||
- Add an `omitempty` `search_diagnostics` object to `catalogResponse`.
|
||||
- **Scope:** diagnostics are produced **only** on the direct provider-backed search path (`useDirectSearchPath` = relevance sort, desc, no advanced rules — `catalog_resolver.go:1416-1427`). They are **omitted** for: browse, non-relevance-sorted `q=` searches (which never hit the provider), and `group=work` responses (the grouped wrapper builds a fresh `CatalogResult` at `catalog.go:287-293` and is already approximate — `TotalExact=false`). Each exclusion gets an explicit handler test.
|
||||
|
||||
### D5 — Capability validation: narrow, rate-limited, non-fatal, admin-path only
|
||||
|
||||
Embedder settings are written by Silo itself, so validation guards only **external drift** (Meili volume reset, manual edits). Add `GetSettings` to the Meili client; verify the configured embedder exists, `source == "userProvided"`, and `dimensions == embeddingvectors.CanonicalDimensions`. The hybrid probe:
|
||||
- runs **only on the admin status path** (`HandleGetCatalogSearchStatus`), **not** on `CheckConnection`/health (which only does `GET /health`), and the admin query has no `refetchInterval` (`settings.ts`, `staleTime: 15_000`);
|
||||
- runs **only when `state.ActiveIndexUID != ""`**, and is **cached for `semanticCapabilityProbeTTL` (5m)** → ≤ 1 probe / 5m regardless of refresh rate;
|
||||
- uses a **non-degenerate unit sample vector** (`v[0]=1.0`, rest 0, length `CanonicalDimensions`) — an all-zero vector has zero norm and can spuriously fail cosine hybrid; targets `ActiveIndexUID` with `Hybrid.Embedder = config.Embedder`, `limit:1`.
|
||||
|
||||
Failures surface in admin status and force keyword-only; they **never** flip provider `Healthy` or trip the circuit.
|
||||
|
||||
### D6 — Backfill split: cheap discovery vs. bounded text-staleness, with fairness
|
||||
|
||||
The expensive part of `ListEmbeddingTextCandidates` is **building `current_text` for every eligible row** via 5 `LATERAL` joins to `item_people` (`recommendations/repo.go:544-589`) — *not* the `IS DISTINCT FROM` comparison.
|
||||
|
||||
- **Cheap discovery** (`ListMissingOrModelStaleEmbeddingIDs`): `LEFT JOIN media_item_embeddings`, filter `ELIG("mi") AND (e.media_item_id IS NULL OR e.model = '' OR e.model <> $currentModel)`, `ORDER BY mi.content_id`, `LIMIT`. **No `LATERAL`, no `current_text`.** The SQL is built by a package-private `buildMissingOrModelStaleEmbeddingIDsSQL() string` helper so a unit test can assert it contains neither `item_people` nor `LATERAL` (mirrors the existing `embeddingEligibilityWhereClause()` string-helper test pattern). Returns IDs; `current_text` is then built **only for that bounded batch** via `BuildEmbeddingTextForIDs(ids)`.
|
||||
- **Text-staleness** (existing `ListEmbeddingTextCandidates`): keep, but run as a **bounded, unconditional quota per cycle** (`embeddingTextStaleQuotaPerRun`) so canonical-text-stale items progress even when the cheap backlog never empties (fairness against starvation).
|
||||
- **Cursors:** the cheap pass advances `afterID` from the **cheap query's** last id (it is the ordered cursor), independent of `BuildEmbeddingTextForIDs` row order (`ANY($1)` does not preserve slice order, and a dropped id must not desync the cursor). The text-stale pass keeps its **own** `afterID`. Both monotonic.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | Change |
|
||||
| --- | --- | --- |
|
||||
| `migrations/sql/<ts>_media_item_embeddings_model_index.sql` | `(model)` btree, `CONCURRENTLY` / `NO TRANSACTION` | Create |
|
||||
| `internal/embeddingvectors/eligibility.go` | `ItemEligibilityWhereClause(alias)` — single source of truth | Create |
|
||||
| `internal/catalog/semantic_coverage.go` | tracker, snapshot, querier seam, per-type SQL, hysteresis, single-flight refresher | Create |
|
||||
| `internal/catalog/search_provider.go` | gate + model-provider interfaces; `Mode`/`SemanticUsed`; semantic status structs | Modify |
|
||||
| `internal/catalog/search_meilisearch_provider.go` | consult gate; set `Mode`/`SemanticUsed` (incl. downgrade); capability probe (unit vector) | Modify |
|
||||
| `internal/catalog/search_meilisearch_client.go` | `GetSettings` + settings DTOs | Modify |
|
||||
| `internal/catalog/search_service.go` | build/own tracker; `StartCoverageRefresh`; comma-ok model-provider; `Status()` reads snapshot | Modify |
|
||||
| `internal/catalog/search_indexer.go` | `ELIG`+`model` numerator & eligible denominator; update **all 4** `countCatalogSearchVectorDocuments` callers | Modify |
|
||||
| `internal/catalog/catalog_resolver.go` | carry diagnostics through `CatalogResult` (direct path only) | Modify |
|
||||
| `internal/recommendations/repo.go` | delegate eligibility to `embeddingvectors`; `ListMissingOrModelStaleEmbeddingIDs` (+SQL helper); `BuildEmbeddingTextForIDs` | Modify |
|
||||
| `internal/recommendations/similar.go` | `EmbedAll`: cheap drain + unconditional text-stale quota; dual cursors | Modify |
|
||||
| `internal/recommendations/engine.go` | `ActiveEmbeddingModel(ctx)` from the lock | Modify |
|
||||
| `internal/api/handlers/catalog.go` | `searchDiagnostics` DTO; populate on direct path; omit for grouped | Modify |
|
||||
| `internal/api/router.go` | start `service.StartCoverageRefresh(deps.AppContext)` inside `NewRouter` | Modify |
|
||||
| `web/src/hooks/queries/admin/settings.ts`, `web/src/pages/admin-settings/SearchSettings.tsx` | surface readiness/ratio/per-type/capability | Modify |
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Shared eligibility predicate, model+eligibility coverage counts, supporting index
|
||||
|
||||
**Files:** Create `internal/embeddingvectors/eligibility.go`, `migrations/sql/<ts>_media_item_embeddings_model_index.sql`; Modify `internal/recommendations/repo.go:150-160`, `internal/catalog/search_indexer.go:757-782` (+ callers `:133,:203,:310`, `search_service.go:139`); Test `internal/embeddingvectors/eligibility_test.go`, `internal/catalog/search_indexer_test.go`.
|
||||
|
||||
**Interfaces — Produces:**
|
||||
- `embeddingvectors.ItemEligibilityWhereClause(alias string) string`
|
||||
- `countCatalogSearchVectorDocuments(ctx, q coverageQuerier, itemTypes []string, model string) (int, error)` (adds `model`; applies `ELIG`)
|
||||
- `catalogSemanticCoverageByType(ctx, q coverageQuerier, itemTypes []string, model string) ([]catalogTypeCoverage, error)` → `{Type string; Eligible, Vectorized int}`
|
||||
|
||||
- [ ] Migration: `make migrate-create NAME=media_item_embeddings_model_index`; body uses `-- +goose NO TRANSACTION` then a self-healing guard for a leftover invalid index from a crashed build (follow the `20260618164519_add_episodes_season_id_index.sql` precedent: a `DO $$ ... NOT i.indisvalid ... DROP INDEX $$` block), then `CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_media_item_embeddings_model ON public.media_item_embeddings (model);` (down: `DROP INDEX CONCURRENTLY IF EXISTS`). The runner honors `NO TRANSACTION` (three shipping precedents). Run `make migrate-up`; verify with `make migrate-status`.
|
||||
- [ ] Move the eligibility predicate to `embeddingvectors`; refactor `recommendationItemEligibilityWhereClause` to delegate (assert output string unchanged).
|
||||
- [ ] Failing tests: (a) `model` filter excludes rows whose `e.model` differs; (b) an **unmatched, non-book** item inflates neither numerator nor denominator (the C1 regression guard); (c) denominator counts eligible items with no embedding row; (d) ratio ∈ [0,1] when a stale embedding exists on a now-unmatched item.
|
||||
- [ ] Implement both queries with `ELIG`; merge per type in Go; update **all four** callers (pass active model where available, else `""`).
|
||||
- [ ] `GOWORK=off go test ./internal/embeddingvectors ./internal/catalog -run 'Eligib|Coverage' -count=1` → PASS. Commit.
|
||||
|
||||
### Task 2: Active embedding model from the lock
|
||||
|
||||
**Files:** Modify recommendations `Engine` (`engine.go`), `internal/catalog/search_provider.go`; Test `internal/recommendations/*_test.go`.
|
||||
|
||||
**Produces:** `recommendations: func (e *Engine) ActiveEmbeddingModel(ctx) (string, error)` (→ `GetEmbeddingLock`; `lock.Model` or `""`); `catalog: type CatalogSemanticModelProvider interface { ActiveEmbeddingModel(ctx context.Context) (string, error) }`.
|
||||
|
||||
- [ ] Test: lock present → its `Model`; no lock → `("", nil)`.
|
||||
- [ ] Implement; confirm `recEngine` satisfies both `CatalogSearchQueryVectorizer` (the real interface name, `search_provider.go:77`) and `CatalogSemanticModelProvider`.
|
||||
- [ ] Run recommendations tests → PASS. Commit.
|
||||
|
||||
### Task 3: Coverage tracker — querier seam, snapshot, hysteresis, single-flight refresher
|
||||
|
||||
**Files:** Create `internal/catalog/semantic_coverage.go`, `semantic_coverage_test.go`; Modify `internal/catalog/search_provider.go` (consts + gate interface).
|
||||
|
||||
**Produces:**
|
||||
```go
|
||||
const (
|
||||
semanticCoverageEnableRatio = 0.90
|
||||
semanticCoverageDisableRatio = 0.80
|
||||
semanticCoverageRefreshInterval = 2 * time.Minute
|
||||
)
|
||||
|
||||
// coverageQuerier is the seam so Refresh is unit-testable without a real *pgxpool.Pool.
|
||||
type coverageQuerier interface {
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||
}
|
||||
|
||||
type catalogTypeCoverage struct { Type string; Eligible, Vectorized int; Ratio float64; Ready bool }
|
||||
type semanticCoverageSnapshot struct { PerType map[string]catalogTypeCoverage; Overall float64; Model string; UpdatedAt time.Time }
|
||||
|
||||
type SemanticCoverageGate interface { CoverageReady(itemTypes []string) (ready bool, reason string) }
|
||||
|
||||
type semanticCoverageTracker struct { /* q coverageQuerier; indexTypes []string; models CatalogSemanticModelProvider; mu sync.Mutex; snap atomic.Pointer[semanticCoverageSnapshot]; clock func() time.Time */ }
|
||||
func (t *semanticCoverageTracker) Refresh(ctx context.Context) error
|
||||
func (t *semanticCoverageTracker) CoverageReady(itemTypes []string) (bool, string) // nil-snapshot ⇒ (false, "coverage not yet computed")
|
||||
func (t *semanticCoverageTracker) Snapshot() *semanticCoverageSnapshot
|
||||
func (t *semanticCoverageTracker) Run(ctx context.Context) // ticker; returns on ctx.Done()
|
||||
```
|
||||
|
||||
- [ ] Test (no panic / fail-safe): `CoverageReady` on a freshly-constructed tracker (no `Refresh` yet) returns `(false, "coverage not yet computed")` and touches the querier **zero** times (querier stub fails the test if called).
|
||||
- [ ] Test (hysteresis): 0.85→not ready; →0.92 ready; →0.85 **stays** ready; →0.79 not ready.
|
||||
- [ ] Test (scope): `CoverageReady(["movie","series"])` false when `series` < threshold though `movie` ready; reason names `series`. Empty scope requires all snapshot types ready.
|
||||
- [ ] Test (model collapse): snapshot computed for model A; active model becomes B → next `Refresh` publishes all-not-ready before recomputing.
|
||||
- [ ] Test (`-race`): concurrent `Refresh` + `CoverageReady` loops; assert monotonic `UpdatedAt`, no race.
|
||||
- [ ] Test (error retention): a `Refresh` whose query errors retains the prior snapshot.
|
||||
- [ ] Implement single-flight mutex, fresh-map immutability, nil-safe reads, model-collapse, retain-last-good, ticker `Run`.
|
||||
- [ ] `GOWORK=off go test ./internal/catalog -run Coverage -race -count=1` → PASS. Commit.
|
||||
|
||||
### Task 4: Wire gate + model provider; comma-ok degradation; start refresher in NewRouter
|
||||
|
||||
**Files:** Modify `internal/catalog/search_meilisearch_provider.go:27-66,329-334` (`MeilisearchProviderConfig` opens at `:27`), `internal/catalog/search_service.go:31-74`, `internal/api/router.go:437-552`.
|
||||
|
||||
**Consumes:** `SemanticCoverageGate`, `CatalogSemanticModelProvider`. **Produces:** `MeilisearchProviderConfig.Coverage SemanticCoverageGate`; `func (s *CatalogSearchService) StartCoverageRefresh(ctx context.Context)`.
|
||||
|
||||
- [ ] `shouldUseSemanticSearch` → `SemanticEnabled && wordCount>=3 && (p.config.Coverage == nil || ready)`; not-ready yields fallback reason `"semantic_not_ready: " + reason` (a **diagnostic, not an error**). Keep the `SkipTotal`-builds-hybrid test green.
|
||||
- [ ] In `NewCatalogSearchServiceFromSettings`: only when `SemanticEnabled`, derive the model provider via **comma-ok** `mp, ok := queryVectorizer.(CatalogSemanticModelProvider)`. If `!ok` or `queryVectorizer == nil` (semantic enabled but recommendations disabled — a real config), build the tracker with a model provider that yields `""` ⇒ every type not-ready (reason `"embedding model provider unavailable"`); **never** assert without comma-ok (nil-interface assertion panics). Store the tracker on the service; pass it as `Coverage`.
|
||||
- [ ] `StartCoverageRefresh` runs `tracker.Run(ctx)` once; no-op when there is no tracker (postgres/semantic-off). Call it from `NewRouter` after the service is built: `if catalogSearchService != nil { catalogSearchService.StartCoverageRefresh(deps.AppContext) }`. (The service is local to `NewRouter` and not returned; `deps.AppContext` is the process-lifetime ctx — Baseline #8/#9.)
|
||||
- [ ] Test: stub gate not-ready ⇒ long query stays keyword-only; ready gate + vectorizer ⇒ hybrid emitted; nil vectorizer + semantic-enabled ⇒ not-ready, no panic.
|
||||
- [ ] `GOWORK=off go test ./internal/catalog -count=1` → PASS. Commit.
|
||||
|
||||
### Task 5: Diagnostics plumbing (provider → resolver → handler), correctly scoped
|
||||
|
||||
**Files:** Modify `internal/catalog/search_provider.go:64-71`, `internal/catalog/search_meilisearch_provider.go:189-237`, `internal/catalog/catalog_resolver.go:24-30,269-297`, `internal/api/handlers/catalog.go:55-61,91-134`; Test `search_provider_test.go`, `catalog_test.go`.
|
||||
|
||||
**Produces:** `CatalogSearchResult{+Mode string; +SemanticUsed bool}`; `CatalogResult{+Provider,Mode string; +SemanticUsed bool; +FallbackReason string}`; handler `type searchDiagnostics struct { Provider, Mode string; SemanticUsed bool; FallbackReason string \`json:"fallback_reason,omitempty"\` }`; `catalogResponse{+SearchDiagnostics *searchDiagnostics \`json:"search_diagnostics,omitempty"\`}`.
|
||||
|
||||
- [ ] Provider: `Mode="hybrid", SemanticUsed=true` only when the hybrid request is issued **and survives**; on the downgrade (`:221-228`) set `Mode="keyword", SemanticUsed=false, FallbackReason=semanticFallback`.
|
||||
- [ ] Resolver: copy the four fields in `resolveDirectSearchSource` (direct path only).
|
||||
- [ ] Handler: populate `search_diagnostics` only on the non-grouped direct path; **omit** for `group=work` and for non-relevance-sort `q=` requests.
|
||||
- [ ] Tests: (a) hybrid error ⇒ `semantic_used:false, mode:"keyword", fallback_reason` set (post-downgrade truth); (b) existing `total/total_exact/has_more/items/snapshot` byte-stable; (c) field **absent** for browse, for `group=work`, and for a `q=`+title-sort request.
|
||||
- [ ] `GOWORK=off go test ./internal/catalog ./internal/api/handlers -count=1` → PASS. Commit.
|
||||
|
||||
### Task 6: Capability validation + rate-limited probe; admin status from snapshot
|
||||
|
||||
**Files:** Modify `internal/catalog/search_meilisearch_client.go:167+`, `internal/catalog/search_meilisearch_provider.go`, `internal/catalog/search_service.go:90-145`, `internal/catalog/search_provider.go` (status structs).
|
||||
|
||||
**Produces:**
|
||||
```go
|
||||
func (c *meilisearchClient) GetSettings(ctx, uid string) (meilisearchIndexSettings, error) // GET /indexes/{uid}/settings
|
||||
type meilisearchIndexSettings struct { Embedders map[string]meilisearchEmbedderSettings `json:"embedders"` }
|
||||
type meilisearchEmbedderSettings struct { Source string `json:"source"`; Dimensions int `json:"dimensions"` }
|
||||
type CatalogSearchSemanticStatus struct {
|
||||
Ready bool `json:"ready"`; DisabledReason string `json:"disabled_reason,omitempty"`
|
||||
CoverageRatio float64 `json:"vector_coverage_ratio"`; CoverageUpdatedAt *time.Time `json:"coverage_updated_at,omitempty"`
|
||||
PerType []CatalogSearchTypeCoverage `json:"per_type,omitempty"`; Capability CatalogSearchSemanticCapability `json:"capability"`
|
||||
}
|
||||
type CatalogSearchTypeCoverage struct { Type string `json:"type"`; Eligible int `json:"eligible"`; Vectorized int `json:"vectorized"`; CoverageRatio float64 `json:"vector_coverage_ratio"`; Ready bool `json:"ready"` }
|
||||
type CatalogSearchSemanticCapability struct { OK bool `json:"ok"`; Reason string `json:"reason,omitempty"`; Embedder string `json:"embedder,omitempty"`; Dimensions int `json:"dimensions,omitempty"` }
|
||||
const semanticCapabilityProbeTTL = 5 * time.Minute
|
||||
```
|
||||
|
||||
- [ ] Add `Semantic CatalogSearchSemanticStatus` to `CatalogSearchRuntimeStatus`. **Keep** `document_count`/`vector_document_count` unchanged (additive only). The `Semantic` block (ratio, per-type, ready) is read from `tracker.Snapshot()` — **no fresh query** in `Status()`; if no tracker, `Ready=false, DisabledReason="semantic disabled"`.
|
||||
- [ ] Capability: embedder present, `source=="userProvided"`, `dimensions==embeddingvectors.CanonicalDimensions`; each failure → distinct `Reason`. Probe (unit vector, `limit:1`, `Embedder=config.Embedder`) only when `ActiveIndexUID != ""`, cached `semanticCapabilityProbeTTL`.
|
||||
- [ ] Tests: missing embedder / wrong source / wrong dimensions / probe failure each distinct; provider `Healthy` and circuit untouched in all four.
|
||||
- [ ] `GOWORK=off go test ./internal/catalog -count=1` → PASS. Commit.
|
||||
|
||||
### Task 7: Backfill cheap/expensive split with fairness
|
||||
|
||||
**Files:** Modify `internal/recommendations/repo.go:472-621`, `internal/recommendations/similar.go:251-362`; Test `repo_test.go`, `similar_test.go`.
|
||||
|
||||
**Produces:** `buildMissingOrModelStaleEmbeddingIDsSQL() string`; `ListMissingOrModelStaleEmbeddingIDs(ctx, afterID, currentModel string, limit int) ([]string, error)`; `BuildEmbeddingTextForIDs(ctx, ids []string) ([]EmbeddingTextCandidate, error)`; `EmbedAll` two-pass loop with dual cursors and an unconditional `embeddingTextStaleQuotaPerRun`.
|
||||
|
||||
- [ ] Test (SQL shape): `buildMissingOrModelStaleEmbeddingIDsSQL()` contains neither `"item_people"` nor `"LATERAL"`.
|
||||
- [ ] Test (behavior, no real DB needed for the shape test; behavior tests use existing recommendations test seams): an item that is *only* canonical-text-stale (people changed, model current) is **not** returned by the cheap query; a missing/model-stale item **is**.
|
||||
- [ ] Test (parity): `BuildEmbeddingTextForIDs(ids)` reproduces the same `canonical_text` as the legacy full CTE for the same ids, regardless of `ids` order.
|
||||
- [ ] Test (no starvation): with a permanently non-empty cheap backlog, a text-stale item is still embedded within a bounded number of `EmbedAll` runs (quota is unconditional); both cursors stay monotonic.
|
||||
- [ ] Implement; cheap `afterID` advances from the cheap query's last id.
|
||||
- [ ] `GOWORK=off go test ./internal/recommendations -count=1` → PASS. Commit.
|
||||
|
||||
### Task 8: Admin UI surfacing
|
||||
|
||||
**Files:** Modify `web/src/hooks/queries/admin/settings.ts:21-54`, `web/src/pages/admin-settings/SearchSettings.tsx:202-261`.
|
||||
|
||||
- [ ] Extend the `CatalogSearchStatus` TS type with the `semantic` block (ready, disabled_reason, vector_coverage_ratio, coverage_updated_at, per_type, capability).
|
||||
- [ ] Render a "Semantic readiness" row (ready/disabled + reason), overall coverage %, a per-type coverage table, a capability badge, and a "coverage updated" timestamp. Keep "Vectorized Documents" (`vector_document_count`).
|
||||
- [ ] `cd web && pnpm run lint && pnpm run format:check` → PASS. Commit.
|
||||
|
||||
---
|
||||
|
||||
## Test Plan
|
||||
|
||||
**Go unit (`internal/embeddingvectors`, `internal/catalog`):**
|
||||
- Eligibility predicate single-sourced; unmatched non-book item excluded from numerator **and** denominator (C1 guard); ratio ∈ [0,1].
|
||||
- Hybrid omitted when gate not ready; emitted when ready + capable + long query + vectorizer present.
|
||||
- **Model-stale safety:** only-old-model embeddings ⇒ coverage ~0 ⇒ keyword-only; model-change collapse publishes not-ready within one interval.
|
||||
- Hysteresis latch; mixed-scope AND; empty-scope = all-types; nil-snapshot returns not-ready with **no panic** and **no DB call**; `-race` refresh/read; error retention.
|
||||
- Capability: missing embedder / wrong source / wrong dimensions / probe failure each distinct; keyword stays up.
|
||||
|
||||
**Go unit (`internal/recommendations`):**
|
||||
- Cheap SQL excludes `item_people`/`LATERAL`; people-only-stale row excluded from cheap pass; per-ID text build matches legacy output order-independently; no text-stale starvation; dual cursors monotonic.
|
||||
|
||||
**Go unit (`internal/api/handlers`):**
|
||||
- `search_diagnostics` present on direct path; **absent** for browse, `group=work`, and non-relevance-sort `q=`; `semantic_used` reflects post-downgrade reality; existing fields byte-stable.
|
||||
|
||||
**Admin status:** `semantic.ready`, `disabled_reason`, `vector_coverage_ratio`, `per_type`, `capability`, `coverage_updated_at` sourced from the snapshot (no fresh query).
|
||||
|
||||
**Focused command:**
|
||||
```bash
|
||||
GOWORK=off go test ./internal/embeddingvectors ./internal/catalog ./internal/api/handlers ./internal/recommendations -race -count=1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Descoped — not in this pass
|
||||
|
||||
**Routing legacy `/search?q=` through the provider pipeline.** Sunset **Wed, 01 Jul 2026** (`internal/api/handlers/legacy_read_routes.go:10`, `legacyReadSunset`) — days away; hardening a path scheduled for deletion is negative ROI. The typed legacy path already routes through the resolver; only the bare-`q` fallback hits `itemRepo.Search`, which the sunset removes. Re-routing also risks `browseResponse`↔`catalogResponse` shape drift. Reopen as a one-task follow-up **only if the sunset slips**, with a byte-for-byte `browseResponse` assertion.
|
||||
|
||||
**After-sync coverage refresh trigger.** The indexer (`main.go:1730`) and the search service (`router.go:457`) share no handle, so an after-drain trigger would need new cross-component wiring and would race the interval refresh. The 2-minute interval plus model-change collapse is sufficient; the trigger is intentionally omitted.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- **One additive index migration** (`media_item_embeddings(model)`, `CONCURRENTLY`). No schema/column changes. *(Revised from the original "no migration": the model-filtered numerator is only cheap with this index, and performance is priority #1.)*
|
||||
- All runtime coverage state is **in-memory** (atomic per-type snapshot), rebuilt on boot and refreshed every 2 minutes; not persisted.
|
||||
- Coverage is computed **entirely from Postgres** over the **embed-eligible** population (`status='matched' OR audiobook/ebook`), model-filtered, manga excluded, restricted to configured index types; it **never** uses the Meili `document_count`.
|
||||
- Active embedding model comes from `recommendations.EmbeddingLock.Model`; **no lock / no model provider ⇒ semantic not ready.**
|
||||
- A model change is reflected within ≤ one refresh interval and **fails safe** within that window via the model-collapse rule; the residual ≤2-minute mismatch window is acceptable for a rare, admin-gated reconfiguration.
|
||||
- Thresholds (enable 0.90 / disable 0.80) and the 2-minute interval are named consts and starting heuristics; the diagnostics shipped here are the instrument for retuning them. Promoting them to settings is a possible follow-up, not this pass.
|
||||
- `semantic_ratio` stays `0.30` (`DefaultMeilisearchSemanticRatio`).
|
||||
- Scope is hybrid-search hardening only — not conversational search.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (writing-plans checklist)
|
||||
|
||||
- **Findings coverage:** C1 eligibility denominator (D1, Task 1) · C2 empty-scope semantics (D3, Task 3) · missing `model` index (D1 migration, Task 1) · 4-caller signature change (Baseline #4, Task 1) · refresher wiring in `NewRouter`/`AppContext` (Baseline #9, Task 4) · concurrent-refresh single-flight + immutability + `-race` (D2, Task 3) · nil-snapshot/error fail-safe (D2, Task 3) · model-change collapse (D2, Task 3) · comma-ok nil vectorizer (Task 4) · querier seam + SQL-string helper for testability (Task 3, Task 7) · grouped/non-relevance diagnostics scoping (D4, Task 5) · unit probe vector + admin-only/5m (D5, Task 6) · `Status()` reads snapshot (Task 6) · backfill cursors + fairness (D6, Task 7) · legacy & after-sync descoped (Descoped) — all mapped.
|
||||
- **Type consistency:** `SemanticCoverageGate`/`CatalogSemanticModelProvider`/`coverageQuerier`/`semanticCoverageTracker`/`catalogTypeCoverage`/`CatalogSearchSemanticStatus` used identically across tasks; `Eligible` (not `Indexable`) is the denominator field name throughout.
|
||||
- **No placeholders:** every new symbol has a signature; SQL is concrete; the migration body is specified.
|
||||
@@ -0,0 +1,180 @@
|
||||
# OPA Policy Engine Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Commands assume the repository root is the cwd.
|
||||
|
||||
**Spec:** [`../specs/2026-07-02-opa-policy-engine-design.md`](../specs/2026-07-02-opa-policy-engine-design.md) — read it first; this plan does not restate the full design rationale.
|
||||
|
||||
**Goal:** Embed OPA (`github.com/open-policy-agent/opa/v1/rego`) as Silo's authorization engine: vendor Rego reproduces today's viewer-scope, permission-gate, and download/playback decisions exactly (proven by dual-execution parity tests), admins get a full policy editor (CodeMirror, validate, simulate, versioning, rollback) for narrowing-only custom overrides, and every decision feeds a partitioned audit log.
|
||||
|
||||
**Architecture:** One new `internal/policy` package (engine, typed PDP, Postgres-backed document store, compile sandbox, async decision logger, System lifecycle). Vendor Rego ships via `go:embed`; admin documents live in Postgres as immutable versions with an activate pointer and a global `policy_generation` counter; cross-node invalidation via a new `cache.EventPolicyChanged` on the existing `ChannelAdmin` plus a 60s poll fallback. Adapters implement the existing interfaces (`middleware.ViewerResolver`, permission middleware, downloads gate, `playback.SessionLimitProvider`) so cutover is a constructor swap per surface. Direct replacement, staged per-surface, no runtime toggle.
|
||||
|
||||
**Tech Stack:** Go (pgx, chi, `opa/v1/rego` + `opa/v1/ast` + `opa/v1/tester`), Goose migrations, `internal/partman`, Redis `cache.EventBus`, React/TypeScript + TanStack Query + CodeMirror 6 (`@uiw/react-codemirror`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **API additive-only within `/api/v1`** — new endpoints/fields only; capability endpoint for feature detection. (CLAUDE.md)
|
||||
- **Performance first, reliability first** — prepared queries only; input-document-only (no OPA store mirroring, no DB reads inside eval); async decision logging that never blocks a decision; fail closed on every eval error/timeout.
|
||||
- **Frozen contracts:** `access.Scope` / `access.ResolveInput` shapes; PIN/profile-token crypto stays in `internal/access/profile_token.go`; `access_policy_revision` semantics (library_ids changes do NOT bump it — `internal/auth/repository.go` has an explicit comment); jellycompat and API keys pass `SkipPINVerification: true`.
|
||||
- **Narrowing-only overrides**, enforced in vendor Rego by tightening-direction merges (intersect libraries, min ceilings, AND booleans) — never by heuristic post-checks.
|
||||
- **Sandbox invariants:** custom modules compile under stripped `ast.Capabilities` (no `http.send`, `net.*`, `opa.runtime`) at save, activate, AND reload; per-eval `context.WithTimeout` (default 25ms, setting `policy.eval_timeout_ms`); package path must be `silo_custom.<domain>`.
|
||||
- **Migrations:** timestamped Goose files via `make migrate-create NAME=...`; never touch legacy numeric migrations.
|
||||
- **One concern per PR**; Conventional Commit subjects; each PR links the OPA epic (`Part of #NNN` — create the epic issue before Phase 1 lands).
|
||||
- Worktree builds need `GOWORK=off` and a stubbed `web/dist` (see worktree-build-quirks memory). DB-backed Go tests use the `SILO_TEST_DATABASE_URL` skip pattern.
|
||||
- Before opening each PR: `cd web && pnpm run lint && pnpm run format:check`, `make verify-local-paths`, `make lint`.
|
||||
|
||||
---
|
||||
|
||||
## Verified Baseline (confirmed against the tree — work from this, not intuition)
|
||||
|
||||
1. **OPA is greenfield**: `go.mod` has no `open-policy-agent` dependency; no `.rego` files exist anywhere in the repo. `web/package.json` has no code-editor dependency.
|
||||
2. **Five `access.NewResolver` construction sites** (all must cut over in Phase 7, not just the router):
|
||||
- `internal/api/router.go:342` — viewer middleware resolver.
|
||||
- `cmd/silo/main.go:1384` — notifications scopes.
|
||||
- `cmd/silo/main.go:1813` — reconciler resolver.
|
||||
- `cmd/silo/main.go:2271` — jellycompat `AccessFilterFn` via `jellycompat.NewScopeAccessFilter`.
|
||||
- `internal/audiobooks/access_resolver.go:24` — ABS access resolver (constructed with `tokens = nil`; PIN verification intentionally skipped there).
|
||||
3. **`middleware.ViewerResolver`** (`internal/api/middleware/viewer_access.go:14`) is a 1-method interface: `Resolve(ctx, access.ResolveInput) (access.Scope, error)`. The middleware maps `ErrProfileUnverified`→403, `ErrProfileNotFound`→404, anything else→500. Fail-closed already.
|
||||
4. **Scope semantics:** `AllowedLibraryIDs == nil` means unrestricted; empty slice means "nothing". `DisabledLibraryIDs` only set when `AllowedLibraryIDs` is nil (`internal/access/resolver.go:95-106`). Profile PIN token must match `UserID`, `SessionID`, `ProfileID`, AND `PolicyRevision` (`resolver.go:88`).
|
||||
5. **`RequireAdmin` is dead in production routing** — only `RequireActingAdmin` (`internal/api/middleware/auth.go:184`) is wired. Model one admin tier.
|
||||
6. **Permissions today:** exactly two assignable permissions, `marker_edit` + `metadata_curation` (`internal/auth/permissions.go:13-16`); `HasEffectivePermission` grants all to enabled admins. `RequireMetadataCurationForItem` (`internal/api/middleware/permissions.go:44`) additionally requires every library containing the item to be inside `users.library_ids`.
|
||||
7. **Downloads gating:** `internal/downloads/policy.go` — `ensureTranscodeAllowed(user, cfg)` (line 166), `DownloadQualityResolver.PresetsFor(user, cfg, artifactsAvailable)` (line 119), driven by `users.download_allowed` / `download_transcode_allowed` + `config.DownloadConfig`. Capability surface: `internal/api/handlers/downloads.go` `HandleCapability` (line 163).
|
||||
8. **Playback limits:** `playback.SessionLimitProvider = func(ctx, userID) (SessionLimits, error)` (`internal/playback/session.go:115`), installed via `SessionManager.SetLimitProvider` (line 141). Live admission counting is in-memory inside `SessionManager` — Go keeps the counting; policy decides given counts.
|
||||
9. **Log-table pattern to copy:** `opsPM := partman.NewManager(pool, "operational_logs", partman.Daily, 3)` (`cmd/silo/main.go:174`), `activityPM := partman.NewManager(pool, "activity_log", partman.Weekly, 2)` (`main.go:1629`); cleanup tasks registered via `taskMgr.Register(...)` (~`main.go:1710-1875`). Migration `migrations/sql/028_log_partitioning.sql` shows the PARTITION BY RANGE + DEFAULT partition idiom. `internal/opslog/repo.go` has the cursor-pagination shape to mirror.
|
||||
10. **Event bus:** `cache.ChannelAdmin = "silo:admin"`, `EventSettingsChanged = "settings_changed"` (`internal/cache/redis.go:24,49`). No `EventPolicyChanged` exists yet. `internal/nodeconfig/watcher.go` shows the subscribe + 60s poll-fallback idiom.
|
||||
11. **System lifecycle template:** `notifications.System` construction at `cmd/silo/main.go:1382-1409` (Start/Wait, wired into `api.Dependencies`).
|
||||
12. **Admin routing:** `/admin` route group with `requireActingAdmin` in `internal/api/router.go` (~line 2259+); `Dependencies` struct at `router.go:78-187`. Capability-endpoint precedent: `internal/api/handlers/downloads.go` `HandleCapability`.
|
||||
13. **Frontend:** admin nav registry `web/src/lib/adminNavigation.ts`; dedicated-page precedent (`/admin/logs`, `/admin/tasks`); query-hook shape `web/src/hooks/queries/admin/settings.ts`; query key factory `web/src/hooks/queries/keys.ts`; DTOs in `web/src/api/types.ts`; jsdom localStorage needs the per-file in-memory stub (see `web/src/api/client.test.ts`).
|
||||
14. **Settings:** flat `server_settings` via `catalog.SettingsStore`; per-key validation switch in `internal/api/handlers/admin.go` `HandleUpdateSetting` (~line 2075+); typed parsing in `internal/config/db_loader.go`; restart-required registry `internal/config/restart_keys.go` (policy keys must all hot-reload — add none there). Policy Rego is not a secret: no `SensitiveSettingKeys` entries.
|
||||
|
||||
## Design Decisions (summary — details in the spec)
|
||||
|
||||
- **D1** One package `internal/policy`; nothing else imports `opa/v1/*`. Typed PDP methods (`ResolveViewerScope`, `CheckPermission`, `CheckAction`) over three prepared queries (`data.silo.{scope,permission,action}.decision`).
|
||||
- **D2** Vendor Rego via `go:embed`, never in the DB. Admin documents: `policy_documents` + immutable `policy_document_versions` + `active_version_id` pointer + single-row `policy_generation` counter.
|
||||
- **D3** Override contract: vendor computes `base_decision`, then `decision := data.silo_custom.<domain>.override(base_decision, input) else base_decision`, merged tightening-only in vendor Rego. Custom package path `silo_custom.<domain>` enforced at compile-check.
|
||||
- **D4** `ScopeDecision` carries an explicit `unrestricted` bool; the adapter maps it to nil `AllowedLibraryIDs`. Combinatorial parity tests own this boundary.
|
||||
- **D5** Decision log: dedicated partitioned `policy_decisions` (daily), async buffered writer, digest-by-default verbosity, 1-in-50 scope sampling, denials/errors always logged, retention task.
|
||||
- **D6** New `cache.EventPolicyChanged` on `ChannelAdmin` + poll fallback on `policy_generation`.
|
||||
- **D7** Frontend: dedicated `/admin/policy` page; CodeMirror 6 with hand-rolled Rego StreamLanguage + `@codemirror/lint` diagnostics from `/validate`.
|
||||
- **D8** Direct replacement staged per surface; legacy Go logic retained (compiled + tested) for one release post-cutover, then deleted.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — `internal/policy` engine core + vendor scope Rego + parity suite (PR 1, dead code)
|
||||
|
||||
- [ ] Add dependency: `go get github.com/open-policy-agent/opa@latest` (v1 module path; confirm `opa/v1/rego`, `opa/v1/ast`, `opa/v1/tester` import cleanly). Note binary-size delta in the PR body (~+19 MB expected).
|
||||
- [ ] `internal/policy/input.go`: `ScopeInput`/`ScopeDecision` structs (JSON tags per spec, `schema_version`, request-context fields `request_time`/`device_id`/`client_ip`/`is_api_key`). Doc comments — these render in the editor reference panel later.
|
||||
- [ ] `internal/policy/errors.go`: `ErrPolicyEvalFailed`, `ErrUnknownDecision`, `ErrCompileFailed` (wraps structured `{Row, Col, Message}` list).
|
||||
- [ ] `internal/policy/vendor/lib/quality.rego` + `lib/ratings.rego`: port rank tables + `min`/`allowed`/`normalize` helpers from `internal/access/quality.go` and `rating.go`. Byte-for-byte table parity asserted by test (Task below).
|
||||
- [ ] `internal/policy/vendor/scope.rego` (`package silo.scope`, `import rego.v1`): reproduce `access.Resolver.Resolve` — `effectiveLibraries` intersection logic, disabled-library subtraction (both restricted/unrestricted branches), quality min-merge, rating passthrough, `profile_verified` passthrough, explicit `unrestricted` output. Include the `data.silo_custom.scope.override(base_decision, input)` extension hook with tightening-only merge (intersect `allowed_library_ids`, union `disabled_library_ids`, min ceilings, AND `profile_verified`).
|
||||
- [ ] `internal/policy/vendor/scope_test.rego`: one Rego test per branch of `Resolve` (no profile / account-restricted / profile-restricted / both-intersect / disabled-subtraction / unverified profile), plus override-merge tests proving a widening override has no effect.
|
||||
- [ ] `internal/policy/vendor.go`: `//go:embed vendor` FS + module loader.
|
||||
- [ ] `internal/policy/engine.go`: `Engine` with `queries map[DecisionName]rego.PreparedEvalQuery`, RWMutex atomic `swap`, `Evaluate(ctx, name, input, out) (Meta, error)` with `context.WithTimeout` (25ms default), fail-closed on err/empty result/decode failure. Vendor-only compile path for now (no DB).
|
||||
- [ ] `internal/policy/compile.go` (first half): `LockedCapabilities()` stripping `http.send`, `net.*`, `opa.runtime`, `rego.parse_module` from `ast.CapabilitiesForThisVersion()`. (Custom-doc compile-check lands Phase 2; capabilities are needed now for the sandbox tests.)
|
||||
- [ ] `internal/policy/pdp.go`: `PDP.ResolveViewerScope(ctx, ScopeInput) (ScopeDecision, Meta, error)` only (other methods come with their surfaces).
|
||||
- [ ] `internal/policy/vendor_rego_test.go`: run all `vendor/*_test.rego` via `opa/v1/tester` inside `go test` (no CLI dependency).
|
||||
- [ ] **Parity tests** `internal/policy/scope_parity_test.go`: table-driven fixtures run through BOTH `access.Resolver`-equivalent logic (drive the real resolver with stub repos, mirroring `internal/access` existing tests) and `PDP.ResolveViewerScope`, asserting identical `access.Scope`. Include the combinatorial battery: `account ∈ {nil, [], [1,2,3]} × profile ∈ {absent, unrestricted, [], [2,3,4]} × disabled ∈ {[], [2]} × verified ∈ {true,false}`.
|
||||
- [ ] Sandbox test: a module using `http.send` fails to compile under `LockedCapabilities()`; a `while`-style pathological comprehension trips the eval timeout and returns `ErrPolicyEvalFailed`.
|
||||
- [ ] Benchmark `internal/policy/engine_bench_test.go`: prepared-query scope eval incl. input marshaling; assert well under 200µs p99 locally; record the number in the PR body.
|
||||
- [ ] Verify: `GOWORK=off go build ./... && GOWORK=off go test ./internal/policy/...` green; `make lint` green. **No wiring into router/main — dead code by design.**
|
||||
|
||||
## Phase 2 — Data model + PolicyStore + compile-check pipeline (PR 2)
|
||||
|
||||
- [ ] `make migrate-create NAME=policy_foundation`: `policy_documents`, `policy_document_versions` (immutable, `UNIQUE(document_id, version_number)`, deferred FK for `active_version_id`), `policy_generation` (single-row, seeded), `policy_decisions` (PARTITION BY RANGE on `"timestamp"` + DEFAULT partition + indexes incl. partial `WHERE allowed = false`) — schema per spec. Goose Up/Down both present.
|
||||
- [ ] `internal/policy/store.go`: `PolicyStore` — CRUD for documents/versions, `ActiveSources(ctx) map[domain][]source`, `Activate(documentID, versionID)` bumping `policy_generation` in the same tx (`UPDATE ... SET generation = generation + 1 ... RETURNING`), `Generation(ctx)`, enable/disable, delete-guard (only when no active version).
|
||||
- [ ] `internal/policy/compile.go` (second half): `CompileCheck(ctx, domain, source) error` — parse with locked capabilities, enforce `package silo_custom.<domain>`, compile candidate layered over vendor + other active docs, 2s compile budget, structured errors.
|
||||
- [ ] Extend `Engine` load path: vendor (full caps) + enabled custom actives (locked caps); a custom doc failing compile at load is skipped with WARN, never fatal. `revision` = generation from store.
|
||||
- [ ] DB-backed tests (`SILO_TEST_DATABASE_URL` skip pattern): store CRUD, activation atomicity under concurrent activates (no lost generation bump), version immutability, delete-guard.
|
||||
- [ ] Verify: migration applies cleanly via `make migrate-up` on a scratch DB; `GOWORK=off go test ./internal/policy/...`.
|
||||
|
||||
## Phase 3 — System lifecycle, hot reload, cross-node invalidation (PR 3)
|
||||
|
||||
- [ ] `internal/cache/redis.go`: add `EventPolicyChanged = "policy_changed"` beside `EventSettingsChanged`.
|
||||
- [ ] `internal/policy/system.go`: `System` (mirrors `notifications.System`) — `NewSystem(pool, eventBus, settingsReader)`; `Start(ctx)` does initial `reloadFromStore` (vendor-only compile failure = fatal; custom failure = degraded WARN), subscribes to `ChannelAdmin` for `EventPolicyChanged`, runs 60s poll fallback comparing `policy_generation` to loaded generation (idiom: `internal/nodeconfig/watcher.go`); graceful stop.
|
||||
- [ ] Settings: `policy.eval_timeout_ms` (default 25) parsed in `internal/config/db_loader.go`, hot-applied via atomic value read by `Engine` — NOT in `restart_keys.go`.
|
||||
- [ ] Wire in `cmd/silo/main.go` (near notifications wiring ~1382): construct `policy.System`, `Start(appCtx)`, add `deps.PolicySystem` field to `api.Dependencies`, deferred stop in shutdown ordering. Skip construction in proxy/transcode standalone modes.
|
||||
- [ ] Cross-node test: two `System` instances over one test DB + EventBus double; activate on A, assert B converges via event AND (separately, with events suppressed) via poll fallback.
|
||||
- [ ] Verify: `GOWORK=off go build ./...`; integrated-mode boot smoke (`make dev-backend` locally) shows policy system start log; no behavior change anywhere (still nothing querying the PDP in request paths).
|
||||
|
||||
## Phase 4 — Decision logging (PR 4)
|
||||
|
||||
- [ ] `internal/policy/decisionlog.go`: `DecisionLogger` — non-blocking buffered channel + batch-insert flush goroutine (shape: `internal/activitylog` writer/consumer); drop-and-count metric on full buffer; fields per spec (`decision_name`, `policy_generation`, identity, `allowed`, `eval_time_ns`, `input_digest`, verbosity-gated `input_sample`/`result_sample`, `error`).
|
||||
- [ ] Sampling/verbosity settings (hot-reloaded, parsed in `db_loader.go`): `policy.decision_log_verbosity` (`digest`|`verbose`), `policy.decision_log_scope_sample_rate` (default 50 ⇒ 1-in-50), `policy.decision_log_retention_days` (default 14). Denials + eval errors bypass sampling always.
|
||||
- [ ] Hook `PDP` methods to emit entries post-decision (never on the eval critical path — enqueue only).
|
||||
- [ ] `internal/policy/decisionlog_repo.go`: cursor-paginated `List` (filters: decision_name, user_id, allowed, time range) mirroring `internal/opslog/repo.go`.
|
||||
- [ ] `cmd/silo/main.go`: `policyPM := partman.NewManager(pool, "policy_decisions", partman.Daily, 3)` + `EnsureFuturePartitions` (non-fatal), register `tasks.NewPolicyDecisionLogCleanupTask(...)` beside the other log cleanup tasks (`internal/taskmanager/tasks/`).
|
||||
- [ ] DB-backed tests: writer batch/flush/drop behavior, repo pagination + filters, partition creation, cleanup task drops expired partitions.
|
||||
- [ ] Verify: `GOWORK=off go test ./internal/policy/... ./internal/taskmanager/...`.
|
||||
|
||||
## Phase 5 — Admin HTTP API (PR 5)
|
||||
|
||||
- [ ] `internal/policy/simulate.go`: throwaway-bundle simulate (`domain`, optional candidate `source`, `decision_name`, raw `input`) → decision + `eval_time_ns` + optional `rego.Tracer` trace. Never touches the live engine; never writes a decision-log row.
|
||||
- [ ] `internal/api/handlers/policy.go`: thin handlers per the spec's endpoint table — vendor viewer, documents CRUD, versions (create = CompileCheck + persist, 422 with `{errors:[{row,col,message}]}`), activate (= rollback), enabled toggle, delete-guard, `/validate`, `/simulate`, `/decisions` list + detail. DTOs follow existing handler naming (`policyDocumentResponse`, ...).
|
||||
- [ ] Capability endpoint `GET /api/v1/policy/capability` (authenticated, non-admin — mirror `downloads.HandleCapability`): `{enabled, editor_available, decision_types, generation}`.
|
||||
- [ ] Mount in `internal/api/router.go`: `/policy/capability` in the authenticated group; `r.Route("/policy", ...)` inside the acting-admin `/admin` group.
|
||||
- [ ] Handler tests: validate/simulate happy + compile-error paths, activation flow publishes `EventPolicyChanged`, capability shape, non-admin gets 403 on admin routes.
|
||||
- [ ] Verify: `GOWORK=off go test ./internal/api/...`; manual smoke: create → validate → save version → simulate → activate → decision generation bumps.
|
||||
|
||||
## Phase 6 — Frontend `/admin/policy` page (PR 6)
|
||||
|
||||
- [ ] Deps: `pnpm add @uiw/react-codemirror @codemirror/lint @codemirror/language` in `web/`. Record bundle-size delta in the PR body.
|
||||
- [ ] `web/src/api/types.ts`: additive DTOs (`PolicyCapability`, `PolicyDocument`, `PolicyVersion`, `PolicyValidateResult`, `PolicySimulateResult`, `PolicyDecisionEntry`).
|
||||
- [ ] `web/src/hooks/queries/admin/policy.ts` + entries in `web/src/hooks/queries/keys.ts`: `usePolicyCapability`, `usePolicyDocuments`, `usePolicyVersions`, `useValidatePolicy`, `useCreatePolicyVersion`, `useActivatePolicyVersion`, `useSimulatePolicy`, `usePolicyDecisions` (mutations invalidate document/version keys).
|
||||
- [ ] `web/src/lib/regoLanguage.ts`: ~60-line `StreamLanguage` Rego mode (keywords `package import default if else not in every some as with contains`, `#` comments, strings/numbers).
|
||||
- [ ] Pages per spec layout under `web/src/pages/admin-policy/`: `AdminPolicyLayout` (sub-nav Documents | Vendor | Decision Log), `PolicyDocumentList`, `PolicyEditorPanel` (CodeMirror + Validate → lint diagnostics at row/col + Save version + separate Activate with confirm), `PolicyVendorViewer` (read-only), `PolicySimulatePanel` (JSON input seeded with per-decision example + result/trace + eval µs), `PolicyVersionHistory` (list, client-side diff vs active, rollback confirm), `PolicyDecisionLogTable` (filters + cursor pagination + row expand).
|
||||
- [ ] Register route in `web/src/App.tsx` under `/admin/*`; nav entry in `web/src/lib/adminNavigation.ts` (System group, ShieldCheck-style icon). Hide/disable via `usePolicyCapability` when the engine reports disabled.
|
||||
- [ ] Settings additions: policy log verbosity/sample-rate/retention + eval timeout fields in the existing admin settings area (small section, `useSettingsForm` pattern) + `HandleUpdateSetting` validation cases for the new keys.
|
||||
- [ ] Vitest: editor validate-flow (mock 422 diagnostics render), simulate hook, decision-log pagination (remember the jsdom localStorage stub).
|
||||
- [ ] Verify: `cd web && pnpm run lint && pnpm run format:check && pnpm test`; manual walkthrough — author a schedule-based narrowing override, validate, simulate, activate, watch it in the decision log, roll back. Screenshots in the PR (UI-change convention).
|
||||
|
||||
## Phase 7 — Cutover surface 1: viewer scope (PR 7)
|
||||
|
||||
- [ ] `internal/access/policy_resolver.go`: `PolicyResolver` implementing `middleware.ViewerResolver` — owns the same repo lookups `access.Resolver` does today (user row, profile via userstore, disabled-library setting), performs PIN/profile-token verification in Go (identical claims checks incl. `PolicyRevision`), builds `ScopeInput` (`profile_verified` as fact), calls `PDP.ResolveViewerScope`, maps `unrestricted` → nil, returns `access.Scope` with `PolicyRevision`/`ProfileID`/`UserID` filled Go-side. Error taxonomy preserved: `ErrProfileNotFound`, `ErrProfileUnverified`, wrapped internals.
|
||||
- [ ] Swap **all five** construction sites to the policy-backed resolver: `internal/api/router.go:342`, `cmd/silo/main.go:1384` (notifications), `main.go:1813` (reconciler), `main.go:2271` (jellycompat), `internal/audiobooks/access_resolver.go:24` (ABS — preserve its skip-PIN semantics via `tokens=nil` equivalent). Do NOT delete `access.Resolver`.
|
||||
- [ ] Re-point/extend existing tests: middleware viewer-access tests and jellycompat scope tests must pass unchanged against the new resolver (that is the point). Parity suite from Phase 1 remains the gate.
|
||||
- [ ] Verify: full `GOWORK=off go test ./...` (jellycompat `TestBeginWebOperation*` flakes are pre-existing); manual smoke on dev: restricted profile + PIN profile + disabled libraries behave identically across native web and a Jellyfin client; decision log shows sampled scope entries.
|
||||
|
||||
## Phase 8 — Cutover surface 2: permission gates (PR 8)
|
||||
|
||||
- [ ] `internal/policy/vendor/permission.rego` + tests: reproduce `HasEffectivePermission` (enabled + admin-grants-all + assigned list), acting-admin rule (`role==admin` AND (no declared profile OR declared profile primary)), `metadata_curation` item-scope rule (every target library ∈ user allowlist; empty allowlist = unrestricted). Override hook `silo_custom.permission.override`, AND-merge only.
|
||||
- [ ] `internal/policy/input.go` + `pdp.go`: `PermissionInput`/`PermissionDecision`, `CheckPermission`.
|
||||
- [ ] Parity tests against `auth.HasEffectivePermission`/`EffectivePermissions` and `actingAdminAllowed` fixtures.
|
||||
- [ ] `internal/api/middleware/policy_gates.go`: acting-admin middleware + `RequireMetadataCurationForItem` equivalent backed by `CheckPermission` — same signatures as the existing constructors so `router.go` call sites are one-line swaps. Go still resolves the declared-profile-primary fact (DB lookup) before eval. Eval failure → 500 (matches existing `actingAdminAllowed` error contract); clean deny → 403.
|
||||
- [ ] Swap wiring in `internal/api/router.go` (acting-admin group construction + permission middleware). Keep legacy middleware code compiled.
|
||||
- [ ] Verify: existing middleware tests green against the new gates; manual: non-primary-profile admin still blocked from `/admin`, curator without library access still 403 on out-of-scope items.
|
||||
|
||||
## Phase 9 — Cutover surface 3: download/playback actions (PR 9)
|
||||
|
||||
- [ ] `internal/policy/vendor/action.rego` + tests: download rules (`download_allowed`; transcode requires `download_transcode_allowed` + artifacts; quality/rating ceilings via lib helpers) and playback admission (`current_active_streams < max_streams` when limit > 0; transcode analog; 0 = unlimited). Override hook `silo_custom.action.override`, tightening-only.
|
||||
- [ ] `ActionInput`/`ActionDecision` + `PDP.CheckAction`; parity tests against `ensureTranscodeAllowed`, `PresetsFor`, and `SessionLimits` admission math.
|
||||
- [ ] Downloads integration: `internal/downloads` gains a required `PolicyGate` dependency used by `Capability()`/`Create()`/preset resolution; map deny → existing `ErrDownloadNotAllowed`-family sentinels so `HandleCapability`/`writeDownloadError` responses are unchanged.
|
||||
- [ ] Playback integration: reimplement the `SessionLimitProvider` closure (wired via `SetLimitProvider`) to consult `CheckAction` with Go-computed live counts; keep `SessionManager` counting untouched.
|
||||
- [ ] Verify: downloads handler/service tests + playback session tests green; manual: user without download rights sees unchanged capability response; stream-limit enforcement unchanged; a custom action override (e.g. "no downloads 22:00–07:00") works end-to-end via the editor.
|
||||
|
||||
## Phase 10 — Bake, cleanup, docs (PR 10, next release)
|
||||
|
||||
- [ ] After one release of bake with all three surfaces cut over: delete legacy logic — `internal/access/resolver.go` resolve internals (keep `types.go`, `errors.go`, `context.go`, `profile_token.go`), `rating.go`/`quality.go` rank tables IF no non-policy callers remain (check `catalog.applyAccessFilter`'s `AllowedRatingsUpTo` usage — if catalog still needs the Go tables for SQL filtering, keep them and drop only dead paths; the SQL filter surface is explicitly deferred), legacy permission middleware, legacy downloads inline checks. Parity tests convert to golden tests against vendor Rego alone.
|
||||
- [ ] Remove any temporarily duplicated fixtures; run `/simplify`-style pass on `internal/policy`.
|
||||
- [ ] `docs/architecture/policy-engine.md`: subsystem overview, input-document contracts (source-of-truth tables), override-authoring guide with schedule/device examples, operational notes (generation, invalidation, decision-log knobs).
|
||||
- [ ] Flag follow-up issues: repeated-timeout circuit breaker, persisted test suites, elevated (widening) override path, compile-to-SQL exploration, plugin gating.
|
||||
- [ ] Verify: `GOWORK=off go build ./... && GOWORK=off go test ./...`, `make lint`, `make verify-local-paths`.
|
||||
|
||||
---
|
||||
|
||||
## Test & verification summary
|
||||
|
||||
| Gate | Command |
|
||||
|---|---|
|
||||
| Unit + Rego tests | `GOWORK=off go test ./internal/policy/...` |
|
||||
| Parity (per surface, pre-cutover) | `GOWORK=off go test ./internal/policy/ -run 'Parity'` |
|
||||
| DB-backed | `SILO_TEST_DATABASE_URL=... go test ./internal/policy/...` |
|
||||
| Perf guardrail | `go test ./internal/policy/ -bench BenchmarkResolveViewerScope` (assert < 200µs incl. marshaling) |
|
||||
| Lint/format | `make lint`; `cd web && pnpm run lint && pnpm run format:check` |
|
||||
| Local-path hygiene | `make verify-local-paths` |
|
||||
|
||||
## Risks to watch during implementation
|
||||
|
||||
1. **nil-vs-empty `AllowedLibraryIDs`** — the `unrestricted` field mapping is the invariant; the combinatorial parity battery is the gate. Do not ship Phase 7 with any parity case skipped.
|
||||
2. **Five call sites, not one** — missing the audiobooks/reconciler/notifications resolvers leaves silently divergent authz paths (the exact failure mode CLAUDE.md's duplicate-logic rule warns about).
|
||||
3. **ABS resolver PIN semantics** — it constructs the resolver with a nil token validator today; the policy-backed replacement must preserve that (verified-by-construction), not accidentally start requiring profile tokens on ABS clients.
|
||||
4. **Decision-log volume** — scope decisions fire per request; keep the 1-in-50 default and digest verbosity, and confirm the drop-counter metric is visible before Phase 7 ships.
|
||||
5. **Vendor Rego drift vs Go tables** — until Phase 10 deletes the Go rank tables, the byte-parity test pinning `lib/{ratings,quality}.rego` to `internal/access/{rating,quality}.go` must stay green.
|
||||
@@ -0,0 +1,252 @@
|
||||
# OpenTelemetry Observability: Logs + Traces Adoption Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Commands assume the repository root is the cwd unless a task explicitly says otherwise.
|
||||
|
||||
**Goal:** Adopt OpenTelemetry for **logs and traces** (metrics explicitly deferred), so Silo emits OTLP-exportable structured logs correlated with distributed traces, without discarding the existing stderr + `opslog` DB/admin-stream pipeline. Standardize all `slog` call sites so every log record can carry `trace_id`/`span_id` when a span is active.
|
||||
|
||||
**Architecture:** Add a single OTel SDK bootstrap module (`internal/telemetry`) that builds a shared `resource.Resource`, a `TracerProvider`, and a `LoggerProvider` from `OTEL_*` env config, with graceful shutdown wired into the existing signal path. Bridge logs by inserting an `otelslog` handler into the existing `slog.Handler` chain via **fan-out** (`slog.MultiHandler`, stdlib in Go 1.26) — the current stderr + `opslog` sinks are untouched; OTel receives the same stream. Standardize the ~1,200 `slog.*` call sites to `slog.*Context(ctx, …)` and to explicit `component` attrs (retiring the `subsystem:` message-prefix convention as the classification signal, while keeping `opslog.InferComponent` as a backward-compatible fallback). Add tracing at the natural seams: chi HTTP middleware, `pgx` pool tracer, `go-redis` hook, outbound `http.Client` transports, host-side plugin gRPC stats handlers, and domain spans for scanner/playback/taskmanager. W3C `traceparent` propagation ties main-server → transcode-node and → plugin-subprocess traces together.
|
||||
|
||||
**Tech Stack:** Go 1.26, `log/slog`, chi/v5, pgx/v5 + pgxpool, go-redis/v9, gRPC, PostgreSQL. New deps: `go.opentelemetry.io/otel` (+ `sdk`, `sdk/trace`, `sdk/log`), OTLP exporters (`otlptracegrpc`/`otlptracehttp`, `otlploggrpc`/`otlploghttp`), `go.opentelemetry.io/contrib/bridges/otelslog`, `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp`, `go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc`, `github.com/redis/go-redis/extra/redisotel/v9`, and a pgx tracer (`github.com/exaring/otelpgx`).
|
||||
|
||||
**Non-goals (explicit):**
|
||||
- **Metrics stay on Prometheus — and must not break.** The existing `client_golang` instrumentation (`/metrics` on the dedicated `metricsMux` at `cmd/silo/main.go:2078`, `internal/api/middleware/metrics.go`, `internal/api/middleware/domain_metrics.go`) is **not** migrated to the OTel metrics SDK in this plan. Prometheus and OTel run on **separate, parallel rails**: Prometheus → `/metrics` → Grafana scrape (unchanged), OTel → OTLP → traces/logs backend. See the "Prometheus / Grafana coexistence" section below for the one footgun that must be guarded.
|
||||
- **Plugin-SDK-side instrumentation is out of repo.** Only host-side gRPC handler options live here; the plugin subprocess side is owned by `silo-plugin-sdk` and is a coordination follow-up.
|
||||
- **Log redaction now covers all sinks.** A shared `internal/logredact` handler masks secret-keyed attrs on console + OTLP (opslog DB path already redacted); `opslog.shouldRedact` delegates to `logredact.SecretKey` so the marker list is single-sourced. (Superseded the earlier "OTel output is raw" risk.)
|
||||
|
||||
---
|
||||
|
||||
## Validated Findings
|
||||
|
||||
Established by codebase research on the `feat/opentelemetry-plan` branch (based off `origin/main` @ `5172155a`).
|
||||
|
||||
### Logging
|
||||
- The `slog.Handler` chain is assembled in two stages. Base handler is `slog.NewJSONHandler`/`NewTextHandler(os.Stderr, …)` in `buildBaseHandler` (`cmd/silo/main.go:132-138`), gated by a shared `*slog.LevelVar` (`cmd/silo/main.go:540-544`).
|
||||
- `internal/logfilter/handler.go` wraps the base handler to drop records whose message has a configured `prefix:` (hot-reloadable `SetQuiet`). Installed as the first default logger at `cmd/silo/main.go:544-545`.
|
||||
- Once Postgres/settings exist, `configureOperationalLogging` (`cmd/silo/main.go:169-211`) re-wraps: `slog.New(opslog.NewHandler(filteredHandler, operationalWriter, opsCaptureLevel, nodeID))` (`cmd/silo/main.go:209`).
|
||||
- `opslog.Handler.Handle` (`internal/opslog/handler.go:33-93`) forwards to `inner` first, then (if level ≥ capture) flattens attrs, redacts secret-ish keys (`internal/opslog/handler.go:188-196`), and writes an `opslog.Entry` to a `Writer` (Redis list or in-memory chan → `Consumer` → partitioned `operational_logs` table → `logstream.Hub` admin stream).
|
||||
- **Component classification**: explicit `component` attr, else `inferComponent(message)` splitting on first `:` (`internal/opslog/handler.go:51-54,166-174`), default `"app"`. Correlation attrs already extracted if present: `request_id`, `session_id`, `playback_session_id`, `client_ip`/`remote_addr`, `node_id`, `user_id`.
|
||||
- **~1,211 `slog.(Info|Warn|Error|Debug)(` call sites** across `internal/`. Heaviest: `internal/api/handlers/libraries.go` (83), `internal/metadata/service.go` (70), `internal/api/handlers/playback.go` (60), `internal/metadata/worker.go` (45), `internal/recommendations/worker.go` (43), `internal/scanner/scanner.go` (40), `internal/adminjob/runner.go` (37), `internal/api/handlers/plugins.go` (35). Dominant idiom is the bare package-level `slog.Info("subsystem: msg", "k", v)` (no context). A few subsystems bind `slog.Default().With("component", …)` on a struct field (`internal/notifications/*`, `internal/playback/ffmpeg_log_sink.go`, `internal/api/middleware/request_logger.go`).
|
||||
- **No `trace_id`/`span_id` concept exists** anywhere yet. No automatic context→attr bridging; each call passes correlation attrs manually.
|
||||
|
||||
### Instrumentation seams
|
||||
- Router is chi/v5. Main middleware stack (`internal/api/router.go:209-231`): `RequestID` (`router.go:213`) → `clientip.Middleware` → `apimw.RequestLogger` (`internal/api/middleware/request_logger.go:19`) → `Recoverer` → `apimw.Metrics` (`internal/api/middleware/metrics.go:35`) → `Compress`. `request_logger.go:53-70` already logs `request_id`/`user_id`/`session_id`/`playback_session_id`. `metrics.go:79-89` has `sanitizePath` (solves `http.route` cardinality).
|
||||
- jellycompat has its own chi router mounting `middleware.RequestID` (`internal/jellycompat/router.go:30`). `internal/transcodenode/server.go` is a standalone chi server in a separate process.
|
||||
- Postgres pool built at `internal/database/postgres.go:15` via `pgxpool.ParseConfig`; no `Tracer` set (pgx v5 `QueryTracer` seam).
|
||||
- Redis clients built at `internal/cache/redis.go:145-254` (two `redis.NewClient` sites); go-redis v9 `AddHook`/`redisotel` seam.
|
||||
- Outbound `http.Client` literals (not a shared factory): `internal/nodepool/health.go:22-26`, metadata providers (`internal/metadata/tmdb`, `.../trakt`, `.../translation`, `internal/mdblist`), subtitle providers (`internal/subtitles/opensubtitles`, `subdl`, `subsource`), plugin HTTP proxy (`internal/plugins.HTTPProxy`, wired `router.go:149`).
|
||||
- Host-side plugin gRPC server: `grpc.NewServer(opts...)` at `internal/pluginhost/host.go:336` — but note this is inside `broker.AcceptAndServe(...)`, a hashicorp/go-plugin **brokered sub-stream** (reverse channel), not the primary host→plugin invocation channel. The load-bearing seam is the client dial (`grpc.NewClient`, pattern in `internal/pluginhost/client_test.go:18`). See Task 7.
|
||||
- Background entrypoints: scanner `NewScanner` (`internal/scanner/scanner.go:192`), scoped scan `applyScopedScan` (`internal/scanner/scanner.go:1287`); playback `NewSessionManager` (`internal/playback/session.go:152`); taskmanager `RunTask` (`internal/taskmanager/manager.go:231`).
|
||||
- `context.Context` is threaded pervasively (`Dependencies.AppContext` at `internal/api/router.go:88`; every repo/service method takes `ctx` first). Span context propagates cleanly for the **request/service call chains** — the work there is adding spans + switching call sites to the `…Context` variants. **Exception:** struct-bound loggers whose methods don't take a `ctx` (`taskmanager`, `notifications/*`, `ffmpeg_log_sink.go`) need signature plumbing or stay uncorrelated; see Task 3 Step 3.
|
||||
- Existing endpoints: `/metrics` on a dedicated `metricsMux` (`cmd/silo/main.go:2078`), `/health` + `/ready` (`internal/api/router.go:247,1436`). No pprof registered.
|
||||
|
||||
### Dependencies
|
||||
- `go 1.26.4` → `slog.MultiHandler` is available in the stdlib (no `samber/slog-multi` needed).
|
||||
- Already present: `github.com/prometheus/client_golang v1.23.2`; `go.opentelemetry.io/otel/sdk/metric v1.41.0` is present only as an **indirect** transitive dep (not wired). No `go.opentelemetry.io/otel` core, no trace SDK, no exporters, no otelslog/otelhttp/otelgrpc.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### New files
|
||||
- `internal/telemetry/telemetry.go` — SDK bootstrap: `Config` (parsed from env), `Setup(ctx, Config) (*Providers, shutdown func(context.Context) error, error)`, shared `resource.Resource`, `TracerProvider`, `LoggerProvider`, W3C propagator, batch processors.
|
||||
- `internal/telemetry/config.go` — `OTEL_*` + `SILO_OTEL_*` env parsing (enable gate, endpoint, protocol, sampler, service name/version).
|
||||
- `internal/telemetry/loghandler.go` — a best-effort wrapper around `otelslog.Handler` whose `Handle` never propagates an OTel export error (mirrors the `logsink` best-effort contract), plus the fan-out assembly helper.
|
||||
- `internal/telemetry/httpclient.go` — shared `otelhttp.NewTransport`-wrapped `http.RoundTripper` factory for outbound clients.
|
||||
- `internal/telemetry/telemetry_test.go`, `internal/telemetry/config_test.go`, `internal/telemetry/loghandler_test.go`.
|
||||
|
||||
### Modified files (by phase)
|
||||
- `go.mod` / `go.sum` — add OTel core/sdk/exporters + contrib instrumentation + pgx/redis tracers.
|
||||
- `cmd/silo/main.go` — call `telemetry.Setup`, insert otelslog fan-out into `buildBaseHandler`, defer `shutdown` in `run()`.
|
||||
- `internal/api/router.go` — `otelhttp` handler/middleware in the chi chain; inject `trace_id`/`span_id` into request-logger attrs.
|
||||
- `internal/api/middleware/request_logger.go` — add trace/span IDs to the emitted attrs.
|
||||
- `internal/jellycompat/router.go`, `internal/transcodenode/server.go` — `otelhttp` server instrumentation + propagation.
|
||||
- `internal/database/postgres.go` — `otelpgx` `QueryTracer` on the pool config.
|
||||
- `internal/cache/redis.go` — `redisotel.InstrumentTracing` on both client constructions.
|
||||
- Outbound-client packages listed above — accept/use the shared traced transport.
|
||||
- `internal/pluginhost/host.go` (+ client dial sites) — `otelgrpc` server/client stats handlers.
|
||||
- `internal/scanner/scanner.go`, `internal/playback/session.go`, `internal/taskmanager/manager.go` — domain spans.
|
||||
- **All packages under `internal/`** — call-site sweep (`slog.* → slog.*Context`, `component` attr). Batched per-package in Phase 3.
|
||||
- `docs/architecture/observability.md` — operator docs (env vars, collector wiring, what is/isn't instrumented).
|
||||
|
||||
---
|
||||
|
||||
## Phasing overview
|
||||
|
||||
- **Phase 0** — Dependencies + `internal/telemetry` SDK bootstrap (no behavior change; disabled unless configured).
|
||||
- **Phase 1** — Logs bridge fan-out (logs export works end-to-end; call sites unchanged).
|
||||
- **Phase 2** — Tracing providers + propagation plumbing (spans exist, exported).
|
||||
- **Phase 3** — Full call-site sweep to `…Context` + `component` attrs (unlocks `trace_id` in logs).
|
||||
- **Phase 4** — HTTP server + client tracing seams.
|
||||
- **Phase 5** — pgx + Redis tracing seams.
|
||||
- **Phase 6** — Plugin gRPC host-side propagation.
|
||||
- **Phase 7** — Domain spans (scanner / playback / taskmanager).
|
||||
- **Phase 8** — Shutdown wiring, docs, verification hardening.
|
||||
|
||||
Each phase is independently shippable and leaves `main` green. Phases 1–2 are prerequisites for Phase 3's payoff; Phases 4–7 can land in any order after Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Prometheus / Grafana coexistence
|
||||
|
||||
**Question this answers:** does adopting OTel break, replace, or entangle the existing Prometheus + Grafana metrics? **Answer: no — they stay separate rails, provided one footgun is guarded. Metrics stay on Prometheus permanently for this plan; migrating them to OTel is not in scope.**
|
||||
|
||||
- **Separate lines, not merged (this plan).** OTel `telemetry.Setup` installs **only** a `TracerProvider` and a `LoggerProvider`. It **must not** call `otel.SetMeterProvider` (Task 1 Step 5 builds no MeterProvider). Prometheus keeps its own independent rail: `client_golang` registry → `/metrics` (`cmd/silo/main.go:2078`) → Grafana scrape. Nothing about the metrics path changes. Grafana metric dashboards are untouched; traces become a *new, additive* Grafana/Tempo capability, not a change to existing panels.
|
||||
- **THE FOOTGUN — global MeterProvider (guard required).** The OTel instrumentation libraries used for traces (`otelhttp`, `otelgrpc`, `otelpgx`, `redisotel`) **also emit metrics by default**, through `otel.GetMeterProvider()`. Because this plan never sets a MeterProvider, that global stays the built-in **no-op** — so those metric calls are silently discarded: no double-counting into Prometheus, no second `/metrics` source, no crash, no measurable overhead. This is safe *by omission*, so make it **explicit and tested**, not incidental:
|
||||
- In each instrumentation call, prefer passing an explicit no-op meter provider option where the library supports it (e.g. `otelhttp.WithMeterProvider(noop.NewMeterProvider())`) so a future accidental `SetMeterProvider` can't silently start double-emitting HTTP/DB metrics.
|
||||
- Add a guard test asserting `otel.GetMeterProvider()` is the no-op after `telemetry.Setup` (i.e. scope B did not wire metrics).
|
||||
- HTTP request metrics continue to come **solely** from the existing `apimw.Metrics` Prometheus middleware (`internal/api/middleware/metrics.go:35`); `otelhttp` contributes **traces only**.
|
||||
- **Metrics remain fully on Prometheus.** Migrating metrics to the OTel metrics SDK is explicitly out of scope and not planned here — the current `client_golang` instrumentation is retained as-is.
|
||||
|
||||
## Blast radius & complexity
|
||||
|
||||
Grounded in actual counts (`grep` over `internal/` + `cmd/`): **173 files** contain `slog.*` calls totalling **1,339 call sites** (39 already on the `…Context` variant → **~1,300 to convert**). Total change spans **~185–190 files** and **~2,500–3,200 diff lines**.
|
||||
|
||||
Complexity is **concentrated, not spread** — line count is a poor proxy for risk here:
|
||||
|
||||
| Phase group | Files | ~LOC | Nature | Risk |
|
||||
|---|---|---|---|---|
|
||||
| **Phase 0–2** — telemetry bootstrap, logs fan-out bridge, tracing providers + propagation | ~10–12 (mostly new `internal/telemetry/*` + `cmd/silo/main.go`) | ~900–1,100 net-new | Genuinely new code | **High** — the level-gated fan-out, provider lifecycle, shutdown flush |
|
||||
| **Phase 3** — call-site sweep | 173 | ~1,300–1,700 modified | Mechanical, codemod-assisted, per-package-green | **Low per file** — a bad conversion fails the build, not runtime; the burden is review volume, not engineering |
|
||||
| **Phase 4–7** — HTTP/pgx/redis/gRPC/client + domain spans | ~12–15 | ~350–500 net-new | Small, localized seams | **Medium** — ~4 seams are subtly easy to get wrong (gRPC broker-vs-real channel, per-session playback span, chi route timing, struct-bound loggers) |
|
||||
| **Phase 8** — shutdown wiring, docs | ~2 | ~170 | Wiring + prose | Low |
|
||||
|
||||
**Takeaway:** ~90% of the *file count* (the 173-file sweep) is the *safe* part; ~90% of the *risk* lives in the few hundred lines of `internal/telemetry` plus ~4 seams. The scary number (1,300 sites) is mechanical churn.
|
||||
|
||||
### Commit strategy
|
||||
|
||||
- **Commit 1 — Phases 0–2** (the observability capability itself): telemetry bootstrap + level-gated logs fan-out + tracing providers/propagation. Small, high-value, reviewable as a unit; delivers OTLP log/trace export end-to-end without touching call sites.
|
||||
- **Commit 2 — Phase 3** (the call-site sweep, in isolation): the ~1,300-site `…Context` + `component` conversion across 173 files, as its own commit so the large mechanical diff never obscures the load-bearing logic in Commit 1. (May itself be split per-package internally, but lands as a distinct changeset from Commit 1.)
|
||||
- **Later commits — Phases 4–8**: seam tracing + domain spans + shutdown/docs, each landing independently after the foundation is in.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add dependencies and the telemetry SDK bootstrap
|
||||
|
||||
**Files:**
|
||||
- Modify: `go.mod`, `go.sum`
|
||||
- Create: `internal/telemetry/config.go`, `internal/telemetry/config_test.go`, `internal/telemetry/telemetry.go`, `internal/telemetry/telemetry_test.go`
|
||||
|
||||
- [ ] **Step 1: Add dependencies.** `go get go.opentelemetry.io/otel go.opentelemetry.io/otel/sdk go.opentelemetry.io/otel/sdk/trace go.opentelemetry.io/otel/sdk/log go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp go.opentelemetry.io/contrib/bridges/otelslog`. Run `go mod tidy`.
|
||||
- [ ] **Step 2: Write failing config tests** (`config_test.go`) covering: disabled by default (no endpoint, no `SILO_OTEL_ENABLED`); enabled when `OTEL_EXPORTER_OTLP_ENDPOINT` set; `service.name` from `OTEL_SERVICE_NAME` overriding a default of `silo-server`; protocol selection (`grpc` default / `http/protobuf`); sampler default (`parentbased_always_on`) and ratio override; invalid numeric sampler arg falls back to default (not a crash).
|
||||
- [ ] **Step 3: Implement `config.go`.** Parse `OTEL_*` (respect the OTel env spec) plus a `SILO_OTEL_ENABLED` convenience gate. Default `service.name=silo-server`, `service.version` from build info, `node.id` from the existing `SILO_NODE_NAME`/nodeID signal. Return a fully-defaulted `Config` with an `Enabled bool`.
|
||||
- [ ] **Step 4: Write failing telemetry test** (`telemetry_test.go`): `Setup` with `Enabled=false` returns a no-op shutdown and installs nothing; `Setup` with a `console`/`none` exporter builds providers and a shutdown that returns nil; shutdown is idempotent.
|
||||
- [ ] **Step 5: Implement `telemetry.go`.** Follow the canonical `setupOTelSDK` shape: build one shared `resource.Resource` (`resource.WithAttributes(semconv.ServiceName(cfg.ServiceName))`, `WithFromEnv`, `WithProcess`, `WithHost`), a composite W3C `TraceContext{}+Baggage{}` propagator, a `TracerProvider` (`trace.WithBatcher`), and a `LoggerProvider` (`log.WithProcessor(log.NewBatchProcessor(...))`). Register `otel.SetTracerProvider`, `otel.SetTextMapPropagator`, `global.SetLoggerProvider`. **Do NOT build or register a MeterProvider** — metrics stay on Prometheus (see "Prometheus / Grafana coexistence"); leaving `otel.GetMeterProvider()` as the built-in no-op is what keeps the trace instrumentation libraries from double-emitting metrics. Accumulate `shutdownFuncs` and join their errors. When `Enabled=false`, return early with a no-op shutdown so the entire feature is dormant unless configured.
|
||||
- [ ] **Step 6: Verify.** `go build ./...`, `go vet ./internal/telemetry/`, `go test ./internal/telemetry/ -race`, `gofmt -l` clean.
|
||||
|
||||
## Task 2: Logs bridge — fan-out otelslog into the handler chain
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/telemetry/loghandler.go`, `internal/telemetry/loghandler_test.go`
|
||||
- Modify: `cmd/silo/main.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests** (`loghandler_test.go`): a fan-out handler forwards a record to both a capturing stderr-substitute handler and an otelslog handler backed by an in-memory `LoggerProvider`; an OTel-branch `Handle` error is swallowed and does **not** prevent the console branch from receiving the record (mirrors the `logsink` best-effort contract that keeps `opslog`'s DB path alive — see `internal/opslog/handler.go` "inner error skips capture" behavior).
|
||||
- [ ] **Step 2: Implement `loghandler.go`.** Provide `NewOTelHandler(lp) slog.Handler` = `otelslog.NewHandler("silo-server", otelslog.WithLoggerProvider(lp))` wrapped so its `Handle`/`WithAttrs`/`WithGroup` never propagate export errors. Provide `FanOut(console, otel slog.Handler) slog.Handler` returning `slog.NewMultiHandler(console, otelBestEffort)` (stdlib, Go 1.26).
|
||||
- [ ] **Step 2a (REQUIRED — level gating): gate the OTel branch by the shared `LevelVar`.** `slog.MultiHandler.Enabled` returns true if **any** child is enabled. The stderr child is gated by the shared `*slog.LevelVar` (`cmd/silo/main.go:542-543`), but otelslog's `Enabled` follows the (unfiltered-by-default) `LoggerProvider`. Left unfixed, the logger would evaluate `Enabled==true` for `Debug` at `LogLevel=info` — constructing + dispatching Debug records it previously skipped across ~1,200 sites (per-call allocation, violates "Performance first") **and** exporting Debug to OTLP while stderr stays silent (a confusing, silent divergence). Wrap `NewOTelHandler` in a level-gate bound to the same `logLevelVar` (or an equivalent `otelslog` level-bridge option) so console and OTLP share exactly one verbosity knob. Add a test asserting a Debug record is dropped by both branches when the shared level is `info`.
|
||||
- [ ] **Step 3: Wire into `buildBaseHandler`** (defined `cmd/silo/main.go:132-138`, **called at `cmd/silo/main.go:543`** inside `run()` after the pool is up). When telemetry is enabled, return `FanOut(stderrHandler, levelGated(NewOTelHandler(lp)))` instead of the bare stderr handler; otherwise return today's stderr handler unchanged. The `logfilter` (`:544`) → `opslog` (`:209`, installed `:567`) wrapping above stays exactly as-is, so quiet-filtering and DB capture are preserved and OTel mirrors the console stream (document this mirror behavior, matching the prior file-sink decision). Ordering is safe: `telemetry.Setup` depends only on `OTEL_*` env (not the DB), so call it earlier in `run()` and thread the `LoggerProvider` into `buildBaseHandler` — the provider is available well before `:543`.
|
||||
- [ ] **Step 3a (coverage caveat): early-boot logs are not exported.** Many `slog` calls fire before the fan-out is installed at `:543` — DB connect (`:389`), migrations (`:423`,`:441`), secret backfill (`:287-305`), auto-tuning (`:223-272`), and everything inside `database.NewPool`. These reach only Go's default stderr handler today (not even opslog), so OTel not capturing them is **not** a regression — but it means "logs export end-to-end" excludes boot. Either document this gap in the observability doc, or (if boot logs matter to operators) move `telemetry.Setup` + a minimal OTel-only tee to the very top of `run()`.
|
||||
- [ ] **Step 4: Verify** logs actually export. Bring up a local collector (`otel/opentelemetry-collector` with a `debug`/`logging` exporter, documented in Task 10) or use the `console` log exporter; run the server, confirm `slog.Info` records appear on both stderr and the OTLP/console sink. Run `go build ./...`, `go test ./cmd/silo/ ./internal/telemetry/`.
|
||||
|
||||
## Task 3: Full call-site sweep — `…Context` variants + `component` attrs
|
||||
|
||||
This is the largest task. Batch per-package; each batch is its own commit and must keep the build green. The goal state: every `slog.Info/Warn/Error/Debug` becomes the `…Context(ctx, …)` variant wherever a `ctx` is in scope, and the `"subsystem: message"` prefix convention is replaced by an explicit `slog.String("component", "<subsystem>")` attr. `opslog.InferComponent` stays as the fallback for any residual prefix-only calls (backward compatible — do **not** remove it).
|
||||
|
||||
- [ ] **Step 1: Establish the pattern + guardrail.** In a heavily-used package (start with `internal/scanner`), convert calls to `slog.InfoContext(ctx, "msg", slog.String("component", "scanner"), …)`. Confirm `opslog` still classifies correctly (component attr wins over inference — `internal/opslog/handler.go:51-54`). Prototype the `sloglint` rule locally to sanity-check the target form, but do **not** enable it repo-wide yet — it is turned on at `error` severity only as the closing step of the sweep (Task 9), since enabling it before all ~1,300 sites convert turns CI red.
|
||||
- [ ] **Step 2: Codemod the mechanical conversions.** Use an AST-based rewrite (`gofmt -r` is insufficient for adding a `ctx` arg) — a small `golang.org/x/tools/go/analysis` or `astutil` script that, for each `slog.Info(...)` call inside a function with a `context.Context` in scope, rewrites to `slog.InfoContext(ctx, ...)`. Calls with no reachable `ctx` are left on the non-context variant and flagged for manual review (background init, `main`, top-level goroutines). Review every rewrite — do not blind-commit the codemod.
|
||||
- [ ] **Step 3: Convert per-package, hottest first**, one commit each, `go build`/`go test` between: `internal/api/handlers/libraries.go` (83), `internal/metadata/service.go` (70), `internal/api/handlers/playback.go` (60), `internal/metadata/worker.go` (45), `internal/recommendations/worker.go` (43), `internal/scanner/scanner.go` (40), `internal/adminjob/runner.go` (37), `internal/api/handlers/plugins.go` (35), then the long tail. For struct-bound `.With("component", …)` loggers (`internal/notifications/*`, `internal/playback/ffmpeg_log_sink.go`, `request_logger.go`, `taskmanager.New(logger *slog.Logger)` at `internal/taskmanager/manager.go:25`), keep the bound `component` and migrate emit calls to the context variants. **Caveat (corrects the "no structural re-plumbing" claim in Validated Findings):** many such structs log from methods that do **not** currently take a `ctx`. For those, either thread a `ctx` parameter through the method signatures (structural change — budget for it) or, where a request/operation ctx genuinely isn't reachable, leave the call on the non-context variant (still captured via fan-out, just without `trace_id`). Span-context propagation is free *only where a `ctx` already flows*; the struct-bound method surfaces are the exception, not the rule.
|
||||
- [ ] **Step 4: Normalize component names.** Produce a canonical component list (jellycompat, playback, api, scanner, metadata, notifications, catalog, auth, recommendations, adminjob, taskmanager, …) and apply it consistently so trace/log/DB classification agree. Document the list in the package doc.
|
||||
- [ ] **Step 5: Verify.** `go build ./...`, `go vet ./...`, full `go test ./...`, `gofmt -l` clean, the new sloglint guard passes repo-wide. Spot-check that `opslog` entries still populate `component` after the sweep.
|
||||
|
||||
## Task 4: Tracing — HTTP server middleware + request-logger correlation
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/router.go`, `internal/api/middleware/request_logger.go`, `internal/jellycompat/router.go`, `internal/transcodenode/server.go`
|
||||
|
||||
- [ ] **Step 1: Add deps.** `go get go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp`; `go mod tidy`.
|
||||
- [ ] **Step 2: Instrument the main router.** Insert `otelhttp` server handling into the chi chain right after `middleware.RequestID` (`internal/api/router.go:213`) and before `apimw.RequestLogger`, so a server span exists for the whole handler and `request_logger` can read it. **Route-tag timing:** chi resolves the route *after* the middleware stack runs, so `http.route` cannot be set at middleware entry — set it post-routing from `chi.RouteContext(r).RoutePattern()`, and commit to `sanitizePath` (`metrics.go:79-89`) on the raw path as the fallback when no pattern is available. Exclude `/health`/`/ready` like the logger already does (`request_logger.go:22`). Note `activitylog.NewMiddleware` (`internal/api/router.go:230`) is also request-scoped and may want the span/`trace_id` — evaluate wiring it in the same pass.
|
||||
- [ ] **Step 3: Correlate logs.** In `request_logger.go:53-70`, pull the active span from `r.Context()` and add `slog.String("trace_id", …)`/`slog.String("span_id", …)` to the emitted attrs. (Once Task 3 lands and handlers log with `ctx`, the otelslog bridge attaches these automatically to every downstream record too — this step guarantees the access-log line itself carries them.)
|
||||
- [ ] **Step 4: Instrument jellycompat + transcode-node servers** the same way (`internal/jellycompat/router.go:30`, `internal/transcodenode/server.go`), ensuring incoming W3C `traceparent` headers continue the trace across processes.
|
||||
- [ ] **Step 5: Verify.** Drive a request through a local collector; confirm one server span per request with `http.route`, `trace_id` present in the access log, and a continued trace when hitting a transcode-node endpoint.
|
||||
|
||||
## Task 5: Tracing — outbound HTTP clients (shared traced transport)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/telemetry/httpclient.go`
|
||||
- Modify: `internal/nodepool/health.go`, metadata/subtitle provider packages, `internal/plugins` HTTP proxy
|
||||
|
||||
- [ ] **Step 1: Shared factory.** Implement `telemetry.NewHTTPTransport(base http.RoundTripper) http.RoundTripper` = `otelhttp.NewTransport(base)` with sane span-name/route options. This satisfies CLAUDE.md's "avoid duplicate logic" — one wrapper, injected everywhere, not per-package patches.
|
||||
- [ ] **Step 2: Inject** the traced transport into the ad-hoc `http.Client` literals: `internal/nodepool/health.go:22-26` and other nodepool clients, `internal/metadata/tmdb`, `internal/metadata/trakt`, `internal/metadata/translation`, `internal/mdblist`, `internal/subtitles/{opensubtitles,subdl,subsource}`, and `internal/plugins.HTTPProxy` (`router.go:149`). Prefer constructing the transport once in `cmd/silo/main.go` and passing it down over editing each package's client construction independently where a seam exists.
|
||||
- [ ] **Step 3: Verify.** A play/scan flow that calls a metadata provider and a transcode node shows child client spans nested under the originating server span, with propagated `traceparent`.
|
||||
|
||||
## Task 6: Tracing — pgx pool + Redis
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/database/postgres.go`, `internal/cache/redis.go`, `go.mod`
|
||||
|
||||
- [ ] **Step 1: pgx tracer.** `go get github.com/exaring/otelpgx`; set `poolCfg.ConnConfig.Tracer = otelpgx.NewTracer(...)` on the config returned by `pgxpool.ParseConfig` (`internal/database/postgres.go:14`), guarded on telemetry enabled. Zero call-site changes — instruments every query. Use `otelpgx` options to avoid logging full SQL args if that is a concern.
|
||||
- [ ] **Step 2: Redis hook.** `go get github.com/redis/go-redis/extra/redisotel/v9`; call `redisotel.InstrumentTracing(client)` after **both** `redis.NewClient` constructions (`internal/cache/redis.go:145-254`).
|
||||
- [ ] **Step 3: Verify.** A request that hits Postgres and Redis shows DB and cache child spans under the server span. `go build ./...`, `go test ./internal/database/ ./internal/cache/`.
|
||||
|
||||
## Task 7: Tracing — host-side plugin gRPC propagation
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/pluginhost/host.go`, plugin gRPC client dial sites, `go.mod`
|
||||
|
||||
- [ ] **Step 1: Add dep.** `go get go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc`.
|
||||
- [ ] **Step 2: Find the real invocation channel FIRST.** `grpc.NewServer(opts...)` at `internal/pluginhost/host.go:336` sits inside `broker.AcceptAndServe(...)` — a hashicorp/go-plugin **brokered sub-stream** (host services exposed back to the plugin), *not* the primary host→plugin RPC channel (go-plugin stands that up internally; silo does not construct it at this line). Instrumenting only `:336` would capture the reverse/broker channel and **miss primary plugin invocations**. Identify where the primary plugin client connection is created/dialed (the load-bearing path) before instrumenting.
|
||||
- [ ] **Step 3: Client side (load-bearing).** Add `grpc.WithStatsHandler(otelgrpc.NewClientHandler())` to the host→plugin dial that carries actual invocations (dial pattern per `internal/pluginhost/client_test.go:18`). This is the half that must cover the real call path.
|
||||
- [ ] **Step 4: Server side.** Add `grpc.StatsHandler(otelgrpc.NewServerHandler())` to `grpc.NewServer(...)` at `:336` for the brokered channel — useful but secondary.
|
||||
- [ ] **Step 5: Note the boundary.** The plugin-subprocess side that receives `traceparent` is owned by `silo-plugin-sdk` — record a coordination follow-up; do **not** attempt SDK-side changes here.
|
||||
- [ ] **Step 6: Verify against a real plugin call.** Confirm an actual plugin invocation (not just a broker sub-stream) produces a gRPC client span; full end-to-end continuation into the plugin process is deferred to the SDK follow-up.
|
||||
|
||||
## Task 8: Domain spans — scanner, playback, taskmanager
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/scanner/scanner.go`, `internal/playback/session.go`, `internal/taskmanager/manager.go`
|
||||
|
||||
- [ ] **Step 1: Scanner.** Start a span per scan run in `applyScopedScan` (`internal/scanner/scanner.go:1287`) and, if not too chatty, a child per file. Attach library/scope attrs.
|
||||
- [ ] **Step 2: Playback.** Do **not** span `NewSessionManager` (`internal/playback/session.go:152`) — that is the singleton manager constructor, called once at startup; a span there covers the whole process lifetime and cannot carry per-session attrs. Instead attach the span at the actual per-session create/start path (where a `*Session` is added to the `sessions` map), spanning start→stop with `play_method`/session-id attrs, so it links to the transcode-node client spans (Task 5) for end-to-end session traces.
|
||||
- [ ] **Step 3: Taskmanager.** Wrap `RunTask` (`internal/taskmanager/manager.go:231`) in a per-run span; these background goroutines start new root traces (no incoming context), so create a fresh root span with baggage identifying the task.
|
||||
- [ ] **Step 4: Verify.** A scan and a playback session each produce a coherent trace tree; background task runs appear as their own traces.
|
||||
|
||||
## Task 9: Durable enforcement — keep future code (human + AI) on the standard
|
||||
|
||||
The sweep is worthless if new code drifts back to context-less `slog.Info(...)`. Lock the standard in machine-enforced form so it holds without relying on reviewer vigilance. **Sequencing: lands together with the Phase-3 sweep in the same commit, after the legacy sites are converted — enabling it earlier turns CI red repo-wide.**
|
||||
|
||||
**Files:**
|
||||
- Modify: `.golangci.yml`
|
||||
- Reference: `docs/architecture/observability.md` (from Task 10)
|
||||
|
||||
- [x] **Step 1: Machine gate (the real guarantee) — add `sloglint` to `.golangci.yml`.** Added to `linters.enable` with all four rules configured: `context: "scope"`, `static-msg: true`, `key-naming-case: snake`, `no-mixed-args: true`. Since `make lint` runs `golangci-lint run` (`Makefile:54`), this blocks any PR — human- or AI-authored — that regresses the call form. Verified via standalone `sloglint`: after the sweep all four rules report **0 violations** repo-wide (production and test), so no `_test.go` exclusion was needed. (`key-naming-case` and `no-mixed-args` were already clean pre-sweep; `static-msg` had 6 sites, fixed in the sweep.)
|
||||
- [x] **Step 2: Document the honest gap.** Recorded in `docs/architecture/observability.md`: `sloglint` enforces the *shape* (`…Context`, snake keys, static msg) but **cannot** enforce that a `component` attr is present. That convention rests on the observability doc, the canonical component list, and review.
|
||||
- **Note (CLAUDE.md subsection dropped):** per maintainer direction, no Logging subsection is added to `CLAUDE.md`. The post-sweep codebase already models the idiom everywhere and the `sloglint` gate enforces it, so the standard holds without a CLAUDE.md entry; `docs/architecture/observability.md` carries the human/LLM-facing rationale and canonical list.
|
||||
|
||||
## Task 10: Shutdown wiring, docs, and final verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `cmd/silo/main.go`
|
||||
- Create: `docs/architecture/observability.md`
|
||||
|
||||
- [ ] **Step 1: Graceful shutdown.** `defer shutdown(ctx)` from `telemetry.Setup` in `run()`, integrated with the existing signal handler, using a **generous** timeout so batch processors flush (buffered spans/logs are lost otherwise). Ensure it runs before process exit on every path.
|
||||
- [ ] **Step 2: Docs.** Write `docs/architecture/observability.md`: the `OTEL_*`/`SILO_OTEL_ENABLED` surface, default-off behavior, a local `docker compose` collector example (OTLP in → debug/Loki/Tempo out), the canonical component list, and the documented non-goals/risks (metrics stay Prometheus; logs unredacted at the OTLP sink; quiet-filter also suppresses OTel logs; plugin-SDK side is a follow-up).
|
||||
- [ ] **Step 3: Full verification.** `go build ./...`; `go vet ./...`; `go test ./... -race` (at least the touched packages under `-race`); `gofmt -l` clean; `cd web && pnpm run lint && pnpm run format:check` (no-op for this backend change but part of the MR gate); `make verify-local-paths`. Manual: default-off start (no OTel env) behaves exactly as today; enabled start exports logs+traces to a local collector; a single request produces correlated log `trace_id` + a server→DB→cache→client span tree. **Metrics coexistence:** with OTel enabled, confirm `/metrics` still serves the full existing Prometheus set (HTTP + domain metrics), Grafana scraping is unaffected, and the guard test confirms `otel.GetMeterProvider()` is the no-op (no OTel metrics rail wired).
|
||||
|
||||
---
|
||||
|
||||
## Risk / follow-ups
|
||||
|
||||
- **Level-gating is load-bearing (see Task 2 Step 2a).** `slog.MultiHandler.Enabled` is an OR across children; without gating the OTel branch by the shared `LevelVar`, Debug records get allocated + OTLP-exported at `level=info`. Treated as a required step, not optional.
|
||||
- **Early-boot logs (pre-`:543`) are not exported** — matches existing opslog behavior, documented not fixed (Task 2 Step 3a).
|
||||
- **Playback span must attach per-session, not at the manager constructor**, and **plugin gRPC instrumentation must target the primary invocation channel, not just the broker sub-stream** — both are easy to get wrong (Tasks 8/7).
|
||||
- **Call-site sweep is a large, review-heavy diff.** Mitigated by per-package batching, an AST codemod with mandatory human review, and a `sloglint` guardrail. Background/init calls with no `ctx` legitimately stay on the non-context variants.
|
||||
- **Quiet-filter also suppresses OTel logs** (the fan-out sits at the base, below the filter). This mirrors stderr exactly — intended, but if durable-capture-despite-quiet is wanted, the OTel branch must tee in above `logfilter`. Deferred, product call.
|
||||
- **Redaction is key-based** (handled via `internal/logredact` on all sinks): secrets under a recognized key are masked, but a secret in a free-text message or under an unrecognized key is not caught.
|
||||
- **Metrics remain on Prometheus** by explicit non-goal; dual observability backends until a future OTel-metrics plan.
|
||||
- **Plugin-subprocess trace continuation** needs a coordinated `silo-plugin-sdk` change; host-side only here.
|
||||
- **Sampler config in multi-node deploys** must use `parentbased_traceidratio` (not bare `traceidratio`) so transcode/proxy nodes respect the parent decision — documented in Task 10.
|
||||
- **Batch-processor flush on shutdown** is critical; verify no lost telemetry on redeploy/signal paths.
|
||||
|
||||
## Verification summary (gate before MR)
|
||||
|
||||
- `go build ./...`, `go vet ./...`, `go test ./... -race` (touched packages), `gofmt -l` clean.
|
||||
- `cd web && pnpm run lint && pnpm run format:check`; `make verify-local-paths`.
|
||||
- Manual: default-off parity; enabled logs+traces to a local collector; correlated `trace_id` in logs; end-to-end span tree across HTTP → pgx → redis → outbound client → gRPC.
|
||||
@@ -0,0 +1,324 @@
|
||||
# Section-fetch & jellycompat performance plan
|
||||
|
||||
Status: in progress. Commands assume the repository root is the cwd.
|
||||
|
||||
> **Log provenance / staleness check.** The production logs analyzed here were
|
||||
> captured against commit `b1b3a9b4` (2026-07-01). Current `origin/main` is
|
||||
> `3d82aa59` (2026-07-02), 15 commits ahead. Only two of those commits touch the
|
||||
> hot files, and both are unrelated (`3d8a63de` metadata-language upsert
|
||||
> precedence; `d6c4dce1` playback-session conflict error) — verified they do not
|
||||
> change `ListBySeries`, `loadProgressPage`, the next-up query, the browse
|
||||
> fast-path, or the detail-enrich path. So the diagnoses below still apply to
|
||||
> current `main`; nothing since the logs has fixed these.
|
||||
|
||||
## Revised implementation scope (2026-07-03, after code verification)
|
||||
|
||||
After reading the current code, several original fixes were re-scoped or
|
||||
deferred. This section is authoritative; the "Proposed fixes" section further
|
||||
down is the original analysis and is kept for context.
|
||||
|
||||
**Commit 1 (safe subset):**
|
||||
- **#2 Resume scan cap — IMPLEMENTED.** `loadProgressPage` now bounds its scan to
|
||||
`resumeScanMaxRows = 300` so `EnableTotalRecordCount=true` can't drive the
|
||||
O(history) scan (`internal/jellycompat/handlers_items.go`). Builds clean.
|
||||
Caveat: the specific logged 35.9s Resume call actually went through the capped
|
||||
`FetchOne` fast path (the deferred #1 cold recompute), so this removes a latent
|
||||
risk rather than that exact spike.
|
||||
- **#9 drop redundant enrich — RE-SCOPED (do not remove wholesale).**
|
||||
`enrichDetailUserData` (`content_direct.go:612-626`) is redundant only for
|
||||
**movie/episode leaf** items (all `batchListItemDetails` callers re-apply
|
||||
batched `progress[...]` which overrides it). For **series** it builds the
|
||||
episode-**rollup** `UserData` (Played/UnplayedItemCount) that no progress row
|
||||
covers — removing it would strip series watch-state from `/Items`/`/Items/Latest`
|
||||
(which can return Series). Correct fix: skip enrich for leaf types only, keep it
|
||||
for series (or batch the leaf-progress lookup so output is identical).
|
||||
- **#4 concurrency — RE-SCOPED (pool-constrained).** `pgxpool` MaxConns defaults
|
||||
to **20** (`internal/config/db_loader.go:162`); live usage already ~25 across the
|
||||
DB. Raising `fetchAllMaxConcurrency` 4→8 doubles per-request pool pressure, so
|
||||
~2–3 concurrent home requests would saturate the pool and could regress under
|
||||
load. Only bump conservatively (e.g. 4→6) or after a load test; not a blind
|
||||
constant change.
|
||||
|
||||
**Deferred to their own PRs (heavier than "light"):**
|
||||
- **#1 continue-watching latency.** There is no existing result cache to "warm";
|
||||
the Resume/CW path recomputes via `FetchOne` every call. The hourly `:12` spike
|
||||
lines up with the `collection sync scheduler` / hourly session-cleanup jobs, so
|
||||
the root cause may be periodic DB contention, not a cache miss. Needs its own
|
||||
investigation (contention vs. adding a short-TTL result cache with progress-write
|
||||
invalidation) — carries user-visible staleness risk.
|
||||
- **#3 `/Shows/{id}/Episodes`.** A `LIMIT` on `ListBySeries` truncates a series;
|
||||
the real fix is client-visible pagination requiring Android/Apple coordination.
|
||||
- **#5 dedup + SQL-side filtering.** The dedup needs cross-section memoization that
|
||||
`FetchAll` does not currently have; the SQL-filter half is a `userstore`
|
||||
interface refactor. Both are structural, not light.
|
||||
|
||||
**Dropped:**
|
||||
- **#6 content_id NextUp rewrite.** ~28ms off an already ~91ms warm query,
|
||||
invisible once cached, highest correctness risk (`::int`/lexicographic trap).
|
||||
Not worth it.
|
||||
|
||||
**Commit 2 (separate) — IMPLEMENTED:**
|
||||
- **#8 browse fast-path.** When a cross-library `recently_added` browse also has
|
||||
an `isPlayed` filter, the over-fetch loop no longer advances `filters.Offset`
|
||||
into `BrowsePage`'s whole-catalog GROUP BY on the 2nd chunk. Instead it pulls
|
||||
the entire over-fetch budget (`maxScannedRows`) in a single
|
||||
`BrowseRecentlyAddedAcrossLibraries` merged index walk
|
||||
(`internal/jellycompat/content_direct.go`), so `/Items/Latest` stays on the
|
||||
~1ms/library fast path. Expected ~0.8–1.6s → ~50–150ms for heavy watchers with
|
||||
2+ libraries.
|
||||
|
||||
## Problem (what operators/users saw)
|
||||
|
||||
Production `silo` logs (24h window) show recurring multi-second stalls on the
|
||||
home screen and continue-watching rails, on both native clients and Jellyfin
|
||||
compat clients.
|
||||
|
||||
- **Continue Watching is the worst offender.** The native `slow section fetch`
|
||||
warning fired 132× in 24h (126 of them `type=continue_watching`), with the
|
||||
aggregate home fetch (`slow aggregate section fetch`) firing 17× at
|
||||
`section_count=32`. Distribution of `slow section fetch`: p50 ≈ 1.1s,
|
||||
p90 ≈ 3.8s, worst 35.8s.
|
||||
- **A very regular hourly spike.** `section_id=compat-resume` reliably jumps to
|
||||
~3.5s clustered at **:12–:14 past every hour**, then returns to fast. This is
|
||||
the fingerprint of an ~1h cache TTL expiring followed by a cold recompute that
|
||||
a live request pays for.
|
||||
- **Jellycompat resume/next-up/episodes are slow under field expansion.**
|
||||
Excluding long-lived websocket/stream/transcode connections (expected to be
|
||||
long), the slowest query-backed endpoints were:
|
||||
- the Resume endpoints (`UserItems/Resume` + the per-user
|
||||
`Users/{id}/Items/Resume` form) — 275 calls >500ms, worst **35.9s** (a VidHub
|
||||
client requesting `Limit=20` with total-record-count).
|
||||
- `Shows/NextUp` — 399 >500ms, 139 >1000ms.
|
||||
- `Shows/{id}/Episodes` — 58 >500ms, worst **10.1s** (long series, all slow
|
||||
calls requested `MediaSources`/`MediaStreams` expansion).
|
||||
- the native home-sections aggregate (`api/v1/home` sections) — worst 5.4s.
|
||||
- `/api/v1/recommendations/taste-seed/items` — 55 >1000ms, worst 5.6s.
|
||||
|
||||
## What is NOT the problem
|
||||
|
||||
The base SQL is fast. `EXPLAIN ANALYZE` of the continue-watching base fetch for
|
||||
the single heaviest user on the box (`user_id=627`, 11,459 in-progress rows)
|
||||
returns in **~1ms** on the existing partial indexes
|
||||
(`idx_uwp_profile_in_progress`, `idx_uwp_profile_completed`). The multi-second
|
||||
cost is application-side: over-scanning, per-section serialization, cold-cache
|
||||
recompute, and one avoidable large join in the NextUp query.
|
||||
|
||||
## Evidence: the NextUp query cost breakdown
|
||||
|
||||
`EXPLAIN (ANALYZE, BUFFERS)` of the real `buildListNextUpQuery`
|
||||
(`internal/catalog/nextup_repo.go:112`) for `user_id=627` executed in **91ms
|
||||
warm**, and the cost is dominated by one thing:
|
||||
|
||||
```text
|
||||
completed_episodes CTE:
|
||||
Nested Loop (actual 60.8ms, 56,884 buffers)
|
||||
-> Index Scan idx_user_watch_progress_profile (14,512 rows, 847 buffers)
|
||||
-> Index Scan episodes_pkey (loops=14,512, 56,037 buffers) <-- dominant
|
||||
LATERAL next-episode lookup: fast (~9k buffers total across 137 series)
|
||||
Execution Time: 91.163 ms (warm; cold/disk-bound is the 3.5s production case)
|
||||
```
|
||||
|
||||
The `completed_episodes` CTE does
|
||||
`JOIN episodes e ON e.content_id = uwp.media_item_id` and probes
|
||||
`episodes_pkey` **once per completed-progress row (14,512×)** purely to read
|
||||
`series_id`, `season_number`, and `episode_number`. That single join is ~60ms of
|
||||
91ms warm and is the largest driver of the cold-cache blowup.
|
||||
|
||||
## The content_id insight (validated)
|
||||
|
||||
`content_id` is a **deterministic, structured natural key**, not a random DB id
|
||||
(`internal/contentid/contentid.go`, migration
|
||||
`migrations/sql/20260612130000_deterministic_content_id.sql`). An episode's id
|
||||
embeds its series anchor, season, and episode:
|
||||
|
||||
```text
|
||||
episode-tvdb-296762-1-5 -> series = series-tvdb-296762, season = 1, episode = 5
|
||||
```
|
||||
|
||||
This is a **documented, frozen, load-bearing invariant** (`contentid.go:29-32`:
|
||||
"the watch-history query relies on this to resolve a show without an episodes
|
||||
table lookup. Never break it."). It is exposed as `SeriesIDFromContentID`
|
||||
(`contentid.go:256`).
|
||||
|
||||
Measured coverage on this DB:
|
||||
- 2,110,532 of 2,111,778 episode rows (**99.94%**) are provider-anchored and
|
||||
derivable; the derived series id matches `series_id` for **100%** of them.
|
||||
- Only 1,246 rows / 134 series are legacy/`local-` ids with no embedded anchor.
|
||||
- Of in-progress `user_watch_progress` rows, 86k are `episode-` (derivable) and
|
||||
44k are movies/books/other (not episodes at all).
|
||||
|
||||
Crucially, **there is already a proven in-repo SQL pattern** for exactly this,
|
||||
used by the watch-history source to avoid the episodes join
|
||||
(`internal/catalog/history_source.go:218-282`):
|
||||
- `seriesFromAnchoredEpisodeExpr` — `'series-' || split_part(id,'-',2) || '-' || split_part(id,'-',3)`.
|
||||
- `anchoredEpisodePredicate` — requires 5 non-empty `-` components before
|
||||
treating an id as anchored.
|
||||
- Null-poisons the episodes join key for anchored ids
|
||||
(`CASE WHEN <anchored> THEN NULL ELSE media_item_id END`) so the planner skips
|
||||
the `episodes_pkey` probe, and only legacy/local ids `LEFT JOIN episodes`.
|
||||
|
||||
Season/episode numbers are also parseable from the id (last two `-` segments),
|
||||
so the CTE can obtain **all three** values it needs (`series_id`,
|
||||
`season_number`, `episode_number`) without touching `episodes` for the 99.94%
|
||||
anchored majority — while COALESCE-ing to the existing join for the legacy tail.
|
||||
|
||||
This makes the NextUp optimization **low-risk and well-precedented**, not novel.
|
||||
|
||||
## Proposed fixes (ranked by expected impact)
|
||||
|
||||
The ranking below was re-ordered after an adversarial review. The production
|
||||
pain is cold-cache spikes hitting live requests, so **caching is the highest-
|
||||
impact lever**, not the query rewrite. The content_id rewrite is a real warm/cold
|
||||
I/O trim but is demoted and gated on a correctness fix (see #6).
|
||||
|
||||
### Tier 1 — highest impact, addresses the actual production symptom
|
||||
|
||||
1. **Warm / lengthen the continue-watching cache** so the hourly `:12` cold
|
||||
recompute never lands on a live request. Options: background refresh before
|
||||
TTL expiry, or a longer TTL with async invalidation on progress write. The
|
||||
evidence (base query ~1–91ms, prod 3.5s clustered at `:12–:14` hourly) says
|
||||
this single fix most likely kills the headline spike on its own.
|
||||
|
||||
2. **Cap the Resume full-history scan.** When `EnableTotalRecordCount=true`,
|
||||
`loadProgressPage` (`handlers_items.go:2446-2483`) keeps paging the user's
|
||||
entire in-progress history to compute a total — the documented 35.9s path.
|
||||
Stop scanning to the end: cap scan depth (e.g. reuse
|
||||
`continueProgressMaxScanned`) and return an approximate/clamped total, or omit
|
||||
the exact total for oversized histories.
|
||||
|
||||
3. **Bound `/Shows/{id}/Episodes`.** `ListBySeries`
|
||||
(`internal/catalog/episode_repo.go:684`) returns the whole series with no
|
||||
`LIMIT`; paginate it (the worst 10.1s case). Also fix the `episodeRepo == nil`
|
||||
fallback that fans out one `ListEpisodes` per season — a genuine N+1
|
||||
(`internal/jellycompat/handlers_items.go:2673`).
|
||||
- NOTE (review correction): do **not** bother adding a `LIMIT` to
|
||||
`listResumableFirstEpisodes` — its input already comes from
|
||||
`ListProgress(..., 100, 0)` (`nextup_repo.go:290`), so `ANY($3)` is already
|
||||
≤100 ids. That would be a cosmetic no-op.
|
||||
|
||||
### Tier 2 — cheap serialization/dedup wins
|
||||
|
||||
4. **Raise/tune `fetchAllMaxConcurrency`** (currently `4`,
|
||||
`internal/sections/fetcher.go:51`). With 32 home sections this serializes into
|
||||
~8 waves; total ≈ `ceil(N/4) × slowest-section`. Likely a bigger aggregate-
|
||||
latency lever than the NextUp micro-optimization and cheaper. **Measure
|
||||
`pgxpool` max conns first** — raising concurrency while cold queries are slow
|
||||
amplifies pool pressure.
|
||||
|
||||
5. **Avoid double NextUp work in combined mode.** `fetchContinueWatchingSection`
|
||||
calls `FetchNextUpItems` inline when `next_up_mode="combined"`
|
||||
(`fetcher.go:436`) while the `next_up` section computes it again — compute once
|
||||
and share. Also **push dismissal/access filtering into SQL** for
|
||||
continue-watching so it stops scanning up to 1000 rows across 10 sequential
|
||||
`ListProgress` round-trips to fill a 20-item section (`fetcher.go:498-522`).
|
||||
|
||||
### Tier 3 — the NextUp content_id rewrite (warm/cold I/O trim, NOT the spike fix)
|
||||
|
||||
6. **Derive series/season/episode from `content_id` in the NextUp CTE instead of
|
||||
joining `episodes`.** Confirmed effect: the `completed_episodes` CTE for user
|
||||
627 drops from **67.5ms / 56,893 buffers** to **38.8ms / 13,362 buffers** —
|
||||
the 56k `episodes_pkey` probes vanish. But this does NOT touch the LATERAL
|
||||
(episodes + media_files), which is the legitimately irreducible part and
|
||||
becomes the new dominant term, and it does NOT address the cold-cache spike
|
||||
that Tier 1 #1 fixes. Keep it, but as an optimization, not the headline.
|
||||
|
||||
**Mandatory correctness requirements (do not skip):**
|
||||
- **`::int` casts are required.** `split_part` returns TEXT. The LATERAL
|
||||
compares against integer columns `(e2.season_number, e2.episode_number)`, and
|
||||
the `DISTINCT ON` orders by `season_number DESC, episode_number DESC`. Mixed
|
||||
`(text,text) > (int,int)` raises `operator does not exist: text > integer`;
|
||||
forcing both sides to text makes `('1','2') > ('1','10')` return TRUE
|
||||
(lexicographic), so NextUp would surface the wrong episode. Cast every derived
|
||||
season/episode value to `int`.
|
||||
- **This is NOT fully precedented.** `history_source.go:275-282` derives only
|
||||
the series-id *string* (never used numerically). The numeric season/episode
|
||||
derivation is new and is exactly where the lexicographic landmine lives —
|
||||
copying `history_source` verbatim gives the series expr but not the casts.
|
||||
- **Fallback for the legacy tail is mandatory.** COALESCE to the episodes join
|
||||
for the 0.06% legacy/local (`local-`/Sonyflake) ids using the same
|
||||
`anchoredEpisodePredicate` (5 non-empty `-` components) as `history_source.go`.
|
||||
- Data validated safe for anchored ids: across all 2,110,532 anchored episode
|
||||
rows, derived season/episode match the table columns with **0 mismatches**
|
||||
(including season-0/specials); all segments numeric.
|
||||
|
||||
### Tier 2 — high-frequency jellycompat browse (`/Items/Latest`, `/Items`)
|
||||
|
||||
These are very high traffic — 10,837 `/Items/Latest` + 6,030 `/Items` calls in
|
||||
24h — so even sub-second slowness is a large aggregate load. 333 `/Items/Latest`
|
||||
calls ran >800ms (p50 1.1s, max 1.6s); every slow one requested full `Fields`
|
||||
(`MediaSources`+`MediaStreams`) with `isPlayed=false&groupItems=true`.
|
||||
|
||||
Root cause is **not** the detail expansion. It is `isPlayed=false` (a per-profile
|
||||
overlay that can't be pushed into SQL) forcing `BrowseItems` into an over-fetch
|
||||
loop that advances `filters.Offset` (`internal/jellycompat/content_direct.go:477`).
|
||||
The cross-library recently-added **fast path is gated on `Offset == 0`**
|
||||
(`content_direct.go:430`); as soon as a heavy watcher needs a 2nd chunk it falls
|
||||
through to the generic `BrowsePage` (`content_direct.go:435`), whose multi-library
|
||||
plan is a whole-catalog `GROUP BY` HashAggregate + top-N heapsort over ~147k
|
||||
movies (`internal/catalog/browse.go:544-548`).
|
||||
|
||||
`EXPLAIN (ANALYZE, BUFFERS)` of the 2nd-chunk shape (`LIMIT 150 OFFSET 150`,
|
||||
`type=movie`, full projection): **755ms per call** (HashAggregate over 147,212
|
||||
rows, top-N heapsort). The offset-0 fast path
|
||||
(`BrowseRecentlyAddedAcrossLibraries`, index walk on
|
||||
`idx_item_libraries_folder_seen_content`) is ~1ms/library. So one fall-through =
|
||||
0.8–1.6s; two (very heavy watchers) reach the top of the range. Only affects
|
||||
users with 2+ libraries and lots of movie watch history — consistent with "some
|
||||
calls slow."
|
||||
|
||||
8. **Keep the `isPlayed` over-fetch loop on the fast path.** Track a separate
|
||||
chunk offset and re-call `BrowseRecentlyAddedAcrossLibraries` for each chunk
|
||||
(growing top-N bound) instead of advancing `filters.Offset` into `BrowsePage`
|
||||
— or give the multi-library recently-added GROUP BY an offset-capable
|
||||
index-ordered plan. Expected: **~0.8–1.6s → ~50–150ms (≈8–10×)**.
|
||||
|
||||
9. **Drop the redundant per-item `enrichDetailUserData`** in
|
||||
`GetItemDetailsByIDs` (`content_direct.go:693-702` → `:612-614`): it runs a
|
||||
single-item `GetProgress` + `ListCompletedHistoryItems` **per item (~100
|
||||
sequential point queries for 50 items)**, but the handler already fetched
|
||||
progress in one batched call (`resolveUserStateForContentIDs`,
|
||||
`handlers_items.go:961`) and `userDataDTO` overrides it (`mapping.go:551-559`),
|
||||
so the per-item work is thrown away. Remove or batch it. Expected: shave
|
||||
~50–150ms of pure waste.
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
- **`/api/v1/recommendations/taste-seed/items` (4.4–5.5s, every call).** Confirmed
|
||||
it only fires on the explicit `/taste-seed` onboarding page
|
||||
(`web/src/pages/TasteSeed.tsx` via `useTasteSeedItems`); the Home screen renders
|
||||
only `TasteSeedBanner` (dismissed-state check, no items query). 56 calls / 4
|
||||
users in 24h. Rare, opt-in, not on the homescreen — deliberately deferred.
|
||||
|
||||
### Ops (not code)
|
||||
|
||||
7. **Enable `pg_stat_statements`** — confirmed OFF (`shared_preload_libraries`
|
||||
empty). Enabling it makes future regressions measurable, but it **requires a
|
||||
Postgres restart** (not a live toggle) — schedule accordingly.
|
||||
|
||||
## Risk / follow-ups
|
||||
|
||||
- The content_id rewrite's `::int` cast + lexicographic-ordering trap is the
|
||||
single biggest correctness gap; gate it on an explicit test (episode 2 vs 10
|
||||
within a season, and the `season DESC, episode DESC` tiebreak) before merge.
|
||||
- After the rewrite, the LATERAL (episodes + media_files) becomes the dominant
|
||||
term; measure its cold cost separately — it may warrant its own index review.
|
||||
- Raising `fetchAllMaxConcurrency` trades DB pool pressure for latency; validate
|
||||
against `pgxpool` max conns under real concurrency, not just latency.
|
||||
- Cache warming changes staleness semantics for continue-watching; confirm
|
||||
progress writes still invalidate promptly enough that a just-watched item moves.
|
||||
- `/Shows/{id}/Episodes` pagination is client-visible (jellycompat); verify
|
||||
Android/Apple clients tolerate a bounded page + `startItemId` continuation.
|
||||
|
||||
## Verification plan
|
||||
|
||||
- `EXPLAIN (ANALYZE, BUFFERS)` before/after on the NextUp rewrite for `user_id=627`;
|
||||
assert the `episodes_pkey` loop count drops from ~14.5k to ~0 for anchored users.
|
||||
- Replay the worst production queries (Resume `Limit=20` + total-count; long-series
|
||||
Episodes) and confirm sub-second.
|
||||
- Watch `slow section fetch` / `slow aggregate section fetch` counts in logs after
|
||||
deploy; the hourly `:12` compat-resume spike should disappear.
|
||||
- Dedicated ordering test for the content_id derivation: a series with episodes
|
||||
2 and 10 in one season, asserting NextUp picks episode 2 → 3 (not 10 → 11) and
|
||||
the `DISTINCT ON ... DESC` tiebreak picks the highest-numbered completed episode.
|
||||
- `make lint`, `go build ./...`, targeted unit tests for the content_id SQL
|
||||
derivation (mirror existing `history_source` tests, plus the `::int` cast path).
|
||||
@@ -0,0 +1,122 @@
|
||||
# Shared List Cache (Home Rails) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Commands assume the repository root is the cwd.
|
||||
|
||||
**Goal:** Cut repeated database work and tail latency on the home screen by caching the *shared* part of every user-agnostic home rail once per access scope, refreshing it in the background before it expires (target TTL ~15 min), and layering the cheap per-user part (watched flags, play position, poster links) on top of the cached list on every request.
|
||||
|
||||
**Architecture:** Option A — an in-process, **process-global** resolved-list cache inserted at the native section fetch choke point (`internal/sections`). The section fetcher already returns a *shared, non-personalized* item list; the per-user overlay is applied later in the API handler. We cache at that boundary for the wider/fuller set of user-agnostic section types (including admin-curated collections). Redis is **not** the store — it is an optional invalidation signal only.
|
||||
|
||||
**Tech Stack:** Go, `golang.org/x/sync/singleflight` (already used in `internal/sections`), in-process map + mutex (mirrors the existing `editorialCandidateCache`), existing `catalog.AccessFilter`, targeted Go tests.
|
||||
|
||||
**Scope target:** Native Silo sections path (`internal/sections/fetcher.go` + `internal/api/handlers/sections.go`).
|
||||
|
||||
---
|
||||
|
||||
## Validated Direction
|
||||
|
||||
Confirmed directly against the code on `main`:
|
||||
|
||||
- **The shared base list and the per-user overlay are already separated.** `Fetcher.fetchSection` (`internal/sections/fetcher.go`) returns raw `[]*models.MediaItem` and, for the user-agnostic section types, takes only the access `filter` — no user/profile. The per-user overlay (watched state, play position, presigned poster URLs, overlay badges) is applied afterward in `SectionHandler.buildSectionsResponse` (`internal/api/handlers/sections.go`) on every request. This makes the fetcher output a clean, presign-free cache boundary.
|
||||
- **There is precedent to copy.** `editorialCandidateCache` (`internal/sections/fetcher.go`) is already an in-process TTL cache guarded by a mutex + `singleflight.Group`, keyed by subject/library/access-filter (never by user). It caches *candidate ID lists*; this plan generalizes it to a *resolved item list* with a short TTL and refresh-ahead.
|
||||
- **The fetch choke point is `Fetcher.FetchOne`.** Both `HandleHomeSections` (via `FetchAll` → `FetchOne`) and `HandleHomeSectionItems` route through it. Caching there covers the whole native home path in one place.
|
||||
- **The cache must be process-global, not per-`Fetcher`.** The process constructs several independent `sections.NewFetcher(...)` instances (e.g. native API and recommendations). A cache stored on the `Fetcher` struct (as `editorialCandidateCache` is today) would be duplicated per instance and could never be shared across surfaces. A package-level cache lets any current or future consumer of `FetchOne` reuse the same warm entries.
|
||||
- **`Collection` is not uniformly user-agnostic.** `fetchCollection` serves library collections (shared) but routes to `fetchUserCollection` when `cfg.UserCollectionID != ""` (profile-scoped). The cache must exclude the user-collection case or it would leak one profile's list to another.
|
||||
|
||||
---
|
||||
|
||||
## What is cached (the fuller user-agnostic set)
|
||||
|
||||
These rows are identical for everyone who can see the same libraries at the same content-rating cap, so they are cached by access scope:
|
||||
|
||||
- Recently Added (`SectionRecentlyAdded`)
|
||||
- Recently Released / New Releases (`SectionRecentlyReleased`)
|
||||
- Genre and custom-filter rows (`SectionGenre`, `SectionCustomFilter`)
|
||||
- Trending on this server (`SectionTrendingOnServer`), Most Watched (`SectionMostWatched`)
|
||||
- New to Library (`SectionNewToLibrary`)
|
||||
- Critically Acclaimed (`SectionCriticallyAcclaimed`), Award Winners (`SectionAwardWinners`)
|
||||
- Editorial Spotlight / featured (`SectionEditorialSpotlight`)
|
||||
- Seasonal (`SectionSeasonalThemed`), Mood (`SectionMoodCollection`), Format Showcase (`SectionFormatShowcase`)
|
||||
- Trending Discover (`SectionTrendingDiscover`)
|
||||
- **Admin-curated lists (`SectionAdminCuratedList`)**
|
||||
- **Library** collections only (`SectionCollection` where `cfg.UserCollectionID == ""`)
|
||||
|
||||
## What is NOT cached
|
||||
|
||||
- **Per-user rows (no shared base):** Continue Watching, Next Up, Next in Series, Recommended For You / Because You Watched / Similar Users Liked / Taste Match, Hidden Gems, Forgotten Favorites, Profile Activity Feed, and **user** collections (`SectionCollection` with a `UserCollectionID`). These bypass the cache entirely.
|
||||
- **`SectionRandom`** is technically user-agnostic but intentionally randomized per request; caching would freeze it. Excluded to preserve behavior.
|
||||
|
||||
## The per-user overlay always runs fresh (correctness)
|
||||
|
||||
Even for a cached row, each request still computes, per person: watched flags (`isPlayed`/UserData), play position, and freshly presigned poster URLs — all in `buildSectionsResponse`. The cache only stores the *membership and ordering* of the row (presign-free `*models.MediaItem`). No request ever sees another user's watched state, and no cached entry carries a poster URL that could expire mid-cache.
|
||||
|
||||
---
|
||||
|
||||
## The access-scope cache key (security-critical)
|
||||
|
||||
If the key fails to capture an access boundary, the cache can serve items a user must not see. The key MUST include, and nothing that is per-user:
|
||||
|
||||
- Section identity: section type + section ID + a hash of the section `Config` (so two genre rows with different filters never collide).
|
||||
- Requested `ItemLimit` (row size). Callers may request different sizes for the same section; the key must separate them (or the cache must store a superset and slice down).
|
||||
- Access scope: sorted accessible library IDs, sorted disabled library IDs, max content rating, excluded media types, plus sort/order.
|
||||
|
||||
Model the string builder on the existing `editorialCandidateCacheKey`, extended with section ID + config hash + `ItemLimit`.
|
||||
|
||||
## Background refresh before expiry (the key behavior)
|
||||
|
||||
Each entry stores `builtAt`, a soft `refreshAfter` (e.g. `builtAt + 12min`), and a hard `expiresAt` (e.g. `builtAt + 15min`). A `getOrRefresh(ctx, key, loader)` helper implements:
|
||||
|
||||
- `now < refreshAfter` → return cached value, do nothing.
|
||||
- `refreshAfter <= now < expiresAt` → return cached value **and** kick off one async rebuild via `singleflight` (only one rebuild per key). The fresh value swaps in when ready.
|
||||
- `now >= expiresAt` → block on the build; `singleflight` collapses concurrent blockers into one build (stampede protection).
|
||||
|
||||
Net effect under steady traffic: entries are refreshed ahead of expiry, so live requests are served warm and never pay the cold rebuild. An optional low-frequency sweeper can keep rarely-hit hot scopes warm.
|
||||
|
||||
## Optional invalidation (freshness)
|
||||
|
||||
Newly scanned content otherwise appears up to ~15 min late in these rails — acceptable for "recently added"/"trending". Optionally subscribe to the existing Redis `EventScanComplete` / `EventMetadataUpdated` events (`internal/cache`) and drop affected scopes for near-instant freshness. This is a nice-to-have layered on top of the TTL, not a dependency.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Add `internal/sections/resolvedlistcache.go`
|
||||
- Package-level cache: `map[string]resolvedListEntry` (value = presign-free `[]*models.MediaItem` + `TotalCount` + `builtAt`/`refreshAfter`/`expiresAt`), guarded by a `sync.RWMutex` and a package-level `singleflight.Group`.
|
||||
- `getOrRefresh(ctx, key, ttl, refreshLead, loader)` implementing serve / refresh-ahead / block-only-when-dead. Reuse the `f.now()` clock indirection for deterministic tests.
|
||||
- `resolvedListCacheKey(...)` builder (section type + section ID + config hash + `ItemLimit` + access scope), modeled on `editorialCandidateCacheKey`.
|
||||
- `isCacheableSectionType(resolved)` guard implementing the whitelist above, including the `cfg.UserCollectionID == ""` check for `SectionCollection` and the `SectionRandom` exclusion.
|
||||
- Modify `internal/sections/fetcher.go`
|
||||
- Wrap the user-agnostic branch of `FetchOne` (and the `SectionEditorialSpotlight` branch) so that cacheable section types resolve through `getOrRefresh`; everything else calls the existing loader path unchanged.
|
||||
- Return defensive copies so callers cannot mutate cached slices.
|
||||
- Add `internal/sections/resolvedlistcache_test.go`
|
||||
- Distinct access scopes never cross-serve (security).
|
||||
- `SectionCollection` with a `UserCollectionID` is never cached.
|
||||
- Refresh-ahead returns the current value without blocking; only a fully-expired entry blocks.
|
||||
- Stampede: concurrent cold requests collapse to a single loader call.
|
||||
- Overlay parity: a cached row produces the same per-user response as the uncached path (drive through `buildSectionsResponse` in `internal/api/handlers`).
|
||||
|
||||
No migration is planned. No plugin repo changes are planned. No client changes are planned.
|
||||
|
||||
---
|
||||
|
||||
## Deferred (explicitly out of scope here)
|
||||
|
||||
- **The cross-library recently-added path** in `directContentService.BrowseItems` is a separate choke point with its own per-user overlay, and is a separate follow-up.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Add `resolvedlistcache.go` (cache struct, `getOrRefresh`, key builder, section-type whitelist).
|
||||
- [ ] Wire `FetchOne` to route cacheable section types through the cache; leave per-user types untouched.
|
||||
- [ ] Add the `SectionCollection` user-collection exclusion and `SectionRandom` exclusion.
|
||||
- [ ] Add unit tests (scope isolation, user-collection exclusion, refresh-ahead non-blocking, stampede collapse).
|
||||
- [ ] Add an overlay-parity test through `buildSectionsResponse`.
|
||||
- [ ] (Optional) Subscribe to `EventScanComplete`/`EventMetadataUpdated` to invalidate affected scopes.
|
||||
- [ ] `make lint`, `go test ./internal/sections/... ./internal/api/...`, `make verify-local-paths`.
|
||||
|
||||
## Risks / follow-ups
|
||||
|
||||
- **Security key completeness** is the number-one risk; when in doubt, add a field to the key and add a scope-isolation test.
|
||||
- **Approximate totals:** hiding watched items post-cache keeps `TotalRecordCount` approximate — already an accepted tradeoff on the `isPlayed` path; unchanged here.
|
||||
- **Staleness window** up to ~15 min for newly scanned content; mitigated by the optional scan-complete invalidation.
|
||||
- **Memory:** ~200 items × small metadata × tens of scopes is negligible; cap entry count and evict LRU as a backstop.
|
||||
@@ -0,0 +1,219 @@
|
||||
# 2026-07-06 — Why the slow endpoints stayed slow after PR #292, and the fix plan
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Context
|
||||
|
||||
PR #292 (commit `5606beed`) shipped the home/Continue Watching/Latest latency work:
|
||||
scan caps in the jellycompat resume path, the shared resolved-list cache, the
|
||||
per-library Latest fast path, batched presign, and the plugin-installation cache.
|
||||
The image built from it has been serving production for a full day, and the
|
||||
deployment's request logs still show the same endpoint groups breaching 1s
|
||||
(analysis window: 19h post-deploy, `log_min_duration_statement = 500ms` on the
|
||||
Postgres side):
|
||||
|
||||
| Endpoint group | slow (≥1s) calls | p95 | worst |
|
||||
|---|---|---|---|
|
||||
| `/Shows/NextUp` | 301 | 17.1s | 44.8s |
|
||||
| `/UserItems/Resume` + `Users/{userId}/Items/Resume` | 121 | 34.8s / 110.7s | 125.3s |
|
||||
| `/Items/Latest` + `Users/{id}/Items/Latest` | 117 | 17.3s / 8.3s | 18.8s |
|
||||
| `/Items` (incl. search-as-you-type) | 83 | 6.3s | 9.6s |
|
||||
| native home sections + section items (`api/v1/home` routes) | 50 | 6.3s / 42.0s | 42.8s |
|
||||
|
||||
The deployed binary provably contains the PR #292 symbols, so the caps and caches
|
||||
are live. They bounded *how many rows the loops touch* — they did not touch *what
|
||||
each underlying query costs*. The Postgres slow-statement log for the same window
|
||||
shows where the time actually goes:
|
||||
|
||||
| Statement | slow execs | total time | worst |
|
||||
|---|---|---|---|
|
||||
| `ListProgress(status="in_progress")` page query | 5,263 | 13,522s | 16.8s |
|
||||
| Next-up `WITH completed_episodes …` CTE | 648 | 1,717s | 44.7s |
|
||||
| `RemoveHistoryItems` history DELETE | 81 | 118s | 10.9s |
|
||||
| Scanner `media_files` subtree lookup | 27 | 74s | 13.6s |
|
||||
| `ListCompletedHistoryItems` (chunked rollup) | 65 | 64s | 1.6s |
|
||||
|
||||
## Root cause 1 — 4.32M stale "completed but resumable" progress rows + no index for the resume ordering
|
||||
|
||||
**Human-readable.** For accounts that bulk-imported their Plex watch history, every
|
||||
imported "watched" row was stored as *finished AND parked at the very end of the
|
||||
video*. The server's definition of "something you can resume" is "position greater
|
||||
than zero" — deliberately, so a rewatch of an already-watched item re-enters
|
||||
Continue Watching. Result: for a heavy importer, the server believes their **entire
|
||||
watch history (232,979 of 233,016 rows for the worst profile) is resumable**. Every
|
||||
Continue Watching page load walks that entire list, filters ~100% of it away in
|
||||
memory, pages deeper, and repeats.
|
||||
|
||||
**Technical.** All current write paths enforce the invariant "completed ⇒
|
||||
`position_seconds = 0`" (`internal/userstore/pgstore/progress.go`,
|
||||
`internal/historyimport/repo.go:879`). But 4,318,693 of 4,579,210
|
||||
`user_watch_progress` rows (94%, across 1,649 profiles) violate it with
|
||||
`completed = TRUE AND position_seconds > 0` — legacy imports from before the
|
||||
invariant. Only 189 of those are genuine mid-rewatch rows
|
||||
(`position_seconds < duration_seconds`); the rest are parked at/past the end and
|
||||
can never be a meaningful resume point.
|
||||
|
||||
The `in_progress` branch of `ListProgress`
|
||||
(`internal/userstore/pgstore/progress.go:426`) filters
|
||||
`position_seconds > 0 … ORDER BY updated_at DESC LIMIT … OFFSET …`. No index
|
||||
serves that shape (the partial indexes cover `completed = true/false`, not
|
||||
`position_seconds > 0`), so **every call walks all of the profile's rows via
|
||||
`idx_user_watch_progress_profile` and top-N-sorts them** — measured 192ms warm /
|
||||
multi-second cold per call for the worst profile (EXPLAIN ANALYZE: 232,962 rows
|
||||
walked per call).
|
||||
|
||||
Every consumer loops this query per request:
|
||||
|
||||
- `internal/sections/fetcher.go` `collectContinueProgressItems`: up to 10 pages
|
||||
(`continueProgressMaxScanned = 1000` / page size 100) — serves the native
|
||||
Continue Watching section (the `api/v1/home` section-items route, 42s) **and**,
|
||||
since PR #292, the jellycompat Resume fast path (`loadResumeViaSections`) — the
|
||||
125s `Users/{userId}/Items/Resume` calls.
|
||||
- `internal/jellycompat/handlers_items.go` `loadProgressPage`: up to
|
||||
`resumeScanMaxRows = 300` rows per request (the PR #292 cap — it bounds pages,
|
||||
not per-page cost).
|
||||
- `internal/catalog/nextup_repo.go` `listResumableFirstEpisodes`: one 100-row call.
|
||||
|
||||
The 00:30 UTC log window shows the failure shape directly: ~17 sequential 1.1s
|
||||
executions of this query (one paging loop) plus 16.5s cold executions saturating
|
||||
the pool while other endpoints queue behind them.
|
||||
|
||||
**Fix (one-time direct DB repair — applied 2026-07-06, no migration shipped).**
|
||||
|
||||
Because this is a one-shot repair of legacy data on a single deployment, it was
|
||||
applied directly against the production database instead of as a Goose
|
||||
migration; the exact SQL and timings are recorded in the deployment's ops notes
|
||||
(silo-base, `slow-endpoint-db-repair-2026-07-06.md`).
|
||||
|
||||
1. Data repair: `UPDATE user_watch_progress SET position_seconds = 0 WHERE
|
||||
completed = TRUE AND position_seconds > 0 AND position_seconds >=
|
||||
duration_seconds`, followed by `ANALYZE user_watch_progress`. Leaves the 189
|
||||
genuine mid-rewatch rows alone; does not touch `updated_at` or `synced_seq`
|
||||
(no sync flood, ordering preserved). These rows were already invisible in
|
||||
Continue Watching (dismissal/superseded/percent filtering), so no visible
|
||||
behavior changes — the data just stops lying to the query planner. Applied:
|
||||
4,318,504 rows in 2m05s.
|
||||
2. Partial index `idx_uwp_profile_resume (user_id, profile_id, updated_at DESC)
|
||||
WHERE position_seconds > 0` (`CREATE INDEX CONCURRENTLY`) — turns every
|
||||
in-progress listing into an ordered index walk regardless of profile size
|
||||
(belt-and-braces against future bad data; after the repair the index is
|
||||
8.8 MB). Verified post-repair: the worst profile's in-progress page query
|
||||
went from 232,962 rows walked / 192ms warm to 37 rows / 1.0ms.
|
||||
|
||||
## Root cause 2 — Next Up anchors on the entire completed history
|
||||
|
||||
**Human-readable.** "Next Up" answers "what's the next episode of each show this
|
||||
person is watching?". To find those shows it re-reads **every episode the person
|
||||
has ever finished** — a quarter-million rows for bulk importers — on every call,
|
||||
then for each fully-watched show walks all of its episodes looking for an
|
||||
unwatched one that isn't there.
|
||||
|
||||
**Technical.** `buildListNextUpQuery` (`internal/catalog/nextup_repo.go`): the
|
||||
`completed_episodes` CTE does `DISTINCT ON (e.series_id)` over **all** of the
|
||||
profile's completed rows joined to `episodes` (233k rows for the worst profile),
|
||||
then `eligible_series` runs a correlated anti-join against the (currently
|
||||
non-selective, see RC1) `position_seconds > 0` set, then a per-series LATERAL
|
||||
probes episodes in order — scanning *every* episode of a fully-watched series
|
||||
before yielding nothing. 648 slow executions, 44.7s worst. This also drags down
|
||||
the native home-sections aggregate (`api/v1/home` sections) via `maybeInjectNextUp` and the jellycompat
|
||||
`/Shows/NextUp` route.
|
||||
|
||||
**Fix.** Bound the anchor for the global (non-`SeriesID`) query: a
|
||||
`recent_completed` pre-CTE takes the most recent `nextUpAnchorMaxRows = 500`
|
||||
completed rows via `idx_uwp_profile_completed` (an ordered index walk), and
|
||||
`completed_episodes` derives series from that subset. A Next Up rail shows ~24
|
||||
series; the 500 most recent completions cover every realistically surfaceable
|
||||
series. Series-scoped calls (show-detail tile) keep the unbounded shape — they
|
||||
are naturally bounded by one series. Prototyped on the live worst profile:
|
||||
**44.7s → 517ms** (and the residual cost is the RC1 anti-join, which the repair
|
||||
removes).
|
||||
|
||||
## Root cause 3 — series watch-state rollup materializes every episode of every series on the page
|
||||
|
||||
**Human-readable.** For any list of TV shows (per-library "Latest", library
|
||||
browse, search results), the server computes each show's "N unwatched episodes"
|
||||
badge by **loading every episode of every show on the page into memory** and then
|
||||
asking the watch database about each episode in batches of 500. One 50-show page
|
||||
of the Sports library expands to 32,467 episodes and ~65 sequential database
|
||||
round-trips. PR #292 wired the cached Latest fast path through this same rollup
|
||||
(for data parity), so even cache-hit responses pay it.
|
||||
|
||||
**Technical.** `enrichSeriesListUserData` (`internal/jellycompat/content_direct.go`)
|
||||
→ `episodeRepo.ListBySeriesIDs` (all episodes) → `chunkedProgressByMediaItems` →
|
||||
`ListProgressWithCompletedHistory` per 500 ids (each chunk hits
|
||||
`user_watch_progress` + the `ListCompletedHistoryItems` GROUP BY). The same
|
||||
per-episode fanout runs in `enrichDetailUserData` for every series row on detail
|
||||
pages. It only ever produces four numbers per series (total/watched/in-progress/
|
||||
played).
|
||||
|
||||
**Fix.** Compute the counts in one SQL aggregate. New optional interface
|
||||
`userstore.SeriesEpisodeRollupStore`, implemented by `PostgresUserStore`
|
||||
(the pgstore already references catalog tables — see
|
||||
`buildProgressCatalogFilter`): a single `GROUP BY e.series_id` query over
|
||||
`episodes` LEFT-JOINed to the profile's progress rows with the same
|
||||
visibility/history semantics as `ListProgressWithCompletedHistory`
|
||||
(hidden-items anti-join, completed-history fold). `enrichSeriesListUserData` and
|
||||
`enrichDetailUserData` use it when the store implements it and keep the existing
|
||||
chunked path as fallback (SQLite-backed user stores). Prototyped on the live
|
||||
worst profile against the real Sports Latest page: **~17s → 123ms**.
|
||||
|
||||
This also fixes the slow `/Items?searchTerm=…` calls (Meilisearch itself is fast;
|
||||
the search handler excludes movies/episodes, returns mostly series, and then paid
|
||||
this same rollup) and the series portions of `/Items` browse.
|
||||
|
||||
## Root cause 4 (secondary) — two index-starved write/maintenance paths
|
||||
|
||||
- `RemoveHistoryItems` (`internal/userstore/pgstore/progress.go`): the watermark
|
||||
MAX and the DELETE filter `user_watch_history` by `(user_id, profile_id,
|
||||
media_item_id = ANY(...))`, but the only complete index is `(user_id,
|
||||
profile_id, watched_at DESC)` — per-user full history scans (10.9s worst; this
|
||||
is the `/UserPlayedItems/{itemId}` DELETE path, 19.4s worst end-to-end).
|
||||
Fix: plain btree `(user_id, profile_id, media_item_id)`.
|
||||
- Scanner subtree lookups (`internal/scanner/file_repo.go`):
|
||||
`media_folder_id = $1 AND (file_path = $2 OR file_path LIKE $3 ESCAPE '\')` has
|
||||
no usable index for the path predicate (13.6s worst; holds pool connections that
|
||||
API requests then queue behind). The LIKE is prefix-anchored
|
||||
(`pathscope.PrefixLike`), so a btree on `(media_folder_id, file_path
|
||||
text_pattern_ops)` serves both arms and the `ORDER BY file_path`.
|
||||
|
||||
Both indexes were created directly on the production database
|
||||
(`CREATE INDEX CONCURRENTLY`) alongside RC1's partial index — see the
|
||||
deployment's ops notes.
|
||||
|
||||
## What is intentionally not changed
|
||||
|
||||
- `resumeScanMaxRows` / `continueProgressMaxScanned` / `maxSeriesUserDataRollups`
|
||||
caps stay — they remain correct guards; the fixes make each capped unit cheap.
|
||||
- The `position_seconds > 0` rewatch semantics stay; the repair only removes rows
|
||||
that violate the documented write-path invariant.
|
||||
- No `updated_at`/`synced_seq` changes in the repair — client sync state is
|
||||
untouched.
|
||||
- `/api/v1/stream/{session_id}/subtitles/{track}` (subtitle *track* conversion)
|
||||
is out of scope per the task. The related font-attachment endpoint
|
||||
`/subtitles/{track}/fonts` *was* folded in as a follow-up — see deliverable 5 —
|
||||
because its per-attachment ffmpeg spawns shared the same slow-endpoint profile.
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. `docs:` this plan.
|
||||
2. One-time DB repair + three indexes, applied directly to the production
|
||||
database on 2026-07-06 (recorded in the deployment's ops notes; deliberately
|
||||
not shipped as a migration).
|
||||
3. `perf(catalog):` bounded next-up anchor scan.
|
||||
4. `perf(jellycompat,userstore):` SQL series watch-state rollup with chunked
|
||||
fallback.
|
||||
5. `perf(playback):` single-pass ffmpeg font extraction for
|
||||
`/subtitles/{track}/fonts` (one media-file open instead of one per
|
||||
attachment), with the 32-attachment / 32 MiB caps preserved via a dump-dir
|
||||
watchdog.
|
||||
|
||||
## Verification
|
||||
|
||||
- `go build ./... && go vet ./...`; `go test -race` on `internal/catalog`,
|
||||
`internal/jellycompat`, `internal/userstore/...`, `internal/sections`.
|
||||
- EXPLAIN ANALYZE numbers above were measured on the production database
|
||||
against the worst real profile, before and after the repair: in-progress page
|
||||
query 192ms/232,962 rows → 1.0ms/37 rows; deployed (unbounded) next-up shape
|
||||
17–44s → 1.1s; bounded next-up shape → 10ms; 50-series rollup ~17s → 119ms.
|
||||
- Post-deploy of the code fixes: re-run the slow-request aggregation over
|
||||
`docker logs` and confirm the five endpoint groups drop out of the ≥1s report.
|
||||
@@ -0,0 +1,376 @@
|
||||
# Implementation Plan: Autoscan Sonarr/Radarr Webhook Intake
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-09-autoscan-arr-webhook-intake-design.md`
|
||||
(Option A, decisions locked — read the spec's "Resolved review decisions" before
|
||||
starting.)
|
||||
**Date:** 2026-07-09
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Overview
|
||||
|
||||
Add a webhook delivery mode to Autoscan so Sonarr/Radarr can POST import,
|
||||
upgrade, rename, and delete notifications directly to Silo without an arr API
|
||||
key. Webhook sources bind to a host-discovered built-in identity
|
||||
(`silo.autoscan.arr-webhook`), never the installed ARR plugin. The host parses
|
||||
payloads and feeds the existing rewrite → resolve → suppress → enqueue → event
|
||||
pipeline.
|
||||
|
||||
Non-negotiable design points from the review:
|
||||
|
||||
1. **Built-in source identity** — no plugin install required for webhook mode.
|
||||
2. **Webhook deliveries are never single-flight-dropped.** Only poll cycles may
|
||||
be skipped when a running event exists (their marker window is re-read).
|
||||
3. Event rows record `delivery_mode` and `provider_event_type` from day one.
|
||||
4. Unknown arr event types → `202` no-op. Secret lookup uses plain SHA-256.
|
||||
Webhook URL stays redisplayable on admin endpoints.
|
||||
|
||||
Suggested commit sequence is one commit per phase below, all on one branch, PR
|
||||
subject `feat(autoscan): add Sonarr/Radarr webhook intake`.
|
||||
|
||||
## Phase 1 — Migration
|
||||
|
||||
Create with `make migrate-create NAME=autoscan_webhook_intake` (single
|
||||
timestamped Goose migration; do not hand-number, do not create paired
|
||||
up/down files).
|
||||
|
||||
Up:
|
||||
|
||||
```sql
|
||||
ALTER TABLE autoscan_sources
|
||||
ADD COLUMN delivery_mode text NOT NULL DEFAULT 'poll'
|
||||
CONSTRAINT autoscan_sources_delivery_mode_check
|
||||
CHECK (delivery_mode = ANY (ARRAY['poll'::text, 'webhook'::text]));
|
||||
|
||||
CREATE TABLE autoscan_webhook_endpoints (
|
||||
source_id uuid PRIMARY KEY
|
||||
REFERENCES autoscan_sources(id) ON DELETE CASCADE,
|
||||
secret_hash text UNIQUE NOT NULL,
|
||||
secret_ref text NOT NULL,
|
||||
secret_suffix text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
rotated_at timestamptz,
|
||||
last_received_at timestamptz,
|
||||
last_error_at timestamptz,
|
||||
last_error_message text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
ALTER TABLE autoscan_events
|
||||
ADD COLUMN delivery_mode text NOT NULL DEFAULT 'poll',
|
||||
ADD COLUMN provider_event_type text NOT NULL DEFAULT '';
|
||||
```
|
||||
|
||||
Down reverses all three (drop table, drop columns). Verify with
|
||||
`make migrate-status` / `make migrate-up` against a scratch DB, and check
|
||||
rollback.
|
||||
|
||||
## Phase 2 — Built-in source identity
|
||||
|
||||
**`internal/autoscan/discovery.go`**
|
||||
|
||||
- Add exported constants:
|
||||
- `BuiltinArrWebhookPluginID = "silo.autoscan.arr-webhook"`
|
||||
- `BuiltinArrWebhookCapabilityID = "arr-webhook"`
|
||||
- display name `"Sonarr/Radarr Webhook"`.
|
||||
- Add a composite lister so the built-in identity lives beside the discovery
|
||||
types it extends (not in wiring code):
|
||||
|
||||
```go
|
||||
// WithBuiltinSources wraps a ScanSourceLister and appends host-built-in
|
||||
// source identities that need no plugin installation.
|
||||
func WithBuiltinSources(inner ScanSourceLister, builtins ...DiscoveredSource) ScanSourceLister
|
||||
```
|
||||
|
||||
It must tolerate `inner == nil` (return only builtins) because
|
||||
`Service.lister` is already allowed to be nil.
|
||||
|
||||
**`internal/api/autoscan_wiring.go`**
|
||||
|
||||
- In `BuildAutoscanService`, wrap the existing lister:
|
||||
|
||||
```go
|
||||
autoscan.WithBuiltinSources(
|
||||
PluginScanSourceLister{installationStore},
|
||||
autoscan.BuiltinArrWebhookSource(), // helper returning the DiscoveredSource
|
||||
)
|
||||
```
|
||||
|
||||
Tests (`internal/autoscan/discovery_test.go`): builtin appears with and without
|
||||
an inner lister; plugin entries pass through unchanged.
|
||||
|
||||
## Phase 3 — Repository and types
|
||||
|
||||
**`internal/autoscan/types.go`**
|
||||
|
||||
- `Source`: add `DeliveryMode string` (values `"poll"`/`"webhook"`; constants
|
||||
`DeliveryModePoll`, `DeliveryModeWebhook`).
|
||||
- `Event`: add `DeliveryMode string`, `ProviderEventType string`.
|
||||
- `EventCreate`: add `DeliveryMode string`, `ProviderEventType string`,
|
||||
`SkipRunningCheck bool`.
|
||||
- New:
|
||||
|
||||
```go
|
||||
type WebhookEndpoint struct {
|
||||
SourceID string
|
||||
SecretSuffix string
|
||||
CreatedAt time.Time
|
||||
RotatedAt *time.Time
|
||||
LastReceivedAt *time.Time
|
||||
LastErrorAt *time.Time
|
||||
LastErrorMessage string
|
||||
}
|
||||
```
|
||||
|
||||
The plaintext token and hash never live on the struct that flows to
|
||||
handlers/responses; creation/rotation return the token separately.
|
||||
|
||||
**`internal/autoscan/repository.go`**
|
||||
|
||||
- Thread `delivery_mode` through `CreateSource`, `UpdateSource`,
|
||||
`ListSources`, `ListEnabledSources`, `GetSource` (default `'poll'` when
|
||||
empty on write).
|
||||
- `CreateEvent`: write the two new columns. When `in.SkipRunningCheck` is
|
||||
true, use the simple no-transaction insert path (the same shape as the
|
||||
existing `sourceID == ""` branch, but with the source id set) — no advisory
|
||||
lock, no running-row check. Poll-mode behavior is unchanged.
|
||||
- `FinishEvent` / `ListEvents` / `ListRunningEvents` / scan-event joins:
|
||||
select and populate the new event fields.
|
||||
- Webhook endpoint methods (token = 32 bytes from `crypto/rand`,
|
||||
`base64.RawURLEncoding`; hash = hex SHA-256 of the token; `secret_ref` =
|
||||
`r.cipher.Encrypt(token, secret.RowAAD("autoscan_webhook_endpoints", "secret_ref", sourceID))`;
|
||||
suffix = last 6 chars of the token):
|
||||
|
||||
```go
|
||||
// CreateWebhookEndpoint creates (or returns the existing) endpoint; token is
|
||||
// "" when the endpoint already existed.
|
||||
CreateWebhookEndpoint(ctx, sourceID string) (WebhookEndpoint, token string, err error)
|
||||
RotateWebhookEndpoint(ctx, sourceID string) (WebhookEndpoint, token string, err error)
|
||||
DeleteWebhookEndpoint(ctx, sourceID string) error
|
||||
GetWebhookEndpoint(ctx, sourceID string) (WebhookEndpoint, error)
|
||||
// RevealWebhookToken decrypts secret_ref for admin redisplay.
|
||||
RevealWebhookToken(ctx, sourceID string) (string, error)
|
||||
// ResolveWebhookToken maps a raw delivery token to its source. Constant
|
||||
// shape: hash the token, look up by secret_hash, join autoscan_sources.
|
||||
// Returns ErrNotFound for unknown hashes.
|
||||
ResolveWebhookToken(ctx, token string) (Source, WebhookEndpoint, error)
|
||||
TouchWebhookReceived(ctx, sourceID string) error
|
||||
RecordWebhookError(ctx, sourceID, msg string) error // bounded, sanitized by caller
|
||||
```
|
||||
|
||||
DB-backed tests follow the existing `SILO_TEST_DATABASE_URL` pattern used by
|
||||
the rest of `internal/autoscan`. Cover: create/rotate invalidates the old
|
||||
token, resolve-by-token round-trip, cascade delete with the source, reveal
|
||||
decrypts to the original token.
|
||||
|
||||
## Phase 4 — Service: shared consume path + `IngestChanges`
|
||||
|
||||
**`internal/autoscan/service.go`**
|
||||
|
||||
- Extract everything in `PollOnce` after `PollChanges` returns into:
|
||||
|
||||
```go
|
||||
type consumeOptions struct {
|
||||
EventID int64
|
||||
Marker string // poll only; "" for webhook
|
||||
NextMarker string // poll only
|
||||
AdvanceMarker bool // false for webhook
|
||||
}
|
||||
|
||||
func (s *Service) consumeSourceChanges(ctx context.Context, src Source, changes []Change, opts consumeOptions) error
|
||||
```
|
||||
|
||||
It owns: `rewriteChanges` → `resolveAndClaim` → target-cap collapse →
|
||||
`enqueueScanTargets` (+ `releaseClaims` on failure) → the
|
||||
transient-error / unresolved / success status decision → `finishEvent`.
|
||||
Marker advancement stays conditional on `opts.AdvanceMarker`; the hold-marker
|
||||
comments and semantics in `PollOnce` must survive the refactor intact.
|
||||
- `PollOnce`: skip sources with `src.DeliveryMode == DeliveryModeWebhook`
|
||||
(before the interval check), then delegate to `consumeSourceChanges`.
|
||||
Existing poll tests in `service_test.go` must pass unmodified except for
|
||||
construction changes.
|
||||
- Add:
|
||||
|
||||
```go
|
||||
type ChangeIngest struct {
|
||||
SourceID string
|
||||
ProviderEventType string
|
||||
Changes []Change
|
||||
ReceivedAt time.Time
|
||||
}
|
||||
|
||||
type IngestResult struct {
|
||||
Enqueued int
|
||||
Suppressed int
|
||||
Unresolved bool
|
||||
}
|
||||
|
||||
func (s *Service) IngestChanges(ctx context.Context, in ChangeIngest) (IngestResult, error)
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Load the source; reject non-webhook sources.
|
||||
- `CreateEvent` with `DeliveryMode: "webhook"`, `ProviderEventType`,
|
||||
`SkipRunningCheck: true` — deliveries are never dropped because another
|
||||
event is running (spec: "Security and reliability").
|
||||
- Delegate to `consumeSourceChanges` with `AdvanceMarker: false` and empty
|
||||
markers.
|
||||
- Transient resolve failure → finish event as error and return an error (the
|
||||
handler maps it; a duplicate later delivery is safe). All paths outside
|
||||
Silo folders → finish as `unresolved`, return success.
|
||||
|
||||
Service tests: webhook ingest enqueues the same targets as an equivalent poll;
|
||||
concurrent ingests for one source both complete (no `ErrPollAlreadyRunning`);
|
||||
suppressor claims dedupe overlapping targets; `PollOnce` skips webhook sources.
|
||||
|
||||
## Phase 5 — Parser: `internal/autoscan/arrwebhook`
|
||||
|
||||
New package, no dependencies on api/handlers.
|
||||
|
||||
```go
|
||||
type ParsedWebhook struct {
|
||||
Provider string // "sonarr" | "radarr"
|
||||
EventType string
|
||||
Test bool
|
||||
Changes []autoscan.Change
|
||||
}
|
||||
|
||||
func Parse(provider string, body []byte) (ParsedWebhook, error)
|
||||
```
|
||||
|
||||
- `provider` is `sonarr`, `radarr`, or `auto`; `auto` infers from top-level
|
||||
`series` vs `movie` keys. Inference failure on a non-test event returns a
|
||||
typed error the handler maps to 400 (sanitized message, no payload echo).
|
||||
- Event handling per the spec's "Payload parsing" section: Test → no changes;
|
||||
Download/Import/Upgrade/DownloadComplete → imported file paths; Rename → new
|
||||
**and** previous paths; EpisodeFileDelete/MovieFileDelete → deleted path.
|
||||
All paths emit `ChangeScopeFile` (`resolveChange` already falls back through
|
||||
`ResolveVanishedPath` for vanished files). Series/movie folder paths are a
|
||||
subtree fallback (`ChangeScopeSubtree`) only when no file path exists.
|
||||
- Unknown event types return `ParsedWebhook` with no changes and a marker the
|
||||
handler can distinguish (e.g. `EventType` set, `Changes` empty, no error) —
|
||||
they must not be an error.
|
||||
- Dedupe exact paths before returning.
|
||||
|
||||
Fixtures in `internal/autoscan/arrwebhook/testdata/`: Sonarr Download, Rename,
|
||||
EpisodeFileDelete, Test; Radarr Download, Rename, MovieFileDelete, Test; plus
|
||||
an unknown-event and a malformed body. Build them from the Servarr custom-
|
||||
scripts docs linked in the spec.
|
||||
|
||||
## Phase 6 — HTTP: public delivery route + admin endpoints
|
||||
|
||||
**New file `internal/api/handlers/autoscan_webhook.go`**
|
||||
|
||||
Public handler `HandleWebhookDelivery` (`POST /api/v1/autoscan/webhooks/{token}`):
|
||||
|
||||
1. `http.MaxBytesReader` at 256 KiB → 413 on overflow.
|
||||
2. Hash token, `ResolveWebhookToken` → 404 on miss (identical body for
|
||||
deleted/never-existed; do not distinguish).
|
||||
3. Global autoscan disabled or source disabled → 202, no enqueue, still
|
||||
`TouchWebhookReceived`.
|
||||
4. `arrwebhook.Parse` with `source_config.webhook_provider` (default `auto`)
|
||||
→ 400 on parse error; 202 no-op for Test and unknown event types.
|
||||
5. `IngestChanges`; on success `TouchWebhookReceived` and return `202`. On
|
||||
transient ingest failure, `RecordWebhookError` (bounded message) and return
|
||||
`500` so arr retries — a duplicate later delivery is safe. Log only source
|
||||
id, provider, event type, and path count. **Never log the token, URL, or
|
||||
body.**
|
||||
|
||||
Admin handlers (same file, wired through `AutoscanHandler` deps):
|
||||
|
||||
- `POST /admin/autoscan/sources/{id}/webhook` → create-if-missing, return
|
||||
URL + status.
|
||||
- `POST /admin/autoscan/sources/{id}/webhook/rotate` → rotate, return new URL.
|
||||
- `DELETE /admin/autoscan/sources/{id}/webhook` → delete.
|
||||
- URL construction: `deps.PublicURL + "/api/v1/autoscan/webhooks/" + token`
|
||||
when `PublicURL` is set; otherwise return the relative path and let the UI
|
||||
prepend `window.location.origin`. Response field is `webhook_url` either way.
|
||||
|
||||
**`internal/api/handlers/autoscan.go`**
|
||||
|
||||
- `sourceResponse`: add `delivery_mode`, `webhook_configured`, `webhook_url`,
|
||||
`webhook_secret_suffix`, `webhook_last_received_at`, `webhook_last_error_at`,
|
||||
`webhook_last_error_message` (fetch endpoint rows for listed sources; a
|
||||
single batched query, not N+1).
|
||||
- `HandleCreateSource` / `HandleUpdateSource` validation:
|
||||
- `delivery_mode` defaults to `poll`; must be `poll` or `webhook`.
|
||||
- `webhook` is valid only for the built-in identity; `poll` is invalid for
|
||||
the built-in identity.
|
||||
- Webhook mode requires no connection; a bound connection is allowed
|
||||
(rewrite suggestions only).
|
||||
- `source_config.webhook_provider` ∈ {`sonarr`, `radarr`, `auto`} when set.
|
||||
- The installed-capability check passes for the built-in identity
|
||||
automatically because the composite lister supplies it — verify with a
|
||||
handler test rather than special-casing `scanSourceInstalled`.
|
||||
- Event list responses: add `delivery_mode` and `provider_event_type`.
|
||||
|
||||
**`internal/api/router.go`**
|
||||
|
||||
- Admin routes: add the three webhook-management routes next to the existing
|
||||
`/autoscan/sources` block.
|
||||
- Public route: register `r.Post("/autoscan/webhooks/{token}", ...)` alongside
|
||||
the other public routes (near the auth/discord-callback blocks), gated on
|
||||
`autoscanHandler != nil`.
|
||||
- Rate limiting: reuse `internal/ratelimit` — mirror the
|
||||
`Middleware.AuthEndpointHandler(endpoint)` per-IP pattern with a
|
||||
`"autoscan_webhook"` endpoint entry (config default on the generous side,
|
||||
e.g. 60/min burst 30; arr bursts are legitimate). Per-token limiting beyond
|
||||
that is unnecessary for v1 given the suppressor already collapses duplicate
|
||||
work; note this in the PR.
|
||||
|
||||
Handler tests (`internal/api/handlers`): status-code matrix from the spec's
|
||||
Testing section — 202 (ok / test / disabled / unknown event), 400, 404, 405
|
||||
(chi handles), 413, 429; rotation invalidates the old token; no token/body in
|
||||
logs (assert via a captured slog handler).
|
||||
|
||||
## Phase 7 — Frontend
|
||||
|
||||
- `web/src/api/types.ts`: extend `AutoscanSource` with the new response
|
||||
fields; add webhook endpoint response type.
|
||||
- `web/src/hooks/queries/useAutoscan.ts` (+ `keys.ts`): mutations for webhook
|
||||
create/rotate/delete; invalidate the sources query on success.
|
||||
- `web/src/pages/admin/autoscan/SourcesPanel.tsx`:
|
||||
- Delivery mode segmented control (`Poll` / `Webhook`) in the source
|
||||
create/edit form. Only offered when the picked capability is the built-in
|
||||
webhook identity (which forces webhook mode) or a plugin capability
|
||||
(which forces poll mode) — in practice the mode follows the picker choice;
|
||||
render the control as the mode indicator rather than a free toggle.
|
||||
- Webhook mode panel: provider select (`Auto`/`Sonarr`/`Radarr` →
|
||||
`source_config.webhook_provider`), webhook URL display with copy button
|
||||
(prepend `window.location.origin` when the API returns a relative path),
|
||||
rotate action with confirm, last-received / last-error status line.
|
||||
- Connection picker hidden in webhook mode (or shown as explicitly optional
|
||||
for rewrite suggestions); poll-interval input hidden.
|
||||
- Path-rewrite editor stays visible in both modes.
|
||||
- `web/src/pages/admin/autoscan/ActivityPanel.tsx`: show delivery mode and
|
||||
provider event type on event rows.
|
||||
- Follow existing panel patterns; 2-space/double-quote Prettier config.
|
||||
|
||||
## Phase 8 — Verification
|
||||
|
||||
```bash
|
||||
GOWORK=off go test ./internal/autoscan/... ./internal/api/handlers
|
||||
GOWORK=off go test ./...
|
||||
cd web && pnpm run lint && pnpm run format:check && pnpm run build
|
||||
make lint
|
||||
make verify-local-paths
|
||||
```
|
||||
|
||||
Manual smoke (dev): create a webhook source, paste the URL into a Sonarr
|
||||
Connect → Webhook (On Import + On Rename + On File Delete), use Sonarr's
|
||||
"Test" button (expect 202 and `last_received_at` updating), import a file,
|
||||
confirm a webhook event row and a file-scope scan appear in the Activity
|
||||
panel.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- The `PollOnce` refactor is the riskiest step: marker-advancement semantics
|
||||
are subtle and documented inline — preserve the decision comments and rely
|
||||
on the existing `service_test.go` suite as the regression harness before
|
||||
adding webhook paths.
|
||||
- `secret_ref` uses `internal/secret.Cipher` with row-bound AAD; never rename
|
||||
the table/column in SQL later without a `db_loader`-style fallback
|
||||
(encryption is AAD-bound).
|
||||
- Keep `/api/v1` additive: every API change above is a new field, new
|
||||
endpoint, or new route; nothing existing is renamed or repurposed.
|
||||
- Client repos: none required for v1 — this is an admin-web-only surface; the
|
||||
public endpoint is consumed by arr, not by Silo clients.
|
||||
@@ -0,0 +1,477 @@
|
||||
# Plugin Hub and Approved Community Catalog Implementation Plan
|
||||
|
||||
> **For implementation:** Use the `executing-plans` skill and complete the
|
||||
> phases in dependency order. Commands assume the repository root is the cwd.
|
||||
|
||||
**Goal:** Replace the current raw plugin cards with an operator-focused Plugin
|
||||
Hub that explains what each plugin does, shows setup and release information,
|
||||
links to its source, and lets a server administrator opt into Silo-approved
|
||||
community plugins without manually managing repository URLs.
|
||||
|
||||
**Audience:** Server administrators and casual homelab operators. This is not an
|
||||
end-user plugin surface. Developer identifiers and repository details remain
|
||||
available, but they are secondary to plain-language purpose, setup, health, and
|
||||
update information.
|
||||
|
||||
**Approval meaning:** An **Approved community** plugin has been reviewed by Silo
|
||||
maintainers, validated to install and work as described, and considered safe for
|
||||
its documented use at the time of approval. It remains community-maintained;
|
||||
approval does not transfer maintenance or support ownership to the Silo core
|
||||
team and is not a permanent guarantee against future vulnerabilities.
|
||||
|
||||
**Architecture:** Add typed presentation fields to `PluginManifest`; keep
|
||||
version-specific GitHub release notes in catalog packages; create a separately
|
||||
owned `silo-community/silo-plugins` catalog with an allowlisted approval gate;
|
||||
and teach Silo to manage official, approved-community, and custom sources as
|
||||
different provenance classes. The admin UI consumes additive `/api/v1` fields,
|
||||
uses a default-off community-channel setting, and opens URL-addressable plugin
|
||||
details from the Installed and Catalog views.
|
||||
|
||||
**Delivery shape:** This is one feature with coordinated PRs across
|
||||
`silo-plugin-sdk`, `silo-plugins`, the new community catalog, `silo-server`, and
|
||||
the affected plugin repositories. Do not combine all repositories into one
|
||||
commit or PR.
|
||||
|
||||
---
|
||||
|
||||
## Product Decisions and Defaults
|
||||
|
||||
- The primary tabs remain **Installed** and **Catalog**.
|
||||
- The catalog contains Silo-maintained plugins by default.
|
||||
- **Include approved community plugins** is a server-wide setting and defaults
|
||||
to `false` for both fresh installs and upgrades.
|
||||
- Enabling the setting manages approved community catalog sources on the
|
||||
administrator's behalf; it does not ask for a GitHub URL.
|
||||
- Disabling the setting hides community catalog entries and pauses update
|
||||
discovery for community installations. It never disables or uninstalls an
|
||||
installed plugin.
|
||||
- When community installations exist, disabling requires confirmation and
|
||||
states how many plugins will stop receiving update discovery.
|
||||
- Manual catalog repositories and archive uploads remain available under
|
||||
**Manage sources → Advanced**.
|
||||
- Cards use **Silo maintained**, **Approved community**, and **External source**
|
||||
as the provenance labels. Do not use one ambiguous `Verified` badge.
|
||||
- The approval badge is catalog/host-derived. A plugin manifest cannot declare
|
||||
itself approved.
|
||||
- In-app release content is labeled **What's new**. The first implementation
|
||||
stores the latest release notes and links to the complete external changelog;
|
||||
it does not retain a full in-app version history.
|
||||
- The Plugin Hub is web-admin-only. No Android or Apple client changes are
|
||||
required.
|
||||
|
||||
## Explicitly Out of Scope
|
||||
|
||||
- An end-user plugin marketplace or plugin controls outside the admin UI.
|
||||
- Selecting and transferring the first batch of repositories into
|
||||
`silo-community`; that migration should follow this infrastructure plan with
|
||||
an explicit repository list.
|
||||
- Plugin rollback or installation of arbitrary historical versions.
|
||||
- Cryptographic artifact signing or reproducible-build attestation beyond the
|
||||
existing release checksum contract.
|
||||
- A remote kill switch. Removing a plugin from the approved catalog stops new
|
||||
installs and updates, but does not remotely stop already-installed code.
|
||||
- Automated proof that a plugin is safe. Approval includes human review and
|
||||
runtime validation.
|
||||
|
||||
---
|
||||
|
||||
## Verified Baseline
|
||||
|
||||
- `PluginManifest` has `metadata` and `category` but no plugin-level display
|
||||
name, summary, description, setup guide, publisher, or source links.
|
||||
`CapabilityDescriptor.description` exists but describes individual
|
||||
capabilities rather than the plugin as a whole.
|
||||
- `silo-plugins` already writes `repo_url` into each catalog package, but
|
||||
`silo-server/internal/plugins/catalog_service.go` does not decode or return
|
||||
it.
|
||||
- The catalog updater fetches the GitHub release tag and assets but does not
|
||||
preserve the release page URL, publication time, or release body.
|
||||
- The server seeds one official repository only when *no* repository rows
|
||||
exist. This is not sufficient for multiple managed channels or for adopting
|
||||
an existing installation that already has custom repositories.
|
||||
- Catalog fetches are live and unbounded, and a failing source is skipped. No
|
||||
last-known-good catalog payload is available to keep browsing and release
|
||||
notes usable during an outage.
|
||||
- Auto-update selection currently chooses the highest version for a plugin ID
|
||||
across all enabled repositories. Updates must instead stay pinned to the
|
||||
installation's repository so a custom source cannot shadow an official or
|
||||
approved plugin.
|
||||
- `AdminPlugins.tsx` is a single large page. Installed and available cards show
|
||||
raw plugin IDs, versions, capability chips, and operational controls but no
|
||||
plugin-level description, release notes, or source provenance.
|
||||
|
||||
---
|
||||
|
||||
## Public Contracts
|
||||
|
||||
### SDK manifest presentation
|
||||
|
||||
In `silo-plugin-sdk/proto/silo/plugin/v1/common.proto`, add a new additive
|
||||
message and field:
|
||||
|
||||
```text
|
||||
PluginPresentation presentation = 13;
|
||||
|
||||
PluginPresentation:
|
||||
display_name
|
||||
summary
|
||||
description_markdown
|
||||
setup_markdown
|
||||
homepage_url
|
||||
source_url
|
||||
support_url
|
||||
changelog_url
|
||||
publisher_name
|
||||
publisher_url
|
||||
license_spdx
|
||||
```
|
||||
|
||||
Contract rules:
|
||||
|
||||
- Existing manifests without `presentation` remain valid.
|
||||
- When `presentation` is present, validate lengths and accept only absolute
|
||||
`http` or `https` links. Reject control characters and unsafe schemes.
|
||||
- Recommended limits: `display_name` 120 characters, `summary` 240 characters,
|
||||
and each Markdown field 32 KiB.
|
||||
- SDK validation does not require presentation fields globally during the
|
||||
compatibility window. Official and approved-community catalog CI applies the
|
||||
stricter publishing requirement.
|
||||
- Markdown is CommonMark-style text. Raw HTML is not part of the contract.
|
||||
- `publisher_*` and source links are self-declared identity information; they
|
||||
never determine Silo approval.
|
||||
|
||||
Regenerate the Go protobuf output, update manifest fixtures and documentation,
|
||||
and publish a new additive SDK minor release before downstream repositories
|
||||
consume these fields.
|
||||
|
||||
### Catalog release and approval metadata
|
||||
|
||||
Extend the catalog package JSON shared by the official and community catalogs:
|
||||
|
||||
```text
|
||||
repo_url
|
||||
release:
|
||||
url
|
||||
published_at
|
||||
notes_markdown
|
||||
approval: # community catalog only
|
||||
approved_at
|
||||
review_url
|
||||
```
|
||||
|
||||
- `repo_url` remains the canonical source-code link generated from the GitHub
|
||||
repository that produced the release.
|
||||
- `release` is generated from the GitHub Releases API. Bound
|
||||
`notes_markdown` to 64 KiB before writing the catalog.
|
||||
- `approval` comes from the community catalog's reviewed allowlist, never from
|
||||
the plugin's manifest or release payload.
|
||||
- `changelog_url` remains in `PluginPresentation`; when absent, the web UI may
|
||||
link to the repository's Releases page derived from `repo_url`.
|
||||
- Preserve the complete protobuf manifest during catalog generation. Do not
|
||||
reduce it to a hand-built subset.
|
||||
|
||||
### Additive server API fields
|
||||
|
||||
Add `GET` and `PUT /api/v1/admin/plugins/catalog-settings`:
|
||||
|
||||
```text
|
||||
include_approved_community_plugins: boolean
|
||||
approved_community_plugin_count: number
|
||||
installed_community_plugin_count: number
|
||||
community_updates_paused: boolean
|
||||
```
|
||||
|
||||
The `PUT` body accepts only
|
||||
`include_approved_community_plugins`. It persists the preference, reconciles
|
||||
managed repository rows, and returns the new state. A source refresh failure
|
||||
does not roll the preference back; the repository status reports the failure
|
||||
and the catalog falls back to cached data when available.
|
||||
|
||||
Add `GET /api/v1/admin/plugins/capabilities` for feature detection. It reports
|
||||
support for typed presentation metadata, release notes, approved community
|
||||
catalogs, and last-known catalog caching.
|
||||
|
||||
Extend existing responses additively:
|
||||
|
||||
- `PluginRepository`: `source_kind`, `managed`, `last_fetch_error`,
|
||||
`last_fetch_error_at`.
|
||||
- `PluginCatalogEntry`: typed `presentation`, `source_kind`, repository display
|
||||
name, `repo_url`, `release`, optional `approval`, and `stale`.
|
||||
- `PluginInstallation`: typed `presentation`, source/repository provenance,
|
||||
the matching current or available release metadata when known, and
|
||||
`updates_paused`.
|
||||
|
||||
Keep existing `metadata`, capability, route, asset, and configuration fields
|
||||
unchanged for `/api/v1` compatibility.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — SDK Presentation Contract
|
||||
|
||||
Repository: `silo-plugin-sdk`
|
||||
|
||||
- [ ] Add `PluginPresentation` and field 13 to the protobuf contract; regenerate
|
||||
`pkg/pluginproto` with `make proto`.
|
||||
- [ ] Add reusable URL and length validation in
|
||||
`pkg/pluginsdk/manifest/manifest.go` without making presentation mandatory for
|
||||
older plugins.
|
||||
- [ ] Add decode/round-trip/validation tests for complete, partial, absent, and
|
||||
unsafe presentation blocks.
|
||||
- [ ] Document every presentation field and provide one complete example
|
||||
manifest for plugin authors.
|
||||
- [ ] Publish an additive SDK minor release and verify the tag is consumable
|
||||
from a clean downstream module.
|
||||
|
||||
Verification: `GOWORK=off go test ./...`, `make proto`, and a clean-tree check
|
||||
after regeneration.
|
||||
|
||||
## Phase 2 — Catalog Tooling and Community Approval Gate
|
||||
|
||||
Repositories: `Silo-Server/silo-plugins` and new
|
||||
`silo-community/silo-plugins`
|
||||
|
||||
- [ ] Extend the shared catalog generator's GitHub `Release` DTO and
|
||||
`CatalogPackage` to preserve source, publication, release-note, and approval
|
||||
fields.
|
||||
- [ ] Keep the update workflow's shared concurrency group so simultaneous
|
||||
plugin releases cannot race on `manifest.json`.
|
||||
- [ ] Add tests proving the generator preserves the full manifest, bounds
|
||||
release notes, derives safe URLs, and produces deterministic JSON.
|
||||
- [ ] Create `silo-community/silo-plugins` with the same catalog package and
|
||||
release-asset contract as the official catalog.
|
||||
- [ ] Add `approved-plugins.json` to the community catalog. Each active entry
|
||||
contains `plugin_id`, exact GitHub repository, `approved_at`, and a URL to the
|
||||
approval review/evidence.
|
||||
- [ ] Make community catalog updates reject releases whose repository or
|
||||
plugin ID is not active in `approved-plugins.json`.
|
||||
- [ ] Protect the community catalog's main branch and approval registry with
|
||||
CODEOWNERS/required review. Release dispatch alone must not grant approval.
|
||||
- [ ] Add a rebuild/check command that removes packages no longer present in
|
||||
the active allowlist so catalog membership cannot remain stale indefinitely.
|
||||
- [ ] Publish an approval policy documenting the minimum review: successful
|
||||
builds/tests, checksum-bearing release artifacts, manifest/setup accuracy,
|
||||
runtime validation on a supported Silo version, source/release-workflow
|
||||
review, license, support path, and no known unsafe behavior.
|
||||
|
||||
Verification: `GOWORK=off go test ./...`; run the updater against fixtures for
|
||||
an approved and rejected repository; run the catalog rebuild/check twice to
|
||||
prove idempotence.
|
||||
|
||||
## Phase 3 — Server Repository Provenance, Preference, and Cache
|
||||
|
||||
Repository: `silo-server`
|
||||
|
||||
- [ ] Create a timestamped Goose migration with
|
||||
`make migrate-create NAME=plugin_catalog_provenance`.
|
||||
- [ ] Extend `plugin_repositories` with a nullable unique `managed_key`, a
|
||||
constrained `source_kind` (`silo`, `approved_community`, `external`), and
|
||||
last-fetch error fields. Existing rows default to `external` and the known
|
||||
official URL is adopted as `silo` during reconciliation.
|
||||
- [ ] Add a `plugin_catalog_cache` table keyed by repository, plugin ID, and
|
||||
version, storing the normalized catalog package JSON plus fetch time. Delete
|
||||
cache rows with the repository.
|
||||
- [ ] Add a plugin-owned setting key
|
||||
`plugins.include_approved_community_plugins`; missing/invalid values resolve
|
||||
to `false`.
|
||||
- [ ] Replace `seedDefaultRepository` with an idempotent managed-repository
|
||||
reconciler. It always adopts/upserts the official catalog and upserts
|
||||
`https://raw.githubusercontent.com/silo-community/silo-plugins/main/manifest.json`
|
||||
enabled according to the community setting. Structure the registry as a list
|
||||
so another approved community catalog can be added without a second toggle.
|
||||
- [ ] Managed repository URLs, names, keys, and provenance are read-only through
|
||||
manual repository CRUD. Custom repositories retain existing create/edit/delete
|
||||
behavior.
|
||||
- [ ] Bound repository-index downloads before JSON decoding. Validate and cache
|
||||
only accepted packages; on a successful refresh transactionally replace that
|
||||
repository's cache and clear its error state.
|
||||
- [ ] On a fetch failure, record a concise error and serve last-known cached
|
||||
entries with `stale=true`. An enabled source with no cache returns no entries
|
||||
but remains enabled for retry.
|
||||
- [ ] Resolve catalog duplicates deterministically by source precedence
|
||||
(`silo` → `approved_community` → `external`) before comparing versions within
|
||||
a source. A higher-version custom package must never shadow the same official
|
||||
or approved plugin ID.
|
||||
- [ ] Make update discovery repository-pinned: installations with a
|
||||
`repository_id` compare only against that repository's entries. Direct uploads
|
||||
without a repository do not gain catalog updates accidentally.
|
||||
- [ ] When a source is disabled, retain `available_version` but return
|
||||
`updates_paused=true`; applying the update is blocked until the source is
|
||||
enabled again.
|
||||
- [ ] Enrich installed responses from the on-disk manifest first and matching
|
||||
cached catalog metadata second, so descriptions and source links survive a
|
||||
network outage and release notes remain available after the community channel
|
||||
is disabled.
|
||||
|
||||
Tests:
|
||||
|
||||
- managed-source reconciliation is idempotent and adopts an existing official
|
||||
row without duplication;
|
||||
- the community preference defaults off and toggles all managed community
|
||||
sources without touching custom rows;
|
||||
- stale cache is served after fetch failure and replaced after recovery;
|
||||
- official/community/external duplicate precedence is deterministic;
|
||||
- update discovery cannot switch an installation to another repository;
|
||||
- disabling the community channel leaves installations enabled and marks their
|
||||
updates paused;
|
||||
- migration Up/Down and repository constraints behave correctly against the
|
||||
test database.
|
||||
|
||||
## Phase 4 — Server HTTP API
|
||||
|
||||
Repository: `silo-server`
|
||||
|
||||
- [ ] Add typed catalog-settings and capability handlers to
|
||||
`internal/api/handlers/plugins.go` or focused files in the same handler
|
||||
package; mount them under the existing acting-admin plugin route group.
|
||||
- [ ] Extend repository, catalog, and installation serializers with the
|
||||
additive presentation, release, provenance, approval, stale, and paused
|
||||
fields.
|
||||
- [ ] Compute `installed_community_plugin_count` by repository provenance, not
|
||||
by parsing plugin IDs or GitHub URLs.
|
||||
- [ ] Validate the `PUT` body strictly and make preference persistence plus
|
||||
managed-row reconciliation atomic from the caller's perspective.
|
||||
- [ ] Keep catalog source failures per-repository: one broken community or
|
||||
custom source must not fail the entire catalog response.
|
||||
- [ ] Add handler tests for default-off state, enable/disable, malformed input,
|
||||
community installation counts, additive response fields, stale-source
|
||||
reporting, and acting-admin authorization.
|
||||
|
||||
Verification:
|
||||
`GOWORK=off go test ./internal/plugins/... ./internal/api/handlers/...`.
|
||||
|
||||
## Phase 5 — Plugin Hub Web UI
|
||||
|
||||
Repository: `silo-server`
|
||||
|
||||
- [ ] Split `AdminPlugins.tsx` into focused components under
|
||||
`web/src/components/admin/plugins/` or `web/src/pages/admin-plugins/`: page
|
||||
shell, Installed list, Catalog grid, catalog controls, detail sheet,
|
||||
configuration content, Markdown renderer, and advanced source manager.
|
||||
- [ ] Add typed DTOs and TanStack Query hooks for plugin capabilities and
|
||||
catalog settings. Mutations invalidate settings, repositories, catalog, and
|
||||
installations together.
|
||||
- [ ] Keep **Installed** as the default tab and rename **Available** to
|
||||
**Catalog**.
|
||||
- [ ] Place **Include approved community plugins** in the Catalog header with
|
||||
this helper meaning: these plugins are validated by Silo to work as described
|
||||
and are considered safe, but are maintained and supported by community
|
||||
contributors.
|
||||
- [ ] Enabling shows a refresh state and then community entries. Disabling with
|
||||
installed community plugins opens a confirmation that reports the count and
|
||||
explains that plugins keep running while update discovery pauses.
|
||||
- [ ] Add search plus provenance/capability filters. Preserve the active tab,
|
||||
search, and filters in URL search parameters.
|
||||
- [ ] Catalog cards show display name, publisher/provenance, two-line summary,
|
||||
capabilities, version, configuration requirement, and Install. Installed
|
||||
rows show operational status, summary, version, Configure/Open, and
|
||||
Review update; move update policy, disable, and uninstall into secondary
|
||||
controls.
|
||||
- [ ] Use URL search parameters for addressable details:
|
||||
`installation=<id>` for installed plugins and
|
||||
`repository=<id>&plugin=<plugin_id>` for catalog entries. Browser Back closes
|
||||
the sheet and focus returns to the originating card. Use a full-width sheet
|
||||
on small screens.
|
||||
- [ ] Detail sections are **About**, **Setup**, **What's new**, and
|
||||
**Technical details**. Technical details include plugin ID, API/platform
|
||||
compatibility, publisher, license, source catalog, source repository, support,
|
||||
and changelog links.
|
||||
- [ ] Change update behavior from immediate `Update` to `Review update`; show
|
||||
the target release notes before the administrator confirms the update.
|
||||
- [ ] Add a constrained Markdown renderer. Raw HTML and images are disabled;
|
||||
only safe `http`/`https` links are rendered, and external links use
|
||||
`rel="noopener noreferrer"`.
|
||||
- [ ] Move repository CRUD and archive upload beneath **Manage sources →
|
||||
Advanced**. Managed official/community sources show status and fetch errors
|
||||
but cannot have their system URL edited or be deleted.
|
||||
- [ ] Backward-compatible UI fallbacks: humanize `plugin_id` when display name
|
||||
is absent; use the first capability description when summary is absent; show
|
||||
“No setup guide provided” or “No release notes were published” rather than an
|
||||
empty panel.
|
||||
- [ ] Add accessible labels, keyboard navigation, focus management, loading
|
||||
skeletons, empty states, and per-source stale/error messaging.
|
||||
|
||||
Frontend tests:
|
||||
|
||||
- default-off toggle and opt-in catalog refresh;
|
||||
- disable confirmation with installed community plugins;
|
||||
- provenance filters and badges;
|
||||
- legacy-manifest fallback rendering;
|
||||
- detail deep link and Back behavior;
|
||||
- safe Markdown links with raw HTML/images suppressed;
|
||||
- Review update requires confirmation and displays release notes;
|
||||
- stale catalog and missing release-note states.
|
||||
|
||||
Verification: `cd web && pnpm run lint`, `cd web && pnpm run format:check`,
|
||||
`cd web && pnpm test`, and `cd web && pnpm run build`. Capture desktop and
|
||||
mobile screenshots for the UI PR.
|
||||
|
||||
## Phase 6 — Manifest Backfill and Community Migration Readiness
|
||||
|
||||
Repositories: current first-party plugins and future community plugin repos
|
||||
|
||||
- [ ] Update every cataloged plugin to the released SDK version and add complete
|
||||
presentation metadata. Use plain language for homelab operators; capability
|
||||
descriptions remain technical and specific.
|
||||
- [ ] Require each repository to publish meaningful GitHub release notes so
|
||||
What's new is not an empty surface.
|
||||
- [ ] Standardize README setup instructions, license, security/support policy,
|
||||
CODEOWNERS or named maintainers, and the existing checksum-bearing release
|
||||
workflow.
|
||||
- [ ] Before transferring a selected plugin to `silo-community`, update its
|
||||
manifest publisher/source links and release dispatch target, add it to the
|
||||
community approval registry through review, and remove it from the official
|
||||
catalog in the same rollout window.
|
||||
- [ ] Verify a transferred plugin appears only once, installs from the community
|
||||
repository, remains repository-pinned for updates, and is hidden for admins
|
||||
who have not enabled the community channel.
|
||||
|
||||
---
|
||||
|
||||
## Rollout Order
|
||||
|
||||
1. Merge and release the SDK contract.
|
||||
2. Upgrade official catalog tooling and create the approved community catalog
|
||||
plus approval policy/registry.
|
||||
3. Land the server migration, managed-source reconciliation, cache, update
|
||||
pinning, and additive APIs.
|
||||
4. Land the Plugin Hub UI and keep the community switch default-off.
|
||||
5. Backfill presentation data and release notes in existing official plugins.
|
||||
6. Transfer selected plugins one at a time only after both catalogs and the
|
||||
deployed server understand provenance.
|
||||
|
||||
Older servers ignore new catalog/manifest fields; newer servers retain fallbacks
|
||||
for older manifests. Do not transfer a plugin out of the official catalog before
|
||||
the community catalog and server opt-in path are deployed.
|
||||
|
||||
## Final Acceptance Scenarios
|
||||
|
||||
- A fresh server shows Silo-maintained catalog entries with friendly names,
|
||||
descriptions, setup instructions, source links, and release notes; approved
|
||||
community plugins are absent.
|
||||
- An admin enables **Include approved community plugins**, sees the approved
|
||||
entries without entering a URL, and can identify their community maintenance
|
||||
and Silo approval clearly.
|
||||
- A community catalog outage leaves cached entries visible with a stale warning
|
||||
and does not break the official catalog.
|
||||
- Disabling the community channel with installed community plugins requires
|
||||
confirmation, leaves those plugins running, and clearly reports paused update
|
||||
discovery.
|
||||
- A custom repository publishing the same plugin ID or a higher version cannot
|
||||
replace or update an official/approved installation.
|
||||
- Review update presents the exact target release notes before installation.
|
||||
- An old plugin without presentation fields remains manageable with readable
|
||||
fallbacks.
|
||||
- Removing a plugin from the approved catalog stops new discovery and updates
|
||||
without remotely disabling existing installations.
|
||||
|
||||
## Cross-Repository Verification Gate
|
||||
|
||||
- `silo-plugin-sdk`: `GOWORK=off go test ./...` and `make proto`.
|
||||
- Official and community catalogs: `GOWORK=off go test ./...` plus catalog
|
||||
rebuild/check idempotence.
|
||||
- `silo-server`: focused plugin/API Go tests, web lint/format/test/build,
|
||||
`make lint`, and `make verify-local-paths`.
|
||||
- End-to-end dev smoke: toggle community off/on, install one approved plugin,
|
||||
configure it, simulate a catalog outage, review an update, disable the
|
||||
community channel, and confirm the installed plugin continues running while
|
||||
updates are paused.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,345 @@
|
||||
# Ebook Enrichment Architecture Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make ebook discovery fast and deterministic while metadata enrichment runs independently as a durable, bounded, observable workflow that can improve records over time.
|
||||
|
||||
**Architecture:** The scanner remains responsible for filesystem reconciliation, embedded metadata, local artwork, format grouping, and identity hints such as ISBN. It never performs broad remote enrichment. A database-backed ebook enrichment state machine claims work with leases, applies outcome-specific retry or refresh horizons, and is drained by the existing task manager. The ebook metadata plugin uses ISBN-first source tiers, ranked matches, and per-source health cooldowns instead of fanning every title query out to every configured source.
|
||||
|
||||
**Tech Stack:** Go, PostgreSQL/goose, pgx, Silo task manager, Silo plugin gRPC API
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Ebook scans must not wait for remote metadata providers.
|
||||
- Existing embedded ebook metadata and local artwork remain higher priority than remote metadata.
|
||||
- Ebooks use ISBN identifiers; audiobook-only identifiers and narrator fields must not be introduced.
|
||||
- Enrichment must survive process restarts and must not let concurrent workers claim the same item.
|
||||
- Provider outages, rate limits, and deterministic client errors must not block scans or create tight retry loops.
|
||||
- Previously enriched records must become eligible for controlled refresh so corrected metadata can be adopted later.
|
||||
- Changes must be based on `Silo-Server/silo-server` upstream `main` and must not modify unrelated local branches.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Decouple Ebook Scans From Remote Retry
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/libraryingest/executor.go`
|
||||
- Modify: `internal/libraryingest/executor_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `librarykind.Of(folder.Type)` and the existing matcher interface.
|
||||
- Produces: `usesDedicatedEnrichment(folderType string) bool`, used to skip synchronous unmatched-item retry for ebooks while preserving existing video behavior.
|
||||
|
||||
- [x] **Step 1: Write the failing executor test**
|
||||
|
||||
Add a matcher that records retry calls and a table test which ingests an ebook folder and a movie folder. Assert that `RetryUnmatchedItemsByFolderAndPathPrefix` is not called for `ebooks`, but remains called for `movies`.
|
||||
|
||||
- [x] **Step 2: Run the focused test and verify red**
|
||||
|
||||
Run: `go test ./internal/libraryingest -run TestIngestFolderSkipsSynchronousRetryForDedicatedEnrichment -count=1`
|
||||
|
||||
Expected: FAIL because ebook ingestion still calls the synchronous retry method.
|
||||
|
||||
- [x] **Step 3: Implement the dedicated-enrichment gate**
|
||||
|
||||
Add:
|
||||
|
||||
```go
|
||||
func usesDedicatedEnrichment(folderType string) bool {
|
||||
return librarykind.Of(folderType).Ebook
|
||||
}
|
||||
```
|
||||
|
||||
In the scoped matching loop, retain `ProcessAllByFolderAndPathPrefix` and variant finalization, but call `RetryUnmatchedItemsByFolderAndPathPrefix` only when `usesDedicatedEnrichment(folder.Type)` is false.
|
||||
|
||||
- [x] **Step 4: Verify the focused and package tests**
|
||||
|
||||
Run: `go test ./internal/libraryingest -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/libraryingest/executor.go internal/libraryingest/executor_test.go
|
||||
git commit -m "fix(ebooks): decouple enrichment from library scans"
|
||||
```
|
||||
|
||||
### Task 2: Replace Failure Counting With a Durable Enrichment State Machine
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/sql/20260719090000_ebook_enrichment_jobs.sql`
|
||||
- Create: `internal/ebooks/enrichment_queue.go`
|
||||
- Create: `internal/ebooks/enrichment_queue_test.go`
|
||||
- Modify: `internal/ebooks/enrichment.go`
|
||||
- Modify: `internal/ebooks/enrichment_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `EnrichmentQueue.Enqueue(ctx, contentID, priority)`, `ClaimBatch(ctx, limit, leaseDuration)`, `Complete(ctx, contentID, outcome, refreshAfter)`, and `Fail(ctx, contentID, errorClass, message, retryAfter)`.
|
||||
- Consumes: `enrichmentItemRow` and the existing `Enricher.enrichItem` persistence path.
|
||||
|
||||
- [x] **Step 1: Write failing queue query and transition tests**
|
||||
|
||||
Test that claims use `FOR UPDATE SKIP LOCKED`, set `lease_until`, and only select `pending` jobs whose `next_attempt_at` is due or whose lease expired. Test outcome policies:
|
||||
|
||||
```go
|
||||
success -> next_attempt_at = now + 90 days
|
||||
no_match -> next_attempt_at = now + 30 days
|
||||
transient failure -> exponential backoff capped at 24 hours
|
||||
rate_limited -> provider retry horizon, capped at 24 hours
|
||||
permanent failure -> next_attempt_at = now + 30 days
|
||||
```
|
||||
|
||||
- [x] **Step 2: Run queue tests and verify red**
|
||||
|
||||
Run: `go test ./internal/ebooks -run 'TestEnrichmentQueue|TestEnrichmentRetryPolicy' -count=1`
|
||||
|
||||
Expected: FAIL because the queue and retry policy do not exist.
|
||||
|
||||
- [x] **Step 3: Add the migration**
|
||||
|
||||
Extend `ebook_enrichment_state` with:
|
||||
|
||||
```sql
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
priority integer NOT NULL DEFAULT 0,
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
next_attempt_at timestamptz NOT NULL DEFAULT now(),
|
||||
lease_until timestamptz,
|
||||
last_attempt_at timestamptz,
|
||||
completed_at timestamptz,
|
||||
outcome text,
|
||||
last_error_class text,
|
||||
last_error text
|
||||
```
|
||||
|
||||
Add a partial claim index on `(priority DESC, next_attempt_at, updated_at)` for rows whose status is `pending` or `running`.
|
||||
|
||||
- [x] **Step 4: Implement atomic queue claims and transitions**
|
||||
|
||||
Use one transaction and a CTE:
|
||||
|
||||
```sql
|
||||
WITH candidates AS (
|
||||
SELECT content_id
|
||||
FROM ebook_enrichment_state
|
||||
WHERE next_attempt_at <= now()
|
||||
AND (status = 'pending' OR (status = 'running' AND lease_until < now()))
|
||||
ORDER BY priority DESC, next_attempt_at, updated_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT $1
|
||||
)
|
||||
UPDATE ebook_enrichment_state state
|
||||
SET status = 'running',
|
||||
lease_until = now() + $2::interval,
|
||||
last_attempt_at = now(),
|
||||
attempts = attempts + 1,
|
||||
updated_at = now()
|
||||
FROM candidates
|
||||
WHERE state.content_id = candidates.content_id
|
||||
RETURNING state.content_id;
|
||||
```
|
||||
|
||||
Keep state rows after success so refresh eligibility and prior outcomes are durable.
|
||||
|
||||
- [x] **Step 5: Route `Enricher.Run` through the queue**
|
||||
|
||||
Materialize missing candidates into `ebook_enrichment_state` with `INSERT ... SELECT ... ON CONFLICT DO NOTHING`, claim a bounded batch, load the existing item fields, and transition each claim to success, no-match, skipped, or failure. Context cancellation must release the lease without incrementing item failure state.
|
||||
|
||||
- [x] **Step 6: Verify ebook package tests**
|
||||
|
||||
Run: `go test ./internal/ebooks -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add migrations/sql/20260719090000_ebook_enrichment_jobs.sql internal/ebooks/enrichment_queue.go internal/ebooks/enrichment_queue_test.go internal/ebooks/enrichment.go internal/ebooks/enrichment_test.go
|
||||
git commit -m "feat(ebooks): add durable metadata enrichment queue"
|
||||
```
|
||||
|
||||
### Task 3: Drain Bounded Work Continuously And Report Honest Progress
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/taskmanager/tasks/sync_ebook_metadata.go`
|
||||
- Modify: `internal/taskmanager/tasks/sync_ebook_metadata_test.go`
|
||||
- Modify: `internal/ebooks/enrichment.go`
|
||||
- Modify: `cmd/silo/main.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `EnrichmentRunResult{Claimed, Enriched, NoMatch, Failed, Deferred, Remaining int}`, an incremental task, and a separate manual legacy-backfill task.
|
||||
- Consumes: the durable queue from Task 2.
|
||||
|
||||
- [x] **Step 1: Write failing task tests**
|
||||
|
||||
Test that the scheduled task drains only priority-zero-or-higher work until the queue is empty or a four-minute execution budget expires. Add a separate `backfill_ebook_metadata` task with no default triggers which may claim the priority `-100` legacy backlog. Assert progress messages include claimed, enriched, failed, and remaining counts, and cancellation stops between batches.
|
||||
|
||||
- [x] **Step 2: Run focused task tests and verify red**
|
||||
|
||||
Run: `go test ./internal/taskmanager/tasks -run TestSyncEbookMetadata -count=1`
|
||||
|
||||
Expected: FAIL because the task runs one opaque batch and only returns `items_enriched`.
|
||||
|
||||
- [x] **Step 3: Add structured enrichment results**
|
||||
|
||||
Change `Enricher.Run` to return:
|
||||
|
||||
```go
|
||||
type EnrichmentRunResult struct {
|
||||
Claimed int `json:"claimed"`
|
||||
Enriched int `json:"enriched"`
|
||||
NoMatch int `json:"no_match"`
|
||||
Failed int `json:"failed"`
|
||||
Deferred int `json:"deferred"`
|
||||
Remaining int `json:"remaining"`
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 4: Implement time-budgeted draining**
|
||||
|
||||
Loop over bounded queue claims while work remains and the execution deadline has not elapsed. The scheduled task must never claim legacy-backfill rows; only the manually triggered backfill task may include them. Report progress after every batch; never estimate 100 percent until no immediately eligible work remains.
|
||||
|
||||
- [x] **Step 5: Verify task and ebook tests**
|
||||
|
||||
Run: `go test ./internal/taskmanager/tasks ./internal/ebooks -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/taskmanager/tasks/sync_ebook_metadata.go internal/taskmanager/tasks/sync_ebook_metadata_test.go internal/ebooks/enrichment.go
|
||||
git commit -m "feat(ebooks): drain enrichment backlog with progress"
|
||||
```
|
||||
|
||||
### Task 4: Rebuild Ebook Provider Strategy In The Metadata Plugin
|
||||
|
||||
**Files (plugin repository `silo-plugin-ebook-metadata`):**
|
||||
- Create: `provider/health.go`
|
||||
- Create: `provider/health_test.go`
|
||||
- Create: `provider/ranking.go`
|
||||
- Create: `provider/ranking_test.go`
|
||||
- Modify: `provider/provider.go`
|
||||
- Modify: `provider/provider_test.go`
|
||||
- Modify: `README.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: source tiers `identifier`, `catalog`, and `extended`; `SourceHealth.Allow`, `RecordSuccess`, and `RecordFailure`; deterministic `RankMatches`.
|
||||
- Consumes: existing `Source.Search`, `Source.Fetch`, normalized ISBN helpers, and plugin settings.
|
||||
|
||||
- [x] **Step 1: Write failing source-tier tests**
|
||||
|
||||
Assert default operation enables reliable catalog sources only, exact ISBN fetches use identifier-capable sources in order, and title/author searches do not invoke optional scraper sources unless explicitly enabled.
|
||||
|
||||
- [x] **Step 2: Write failing health-policy tests**
|
||||
|
||||
Assert HTTP 403/405 disables a source for one hour, HTTP 429 honors `Retry-After`, timeouts use exponential cooldown, and one healthy response closes the circuit.
|
||||
|
||||
- [x] **Step 3: Write failing ranking tests**
|
||||
|
||||
Rank exact normalized ISBN first, then normalized title plus author, then title plus year/language. Reject low-confidence title-only collisions and deduplicate matches by normalized ISBN or source/provider ID.
|
||||
|
||||
- [x] **Step 4: Implement tiered querying**
|
||||
|
||||
For an ISBN query, fetch identifier sources sequentially and stop after a complete high-confidence match. For title/author queries, query the reliable catalog tier with bounded concurrency, rank all results, and query the extended tier only when enabled and the catalog tier produced no acceptable match.
|
||||
|
||||
- [x] **Step 5: Implement per-source health cooldowns**
|
||||
|
||||
Wrap every source call with health admission and outcome recording. A source in cooldown is skipped without returning a provider-wide error; healthy sources continue serving the request.
|
||||
|
||||
- [x] **Step 6: Update plugin documentation**
|
||||
|
||||
Document default sources, optional extended sources, API-key sources, match ranking, cooldown behavior, and the `enabled_sources` override.
|
||||
|
||||
- [x] **Step 7: Verify plugin tests**
|
||||
|
||||
Run: `go test ./... -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add provider/health.go provider/health_test.go provider/ranking.go provider/ranking_test.go provider/provider.go provider/provider_test.go README.md
|
||||
git commit -m "feat: add tiered resilient ebook metadata sources"
|
||||
```
|
||||
|
||||
### Task 5: Add Backfill Safety Controls
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/taskmanager/tasks/sync_ebook_metadata.go`
|
||||
- Modify: `internal/taskmanager/tasks/sync_ebook_metadata_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the manual legacy task from Task 3.
|
||||
- Produces: a claim cap, inter-batch pacing, and a no-progress circuit breaker.
|
||||
|
||||
- [ ] **Step 1: Preserve typed source deferrals**
|
||||
|
||||
Count `ResourceExhausted` provider results as deferred work with the provider's
|
||||
retry delay, not as generic failures.
|
||||
|
||||
- [ ] **Step 2: Stop stalled drains**
|
||||
|
||||
Stop cleanly after one full batch with no terminal outcome. A plugin packaging
|
||||
error, source outage, or rate-limit saturation must never walk the backlog.
|
||||
|
||||
- [ ] **Step 3: Add canary controls**
|
||||
|
||||
Support a maximum number of claims per manual run and an inter-batch delay.
|
||||
The first production trial uses 20 claims, one worker, and a one-second delay.
|
||||
|
||||
- [ ] **Step 4: Verify safety behavior**
|
||||
|
||||
Test all-failed, all-deferred, mixed-progress, claim-cap, pacing, cancellation,
|
||||
and honest result reporting.
|
||||
|
||||
### Task 6: End-To-End Verification And Operational Rollout
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/plans/2026-07-19-ebook-enrichment-architecture.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 1-4.
|
||||
- Produces: verified deployment and backfill procedure.
|
||||
|
||||
- [ ] **Step 1: Run server verification**
|
||||
|
||||
Run: `go test ./internal/libraryingest ./internal/ebooks ./internal/taskmanager/tasks ./internal/scanner -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run race-sensitive server verification**
|
||||
|
||||
Run: `go test -race ./internal/ebooks ./internal/taskmanager/tasks -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Run plugin verification**
|
||||
|
||||
Run from the plugin worktree: `go test -race ./... -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Package and preflight**
|
||||
|
||||
Build the plugin binary and installed manifest together. Verify their checksums,
|
||||
versions, and runtime manifests match before replacing a running installation.
|
||||
|
||||
- [ ] **Step 5: Deploy a bounded production canary**
|
||||
|
||||
Deploy without overwriting compose or `.env`. Use 20 maximum claims, one worker,
|
||||
and a one-second inter-batch delay. Do not enable an automatic legacy trigger.
|
||||
|
||||
- [ ] **Step 6: Apply acceptance gates**
|
||||
|
||||
Promote only when runtime-manifest errors are zero, no full batch fails or
|
||||
defers, at least 80 percent of claimed rows reach success or no-match, database
|
||||
claims remain below one second, and Silo stays healthy without scan regressions.
|
||||
|
||||
- [ ] **Step 7: Expand in measured stages**
|
||||
|
||||
Increase the claim cap only after recording throughput, provider error rate,
|
||||
queue depth, database load, and projected completion time. Stop automatically
|
||||
when a stage misses an acceptance gate.
|
||||
@@ -0,0 +1,248 @@
|
||||
# Unified Literary Works Design
|
||||
|
||||
## Goal
|
||||
|
||||
Silo should expose a server-owned literary work identity that links multiple format-specific catalog items for the same book, especially ebooks and audiobooks, without collapsing those items into one `media_items` row. Clients can then render one detail page with format tabs/actions while existing item detail, playback, reading, download, and catalog APIs keep working.
|
||||
|
||||
The implementation should target the ebook stack head, currently `work/ebook-reader-ruler-profiles`, which includes the ebook foundation, #98 local-cover/grouping work, and #99 reader profile/ruler work. That stack already has ebook scanning, ebook series metadata, ebook reader progress, and ebook detail extensions. The work layer should sit above those existing format-specific systems.
|
||||
|
||||
## Existing Context
|
||||
|
||||
`media_items.content_id` is the stable identity for each catalog item. Audiobooks are `media_items.type = 'audiobook'`; ebooks are introduced as `media_items.type = 'ebook'` in the ebook stack. These identities must remain independent because each format has different files, progress, playback/reader behavior, external compatibility, and library membership.
|
||||
|
||||
Audiobooks already store author and narrator credits in `item_people`, series data in `audiobook_series`, file data in `media_files`, and listening progress in existing playback/watch progress tables. The ebook stack adds ebook author credits, `ebook_series`, `ebook_reader_progress`, reader state/config, and ebook file serving. #98 also groups sibling ebook file formats by book identity during scan; that is an intra-ebook item grouping behavior, not the cross-format work identity.
|
||||
|
||||
The current catalog detail endpoint is `GET /api/v1/catalog/items/{id}` through `CatalogResourceHandler.HandleGetItemDetail`, backed by `catalog.DetailService.GetItemDetail`. Catalog browse/search remains item-oriented by default through catalog query/browse code.
|
||||
|
||||
## Data Model
|
||||
|
||||
Add a neutral work layer:
|
||||
|
||||
```sql
|
||||
CREATE TABLE literary_works (
|
||||
work_id TEXT PRIMARY KEY,
|
||||
canonical_title TEXT NOT NULL,
|
||||
sort_title TEXT,
|
||||
normalized_title TEXT NOT NULL,
|
||||
primary_author_key TEXT NOT NULL DEFAULT '',
|
||||
primary_cover_content_id TEXT REFERENCES media_items(content_id) ON DELETE SET NULL,
|
||||
description TEXT,
|
||||
published_date DATE,
|
||||
publisher TEXT,
|
||||
genres TEXT[] NOT NULL DEFAULT '{}',
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE literary_work_items (
|
||||
work_id TEXT NOT NULL REFERENCES literary_works(work_id) ON DELETE CASCADE,
|
||||
content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
|
||||
format_type TEXT NOT NULL,
|
||||
link_source TEXT NOT NULL,
|
||||
confidence DOUBLE PRECISION NOT NULL DEFAULT 1,
|
||||
confirmed_at TIMESTAMPTZ,
|
||||
ignored_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (work_id, content_id),
|
||||
UNIQUE (content_id),
|
||||
CHECK (format_type IN ('ebook', 'audiobook', 'comic', 'manga')),
|
||||
CHECK (link_source IN ('manual', 'external_id', 'metadata_match', 'series_match', 'scan_seed'))
|
||||
);
|
||||
|
||||
CREATE TABLE literary_work_match_decisions (
|
||||
source_content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
|
||||
target_content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
|
||||
decision TEXT NOT NULL,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (source_content_id, target_content_id),
|
||||
CHECK (source_content_id <> target_content_id),
|
||||
CHECK (decision IN ('confirmed', 'ignored'))
|
||||
);
|
||||
```
|
||||
|
||||
Every ebook or audiobook can belong to at most one work through `UNIQUE (content_id)`. A work can contain many format-specific items. Manual/admin actions update `literary_work_items` and `literary_work_match_decisions`; automatic matching must not override an ignored pair or move a manually linked item without an explicit admin request.
|
||||
|
||||
Work IDs should be generated by the existing server ID generator, not derived directly from title text. Matching keys can change when metadata improves, but the `work_id` remains stable.
|
||||
|
||||
## Matching Rules
|
||||
|
||||
Matching runs in a `literaryworks` service after scan/enrichment or as an admin-triggered repair job. It considers literary item types only: `ebook`, `audiobook`, and later `comic`/`manga`.
|
||||
|
||||
Authority order:
|
||||
|
||||
1. Manual confirmed links and manual unlinks/ignored pairs.
|
||||
2. Shared external IDs from `media_item_provider_ids`, excluding format-specific aliases that are not true work identity.
|
||||
3. Normalized title plus normalized author.
|
||||
4. Normalized series name plus series index plus author.
|
||||
5. Publication year/date and publisher as supporting evidence only.
|
||||
|
||||
Automatic matching should be conservative. Same title alone is not enough. Same title plus different primary author should not link. Same title plus same author can link unless there is strong conflicting series/index evidence. Same series/index plus same author can link even if titles differ slightly due subtitle/edition formatting. Narrator is never required for ebook matching and must not prevent an ebook/audiobook work link; narrator remains audiobook-format metadata.
|
||||
|
||||
The matcher should produce scored candidates. Scores at or above the automatic threshold can create links when neither item has conflicting manual state. Lower scores are exposed as possible matches for admin review.
|
||||
|
||||
## Service Boundaries
|
||||
|
||||
Create an `internal/literaryworks` package with:
|
||||
|
||||
- `Repository`: reads/writes `literary_works`, memberships, decisions, and candidate rows.
|
||||
- `Matcher`: normalizes item metadata, scores candidates, and applies conservative automatic links.
|
||||
- `Service`: public orchestration used by handlers and background jobs.
|
||||
|
||||
The package depends on catalog repositories and the database pool, but catalog detail should depend on a small interface so item detail can include work data without creating a large import cycle.
|
||||
|
||||
The matcher should reuse existing item metadata:
|
||||
|
||||
- Titles from `media_items`.
|
||||
- Authors from `item_people.kind = author`.
|
||||
- Audiobook narrators from `item_people.kind = narrator` only for display inside audiobook format details.
|
||||
- Series from `audiobook_series` and `ebook_series`.
|
||||
- External IDs from `media_item_provider_ids`.
|
||||
- File metadata from `media_files`.
|
||||
|
||||
## API Contract
|
||||
|
||||
Add a work endpoint:
|
||||
|
||||
`GET /api/v1/works/{work_id}`
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"work_id": "work_123",
|
||||
"work_title": "Project Hail Mary",
|
||||
"authors": [{ "name": "Andy Weir", "person_id": "42" }],
|
||||
"formats": [
|
||||
{
|
||||
"type": "ebook",
|
||||
"content_id": "ebook_1",
|
||||
"library_id": 1,
|
||||
"available_files": [
|
||||
{
|
||||
"file_id": 10,
|
||||
"original_filename": "Project Hail Mary.epub",
|
||||
"format": "epub",
|
||||
"mime_type": "application/epub+zip",
|
||||
"size": 12345
|
||||
}
|
||||
],
|
||||
"progress": {
|
||||
"kind": "reading",
|
||||
"progress": 0.42,
|
||||
"updated_at": "2026-06-09T12:00:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "audiobook",
|
||||
"content_id": "audio_1",
|
||||
"library_id": 2,
|
||||
"available_files": [
|
||||
{
|
||||
"file_id": 20,
|
||||
"original_filename": "Project Hail Mary.m4b",
|
||||
"format": "m4b",
|
||||
"mime_type": "audio/mp4",
|
||||
"size": 987654321,
|
||||
"duration_seconds": 58200
|
||||
}
|
||||
],
|
||||
"progress": {
|
||||
"kind": "listening",
|
||||
"position_seconds": 1200,
|
||||
"duration_seconds": 58200,
|
||||
"updated_at": "2026-06-09T12:00:00Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
"primary_cover_url": "/api/v1/images/...",
|
||||
"metadata": {
|
||||
"description": "...",
|
||||
"series": { "name": "The Series", "index": 1 },
|
||||
"genres": ["Science Fiction"],
|
||||
"published_date": "2021-05-04",
|
||||
"publisher": "Ballantine Books"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Extend `GET /api/v1/catalog/items/{content_id}` additively:
|
||||
|
||||
```json
|
||||
{
|
||||
"content_id": "ebook_1",
|
||||
"type": "ebook",
|
||||
"work_id": "work_123",
|
||||
"work_title": "Project Hail Mary",
|
||||
"work_formats": [
|
||||
{ "type": "ebook", "content_id": "ebook_1", "library_id": 1 },
|
||||
{ "type": "audiobook", "content_id": "audio_1", "library_id": 2 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Existing fields and behavior remain unchanged. Clients that do not know about works can ignore the new fields.
|
||||
|
||||
## Catalog Grouping
|
||||
|
||||
Add optional work grouping for literary media:
|
||||
|
||||
`GET /api/v1/catalog?type=reading&group=work`
|
||||
|
||||
The grouped response should return one card per work when a linked work exists, with format badges such as `ebook` and `audiobook`. Unlinked literary items still appear as single-item cards with an implicit one-item work projection. Non-literary media remain item-based.
|
||||
|
||||
Do not change default catalog/search behavior. Work grouping must be opt-in so current clients and Jellyfin/ABS compatibility routes are unaffected.
|
||||
|
||||
## Progress and Files
|
||||
|
||||
Progress remains per format and per existing content identity:
|
||||
|
||||
- Ebook progress reads from `ebook_reader_progress`.
|
||||
- Audiobook progress reads from existing playback/watch progress storage.
|
||||
|
||||
The work response aggregates those format-specific progress rows without merging them.
|
||||
|
||||
Available files come from `media_files`, grouped by format content ID. File metadata should include `file_id`, original basename, extension/container, MIME type when known, byte size, and duration for audio. Android can continue using existing file/download routes by `file_id`; the work endpoint only tells it what is available.
|
||||
|
||||
## Admin Primitives
|
||||
|
||||
Add admin endpoints:
|
||||
|
||||
- `GET /api/v1/admin/literary-works/items/{content_id}/matches`: list possible work/item matches with score and evidence.
|
||||
- `POST /api/v1/admin/literary-works/link`: link two or more content IDs into one work.
|
||||
- `POST /api/v1/admin/literary-works/{work_id}/unlink`: unlink one content ID from a work.
|
||||
- `POST /api/v1/admin/literary-works/matches/ignore`: mark a candidate pair ignored.
|
||||
- `POST /api/v1/admin/literary-works/matches/confirm`: confirm a candidate pair and link them.
|
||||
|
||||
Admin linking must validate that every content ID is a supported literary type and accessible to the admin. Unlinking the last item can delete the empty work.
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
All existing item detail, catalog, reader, playback, download, Jellyfin compatibility, and ABS compatibility routes remain valid. The new tables cascade from `media_items`, so library deletion and orphan cleanup continue to remove format items; work cleanup should delete empty works after membership deletion.
|
||||
|
||||
No caller is required to supply `work_id` for existing format actions. Work APIs are additive and read-oriented except for admin management endpoints.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Repository and matcher tests should cover:
|
||||
|
||||
- Ebook and audiobook with same normalized title and author link to one work.
|
||||
- Same title with different authors does not auto-link.
|
||||
- Same author and series/index links despite title subtitle differences.
|
||||
- Shared external ID links above fuzzy metadata.
|
||||
- Manual ignored pair blocks automatic linking.
|
||||
- Manual confirmed link wins over automatic scoring.
|
||||
- Unlink removes one item without deleting the format media item.
|
||||
- Existing item detail JSON still includes normal fields when no work exists.
|
||||
- Existing catalog browse/search remains item-based unless `group=work`.
|
||||
- Work endpoint returns separate ebook and audiobook progress.
|
||||
- Work endpoint returns file metadata for EPUB/PDF/CBZ and M4B/MP3 without changing download behavior.
|
||||
|
||||
Handler tests should assert route status codes and response shape. SQL tests should include cascade/empty-work cleanup behavior.
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
Implement the schema and service behind additive APIs first. Then wire automatic matching after scan/enrichment. Finally add grouped catalog support. This keeps default behavior stable while giving clients and admin tooling a safe way to adopt works.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Server-side Kindle→EPUB conversion (MOBI / AZW / AZW3) — design
|
||||
|
||||
**Goal:** Let the silo-server render Kindle-family ebooks (mobi, azw, azw3) in the Android in-app reader by converting them to EPUB **server-side, in-process, on demand**, served transparently through the existing read endpoint. No external converter binary, no cgo.
|
||||
|
||||
**Status:** Approach approved by the user: convert via **libmobi**, run **built-in** to the server as **WASM executed by `wazero`** (a pure-Go runtime). On-demand + cached, admin-gated, DRM-free only. This is the MOBI half of the "all 9 server formats in-app" goal; the CBR (client-side) half is a separate Android spec.
|
||||
|
||||
> **Linchpin PROVEN (build spike, 2026-06-17, linux/amd64).** libmobi + `mobitool` cross-compile to `wasm32-wasi` (wasi-sdk 25, libmobi `9062742`, zlib 1.3.1 built to wasm); `-e` (create EPUB) is present; the module converts real MOBI6 / KF8-hybrid / HUFF-CDIC / unicode samples to **well-formed EPUB** (`mimetype`=`application/epub+zip` first, `META-INF/container.xml`, `OEBPS/*`). Verified end-to-end under **wazero** (the prod runtime) with WASI preopen + argv + `_start` + exit handling. Reproducible build stage committed at `tools/mobitool-wasm/Dockerfile` (with a smoke-conversion gate). Build-config gotcha resolved: build libmobi against a **wasm-compiled zlib** — `--with-zlib=no` makes libmobi vendor miniz that collides with mobitool's own miniz at `wasm-ld`. **DRM finding:** `mobitool` prints `Document is encrypted` to **stdout** but still **exits 0** and writes a (garbage) EPUB — so detect failure by capturing stdout + validating output, never by exit code alone.
|
||||
|
||||
## Why this shape
|
||||
|
||||
- Pure-Go MOBI/KF8 *parsers* don't exist at usable quality (the Go MOBI packages only *write*). Calibre is the gold standard but ~300MB+ of Python/Qt — rejected as too heavy. **libmobi** (mature C library behind the `mobitool` CLI) handles MOBI6 + KF8/AZW3 + AZW, decompression, and EPUB generation.
|
||||
- Rather than shell out to an external `mobitool` binary (a deploy moving part) or bind via cgo (forces `CGO_ENABLED=1`, a C toolchain, painful cross-compile, and an LGPLv3 static-link obligation), we compile **`mobitool` to a WASI `.wasm`** once and **run it in-process with `wazero`**.
|
||||
- This keeps the main Go build **`CGO_ENABLED=0`** and pure; one **architecture-independent** `.wasm` runs on amd64/arm64 servers alike; the `.wasm` is `go:embed`-ed into the binary (self-contained); libmobi's LGPL is cleanly isolated as a separate embedded artifact; and — a real bonus — **untrusted ebook bytes are parsed inside the WASM sandbox**, so a memory-safety bug in libmobi on a malicious file cannot reach the host (only the mapped temp dir).
|
||||
|
||||
## Components
|
||||
|
||||
### 1. The WASM module (build-time artifact)
|
||||
- Compile libmobi + its `mobitool` tool to `wasm32-wasi` using the **wasi-sdk** (clang). Output: `mobitool.wasm`.
|
||||
- Build libmobi **without libxml2** (`--with-libxml2=no` / `--disable-xmlwriter` as available) so the module has no external native deps; mobitool's EPUB writer uses a bundled miniz, which is portable C and compiles to WASI cleanly. (Feasibility of the no-libxml2 build for the `-e` path is the #1 thing to verify in the build spike — see Risks.)
|
||||
- Build it in a **dedicated Docker build stage** (or a committed `Makefile`/script under `tools/mobitool-wasm/`). **Commit the resulting `mobitool.wasm`** into the repo so the normal Go build does not need the wasi-sdk — it only `go:embed`s the prebuilt artifact. Rebuild the `.wasm` only when bumping libmobi; record the libmobi version + build flags next to it.
|
||||
|
||||
### 2. `internal/ebookconvert` (new package) — the in-process converter
|
||||
```go
|
||||
// Converter converts a Kindle-family ebook file to EPUB bytes, in-process via wazero.
|
||||
type Converter interface {
|
||||
// Convert reads srcPath (mobi/azw/azw3) and returns the path to a converted .epub.
|
||||
// Returns ErrDRMProtected / ErrUnsupported / ErrConversionFailed as appropriate.
|
||||
Convert(ctx context.Context, srcPath string) (epubPath string, err error)
|
||||
}
|
||||
```
|
||||
- `//go:embed mobitool.wasm` → compile the module **once** at startup with `wazero.NewRuntime` + `CompileModule` (the compiled module is cached and reused; only cheap instantiation happens per conversion).
|
||||
- **wazero command-module specifics** (from Codex review): `wasi_snapshot_preview1.MustInstantiate` the host module once; `Runtime.CompileModule` once at startup; per conversion instantiate with `ModuleConfig.WithArgs(...)` (argv ≈ `mobitool -e -o <outdir> <input>`), `WithFSConfig`, `WithStdout`+`WithStderr` (capture **both** — `mobitool.c` prints many failures with `printf` to stdout, not stderr), and the default `_start`. Use `WithName("")` (or a unique name) for repeated instantiation. Non-zero exits surface as `*sys.ExitError`. Honor ctx timeout via `RuntimeConfig.WithCloseOnContextDone(true)`.
|
||||
- **Filesystem sandboxing** (from Codex review): wazero's `WithDirMount`/`WithReadOnlyDirMount` still permit relative `../../` traversal *within host permissions*, so **never preopen the library directory or the cache root**. Per conversion: copy the source into a private scratch tree, mount input **read-only via an `fs.FS`** (`WithFSMount(os.DirFS(in), …)` — confined, no `..` escape) and a dedicated empty **writable** output dir (`WithDirMount`). **The WASM sandbox is a memory-safety boundary, not a hard filesystem jail** — a writable dir mount can still be traversed by a compromised guest within the server user's reach. Filesystem isolation therefore depends on a **deployment requirement: run silo-server as a constrained, non-root service user (the container already does), so the converter's blast radius is minimal.** This is documented here and in `internal/ebookconvert/converter.go`.
|
||||
- Enforce a wall-clock **timeout** (ctx), a **max source size**, and a **concurrency semaphore** (N simultaneous conversions). WASM memory is bounded by wazero config. **Resource note:** the defaults are `DefaultMaxMemoryPages` = 1 GiB linear memory per conversion × `DefaultConcurrency` = 2, i.e. up to ~2 GiB of wasm memory under saturation (plus the 256 MiB source / 512 MiB output caps). Generous for a small server; tune `ebookconvert.Options` for tight deployments.
|
||||
- Map mobitool's exit code / captured stdout+stderr to typed errors. **Deterministic** verdicts (cacheable): DRM-protected input → `ErrDRMProtected`; corrupt / unconvertible layout / oversize-output → `ErrConversionFailed`. **Transient** verdicts (never negatively cached, retried next read): the per-call timeout firing → `ErrConversionTimedOut`; source over the size cap → `ErrSourceTooLarge`; caller cancellation/deadline → the context error propagated verbatim.
|
||||
|
||||
### 3. Conversion cache (on-demand, keyed)
|
||||
- Cache converted EPUBs on disk under a configured cache root, keyed by **strong source identity + converter identity** — `file_id + size + mtime_ns + content hash` (use the scanner checksum if one exists) **plus** the converter **module fingerprint** (`moduleVersion`: a sha256 of the embedded `mobitool.wasm`, which already pins the libmobi commit, build flags, and wasi-sdk used to produce it). (Plain `path|size|mtime` can false-hit when a file is replaced with preserved mtime/size — Codex.) A stale key misses and reconverts.
|
||||
- **Singleflight** per key so concurrent reads of the same just-opened book convert once. The shared conversion runs on a **detached context** (decoupled from any one caller), so a single caller cancelling its request never aborts the work for the others.
|
||||
- **Negative caching**: remember only **deterministic-bad** sources — DRM (`ErrDRMProtected`) and conversion failures (`ErrConversionFailed`) — so every reader open does not reconvert a known-bad file. Transient outcomes (timeout, cancellation, oversize source) are **not** cached so they retry.
|
||||
- A cache **hit refreshes the entry's mtime**, so the mtime-ordered budget eviction acts as a real LRU; eviction never deletes the entry it is handing back or another conversion's in-flight temp.
|
||||
- Bounded by an LRU / total-size budget with opportunistic eviction (mirrors the existing reader-file/image cache conventions in the codebase).
|
||||
|
||||
### 4. Wiring into the read path
|
||||
- `serveEbookInline` (`internal/api/handlers/ebook_reader.go:602`) currently `os.Open`s `file.FilePath` and `http.ServeContent`s it with `ebookMimeType(...)`.
|
||||
- Change: when the source format is **kindle-family AND conversion is enabled**, resolve the converted EPUB via the cache (convert-on-miss), then serve **that** file with `Content-Type: application/epub+zip`. All other formats are served exactly as today (raw, byte-range, unchanged).
|
||||
- Byte-range/`http.ServeContent` semantics are preserved — we serve the cached EPUB `*os.File` the same way, so resume/range still works.
|
||||
- **Response headers** (from Codex review): `Content-Type: application/epub+zip`, `Content-Disposition` with an `.epub` filename, a strong `ETag` derived from the conversion cache key, and — because the *same URL* can return raw or converted bytes depending on the setting/failure — `Cache-Control: private, max-age=0, must-revalidate` (or `no-store`) to prevent stale client/proxy caching of the wrong representation.
|
||||
- **HEAD is cache-only.** A `HEAD /read` must never trigger a (possibly minute-long, ~1 GiB) conversion. It answers from the cache: a hit serves the real converted headers + `Content-Length`; a negatively-cached source serves the `failed` contract; a miss advertises the converted headers (`Content-Type`, the cache-key `ETag`, `X-Silo-Ebook-Conversion: converted`) **optimistically with no body and no conversion** — the subsequent `GET` produces the body and the authoritative DRM/failure verdict, which then populates the cache that later HEADs read. *(Client note: a HEAD miss intentionally omits `Content-Length`; coordinate with the Android/Apple clients if any relies on it.)*
|
||||
|
||||
### 5. Admin gate + client capability
|
||||
- An **admin-toggleable setting** (`ebook.kindle_conversion_enabled`, default off) gates the whole feature, via the existing server settings mechanism (`internal/api/handlers/settings.go` / `admin.go` `HandleUpdateSetting`). The flag is read with a short TTL cache so the hot read path (and the capability endpoint clients poll) does not hit the DB per request, while toggles still apply within seconds without a restart. When off: kindle-family files serve raw as today and the capability is not advertised.
|
||||
- **Two gates to advertise the capability** (from Codex review): advertise conversion availability to clients **only when** (a) the admin flag is on **and** (b) the embedded WASM module compiled + instantiated successfully at startup. The Android client flips mobi/azw/azw3 from `ExternalOnly`→`InApp` **only when the flag is advertised**; otherwise it keeps today's external-open fallback. (Android-side change is small and tracked separately; this spec is server-side.)
|
||||
- **Capability endpoint (implemented):** `GET /api/v1/ebooks/capability` → `{enabled, source_formats:["mobi","azw","azw3"], served_format:"epub", header:"X-Silo-Ebook-Conversion", header_failed_value:"failed"}`. `enabled` is true only when both gates hold (`EbookConversion.active`). The read response carries `X-Silo-Ebook-Conversion: converted|failed` so the client knows whether the body is EPUB or the raw original (→ external-open). ETag on the converted response is derived from the **exact conversion cache key** (`SourceKey.CacheKey()`) so a validator can never disagree with the cache; the failed/raw fallback is `Cache-Control: no-store` since the same URL flips representations with the flag.
|
||||
|
||||
## Data flow (read of a `.azw3`, conversion enabled)
|
||||
1. `GET /api/v1/ebooks/{cid}/files/{fid}/read` → authorize → `file` is `azw3`, conversion enabled.
|
||||
2. Cache lookup by source identity+version. Hit → serve cached `.epub`. Miss → singleflight: `Converter.Convert` (wazero runs `mobitool.wasm` on a temp copy) → store under key.
|
||||
3. `http.ServeContent` the `.epub` with `application/epub+zip`, byte-range supported.
|
||||
4. Client sees EPUB → existing reflow `EpubReader` renders it. Reader progress stays keyed to the **source** `fileId` (the EPUB is a derived view of the same file), so progress/annotations are unaffected.
|
||||
|
||||
## Error handling — explicit client failure contract
|
||||
Transparent raw fallback is **not** safe on its own: once the server advertises conversion and the Android in-app EPUB path opens `/read`, returning raw MOBI/AZW bytes hands the EPUB reader a non-EPUB body (Codex). So failures need a contract the client can detect:
|
||||
- On conversion failure (DRM, corrupt, timeout, oversize) the read endpoint returns a **structured signal** the Android client maps to its external-open fallback — preferred: a distinct status (e.g. `422 Unprocessable` / `415`) with an error code, **or** serve raw bytes **plus** an explicit `X-Silo-Ebook-Conversion: failed` (with the original MIME) header that the client checks. Pick one and make both sides honor it. Never silently hand raw bytes to the EPUB path.
|
||||
- `ErrDRMProtected` vs `ErrConversionFailed` are distinguished in logs + (optionally) the error code, so DRM books get the "open externally" message and transient failures can be retried.
|
||||
- Module compile/instantiate failure at startup (corrupt embed) → capability **not advertised** (gate b), kindle files serve raw as today, logged loudly.
|
||||
|
||||
## Testing
|
||||
- `internal/ebookconvert` unit tests with **small DRM-free MOBI6, AZW, and KF8/AZW3 fixtures**: `Convert` produces a valid EPUB (zip with `mimetype` first + `META-INF/container.xml` + OPF + at least one content doc), deterministic across runs; corrupt input → `ErrConversionFailed`; oversize → rejected before invocation; timeout honored (ctx cancel).
|
||||
- Cache tests: miss→convert→hit; key changes on source mtime/size and on module-version bump; singleflight collapses concurrent conversions.
|
||||
- Handler tests: enabled+kindle → `application/epub+zip` body is a valid EPUB; disabled → raw bytes + original MIME; non-kindle → unchanged; DRM/failed → graceful fallback path.
|
||||
- A build-stage check (CI) that the committed `mobitool.wasm` matches a rebuild from the pinned libmobi (or at least that it instantiates + converts a fixture), so the artifact can't silently rot.
|
||||
|
||||
## Sequencing — spike first (Codex-recommended)
|
||||
**The first PR is only the build spike + a tiny Go harness that converts fixtures** — no handler/cache/wiring until the linchpin is proven. Codex confirmed libmobi documents both deps as optional (`--with-zlib=no` → bundled `miniz.c`; `--with-libxml2=no` → internal xmlwriter) and that `mobitool.c` gates `-e` behind `USE_XMLWRITER`, not `HAVE_LIBXML2` — so the no-libxml2 wasm build is *plausible*, but the spike must **assert** `mobitool -h` exposes `-e` and a fixture actually converts. Only then design the handler/cache/wiring PR.
|
||||
|
||||
## Risks / spike-first items
|
||||
1. **libmobi+mobitool compiles to `wasm32-wasi` with the `-e` (EPUB) path working without libxml2.** The linchpin. If libxml2 turns out mandatory for `-e`, fall back to compiling libxml2 to wasm too (heavier) or assembling the EPUB in Go from libmobi's `MOBIRawml` parts.
|
||||
2. **wazero WASI preopen file IO** for the file sizes involved (tens of MB) — confirm mapping + memory/time limits.
|
||||
3. mobitool CLI surface as a wasm **command module** (argv + preopens) — confirm wazero runs it via `_start` and we capture exit status + stdout + stderr.
|
||||
|
||||
## Committed `mobitool.wasm` — guardrails (Codex)
|
||||
Commit the artifact (so normal Go builds need no wasi-sdk) **with**: the pinned libmobi source commit, the Dockerfile/build script that produced it, its SHA256, the build flags, and the LGPL license notice. Add a **CI smoke test** that instantiates the embedded module and converts a fixture. Do not gate CI on byte-for-byte rebuild reproducibility — `mobitool -v` embeds `__DATE__`/`__TIME__`, so a rebuild won't match byte-for-byte unless normalized; rely on the fixture-conversion smoke + metadata hash instead.
|
||||
|
||||
## Process gate (Codex)
|
||||
`docs/architecture/v1-scope.md` indicates v1 scope is not locked and new user-facing capabilities may need a proposal / project-tracking entry **before** a feature PR. Confirm this process step with the maintainer before opening the conversion PR. *(Decision deferred to the user.)*
|
||||
|
||||
## Out of scope
|
||||
- CBR (separate Android client-side spec).
|
||||
- DRM removal (never).
|
||||
- Converting at scan/ingest time (on-demand only).
|
||||
- Surfacing a separate "EPUB version" in the catalog (we serve transparently through `/read`).
|
||||
- Reusing the WASM converter for other formats (later, if useful).
|
||||
|
||||
## Commit / deploy rules (project)
|
||||
- silo-server changes go on a **feature branch, committed locally, PR-ready — NOT pushed** (per standing rule; server changes land via PR).
|
||||
@@ -0,0 +1,711 @@
|
||||
# Offline sync for mobile (downloads v2) — design
|
||||
|
||||
**Goal:** Let mobile clients (Android, Apple) download media for **fully offline** playback and keep
|
||||
watch state consistent across reconnects. Three pillars: (1) a **device-scoped download registry** —
|
||||
the server tracks what each device has downloaded; (2) an **offline playback manifest** — one bundle
|
||||
giving the client everything it needs to play with no network (metadata, subtitle files, artwork,
|
||||
chapter + intro/credits markers); (3) **offline progress reconciliation** — the client queues watch
|
||||
progress while offline and the server merges it on reconnect (last-write-wins). Downloads are requested
|
||||
through an admin-gated **quality** ladder (`original`, then bitrate presets), with server-side
|
||||
**transcode-to-a-single-file** for bitrate requests and compatibility fallback for devices that cannot
|
||||
play the source directly.
|
||||
|
||||
**Status:** Approved — implementing. This capability cleared the Silo v1 scope gate on
|
||||
2026-06-18 (via the **v1 capability proposal** triage; template
|
||||
`.github/ISSUE_TEMPLATE/v1-capability-proposal.yml`), and the offline-sync/downloads-v2 work is now
|
||||
being implemented phase by phase against this design. Per the decision below, the
|
||||
`/api/v1/downloads` contract is **reshaped, not just extended** — acceptable because the change lands
|
||||
**before** scope lock (`docs/architecture/v1-scope.md`) and **before** any mobile client ships against
|
||||
it. This document began as the design input to that proposal and now serves as the implementation
|
||||
reference; it is kept in sync as the implementation lands.
|
||||
|
||||
Decisions locked with the requester:
|
||||
- **Replace** the existing `internal/download` package with a new unified package (`internal/downloads`)
|
||||
that absorbs today's download behavior and adds the three pillars.
|
||||
- **The existing `/api/v1/downloads` contract may be reshaped, not just extended.** The only current
|
||||
consumer is the first-party web app, which is updated in lockstep. So downloads v2 is a single
|
||||
coherent surface: one device-aware `downloads` table and one `/downloads/*` endpoint family — *not* a
|
||||
frozen legacy table plus a parallel registry. (This intentionally diverges from the v1 additive-only
|
||||
API rule, which is acceptable here because the change lands **before** scope lock and **before** any
|
||||
mobile client ships against it — exactly the right time to reshape. The proposal must call this out so
|
||||
reviewers/clients know the download contract changed pre-lock.)
|
||||
- **Quality is user-facing; delivery format is internal.** Clients request `quality` from the ordered
|
||||
ladder `original > 20mbps > 10mbps > 5mbps > 2mbps > 1mbps`. `remux` is not a preset; it is a
|
||||
server-selected `delivery_format` when `original` needs container/audio compatibility. Bitrate
|
||||
presets are gated by a server setting **and** the per-user `download_transcode_allowed` flag.
|
||||
- **Transcode-to-single-file is in v1.** (Today's transcode pipeline only emits ephemeral HLS segments.)
|
||||
- **No expiry, no lease, no DRM.** Downloaded files persist on-device until the user deletes them; the
|
||||
server can only revoke *future* downloads via `users.download_allowed`.
|
||||
- **No cross-device visibility** in v1 (a device sees its own entries, not other devices').
|
||||
|
||||
**Implementation update (2026-06-20):** the current client-facing contract is documented in
|
||||
`docs/downloads-api.md`. The public request field is `quality`, capabilities expose
|
||||
`quality_presets`, rows expose `quality`, `effective_quality`, `delivery_format`,
|
||||
`target_bitrate_kbps`, and `revision`, and batch manifests are implemented at
|
||||
`GET /api/v1/downloads/batches/{batch_id}/manifests`.
|
||||
|
||||
> Commands and paths in this document are repository-relative; assume the repository root is the cwd.
|
||||
|
||||
## Why this shape
|
||||
|
||||
- **One device-aware table, not two.** The original plan kept today's `downloads` table
|
||||
(`migrations/sql/042_downloads.sql`, keyed on `user_id`) frozen and added a separate device registry,
|
||||
purely to avoid breaking the existing contract. With that constraint lifted, the cleaner model is a
|
||||
single `downloads` table reshaped to carry `device_id` (and `profile_id`). `device_id` is **nullable**:
|
||||
- `device_id IS NULL` → an **ephemeral / account-level** download (today's web-app browser download:
|
||||
pick a version, pull the file; the row is a convenience record, not a managed library entry).
|
||||
- `device_id` set → a **managed device-library entry** (a phone "has" this file; durable; unique per
|
||||
`(user, profile, device, content, episode)`; the target of manifest + progress reconciliation).
|
||||
One table, one endpoint family, and the format/transcode machinery is shared by web and mobile alike.
|
||||
- **Device identity already exists in Postgres.** `migrations/sql/180_user_devices.sql` defines
|
||||
`public.user_devices` keyed `(user_id, profile_id, device_id)` with FKs to `users` and
|
||||
`user_profiles`. Device id arrives in the `X-Silo-Device-Id` header (see
|
||||
`internal/api/handlers/settings.go:37`). Managed entries reference it; ephemeral rows leave it NULL.
|
||||
- **The last-write-wins primitive already exists.** `userstore.UserStore.SetProgressIfNewer(... updatedAt) (bool, error)`
|
||||
(`internal/userstore/store.go:26`, implemented in `internal/userdb/progress.go:131`) is exactly the
|
||||
merge semantics offline reconciliation needs. Progress reconciliation is therefore **additive changes
|
||||
to the existing progress endpoints**, not new machinery, and it deliberately stays in the watch-state
|
||||
domain rather than being folded into the downloads package.
|
||||
- **Manifests must never carry presigned URLs.** `catalog.ItemDetail` (`internal/catalog/detail.go`)
|
||||
exposes `PosterURL`/`BackdropURL` as short-lived presigned S3 URLs — useless for an offline bundle
|
||||
that may be opened days later. The manifest carries **stable** references (thumbhashes inline +
|
||||
authenticated proxy endpoints the client fetches once at download time), so a stored manifest never
|
||||
expires.
|
||||
- **Remux and transcode both need a produced file, not a live pipe.** A clean, seekable, resumable
|
||||
download requires a finalized file (`-movflags +faststart` relocates the moov atom in a finalization
|
||||
pass — impossible over a pure pipe). `internal/playback`'s current `ServeRemux`/`StartTranscode`
|
||||
stream ephemeral output. So remux and transcode share one **prepare-to-file** job pipeline; only
|
||||
`original` is served straight from the source on disk.
|
||||
|
||||
## The replacement: `internal/downloads` (new package)
|
||||
|
||||
`internal/download` (singular) is replaced by `internal/downloads` (plural). The new package owns the
|
||||
whole downloads domain; everything below lives in it unless noted.
|
||||
|
||||
```
|
||||
internal/downloads/
|
||||
model.go — Download (web + device, one type), Artifact, OfflineManifest,
|
||||
quality/delivery constants, status constants, sentinel errors
|
||||
repo.go — Postgres CRUD: downloads (reshaped), download_artifacts
|
||||
service.go — orchestration: permission + quota checks, quality policy, create/list/serve
|
||||
policy.go — DownloadQualityResolver (quality ladder + delivery decision + server/user gates)
|
||||
bandwidth.go — BandwidthManager (ported from internal/download/bandwidth.go)
|
||||
limiter.go — QuantityLimiter (ported from internal/download/limiter.go)
|
||||
manifest.go — ManifestBuilder (assembles OfflineManifest; strips all presigned URLs)
|
||||
artifacts.go — ArtifactManager (async prepare-to-file jobs: remux + transcode, dedup, cleanup)
|
||||
serve.go — file serving for original source and prepared artifacts
|
||||
```
|
||||
|
||||
**What it absorbs / reshapes** from `internal/download`: the `downloads` table and its endpoints, now
|
||||
reshaped to be device-aware and quality/delivery-aware; `BandwidthManager`; `QuantityLimiter`; the `Service`
|
||||
permission/quota/file-serving logic; and the `config.DownloadConfig` integration.
|
||||
|
||||
**What stays out of the package (by design):**
|
||||
- **Progress reconciliation** is watch-state, not downloads. It is additive changes to
|
||||
`internal/api/handlers/progress.go` + `internal/userstore`/`internal/userdb`, reusing
|
||||
`SetProgressIfNewer`. Folding it into `internal/downloads` would couple two domains and duplicate the
|
||||
LWW write.
|
||||
- **The encode/remux primitive** stays in `internal/playback` (it owns ffmpeg). `internal/downloads`
|
||||
calls a new `playback.PrepareFile(...)`; it does not shell out to ffmpeg itself.
|
||||
|
||||
**Cutover strategy:** because the web app is the only consumer and is updated in lockstep, Phase 0 is a
|
||||
*reshape*, not a verbatim port: move the logic into `internal/downloads`, reshape the table + endpoints
|
||||
(add device-awareness plus quality/delivery fields), update the web app's download hooks/components, delete
|
||||
`internal/download`. The handler `internal/api/handlers/downloads.go` already depends only on a narrow
|
||||
`DownloadService` interface, so the swap is mechanical; its request/response DTOs gain the new fields.
|
||||
|
||||
## Data model (Goose migrations)
|
||||
|
||||
Migrations are timestamped, created with `make migrate-create NAME=...` (never hand-numbered; never
|
||||
`goose fix`).
|
||||
|
||||
### 1. Reshape `downloads` (device-aware, format-aware)
|
||||
|
||||
A single migration alters the existing table. Downloads ship default-off with little/no production data,
|
||||
so widening it in place is safe.
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE public.downloads
|
||||
ADD COLUMN profile_id text, -- NULL for ephemeral/web rows
|
||||
ADD COLUMN device_id text, -- NULL = ephemeral; set = managed device entry
|
||||
ADD COLUMN format text NOT NULL DEFAULT 'original',
|
||||
ADD COLUMN artifact_id text; -- set for remux/transcode once prepared
|
||||
|
||||
ALTER TABLE public.downloads
|
||||
ADD CONSTRAINT downloads_format_check CHECK (format IN ('original','remux','transcode'));
|
||||
|
||||
-- Widen the status enum to cover the managed-entry lifecycle alongside the existing serve states.
|
||||
ALTER TABLE public.downloads DROP CONSTRAINT downloads_status_check;
|
||||
ALTER TABLE public.downloads ADD CONSTRAINT downloads_status_check
|
||||
CHECK (status IN ('queued','downloading','completed','failed','cancelled', -- existing
|
||||
'registered','preparing','ready','revoked')); -- managed entries
|
||||
|
||||
-- One managed entry per (user, profile, device, content, episode). Ephemeral rows (NULL device_id)
|
||||
-- are exempt via the partial index.
|
||||
CREATE UNIQUE INDEX downloads_device_entry_uidx
|
||||
ON public.downloads (user_id, profile_id, device_id, content_id, COALESCE(episode_id, ''))
|
||||
WHERE device_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX downloads_device_idx ON public.downloads (user_id, profile_id, device_id) WHERE device_id IS NOT NULL;
|
||||
CREATE INDEX downloads_artifact_idx ON public.downloads (artifact_id) WHERE artifact_id IS NOT NULL;
|
||||
|
||||
-- Composite FK to user_devices. MATCH SIMPLE (the default) skips the check whenever any column is NULL,
|
||||
-- so ephemeral rows (NULL device_id) are not constrained while managed entries are.
|
||||
ALTER TABLE public.downloads
|
||||
ADD CONSTRAINT downloads_device_fkey
|
||||
FOREIGN KEY (user_id, profile_id, device_id)
|
||||
REFERENCES public.user_devices(user_id, profile_id, device_id) ON DELETE CASCADE;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE public.downloads DROP CONSTRAINT IF EXISTS downloads_device_fkey;
|
||||
DROP INDEX IF EXISTS downloads_artifact_idx;
|
||||
DROP INDEX IF EXISTS downloads_device_idx;
|
||||
DROP INDEX IF EXISTS downloads_device_entry_uidx;
|
||||
ALTER TABLE public.downloads DROP CONSTRAINT IF EXISTS downloads_format_check;
|
||||
ALTER TABLE public.downloads DROP CONSTRAINT IF EXISTS downloads_status_check;
|
||||
ALTER TABLE public.downloads ADD CONSTRAINT downloads_status_check
|
||||
CHECK (status IN ('queued','downloading','completed','failed','cancelled'));
|
||||
ALTER TABLE public.downloads
|
||||
DROP COLUMN artifact_id, DROP COLUMN format, DROP COLUMN device_id, DROP COLUMN profile_id;
|
||||
-- +goose StatementEnd
|
||||
```
|
||||
|
||||
Status lifecycle:
|
||||
- **ephemeral / web** (`device_id IS NULL`): `queued → downloading → completed` / `failed` /
|
||||
`cancelled` — exactly today's behavior, now also able to request `remux`/`transcode`.
|
||||
- **managed entry** (`device_id` set):
|
||||
- `original`: `registered → ready` immediately (client pulls the source), then client reports
|
||||
`completed`.
|
||||
- `remux` / `transcode`: `registered → preparing` (artifact job runs) `→ ready → completed`; or
|
||||
`failed`.
|
||||
- `revoked`: admin/permission revocation of *future* access. Cannot delete the on-device file (no DRM
|
||||
by decision); it only stops the server serving new bytes.
|
||||
|
||||
The unique index uses `COALESCE(episode_id,'')` so movies (NULL `episode_id`) collapse to one managed
|
||||
entry per `(device, content)` without NULL-comparison surprises.
|
||||
|
||||
### 2. `download_artifacts` — prepared (remux/transcode) files, deduplicated
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE public.download_artifacts (
|
||||
id text NOT NULL,
|
||||
media_file_id integer NOT NULL REFERENCES public.media_files(id) ON DELETE CASCADE,
|
||||
format text NOT NULL, -- 'remux' | 'transcode'
|
||||
params_hash text NOT NULL, -- sha256 of the encode parameters (see below)
|
||||
container text NOT NULL DEFAULT 'mp4',
|
||||
codec_video text NOT NULL DEFAULT '',
|
||||
codec_audio text NOT NULL DEFAULT '',
|
||||
resolution text NOT NULL DEFAULT '',
|
||||
audio_track_index integer NOT NULL DEFAULT -1,
|
||||
output_path text NOT NULL DEFAULT '', -- absolute path on the server's artifact volume
|
||||
file_size bigint NOT NULL DEFAULT 0,
|
||||
status text NOT NULL DEFAULT 'queued',
|
||||
error_message text NOT NULL DEFAULT '',
|
||||
-- Durable-queue / crash-recovery columns (see "Durable artifact queue" below).
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
max_attempts integer NOT NULL DEFAULT 3,
|
||||
lease_owner text, -- worker/node id holding the running lease
|
||||
lease_expires_at timestamptz, -- NULL unless status='running'
|
||||
next_retry_at timestamptz, -- backoff gate for re-enqueue after a failure
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
completed_at timestamptz,
|
||||
last_used_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT download_artifacts_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT download_artifacts_unique UNIQUE (media_file_id, format, params_hash),
|
||||
CONSTRAINT download_artifacts_status_check CHECK (status IN ('queued','running','ready','failed'))
|
||||
);
|
||||
|
||||
CREATE INDEX download_artifacts_lru_idx ON public.download_artifacts (last_used_at) WHERE status = 'ready';
|
||||
-- Claimable work: queued rows whose backoff has elapsed, plus running rows whose lease has expired.
|
||||
CREATE INDEX download_artifacts_claimable_idx ON public.download_artifacts (status, next_retry_at);
|
||||
CREATE INDEX download_artifacts_lease_idx ON public.download_artifacts (lease_expires_at) WHERE status = 'running';
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS public.download_artifacts;
|
||||
-- +goose StatementEnd
|
||||
```
|
||||
|
||||
`params_hash = sha256(format | container | codec_video | codec_audio | resolution | audio_track_index | subtitle_burn_in)`.
|
||||
Two devices (or a web user and a phone) requesting the same 1080p H.264/AAC of the same source share one
|
||||
artifact. `last_used_at` drives LRU cleanup; `output_path` is on a configured artifact volume. The
|
||||
`attempts`/`lease_*`/`next_retry_at` columns make the table a **durable, recoverable job queue** (see the
|
||||
"Durable artifact queue" subsection) so a crash mid-encode cannot strand a download in `preparing`.
|
||||
|
||||
### 3. Server settings (no DDL)
|
||||
|
||||
`server_settings` is key/value, so new keys need no migration — written when an admin saves them, parsed
|
||||
in `internal/config/db_loader.go`:
|
||||
- `download.transcode_enabled` (bool, default **false**) — server gate for transcode-to-file.
|
||||
- `download.artifact_dir` (string) — artifact output volume; defaults under the existing transcode/temp root.
|
||||
- `download.max_concurrent_prepares` (int, default 2) — encode/remux worker-pool size.
|
||||
- `download.artifact_max_bytes` (int64, default 0 = unlimited) — LRU eviction budget for artifacts.
|
||||
|
||||
`config.DownloadConfig` (`internal/config/config.go:299`) gains the matching fields; they hot-reload via
|
||||
the existing 30s-TTL cache (`internal/download/service.go:107` → ported), and the keys are registered as
|
||||
non-restart in `internal/config/restart_keys.go`.
|
||||
|
||||
## Quality policy
|
||||
|
||||
Resolved in `internal/downloads/policy.go` at create time, for both web and device flows:
|
||||
|
||||
```go
|
||||
const (
|
||||
QualityOriginal = "original"
|
||||
Quality20Mbps = "20mbps"
|
||||
Quality10Mbps = "10mbps"
|
||||
Quality5Mbps = "5mbps"
|
||||
Quality2Mbps = "2mbps"
|
||||
Quality1Mbps = "1mbps"
|
||||
)
|
||||
|
||||
type QualityDecision struct {
|
||||
RequestedQuality string
|
||||
EffectiveQuality string
|
||||
DeliveryFormat string // original | remux | transcode
|
||||
TargetBitrateKbps int
|
||||
RequiresArtifact bool
|
||||
}
|
||||
|
||||
func (DownloadQualityResolver) Resolve(requested string, user *models.User,
|
||||
cfg config.DownloadConfig, file *models.MediaFile, caps playback.ClientCapabilities,
|
||||
artifactsAvailable bool) (QualityDecision, error)
|
||||
```
|
||||
|
||||
Decision tree:
|
||||
1. Empty quality defaults to `original`.
|
||||
2. `original` direct-plays the source unless supplied device caps require a compatibility artifact.
|
||||
Container/audio-only compatibility records `delivery_format=remux`; video transcode fallback records
|
||||
`effective_quality=20mbps`, `delivery_format=transcode`.
|
||||
3. Bitrate presets require `cfg.TranscodeEnabled` (server), `user.DownloadTranscodeAllowed`, and an
|
||||
available artifact pipeline. Server gate off → `ErrTranscodeDisabled` (403). User flag off →
|
||||
`ErrDownloadNotAllowed` (403). Missing artifact pipeline → `ErrQualityUnavailable` (501).
|
||||
4. `remux` and `transcode` as request values are invalid public qualities.
|
||||
|
||||
Target codec/resolution for compatibility artifacts and bitrate transcodes is chosen by reusing `playback.Resolve` /
|
||||
`playback.SelectVersion` (`internal/playback/resolver.go:60`) against the device's declared capabilities
|
||||
and the admin transcode ceilings, so download encoding matches streaming decisions exactly (no
|
||||
duplicated codec logic).
|
||||
|
||||
## Transcode-to-file pipeline (v1)
|
||||
|
||||
`internal/downloads/artifacts.go` — `ArtifactManager`. Remux and transcode share this; `original` never
|
||||
enters it.
|
||||
|
||||
**On create of a remux/transcode download (web or device):**
|
||||
1. Resolve target params (codec/res/container/audio track) → compute `params_hash`.
|
||||
2. `INSERT ... ON CONFLICT (media_file_id, format, params_hash) DO NOTHING` into `download_artifacts`;
|
||||
read the row. If `status = ready`, link `downloads.artifact_id`, set `status = ready`,
|
||||
`file_size = artifact.file_size`, bump `last_used_at`. Done — no new encode.
|
||||
3. Otherwise the download row is `preparing`; if the artifact is freshly `queued`, enqueue an encode job.
|
||||
|
||||
**Encode worker** (bounded pool of `download.max_concurrent_prepares`, hosted on the existing
|
||||
`internal/taskmanager` worker machinery so jobs are tracked, observable in admin, and cancellable):
|
||||
1. **Claim a job transactionally** (see "Durable artifact queue" below) — never just "enqueue freshly
|
||||
queued rows," which loses work on restart.
|
||||
2. Call a new `playback.PrepareFile(ctx, opts)` that builds **single-file** ffmpeg args and writes to
|
||||
`output_path + ".part"`, then atomically renames to `output_path`:
|
||||
- **remux:** `-map ... -c copy -movflags +faststart -f mp4` (fast, near disk-throughput).
|
||||
- **transcode:** the existing encode args (reuse the `buildFFmpegArgs` logic / `TranscodeOpts` at
|
||||
`internal/playback/transcode.go:30`, incl. HW-accel, audio-track selection, optional subtitle
|
||||
burn-in) but targeting `-movflags +faststart -f mp4` to a file instead of `-f hls`.
|
||||
`TranscodeOpts` already carries every needed field (`InputPath`, `TargetCodecVideo/Audio`,
|
||||
`TargetResolution`, `HWAccel`, `AudioTrackIndex`, `SubtitleBurnIn`, `TotalDuration`).
|
||||
3. On success: artifact → `ready` (`file_size`/`completed_at`, clear lease); flip every linked download
|
||||
row → `ready`; publish a `user_state`/`downloads` event on the events hub (`internal/events`) so a
|
||||
waiting client pulls without polling.
|
||||
4. On failure: increment `attempts`, set `error_message`/`next_retry_at` (backoff). If
|
||||
`attempts >= max_attempts` → artifact `failed` and linked rows `failed`; otherwise return it to
|
||||
`queued` for retry. Either way, delete the stale `.part` file.
|
||||
|
||||
**Durable artifact queue (crash/restart recovery).** The artifact table is the queue of record, so a
|
||||
process exit mid-encode cannot strand a download in `preparing` forever:
|
||||
- **Transactional claim:** a worker claims a job with a single statement —
|
||||
`UPDATE download_artifacts SET status='running', lease_owner=$me, lease_expires_at=now()+$lease, attempts=attempts+1
|
||||
WHERE id = (SELECT id FROM download_artifacts
|
||||
WHERE (status='queued' AND (next_retry_at IS NULL OR next_retry_at <= now()))
|
||||
OR (status='running' AND lease_expires_at < now()) -- steal expired leases
|
||||
ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED) RETURNING *`.
|
||||
`FOR UPDATE SKIP LOCKED` makes concurrent workers (and multiple nodes) safe without double-encoding.
|
||||
- **Lease heartbeat:** a running worker periodically extends `lease_expires_at`; if it dies, the lease
|
||||
expires and another worker (or the startup sweep) reclaims the job.
|
||||
- **Startup sweep:** on boot, reset `running` rows with an expired lease back to `queued` (bounded by
|
||||
`max_attempts`), and re-link any `preparing` download rows to their artifact's real state. A `ready`
|
||||
artifact whose `output_path` is missing on disk is reset to `queued` (the file was lost).
|
||||
- **Idempotency:** the encode writes to `output_path + ".part"` and atomically renames; a reclaimed job
|
||||
overwrites its own `.part`. `output_path` is deterministic from `(media_file_id, format, params_hash)`.
|
||||
- **No silent stranding:** a download stuck in `preparing` past a bound is surfaced (admin task + a
|
||||
`failed` transition with reason), never left invisibly hung.
|
||||
|
||||
**Serving** (`internal/downloads/serve.go`, `GET /downloads/{id}/file`):
|
||||
- `original`: open `media_files.file_path`, `http.ServeContent` (Range/resume) wrapped in the ported
|
||||
`BandwidthManager.ThrottledReader`. Same path as today's `serveFileDownload`.
|
||||
- `remux`/`transcode`: require `status = ready`; serve `artifact.output_path` the same way (Range/resume
|
||||
+ throttle); bump `last_used_at`.
|
||||
|
||||
**Cleanup:** an admin/cron job (mirroring `internal/playback/transcode_cleanup.go`) evicts `ready`
|
||||
artifacts whose `last_used_at` is older than a retention window or when the total exceeds
|
||||
`download.artifact_max_bytes` (LRU), but never one still linked by an active managed download row,
|
||||
including completed rows that represent a device's local library state.
|
||||
Whatever is dropped is logged (no silent truncation).
|
||||
|
||||
## Offline playback manifest
|
||||
|
||||
`internal/downloads/manifest.go` builds `OfflineManifest` from `catalog`'s existing detail path (reusing
|
||||
`catalog.ItemDetail` assembly — markers, chapters, subtitle list, versions) and then **strips every
|
||||
presigned URL**. The manifest is stable: a client can store it and re-read it offline indefinitely.
|
||||
Manifest + asset endpoints are only meaningful for managed device entries (`device_id` set).
|
||||
|
||||
```go
|
||||
type OfflineManifest struct {
|
||||
DownloadID string `json:"download_id"`
|
||||
ContentID string `json:"content_id"`
|
||||
EpisodeID string `json:"episode_id,omitempty"`
|
||||
Type string `json:"type"` // movie | episode
|
||||
Revision int `json:"revision"`
|
||||
Quality string `json:"quality"`
|
||||
EffectiveQuality string `json:"effective_quality"`
|
||||
DeliveryFormat string `json:"delivery_format"`
|
||||
TargetBitrateKbps int `json:"target_bitrate_kbps"`
|
||||
MediaFileID int `json:"media_file_id"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year,omitempty"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
Runtime int `json:"runtime,omitempty"`
|
||||
ContentRating string `json:"content_rating,omitempty"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
SeriesID string `json:"series_id,omitempty"`
|
||||
SeriesTitle string `json:"series_title,omitempty"`
|
||||
SeasonNumber *int `json:"season_number,omitempty"`
|
||||
EpisodeNumber *int `json:"episode_number,omitempty"`
|
||||
|
||||
// Artwork: stable thumbhashes inline (immediate offline placeholder) + authenticated proxy URLs
|
||||
// the client downloads ONCE at download time. Never presigned S3 URLs.
|
||||
PosterThumbhash string `json:"poster_thumbhash,omitempty"`
|
||||
BackdropThumbhash string `json:"backdrop_thumbhash,omitempty"`
|
||||
ArtworkURLs struct {
|
||||
Poster string `json:"poster,omitempty"`
|
||||
Backdrop string `json:"backdrop,omitempty"`
|
||||
Logo string `json:"logo,omitempty"`
|
||||
} `json:"artwork_urls"`
|
||||
|
||||
// Playback metadata
|
||||
Container string `json:"container"`
|
||||
CodecVideo string `json:"codec_video"`
|
||||
CodecAudio string `json:"codec_audio"`
|
||||
Resolution string `json:"resolution"`
|
||||
HDR bool `json:"hdr"`
|
||||
Duration int `json:"duration_seconds"`
|
||||
|
||||
Chapters []OfflineChapter `json:"chapters,omitempty"`
|
||||
Intro *Marker `json:"intro,omitempty"`
|
||||
Credits *Marker `json:"credits,omitempty"`
|
||||
Recap *Marker `json:"recap,omitempty"`
|
||||
Preview *Marker `json:"preview,omitempty"`
|
||||
|
||||
Subtitles []OfflineSubtitle `json:"subtitles"`
|
||||
|
||||
// Stable identity so the client can re-resolve content_id after a server-side rescan
|
||||
// (mirrors userstore.WatchIdentity at internal/userstore/types.go:130).
|
||||
StableIdentity OfflineIdentity `json:"stable_identity"`
|
||||
|
||||
ManifestVersion int `json:"manifest_version"` // bump on DTO shape change
|
||||
GeneratedAt string `json:"generated_at"` // RFC3339
|
||||
}
|
||||
|
||||
type OfflineSubtitle struct {
|
||||
Language string `json:"language"`
|
||||
Format string `json:"format"` // srt | ass | vtt
|
||||
Forced bool `json:"forced"`
|
||||
HearingImpaired bool `json:"hearing_impaired"`
|
||||
External bool `json:"external"`
|
||||
FetchURL string `json:"fetch_url"` // authenticated proxy; client downloads at download time
|
||||
FileSize int64 `json:"file_size,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**Artwork/subtitle proxies** (replace presigning):
|
||||
- `GET /downloads/{id}/artwork/{kind}` (`kind` ∈ `poster|backdrop|logo`) reads the raw stored path and
|
||||
**streams the bytes** through the existing image resolver instead of redirecting to a presigned URL.
|
||||
The client caches these once.
|
||||
- `GET /downloads/{id}/subtitles/{ref}` (`ref` encodes `external:{index}` or `downloaded:{id}`) streams
|
||||
the subtitle file (external sub from disk; downloaded sub from S3 via the existing subtitle manager).
|
||||
|
||||
Both are authorized under the **full managed-entry rule** (see "Security & reliability invariants"):
|
||||
the row must match `(user_id, profile_id, header device_id)`, and content/library access is **re-checked
|
||||
for the requesting profile** before any bytes are served (a download id alone never authorizes access).
|
||||
They are session-authenticated rather than time-limited tokens, so a manifest stored on-device stays valid.
|
||||
|
||||
## Progress reconciliation
|
||||
|
||||
Two **additive** changes. The naïve version (route the client's wall-clock straight into
|
||||
`SetProgressIfNewer` and drive `?since=` off the same column) is **unsafe**: a skewed or malicious device
|
||||
can submit a far-future timestamp, win LWW permanently over genuinely-newer server activity, and poison
|
||||
the cursor (a future-dated row makes a reader advance its cursor past real writes). So the design
|
||||
**separates client event time from server ordering**.
|
||||
|
||||
The watch-progress row gains two server-owned facets (LWW key vs. cursor ordering):
|
||||
- **`event_at`** — the client-observed wall-clock for the progress event, used as the **LWW comparison
|
||||
key**, but **bounded on ingest**: `event_at = min(client_updated_at, server_now + skew)` with a small
|
||||
skew window (e.g. 2m); a value past the window is clamped to `server_now` (and logged). For an online
|
||||
write `event_at = server_now`. Bounding means a client can at most claim "now" for its **own profile's**
|
||||
progress — which it is already entitled to do — and can never lock in a far-future value.
|
||||
- **`synced_seq`** — a **server-assigned monotonic** marker set on every write (a sequence, or
|
||||
server-ingest timestamp with a tie-breaker). It is **never** client-influenced and is the sole basis
|
||||
for the `?since=` cursor.
|
||||
|
||||
**Write — `POST /api/v1/sync/progress`** (`internal/api/handlers/progress.go:230`): add an optional
|
||||
`updated_at` (RFC3339) per item = the client event time. The handler clamps it (above) and merges via an
|
||||
extended `SetProgressIfNewer` whose comparison is on the **bounded `event_at`**, while the server stamps
|
||||
`synced_seq` itself. Absent `updated_at` → behavior is exactly as today (server `now()`), so existing
|
||||
callers (incl. watchsync) are unaffected. Completion is never inferred from the timestamp alone — the
|
||||
existing watched-threshold logic still gates `completed`.
|
||||
|
||||
```go
|
||||
type syncProgressItem struct {
|
||||
MediaItemID string `json:"media_item_id"`
|
||||
Position float64 `json:"position"`
|
||||
Duration float64 `json:"duration"`
|
||||
ForceOverwrite bool `json:"force_overwrite"`
|
||||
UpdatedAt *string `json:"updated_at,omitempty"` // NEW: RFC3339; client EVENT time (server clamps it)
|
||||
}
|
||||
```
|
||||
|
||||
**Read — `GET /api/v1/progress?since=<cursor>`** (`internal/api/handlers/progress.go:86`): the `since`
|
||||
cursor is an **opaque server token over `synced_seq`**, not a client timestamp. When present, only rows
|
||||
with `synced_seq > cursor` are returned (use a sequence, or `>=` + id de-dup if a timestamp marker is
|
||||
used, to avoid tie loss). The response carries the next cursor. This makes delta delivery immune to
|
||||
client clock skew. Requires `UserStore.ListProgressSince(ctx, profileID, cursor)` in both backends
|
||||
(`internal/userdb`, `internal/userstore/pgstore`) ordering/filtering on `synced_seq`. Absent `since` →
|
||||
identical to today.
|
||||
|
||||
Adding a `UserStore` method (and the `event_at`/`synced_seq` columns to the per-user `watch_progress`
|
||||
schema) touches every stub implementation; ship the implementations, the schema change, and the shared
|
||||
store test suite update (`internal/userstore/storetest`) in the same PR.
|
||||
|
||||
**Net safety:** honest clients still get correct "most-recent actual watch wins" LWW; a dishonest clock
|
||||
is bounded to claiming "now" for its own profile (no authority it didn't already have), and cursors —
|
||||
the cross-device delivery mechanism — never depend on a client clock at all.
|
||||
|
||||
## API surface (one `/downloads/*` family; existing endpoints reshaped)
|
||||
|
||||
The web app is updated in lockstep, so existing endpoints are reshaped rather than frozen.
|
||||
|
||||
**Reshaped (existing):**
|
||||
```
|
||||
POST /downloads body {content_id, episode_id?, file_id?, quality?, series?, caps?};
|
||||
X-Silo-Device-Id present → managed entry; absent → ephemeral/web row.
|
||||
`quality` is original by default; bitrate presets are single-item only.
|
||||
GET /downloads managed entries for the CALLING device (device from header); ephemeral rows otherwise
|
||||
DELETE /downloads/{id} remove a row owned by (user, profile, header device)
|
||||
GET /downloads/{id}/file serve original (source) or prepared artifact (Range/resume + throttle)
|
||||
GET|HEAD /direct-download one-shot browser download, original-only (browser-friendly, token-in-URL)
|
||||
```
|
||||
The response DTO gains `device_id`, `quality`, `effective_quality`, `delivery_format`,
|
||||
`target_bitrate_kbps`, `revision`, artifact-derived readiness, and the new statuses.
|
||||
**`device_id` is authoritative only from the `X-Silo-Device-Id` header — never from the body or query**
|
||||
(a body/query value could only ever be a display hint, and is not accepted as authority).
|
||||
|
||||
**Bitrate qualities are single-item only.** A series batch (`series:true`) and
|
||||
`/direct-download` are **original-quality only**: `/direct-download` streams
|
||||
synchronously and cannot wait on an async encode, and a series batch would otherwise
|
||||
fan out N encode jobs. A client that wants prepared transcodes for a series requests
|
||||
each episode individually. `DownloadQualityResolver` rejects bulk bitrate requests
|
||||
with `ErrBulkQualityUnavailable` / HTTP 501.
|
||||
|
||||
**New — capability / feature detection** (pattern from `internal/api/handlers/notifications.go:324`):
|
||||
```
|
||||
GET /downloads/capability
|
||||
→ { "enabled": bool, "download_allowed": bool,
|
||||
"quality_presets": ["original","20mbps","10mbps","5mbps","2mbps","1mbps"],
|
||||
"transcode_enabled": bool, "transcode_user_allowed": bool,
|
||||
"season_download": bool, "series_monitoring": bool }
|
||||
```
|
||||
|
||||
**New — managed-entry lifecycle + offline bundle:**
|
||||
```
|
||||
PATCH /downloads/{id} body {status:"downloading"|"completed"}
|
||||
GET /downloads/{id}/manifest → OfflineManifest
|
||||
GET /downloads/batches/{batch_id}/manifests → {manifests:[OfflineManifest]}
|
||||
GET /downloads/{id}/artwork/{kind} → image bytes (poster|backdrop|logo)
|
||||
GET /downloads/{id}/subtitles/{ref} → subtitle bytes
|
||||
```
|
||||
|
||||
**Progress reconciliation:** additive fields on existing `POST /sync/progress` and `GET /progress`
|
||||
(above). No new routes.
|
||||
|
||||
Routes mount in the existing authenticated group where the current download routes live
|
||||
(`internal/api/router.go:2121`). **Every managed-entry endpoint — create, list, `PATCH`, `DELETE`,
|
||||
`/file`, `/manifest`, `/artwork`, `/subtitles` — is profile-scoped (`RequireProfile`) and authorizes the
|
||||
row on `(user_id, profile_id, header device_id)`** (not `user_id` alone — household profiles share a
|
||||
`user_id`, so a user-only check would leak one profile's downloads to another). A managed call requires
|
||||
`X-Silo-Device-Id` (missing header on a managed create → `400 device_id_required`); ephemeral web rows
|
||||
omit it and stay account-scoped. Byte-serving and asset endpoints additionally **re-check per-profile
|
||||
content/library access** (the existing access filter / `EnsureAccessible`) before serving, so a stale or
|
||||
out-of-scope row — e.g. a child profile, or a profile that lost library access — cannot pull restricted
|
||||
media by download id even if the row exists.
|
||||
|
||||
## Data flows
|
||||
|
||||
**Ephemeral web download (reshaped existing flow, quality-aware)**
|
||||
```
|
||||
POST /downloads {content_id, quality:"original", caps:{...}} (no device id → ephemeral)
|
||||
→ Resolve quality/delivery · permission · quota
|
||||
→ compatibility artifact: ArtifactManager.Ensure → preparing|ready ; direct original: ready immediately
|
||||
client → GET /downloads/{id}/file → serveContent(source|artifact, Range, throttle)
|
||||
```
|
||||
|
||||
**Register a managed bitrate download (mobile)**
|
||||
```
|
||||
POST /downloads {content_id, quality:"5mbps", caps:{...}} (+X-Silo-Device-Id, +X-Profile-Id)
|
||||
→ Resolve: cfg.TranscodeEnabled && user.DownloadTranscodeAllowed → delivery_format=transcode
|
||||
→ upsert downloads (device entry, status=preparing)
|
||||
→ ArtifactManager.Ensure(media_file_id, params): ON CONFLICT dedup → enqueue encode if new
|
||||
encode worker → playback.PrepareFile(... -movflags +faststart) → rename → artifact=ready
|
||||
→ linked rows=ready → events hub notify
|
||||
client (on notify) → GET /downloads/{id}/file → serveContent(artifact, Range, throttle)
|
||||
→ GET /downloads/{id}/manifest → download ArtworkURLs.* and Subtitles[].FetchURL once
|
||||
→ PATCH /downloads/{id} {status:"completed"}
|
||||
```
|
||||
|
||||
**Offline progress, reconnect**
|
||||
```
|
||||
(offline) client queues {media_item_id, position, duration, updated_at} per stop
|
||||
(online) POST /sync/progress {items:[... updated_at ...]} → SetProgressIfNewer per item
|
||||
GET /progress?since=<cursor> → rows changed elsewhere since last sync
|
||||
```
|
||||
|
||||
## Phased build sequence (each phase independently shippable)
|
||||
|
||||
- **Phase 0 — Reshape + scaffolding (coordinated web update).** Create `internal/downloads`; reshape the
|
||||
`downloads` table (device plus quality/delivery columns, widened status) and the `/downloads` +
|
||||
`/direct-download` endpoints to be quality-aware; port `BandwidthManager`/`QuantityLimiter`/serving;
|
||||
extend `config.DownloadConfig` (+ new keys, default-off); add quality/delivery constants + sentinel
|
||||
errors; add `GET /downloads/capability`. Update the web app's download hooks/components
|
||||
(`web/src/hooks/queries/downloads.ts`, `web/src/components/DownloadVersionPicker.tsx`) to the reshaped
|
||||
DTOs. Delete `internal/download`.
|
||||
- **Phase 1 — Managed device entries.** Device-aware create/list/patch/delete; managed-entry serving for
|
||||
`original`; `X-Silo-Device-Id` handling + the device-entry unique constraint. **Invariant 2** lands
|
||||
here: profile+device authorization on every managed endpoint, header-only `device_id` authority.
|
||||
*Acceptance tests:* a second profile on the same `user_id` is denied a managed row's `/file` and
|
||||
`PATCH`/`DELETE`; a body/query `device_id` cannot override the header.
|
||||
- **Phase 2 — Offline manifest.** `ManifestBuilder`; manifest + artwork + subtitle proxy endpoints, each
|
||||
under the invariant-2 authorization + a per-profile content-access re-check before serving.
|
||||
*Acceptance test:* a child/library-restricted profile is denied artwork/subtitle/manifest for an
|
||||
out-of-scope download id.
|
||||
- **Phase 3 — Prepare-to-file (remux + transcode) [v1-critical].** `download_artifacts` migration (incl.
|
||||
lease/attempt columns); `ArtifactManager` as a **durable leased queue** (transactional claim, lease
|
||||
heartbeat, startup sweep, `.part` cleanup) per **invariant 3**; `playback.PrepareFile`; encode worker on
|
||||
`taskmanager`; ready-state serving; events notification; cleanup job. Turn `download.transcode_enabled`
|
||||
on. *Acceptance test:* kill the process mid-encode → on restart the artifact re-enqueues and the linked
|
||||
download reaches `ready` (no permanent `preparing`); concurrent workers never double-encode.
|
||||
- **Phase 4 — Progress reconciliation.** `event_at` (clamped) + server-assigned `synced_seq` columns on
|
||||
`watch_progress`; clamped `updated_at` on `POST /sync/progress`; server-token `?since=` on
|
||||
`GET /progress`; `ListProgressSince` in both stores + store test suite, per **invariant 1**.
|
||||
*Acceptance test:* a future-dated client `updated_at` is clamped (cannot win over a later real write)
|
||||
and never advances another device's cursor.
|
||||
|
||||
Phase 0 precedes all and is the coordinated server+web reshape. Phases 1/2/4 are independent; Phase 3
|
||||
depends on 1. Remux can ship in Phase 3 even if transcode stays admin-off.
|
||||
|
||||
## Client impact
|
||||
|
||||
- **Android (`silo-android`):** persist a stable `device_id` (UUID in app storage) and send
|
||||
`X-Silo-Device-Id`; call `/downloads/capability` on login to gate UI; register downloads, poll/listen
|
||||
for `ready`, pull the file, store the manifest + artwork + subtitle files locally; play the local file
|
||||
with ExoPlayer; queue offline progress with timestamps in a Room table and flush via
|
||||
`POST /sync/progress`, then pull deltas with `GET /progress?since=`.
|
||||
- **Apple (`silo-apple`):** same flow; `device_id` from `identifierForVendor` (or stored UUID on macOS);
|
||||
`URLSession` background download against `GET /downloads/{id}/file`; AVFoundation plays the local file;
|
||||
offline progress queue drained on reconnect. The manifest's markers/chapters reuse the types already
|
||||
used for online playback.
|
||||
- **Web (`web/`):** updated in lockstep in **Phase 0** — the download hooks/components move to the
|
||||
reshaped `/downloads` DTOs (quality/effective quality/delivery fields, new statuses). New admin additions: a
|
||||
`download.transcode_enabled` toggle in admin settings, plus the existing per-user
|
||||
`download_transcode_allowed` switch in `web/src/pages/AdminUserDetail.tsx`. No offline-library UI on web.
|
||||
|
||||
## Security & reliability invariants (must-hold)
|
||||
|
||||
These three are correctness requirements, not nice-to-haves; each ships with the acceptance test named in
|
||||
the phase plan. They were hardened in response to an adversarial design review.
|
||||
|
||||
1. **Server-owned sync ordering.** Cross-device delta delivery (`?since=`) is driven only by the
|
||||
server-assigned `synced_seq`; the client clock is bounded metadata (`event_at`, clamped to
|
||||
`now + skew`) used only for LWW conflict resolution on the caller's own profile. A client-supplied
|
||||
timestamp can never advance another reader's cursor or lock in a far-future "win."
|
||||
2. **Full profile + device authorization.** Every managed-entry endpoint authorizes on
|
||||
`(user_id, profile_id, header device_id)` and re-checks per-profile content/library access before
|
||||
serving bytes/assets. A download id is never sufficient; `device_id` authority is the header only.
|
||||
This holds the "no cross-device visibility" and "no cross-profile leakage" invariants given that
|
||||
household profiles share a `user_id`.
|
||||
3. **Durable artifact recovery.** Artifact preparation is a leased, attempt-counted, transactionally
|
||||
claimed queue with a startup sweep, so no crash/restart can strand a download in `preparing` or
|
||||
double-encode the same artifact.
|
||||
|
||||
## Risks & trade-offs
|
||||
|
||||
- **Coordinated server+web reshape (Phase 0).** Reshaping `/downloads` requires the web app to land in
|
||||
the same milestone. Mitigated because both are first-party and the reshape is mechanical (added fields,
|
||||
not removed behavior for the existing web path).
|
||||
- **One table, two lifecycles.** A nullable `device_id` plus a wide status enum carries both ephemeral
|
||||
and managed rows. Deliberate and managed (partial unique index + MATCH-SIMPLE FK keep the two modes'
|
||||
invariants clean); the alternative two-table split was only justified by the now-lifted compat
|
||||
constraint.
|
||||
- **Artifact disk + CPU cost.** Mitigated by `(media_file_id, format, params_hash)` dedup (shared across
|
||||
web/devices/users), a bounded encode pool, LRU/byte-budget cleanup, and transcode being **off by
|
||||
default** (admin opt-in).
|
||||
- **Remux is not free for a clean downloadable file.** `+faststart` needs a finalization pass, so even
|
||||
remux is a prepare-to-file job. It is fast (`-c copy`) but not instant; `preparing → ready` + push
|
||||
notification covers the UX.
|
||||
- **`content_id` can change on rescan.** The manifest carries `StableIdentity` (provider IDs +
|
||||
season/episode) mirroring `WatchIdentity` so the client re-resolves after a catalog rebind; the stale
|
||||
row doesn't break the on-device file.
|
||||
- **No expiry by decision.** A revoked `download_allowed` stops *future* serves (re-checked each serve,
|
||||
as today at `internal/download/service.go:345`) but cannot remove an already-downloaded file. Call this
|
||||
out in admin copy.
|
||||
- **LWW vs. client clocks (resolved, see invariant 1).** Conflict resolution uses a clamped `event_at`
|
||||
and delivery uses the server-owned `synced_seq`, so a skewed/malicious clock is bounded to "now" on its
|
||||
own profile and cannot poison cursors. Residual: an honest-but-wrong client clock within the skew
|
||||
window can still mis-order two of *its own* near-simultaneous events — acceptable.
|
||||
- **Authorization breadth (resolved, see invariant 2).** The per-endpoint profile+device authorization
|
||||
and pre-serve access re-check are load-bearing; the acceptance suite must include a cross-profile and a
|
||||
cross-device denial test, since a regression here is a content-access leak, not just a bug.
|
||||
- **`UserStore` gains a method + `watch_progress` gains columns.** `ListProgressSince` plus the
|
||||
`event_at`/`synced_seq` columns touch every store implementor/stub and the per-user schema — all in the
|
||||
Phase 4 PR.
|
||||
- **Quota semantics.** v1: all download rows (web + device) count toward the **same** per-user
|
||||
`QuantityLimiter`; revisit if too coarse.
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
Cross-device download visibility; download expiry/lease/DRM; cumulative per-user storage-byte quotas;
|
||||
push-to-client "your transcode is ready" beyond the existing events hub; server-initiated deletion of
|
||||
client-side files.
|
||||
|
||||
## Appendix — proposal-ready summary (maps to the v1 capability template)
|
||||
|
||||
- **Summary:** Mobile apps can download movies/episodes for fully offline playback — choosing original
|
||||
or an admin-gated bitrate quality — and watch progress stays consistent when the device reconnects.
|
||||
The web app's existing download feature is folded into the same reshaped contract. Remux remains an
|
||||
internal delivery format, not a client-facing quality preset.
|
||||
- **User value / why v1:** Offline playback is table-stakes for mobile media clients (commutes, flights,
|
||||
spotty networks) and the client teams need the download + sync contract locked to build against.
|
||||
Reshaping the existing download contract now — before lock and before any mobile client ships against
|
||||
it — is the right time; the only current consumer is the first-party web app, updated in lockstep.
|
||||
- **API surface:** the reshaped + new `/downloads/*` endpoints listed in "API surface" above (capability,
|
||||
device-aware create/list/patch, file serving, manifest/artwork/subtitle proxies) plus
|
||||
`updated_at`/`?since=` on the progress endpoints. **Note for reviewers:** this *changes* the existing
|
||||
`/downloads` contract (additive-only is intentionally waived pre-lock for this first-party endpoint).
|
||||
- **Client impact:** Android — full offline UI + sync; Apple — full offline UI + sync; Web — lockstep
|
||||
Phase-0 DTO update + admin toggles.
|
||||
- **Rough size (server-side):** **L** (new package replacing the old one, table reshape + artifacts
|
||||
table, async encode pipeline, manifest assembly, additive progress sync).
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
# Personal Collection Catalog Filter Standardization - Design
|
||||
|
||||
- **Date:** 2026-06-23
|
||||
- **Status:** Draft (rev. 3 after adversarial review). Incorporates three findings: `match: any` merge safety, display-query fragment normalization, and ebook misclassification under unscoped watched filters.
|
||||
- **Scope:** `silo-server` backend and web collection UI. Client follow-ups in `silo-android` and `silo-apple` are optional unless they adopt the same collection editing surface.
|
||||
- **Note:** All paths are repository-relative. Commands assume the repository root is the cwd.
|
||||
|
||||
## Current Branch State
|
||||
|
||||
This design lives on the in-flight `profile-scoped-collection-filters` branch, which has **already implemented** the first cut of the feature but has **not merged to `main` and has not shipped to any client**. Concretely, on this branch today:
|
||||
|
||||
- A migration adds `watch_filter` and `media_filter` as `text` columns on `user_personal_collections`, each with a CHECK constraint (`migrations/sql/20260623184858_user_collection_display_filters.sql`).
|
||||
- `internal/catalog/user_collection_display_filters.go` implements a custom Go filter pass (`FilterUserCollectionDisplayItems`) called from `internal/sections/fetcher.go` and `internal/catalog/catalog_resolver.go`.
|
||||
- Web controls in `web/src/components/collections/CollectionBuilder.tsx`, `web/src/pages/ImportedCollectionEditor.tsx`, and helpers in `web/src/lib/collectionDisplayFilters.ts` already emit the legacy enums.
|
||||
|
||||
Because nothing is merged or released, this is a **clean-replacement** opportunity, not a backward-compatibility problem. The migration can be reshaped before merge; no client depends on the legacy enums. This resolves Open Question #1 below.
|
||||
|
||||
## Problem
|
||||
|
||||
Personal collection display filtering is growing its own filter model alongside two pre-existing completion implementations. There are **three** places that answer "is this item played/read for this profile?":
|
||||
|
||||
- the custom watched/media pass in `internal/catalog/user_collection_display_filters.go` (exact user-collection reads),
|
||||
- the personalized `watched` query rule in `internal/catalog/query_builder.go` (`userStateCompletionClause`),
|
||||
- the item user-data path in `internal/api/handlers/user_state.go` (`allEpisodesCompleted`), which is the canonical played/read state surfaced to clients.
|
||||
|
||||
That creates more than one way to answer the same product question: "should this item be visible for this profile based on watched state?" If multiple paths persist, they will drift in semantics, performance, and bug fixes. The drift is already visible *and already duplicated in code*:
|
||||
|
||||
- The custom personal collection helper treats series/seasons as watched only when all child episodes are complete; `userCollectionAllEpisodesCompleted` is a near-verbatim copy of `allEpisodesCompleted` in `user_state.go`.
|
||||
- The catalog `watched` rule, by contrast, checks completion for the row's own `content_id` only (`uwp.media_item_id = mi.content_id`), so a series row is effectively never "watched" because progress/history is keyed to episode IDs, not the series ID.
|
||||
|
||||
Consolidating onto the catalog predicate therefore removes one Go copy outright and requires teaching the catalog predicate the rollup the other two already perform.
|
||||
|
||||
## Goals
|
||||
|
||||
- Use the catalog query system as the canonical representation and executor for watched filters.
|
||||
- Make personal collection display filtering, catalog filtering, and collection-builder UI speak the same `QueryDefinition` / `QueryRule` language.
|
||||
- Preserve exact collection source order when a collection is rendered as a shelf or source-ordered list.
|
||||
- Preserve existing access controls and profile scoping.
|
||||
- Avoid persisting a second watched-filter vocabulary unless needed as a compatibility shim.
|
||||
- Keep the implementation narrow enough to land as a refactor/standardization of an already intended collection-filter feature.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No new broad smart-collection DSL.
|
||||
- No change to external sync membership. Imported lists still sync exact item membership before display filtering is applied.
|
||||
- No change to collection ownership, sharing, or profile visibility rules.
|
||||
- No client lockstep requirement. Existing clients can ignore new display-filter capabilities until they adopt the collection editor.
|
||||
- No attempt to optimize very large exact collections beyond the current exact-source behavior in this pass.
|
||||
|
||||
## Scope Gate
|
||||
|
||||
`docs/architecture/v1-scope.md` says v1 is not locked and the proposal window is open. This design should be treated as a standardization of the profile-scoped collection filter work already underway, not as a separate feature expansion. If the final implementation exposes a new public capability beyond personal collection display filtering, file a v1 capability proposal before opening a feature PR.
|
||||
|
||||
## Design Principle
|
||||
|
||||
One feature gets one semantic implementation:
|
||||
|
||||
```json
|
||||
{ "field": "watched", "op": "is", "value": true }
|
||||
```
|
||||
|
||||
is the only canonical expression for "show watched items"; `value: false` is the canonical expression for "show unwatched items".
|
||||
|
||||
UI controls may present simpler labels such as "All", "Watched", and "Unwatched", but those controls must translate into catalog query rules before execution. Backend code should not add another watched evaluator for personal collections.
|
||||
|
||||
## Canonical Semantics
|
||||
|
||||
The canonical `watched` filter should match the user-facing played/read state shown by item user data:
|
||||
|
||||
| Item kind | `watched is true` should mean |
|
||||
|---|---|
|
||||
| Movie / episode / video leaf | Completed progress or visible completed history exists for the active profile. |
|
||||
| Series | At least one child episode exists, and every child episode is completed for the active profile. |
|
||||
| Season | At least one child episode exists, and every child episode in the season is completed for the active profile. |
|
||||
| Ebook | Reader progress is at or above `models.EbookFinishedProgressThreshold`. |
|
||||
| Manga | Use the same read-state source the catalog uses for manga/chapters once manga catalog filtering is finalized; do not add a collection-only interpretation. |
|
||||
|
||||
This intentionally aligns with `internal/api/handlers/user_state.go`, where series and season user data are derived by `allEpisodesCompleted`. If this definition is too expensive for catalog queries, we should optimize the catalog predicate rather than keep a separate personal collection definition.
|
||||
|
||||
For this table to hold when a query is *unscoped* (mixed-media exact collections), the predicate must pick the completion source from each row's own type, not from a single query-wide `media_scope` — see Catalog Watched Predicate Work. Picking the source by `media_scope` alone misclassifies any row whose type differs from the scope (notably ebooks under an unscoped query).
|
||||
|
||||
## Storage Model
|
||||
|
||||
Recommended canonical storage for personal collection display filters:
|
||||
|
||||
- Add a dedicated JSON column named for display filtering, `display_query_definition`. Do **not** overload the existing `query_definition` field (see Risks: it already carries `library_ids` for live/smart collections and is fragile enough to have needed a recent sanitization fix).
|
||||
- Store the standard catalog `QueryDefinition` *shape* in that field, normalized to a filter-only fragment (see normalization rules below).
|
||||
- For the current "media filter" control, store a **`type` rule**, not `media_scope`:
|
||||
- `all` -> no `type` rule
|
||||
- `movie` -> `{ "field": "type", "op": "is", "value": "movie" }`
|
||||
- `series` -> `{ "field": "type", "op": "is", "value": "series" }`
|
||||
- For the current "watch filter" control, store a `watched` rule:
|
||||
- `all` -> no watched rule
|
||||
- `watched` -> `{ "field": "watched", "op": "is", "value": true }`
|
||||
- `unwatched` -> `{ "field": "watched", "op": "is", "value": false }`
|
||||
|
||||
**Why `type` rule, not `media_scope`:** `media_scope` is a single string on `QueryDefinition`, not a rule, and there is no logic to intersect two scopes (only `intersectCatalogDefinitionLibraries` exists, for library IDs). A `type` rule composes as an ordinary AND group and avoids any collision with a smart collection's own `media_scope`. `type` is already a supported, executable query field, so this needs no new merge machinery. Reserve `media_scope` for the source query's own scoping.
|
||||
|
||||
**`display_query_definition` is a filter-only fragment, not a full query.** A full `QueryDefinition` carries fields that are meaningless — and actively dangerous — for a display overlay. In particular the executor honors `def.Limit` internally, so a persisted display query with `limit` set would cap the match set *before* the exact-collection source-order reorder, silently and permanently hiding matching items and producing wrong totals/pages. `sort` and `library_ids` are likewise the source/collection's concern, not the overlay's. Normalize on the API boundary before persisting and before executing:
|
||||
|
||||
- Persist only `match` and `groups` (with rules drawn from the allowed display vocabulary — initially `type` and `watched`).
|
||||
- Reject or strip `limit`, `sort`, `library_ids`, and `media_scope` from the fragment. Prefer reject-with-error on write so clients learn the contract; strip defensively on read for forward-compat.
|
||||
- Storing the `QueryDefinition` *shape* keeps the door open to a richer rule set later; it does not mean accepting the whole `QueryDefinition` surface.
|
||||
|
||||
Compatibility: none required. The legacy `watch_filter` / `media_filter` enums exist only on this unmerged branch (see Current Branch State), so they are dropped rather than aliased. If — and only if — a future build ships the enums to clients before this lands, revisit the compatibility shim; until then there is no legacy surface to preserve.
|
||||
|
||||
## Execution Model
|
||||
|
||||
### Smart/live collections
|
||||
|
||||
Smart and live collections already resolve through `CatalogResolver.resolveQuerySource`. Their stored query is already a catalog `QueryDefinition`. Display filters should be merged into that query before execution.
|
||||
|
||||
**Merge must guarantee `(source) AND (display)` — appending a group does not.** `QueryBuilder.Build` joins *all* top-level groups with a single joiner derived from the definition's top-level `match` (`query_builder.go`: `topJoiner` is ` OR ` when `match == "any"`). So a smart/live source with `match: any` and groups `G1, G2` becomes `G1 OR G2`; appending a display group `D` yields `G1 OR G2 OR D` — which *widens* the result past the collection source and lets contradictory watched rules return rows instead of none. The QueryDefinition model has only one top-level `match` and only two levels (groups of rules), so there is no in-model way to express `(G1 OR G2) AND D`.
|
||||
|
||||
Merge rule:
|
||||
|
||||
- Keep library scope, `media_scope`, limit, and sort from the collection's source query.
|
||||
- Combine the source definition and the display fragment with a true conjunction that is independent of either side's `match`. The robust implementation is an **executor-level conjunction**: build the source predicate and the display predicate as two separate SQL fragments and join them with ` AND ` (each side already parenthesized), rather than merging group lists. This is net-new merge logic, not an existing capability.
|
||||
- The append-as-group shortcut is correct **only** when the source's top-level `match == "all"`. When `match == "any"` it is incorrect. Do not special-case "all" and forget "any"; implement the general conjunction and test both.
|
||||
- Because the media control is stored as a `type` rule (not `media_scope`), it ANDs in through the same conjunction. If the source query's `media_scope` already excludes that type (e.g. a movies-only smart collection plus a `type is series` display filter), the result is empty — no scope-intersection code is needed. (There is no existing media-scope intersection logic to lean on, which is the main reason the media control is modeled as a `type` rule rather than a second `media_scope`.)
|
||||
- If both the source and display layers contain `watched` rules, the conjunction preserves both; contradictory rules naturally return no rows.
|
||||
|
||||
### Exact/manual/imported collections
|
||||
|
||||
Exact collections have a source item set from `user_personal_collection_items`. Display filtering is applied after membership is known but before pagination.
|
||||
|
||||
**This path replaces two existing filters, not one.** Today the source-ordered exact path runs the in-Go `filterCatalogItems` (a metadata-only matcher that cannot evaluate personalized fields — it returns `false` for `watched`) *and* the separate `FilterUserCollectionDisplayItems` Go helper for the watched dimension. Neither is the SQL executor. Routing through the executor folds both metadata and personalized filtering into one SQL path; `FilterUserCollectionDisplayItems` is deleted and `filterCatalogItems` is no longer used for these collections.
|
||||
|
||||
Recommended execution:
|
||||
|
||||
1. Load collection item IDs in source order (from `user_personal_collection_items`, `ORDER BY position`).
|
||||
2. Build a catalog `QueryDefinition` from the normalized display fragment plus any request overlay. Force `Limit = nil` and drop any fragment-level `sort`/`library_ids` so the executor cannot truncate or reorder the match set out from under step 4. (Defense in depth: the fragment is already normalized at persistence per the Storage Model, but the executor call must not reintroduce a limit.)
|
||||
3. Run the normal catalog executor with `AccessFilter.AllowedContentIDs = itemIDs` so all filtering, including `watched`, uses the catalog query path. (`resolveCandidateItemsWithQuery` already establishes this pattern.)
|
||||
4. For source-ordered requests, retrieve the *full* matching set (no limit), collect the matching IDs into a set, reapply the original source order, and only then apply request offset/limit. Request pagination is applied after reordering, never inside the executor.
|
||||
5. For requests that sort via the query, allow the catalog executor to sort normally. **Note:** exact user collections are hardcoded to source order today (`UseSourceOrder: true`); this branch exists for forward-compatibility and is not exercised by current callers. Mark it clearly so it is not mistaken for present behavior.
|
||||
|
||||
This preserves exact collection order without duplicating watched logic. Step 4 is a new shape (run the executor for the match-set, then reorder), distinct from the sorted-pagination shape `resolveCandidateItemsWithQuery` uses today — call out the differing limit handling in the implementation.
|
||||
|
||||
## Catalog Watched Predicate Work
|
||||
|
||||
The existing catalog `watched` rule is already implemented but needs one semantic hardening before it becomes the shared path:
|
||||
|
||||
- Extract a reusable played/read predicate for `QueryBuilder`.
|
||||
- Keep the existing leaf-item checks for progress, completed history, hidden history, and ebook reader progress.
|
||||
- Add series and season rollup branches so `watched` matches item user-data semantics.
|
||||
- Add SQL tests that assert series/season predicates are present only when relevant.
|
||||
- Reuse the same predicate for `last_watched` only if the semantics truly match; otherwise leave `last_watched` as a leaf/history timestamp filter and document the difference. (`last_watched` is a timestamp aggregate built from the `user_last_watched` CTE — different machinery from the completion predicate — so it most likely stays separate.)
|
||||
|
||||
**Episode-set parity is the load-bearing requirement, not the rollup branch itself.** The Go rollups (`allEpisodesCompleted` in `user_state.go` and its copy in the collection helper) define "all episodes complete" over the episode set returned by the episode repository (`ListBySeriesIDs` / `ListBySeasonIDs`). If the SQL rollup's notion of "child episodes" differs — specials, unaired, or unavailable episodes included or excluded differently — the catalog `watched` rule will *disagree* with item user-data, recreating the exact drift this design removes, just relocated into SQL. The hardening must therefore reproduce the repository's child-episode set exactly (same joins, same filters), and a test must pin that equivalence (see Testing Strategy). Treat Open Question #4 (all-available vs all-aired) as a question about *what that repository set already is*, and match it.
|
||||
|
||||
**Row-type branching is mandatory for unscoped queries, including ebooks.** The current predicate selects the ebook source (`ebook_reader_progress`) *only* when `mediaScope == "ebook"`; otherwise it uses video watch progress/history (`userStateCompletionClause` branches on `qb.mediaScope == "ebook"`). Exact collection membership is not type-restricted, so an exact collection can contain an ebook while the query runs unscoped (the `all` media control emits no `type` rule). Under today's code that ebook would be evaluated against video progress/history — i.e. classified as never-watched regardless of read state — reintroducing exactly the kind of drift this design removes. The hardened predicate must therefore branch by the row's own type when the query is unscoped:
|
||||
|
||||
- movie / episode / video leaf -> watch progress + completed history,
|
||||
- series / season -> episode rollup (with repository-parity episode sets, above),
|
||||
- ebook -> `ebook_reader_progress >= EbookFinishedProgressThreshold`,
|
||||
- manga -> deferred (see Non-Goals / Canonical Semantics).
|
||||
|
||||
If row-type branching cannot land in the first cut, v1 must instead *enforce* a video-only scope at execution for exact collections (so ebooks are never silently classified by the video source) and say so explicitly — but row-type branching is the correct end state because it is what makes the canonical semantics table hold under unscoped queries.
|
||||
|
||||
The first implementation may still gate series/season rollup behavior by `mediaScope == "series"` and exact known season rows, but it must not assume a single item kind for unscoped/video queries that can return movies, series, and ebooks together.
|
||||
|
||||
## API Shape
|
||||
|
||||
Preferred API model:
|
||||
|
||||
- Add `display_query_definition?: QueryDefinition` to personal collection create/update/response types.
|
||||
- Replace the branch's `watch_filter` / `media_filter` request and response fields with `display_query_definition`. Since the enums are unreleased (see Current Branch State), no aliasing or deprecation header flow is required.
|
||||
- Keep collection capabilities additive:
|
||||
- expose supported display-query fields using the existing catalog field vocabulary,
|
||||
- optionally expose UI presets for "all/watched/unwatched" and "all/movies/series" as client convenience.
|
||||
|
||||
Do not add a separate `filter_watched` query parameter to catalog endpoints. Catalog already has the watched rule.
|
||||
|
||||
## UI Model
|
||||
|
||||
The web UI can keep simple controls:
|
||||
|
||||
- media segmented control: All / Movies / Series,
|
||||
- watched segmented control: All / Unwatched / Watched.
|
||||
|
||||
Those controls should edit a `QueryDefinition` fragment using shared helpers, not a separate collection-only state model.
|
||||
|
||||
Recommended helper location:
|
||||
|
||||
- `web/src/lib/collectionDisplayFilters.ts` should convert between UI presets and catalog `QueryDefinition`.
|
||||
- `web/src/components/collections/CollectionGuidedRulesEditor.tsx` remains the broader catalog-query editor.
|
||||
- `web/src/components/collections/CollectionBuilder.tsx` and `web/src/pages/ImportedCollectionEditor.tsx` use the shared helper.
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
The legacy `watch_filter` / `media_filter` enums exist only on this unmerged branch and have shipped to no client (see Current Branch State), so the clean-replacement path applies. There is no backfill, dual-read, or deprecation window:
|
||||
|
||||
- Reshape the existing migration `20260623184858_user_collection_display_filters.sql` rather than layering a second migration on top: drop the `watch_filter` / `media_filter` columns and their CHECK constraints, and add the canonical `display_query_definition jsonb` column (nullable; `NULL` / absent means "no display filter"). Because the branch is unmerged, editing the not-yet-applied migration in place is cleaner than a forward-then-backfill sequence.
|
||||
- Update API and web types to use display-query-backed helpers; delete the legacy enum request/response fields.
|
||||
- No server code reads or writes the legacy columns after this change.
|
||||
|
||||
A backfill/dual-read migration would only be warranted if a build ships the legacy enums to clients before this lands. If that happens, fall back to: add `display_query_definition`, backfill (`watch_filter = 'watched'` -> watched rule true; `'unwatched'` -> false; `media_filter = 'movie'`/`'series'` -> `type` rule; `all` omits the rule), read legacy as fallback, stop writing legacy, and remove later under the API deprecation flow. Treat this as a contingency, not the plan of record.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Merge OR-leak (smart/live):** appending a display group to a `match: any` source query widens results past the collection source instead of constraining them. Mitigate with the executor-level conjunction (Execution Model) and a test that a `match: any` source plus a display filter never returns rows the source alone would exclude.
|
||||
- **Display fragment truncation:** a persisted `limit` (or stray `sort`) on `display_query_definition` would cap or reorder the exact-collection match set before the source-order reorder, hiding items and corrupting totals. Mitigate by normalizing the fragment to filter-only on write and forcing `Limit = nil` on the match-set execution.
|
||||
- **Ebook misclassification under unscoped queries:** an ebook in a mixed-media exact collection evaluated by an unscoped `watched` rule is classified by the video source today (`media_scope`-gated ebook predicate). Mitigate with row-type branching in the predicate, or enforce a video-only execution scope for v1 (Catalog Watched Predicate Work).
|
||||
- **Episode-set divergence (highest risk):** if the SQL rollup's child-episode set differs from the repository's `ListBySeriesIDs` / `ListBySeasonIDs` listing, the catalog `watched` rule will disagree with item user-data and reintroduce drift in a new place. Mitigate by reproducing the repository's joins/filters exactly and pinning the equivalence with a test. This, not raw SQL cost, is the thing most likely to ship subtly wrong.
|
||||
- **Series/season SQL cost:** all-episodes-complete checks are more expensive than leaf checks. Mitigate by only emitting rollup branches when item types can include series/seasons and by relying on indexes on episode parent IDs and progress/history media IDs.
|
||||
- **Semantics change for catalog series filters:** extending catalog `watched` to match displayed played state can change existing catalog results for series (today they are effectively never "watched"). This is likely correct, but it must be called out in release notes or PR text.
|
||||
- **Exact collection performance:** source-order rendering requires evaluating the full exact collection before pagination (the executor must return the complete match-set to reorder). This matches current behavior but should not regress further; very large exact collections remain out of scope this pass (see Non-Goals).
|
||||
- **Field overloading (resolved → dedicated column):** code inspection confirms `query_definition` already carries `library_ids` for live/smart collections and recently needed a `library_ids` sanitization fix. Reusing it for display filters would entangle two concerns; use a dedicated `display_query_definition` column.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Backend tests are justified because this changes shared personalized filtering semantics:
|
||||
|
||||
- **Equivalence test (highest value):** for the same series/season/profile fixtures, assert the catalog `watched` rule and `user_state.go`'s `Played` flag agree. This is the "one semantic" goal made executable and is the test most likely to catch episode-set divergence; it should fail loudly if the two definitions drift.
|
||||
- `internal/catalog/query_builder_test.go`: `watched` emits leaf completion checks and series/season rollup checks as appropriate.
|
||||
- `internal/catalog/query_executor_test.go` or focused resolver tests: exact collection filtering routes through catalog `watched` and preserves source order; the executor returns the full match-set so reordering is stable across pages.
|
||||
- `internal/catalog/catalog_resolver_test.go`: contradictory display/source filters return no rows; a `type is series` display filter on a movies-only smart collection yields no rows without any scope-intersection code.
|
||||
- **Merge safety:** a smart/live source with top-level `match: any` plus a display filter must never return rows the source alone excludes (guards against the OR-leak); a contradictory `watched` overlay on a `match: any` source returns zero rows.
|
||||
- **Fragment normalization:** a `display_query_definition` carrying `limit`/`sort`/`library_ids` is rejected on write (or stripped on read); an exact collection larger than any plausible page returns the full match set in source order regardless of fragment contents.
|
||||
- **Ebook under unscoped watched:** a mixed-media exact collection (movie + ebook) with an `unwatched`/`watched` filter classifies the ebook via `ebook_reader_progress`, not video history (guards against the misclassification finding).
|
||||
- `internal/api/handlers/collections_test.go`: create/update round-trips `display_query_definition`; the removed `watch_filter` / `media_filter` fields are gone from request and response.
|
||||
- Migration test only if the contingency backfill path is used (it is not, on the plan of record).
|
||||
|
||||
Frontend tests should stay targeted:
|
||||
|
||||
- helper tests for converting simple UI controls to/from `QueryDefinition`,
|
||||
- one editor test proving the submit payload carries the canonical display query.
|
||||
|
||||
## Implementation Outline
|
||||
|
||||
1. Harden catalog `watched` semantics (series/season rollup with repository-parity episode sets) and land the equivalence test. This is independently valuable and reviewable; consider shipping it as its own PR first.
|
||||
2. Reshape migration `20260623184858` to drop `watch_filter` / `media_filter` and add `display_query_definition jsonb`.
|
||||
3. Add display-query types and normalization helpers in backend collection handling.
|
||||
4. Replace both the custom `FilterUserCollectionDisplayItems` pass and the in-Go `filterCatalogItems` source-order path with catalog-query execution constrained by exact item IDs (match-set, then reorder).
|
||||
5. Update web controls to emit catalog query fragments through shared helpers; delete the legacy enum fields.
|
||||
6. Run focused Go tests for catalog/query/user-collection paths and targeted frontend helper tests.
|
||||
|
||||
Per the v1 process, link the capability sub-issue (`Part of #NNN`) and keep PRs to one concern — predicate-hardening (step 1) is a natural standalone PR separate from the storage/UI reshape (steps 2–5).
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Resolved.** The legacy enums exist only on this unmerged, unreleased branch, so it replaces them cleanly with no compatibility shim (see Current Branch State and Migration Strategy).
|
||||
- **Decided:** the standardized *controls* expose video item scopes (movie/series/episode) for v1; manga read-state filtering is deferred until catalog manga filtering is finalized, then adopts the same catalog read-state source rather than a collection-only interpretation. Note this constrains the *control vocabulary*, not collection *membership* — exact collections can still contain ebooks, so the watched predicate must branch by row type (see Catalog Watched Predicate Work) regardless of what the control offers.
|
||||
- **Decided:** first version exposes only the `type` and `watched` controls, stored as a **filter-only `QueryDefinition` fragment** (`match` + `groups`; no `limit`/`sort`/`library_ids`/`media_scope`). Keeping the fragment shape lets a richer rule set turn on later without a storage change, while normalization prevents the fragment from carrying execution-controlling fields (see Storage Model).
|
||||
- Should catalog `watched` rollup for series mean "all available episodes complete" or "all aired episodes complete"? This is a question about *what the episode repository already returns* — match that set exactly (see Catalog Watched Predicate Work / episode-set parity). Only change the definition by changing the repository path and item user-data together, never the catalog rule alone.
|
||||
- **New:** confirm no `silo-android` / `silo-apple` build has already consumed the branch's `watch_filter` / `media_filter` fields. The design assumes not (the branch is unreleased); if a client has pinned them, the contingency backfill path applies instead.
|
||||
@@ -0,0 +1,220 @@
|
||||
# Download limits & restrictions (downloads v2) — design
|
||||
|
||||
**Goal:** Extend downloads v2 with the restriction knobs admins actually need: (1) a **quality
|
||||
ceiling** — server-wide and per-user — so a user can be allowed to download, even transcode, but
|
||||
only up to a capped bitrate; (2) a **batch size cap** protecting the server from a single series
|
||||
request fanning out into hundreds of rows and transcode jobs; (3) **per-user overrides** for the
|
||||
existing global quantity and bandwidth limits, following the established `users.max_streams` /
|
||||
`users.max_transcodes` pattern, so households can tier users without changing the global policy.
|
||||
|
||||
**Status:** Draft — design input for a v1 capability proposal (Silo v1 scope gate,
|
||||
`docs/architecture/v1-scope.md`). All API changes are additive within `/api/v1`.
|
||||
|
||||
Decisions locked with the requester:
|
||||
- **No device/user storage caps.** Device disk is the client's job (per-subscription
|
||||
`max_storage_bytes` already covers the monitoring case); server disk is already covered by
|
||||
`download.artifact_max_bytes`; serving volume is already covered by bandwidth + per-period quotas.
|
||||
- **No expiry / lease / revocation of already-downloaded files.** Unchanged from the downloads v2
|
||||
design (`docs/superpowers/specs/2026-06-18-offline-sync-mobile-design.md`).
|
||||
- **Transcode restriction already exists** (`download.transcode_enabled` +
|
||||
`users.download_transcode_allowed`) and is kept as-is; the quality ceiling composes with it
|
||||
("transcode allowed, but only up to 5mbps").
|
||||
- **Admin observability (downloads dashboard, admin list/revoke endpoints) is a separate effort**,
|
||||
not part of this spec.
|
||||
|
||||
> Commands and paths in this document are repository-relative; assume the repository root is the cwd.
|
||||
|
||||
## Current state (what this builds on)
|
||||
|
||||
Enforcement today lives in `internal/downloads`:
|
||||
|
||||
- **Policy** — `policy.go`: `DownloadQualityResolver.Resolve` validates a requested quality from the
|
||||
ladder `original > 20mbps > 10mbps > 5mbps > 2mbps > 1mbps` and gates bitrate presets on
|
||||
`cfg.TranscodeEnabled && user.DownloadTranscodeAllowed`. `PresetsFor` advertises the fulfillable
|
||||
ladder through `GET /api/v1/downloads/capability` (`quality_presets`), so clients render only what
|
||||
the server will honor.
|
||||
- **Quantity** — `limiter.go`: `QuantityLimiter.Check(ctx, userID, batchSize)` enforces the global
|
||||
`download.max_concurrent_per_user` and `download.max_per_period` / `download.period_duration`
|
||||
settings (0 = unlimited) against `CountActiveByUser` / `CountByUserSince`.
|
||||
- **Bandwidth** — `bandwidth.go`: `BandwidthManager` token-buckets file serving with one server-wide
|
||||
rate and one per-user rate (`download.server_bandwidth_mbps`, `download.user_bandwidth_mbps`),
|
||||
caching one `rate.Limiter` per user id.
|
||||
- **Per-user flags** — `users.download_allowed`, `users.download_transcode_allowed`
|
||||
(`internal/auth/repository.go`), exposed on the admin users API (`internal/api/handlers/admin.go`)
|
||||
and web UI (`web/src/pages/AdminUserDetail.tsx`).
|
||||
- **Bulk** — `service.go` `createSeriesScoped` fans a series/season request into managed entries with
|
||||
no upper bound; `resolveBulkQuality` intentionally keeps batches **original-only**
|
||||
(`ErrBulkQualityUnavailable`). Subscription sync auto-registers in-scope episodes without
|
||||
consuming the quantity limiter (by design).
|
||||
- **Serve-time re-check** — `Service.ServeFile` re-resolves policy on every file request, so
|
||||
disabling downloads or revoking a user flag takes effect immediately for anything not yet pulled.
|
||||
|
||||
Settings are freeform keys with defaults and validation in `internal/config/db_loader.go`, hot-
|
||||
reloaded by `Service.loadConfig` (which also reloads the limiter and bandwidth manager on change),
|
||||
and edited in `web/src/pages/admin-settings/DownloadSettings.tsx`.
|
||||
|
||||
## 1. Quality ceiling
|
||||
|
||||
### Semantics
|
||||
|
||||
- New server setting **`download.max_quality`** (string; `""` = no cap; otherwise one of the quality
|
||||
ladder values). New nullable per-user column **`users.download_max_quality`** (`NULL` = inherit the
|
||||
server setting; otherwise one of the ladder values).
|
||||
- The **effective ceiling** for a user is the *lower* of the server setting and the user column on
|
||||
the ordered ladder (`original` is the top). A ceiling below `original` deliberately forbids
|
||||
`original` — that is the point: "this user gets at most a 5mbps transcode, never the full file."
|
||||
- Enforcement is **presets-first**: `PresetsFor` truncates the advertised ladder at the effective
|
||||
ceiling, so compliant clients never offer a forbidden quality and **no client changes are
|
||||
required** (clients already read `quality_presets` from the capability endpoint).
|
||||
- `Resolve` independently rejects any request above the ceiling with a new sentinel
|
||||
`ErrQualityNotPermitted` (handler maps it alongside the existing limit errors as HTTP 403,
|
||||
distinct from `ErrInvalidQuality`), so a non-compliant client cannot bypass the cap.
|
||||
- The **original-compatibility fallback** inside `Resolve` (original → remux/transcode when the
|
||||
device can't play the source) must clamp its chosen `EffectiveQuality`/bitrate to the ceiling.
|
||||
In practice a sub-original ceiling makes the fallback moot — `original` is already rejected — but
|
||||
the clamp keeps the invariant local to `Resolve` rather than depending on call-site ordering.
|
||||
- **Serve-time**: the existing `ServeFile` policy re-check additionally denies rows whose
|
||||
`effective_quality` exceeds the user's *current* ceiling. Consistent with today's revocation
|
||||
semantics: lowering a cap stops future serving of higher-quality registered entries; files already
|
||||
on-device are untouched (no expiry).
|
||||
|
||||
### Interactions (documented behavior, not bugs)
|
||||
|
||||
- **Ceiling below `original` + `download_transcode_allowed = false`** → the fulfillable preset list
|
||||
is empty and the capability endpoint reports no presets; the user effectively cannot download.
|
||||
This is legal admin configuration. The admin UI shows a hint on the per-user field when this
|
||||
combination is selected ("a sub-original cap requires the transcode permission").
|
||||
- **Series/season/monitoring downloads are original-only today** (`resolveBulkQuality`). A user
|
||||
capped below `original` therefore cannot create series/season batches or receive subscription
|
||||
auto-registrations; those paths return `ErrQualityNotPermitted` (create) or skip registration with
|
||||
a `SkippedDownload` reason `quality_not_permitted` (subscription sync). This is accepted for now
|
||||
and called out here so it is not rediscovered as a bug. Lifting it means bulk transcode — a
|
||||
deliberate follow-up, not smuggled into this change.
|
||||
|
||||
### Implementation
|
||||
|
||||
- `Resolve`/`PresetsFor` gain the ceiling input. Rather than growing the parameter lists further,
|
||||
introduce a small `PolicyInput`/`userPolicy` struct (user + cfg + artifactsAvailable) if the
|
||||
signatures get unwieldy — implementer's choice; behavior is what is specified above.
|
||||
- Ladder comparison helper in `internal/downloads/model.go` (e.g. `QualityAtMost(q, ceiling) bool`
|
||||
using the `QualityPresets` order) — no string parsing of bitrates.
|
||||
- Validation: `db_loader.go` rejects a `download.max_quality` value not on the ladder; the users
|
||||
API rejects invalid `download_max_quality` the same way (shared validation via
|
||||
`downloads.ValidQuality`, with `""`/`NULL` allowed).
|
||||
|
||||
## 2. Batch size cap
|
||||
|
||||
- New server setting **`download.max_batch_size`** (int; default **100**; 0 = unlimited): the
|
||||
maximum number of managed entries one explicit series/season create may register, and the maximum
|
||||
auto-registered per subscription-sync pass.
|
||||
- **Explicit series/season create** (`createSeriesScoped`): if the in-scope episode count (after
|
||||
access filtering and skip computation) exceeds the cap, **reject** the whole request with a new
|
||||
sentinel `ErrBatchTooLarge` (HTTP 400, error body includes the cap so clients can message "try a
|
||||
season at a time or subscribe"). Rejection is chosen over truncation because a silently partial
|
||||
one-shot batch never self-heals — there is no later pass that picks up the remainder.
|
||||
- **Subscription sync** (`subscription_service.go`): **truncate** each pass at the cap (stable
|
||||
episode order). Truncation is correct here because sync recomputes in-scope-but-unregistered
|
||||
episodes every pass, so the remainder registers on subsequent syncs — natural pagination, and one
|
||||
monster series cannot monopolize a sync pass or the artifact queue.
|
||||
- The capability response additively gains **`max_batch_size`** (int, 0 = unlimited) so clients can
|
||||
preflight ("this series has 340 episodes; the server accepts 100 per request") instead of
|
||||
discovering the limit by error.
|
||||
|
||||
## 3. Per-user quantity & bandwidth overrides
|
||||
|
||||
- Three new nullable columns on `users`, mirroring the `max_streams` pattern
|
||||
(`internal/models/user.go`, `internal/auth/repository.go`):
|
||||
- `download_max_concurrent int` — overrides `download.max_concurrent_per_user`
|
||||
- `download_max_per_period int` — overrides `download.max_per_period` (window stays the global
|
||||
`download.period_duration`; a per-user window is deliberately out of scope)
|
||||
- `download_bandwidth_mbps int` — overrides `download.user_bandwidth_mbps`
|
||||
- Semantics: `NULL` = inherit the global setting; **0 = explicitly unlimited** (matches the global
|
||||
settings' zero-means-unlimited convention, and is the reason `NULL` and `0` must stay distinct).
|
||||
- **QuantityLimiter**: `Check` gains the caller-resolved user overrides — e.g.
|
||||
`Check(ctx, userID, batchSize, overrides Overrides)` where `Overrides` carries the two optional
|
||||
ints. Every existing call site already has the `*models.User` loaded (the create paths load it
|
||||
for policy), so resolution stays in `service.go` and the limiter remains storage-free.
|
||||
- **BandwidthManager**: the per-user limiter cache currently assumes one global rate. Store the
|
||||
rate alongside each cached limiter and recreate the entry when the effective rate for that user
|
||||
changes (`ThrottledReader` gains the resolved per-user bps from the caller; `ServeFile` already
|
||||
loads the user for its policy re-check). `Reload` keeps its existing clear-all behavior.
|
||||
- **Admin API**: the three fields (plus `download_max_quality` from §1) are added to the admin user
|
||||
create/update/response DTOs in `internal/api/handlers/admin.go` and to
|
||||
`UpdateUserInput`/`CreateUserInput` in `internal/models/user.go` +
|
||||
`internal/auth/repository.go` — the exact `max_streams` additive pattern.
|
||||
|
||||
## Data model (Goose migration)
|
||||
|
||||
One timestamped migration (`make migrate-create NAME=user_download_limits`):
|
||||
|
||||
```sql
|
||||
ALTER TABLE users
|
||||
ADD COLUMN download_max_quality text,
|
||||
ADD COLUMN download_max_concurrent integer,
|
||||
ADD COLUMN download_max_per_period integer,
|
||||
ADD COLUMN download_bandwidth_mbps integer;
|
||||
```
|
||||
|
||||
All nullable, no defaults, no backfill — `NULL` means "inherit global" for every column. The two new
|
||||
settings keys (`download.max_quality`, `download.max_batch_size`) are settings rows, not schema.
|
||||
|
||||
## API surface (all additive)
|
||||
|
||||
- `GET /api/v1/downloads/capability` — `quality_presets` now reflects the effective ceiling
|
||||
(existing field, narrowed values); new field `max_batch_size`.
|
||||
- `POST /api/v1/downloads` — new 403 error case (`quality_not_permitted`) and new 400 error case
|
||||
(`batch_too_large`, body includes the configured cap).
|
||||
- `POST /api/v1/downloads/subscriptions/sync` — skipped registrations may carry the new
|
||||
`SkippedDownload` reason `quality_not_permitted`.
|
||||
- Admin users endpoints — four new optional request fields / response fields (§1, §3).
|
||||
- Settings endpoints — two new keys; both hot-reloadable (no restart-required entry in
|
||||
`internal/config/restart_keys.go`).
|
||||
|
||||
## Web admin UI
|
||||
|
||||
- `web/src/pages/admin-settings/DownloadSettings.tsx`: "Max Download Quality" (select over the
|
||||
ladder + "No limit") under Quantity Limits or a new Restrictions group; "Max Batch Size" numeric
|
||||
field with the 0-=-unlimited hint.
|
||||
- `web/src/pages/AdminUserDetail.tsx` (+ the user edit dialog): rows for the four per-user fields,
|
||||
each showing the inherited global value when unset (as the streams/transcodes fields do), plus the
|
||||
§1 hint when a sub-original cap is combined with transcode-not-allowed.
|
||||
|
||||
## Enforcement summary
|
||||
|
||||
| Restriction | Hard/soft | Enforced at |
|
||||
|---|---|---|
|
||||
| Quality ceiling | Hard | `Resolve` (create), `PresetsFor` (capability), `ServeFile` re-check |
|
||||
| Batch size cap | Hard | `createSeriesScoped` (reject), subscription sync (truncate per pass) |
|
||||
| Concurrent / per-period overrides | Hard | `QuantityLimiter.Check` on every create path |
|
||||
| Bandwidth override | Hard | `ThrottledReader` on every byte served |
|
||||
|
||||
Nothing in this spec is client-enforced; the client-pull architecture is unchanged.
|
||||
|
||||
## Client follow-up (silo-android, silo-apple)
|
||||
|
||||
- **Quality ceiling / presets:** zero work if the client renders `quality_presets` from the
|
||||
capability endpoint (the downloads v2 contract). Audit for hardcoded ladders.
|
||||
- **Batch errors:** handle `batch_too_large` with a useful prompt (download per season / subscribe
|
||||
instead of a raw error toast). Handle empty `quality_presets` as "downloads unavailable for this
|
||||
account", which the capability contract already implies.
|
||||
- No manifest or sync-protocol changes.
|
||||
|
||||
## Testing
|
||||
|
||||
- `policy_test.go`: ceiling truncation of `PresetsFor` (server-only, user-only, both, ties at
|
||||
`original`); `Resolve` rejection above the ceiling; fallback clamp; the
|
||||
cap-without-transcode-permission → empty presets case.
|
||||
- `limiter_test.go` (new cases): override precedence NULL/0/positive vs. global for both limits.
|
||||
- `service_*_test.go`: batch reject at cap boundary (cap, cap+1); subscription sync truncation and
|
||||
next-pass continuation; serve-time denial after a cap is lowered.
|
||||
- Handler tests for the new error mappings and admin-user field round-trips — run in a
|
||||
libvips-capable container (bare-host `go test ./...` skips `internal/api/handlers`).
|
||||
|
||||
## Out of scope / follow-ups
|
||||
|
||||
- Admin downloads dashboard + admin list/revoke endpoints (observability; separate spec).
|
||||
- Bulk (series/season) transcode quality — prerequisite for capped users to use series downloads
|
||||
and monitoring; lifts the `resolveBulkQuality` original-only rule.
|
||||
- Profile-level download permission (parental-controls integration; downloads are already keyed by
|
||||
`profile_id`, so the data model is ready).
|
||||
- Per-user managed-device count cap — revisit only if storage/quantity limits prove insufficient.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Access Groups — Design Spec
|
||||
|
||||
**Date:** 2026-07-02
|
||||
**Branch context:** ships with the OPA policy engine PR (epic #272 addendum)
|
||||
**Status:** Approved — implemented alongside the policy engine
|
||||
|
||||
## Goal
|
||||
|
||||
Give admins the everyday permission surface the Rego editor is not: named
|
||||
**groups** with permission toggles, users assigned to a group, and per-user
|
||||
restrictions layered on top. The policy engine stays as invisible
|
||||
infrastructure; the Rego editor becomes an advanced, off-by-default feature.
|
||||
|
||||
User-visible outcome:
|
||||
|
||||
- A new **Groups** admin page: create groups ("Users", "Kids", "Guests"),
|
||||
toggle what each group may do (library access, playback-quality ceiling,
|
||||
downloads, transcoded downloads, stream/transcode limits, assignable
|
||||
permissions, media requests).
|
||||
- The user editor gains a **Group** picker. A user's own restrictions still
|
||||
apply on top of their group.
|
||||
- The Policy page (Rego editor, baseline viewer, decision log) is hidden
|
||||
unless `policy.editor_enabled` is turned on.
|
||||
|
||||
## Model
|
||||
|
||||
- One group per user (`users.access_group_id`, nullable). No group = no group
|
||||
layer, i.e. exactly today's behavior. Existing users are untouched.
|
||||
- **Composition is restriction-only: the strictest layer wins.** This is the
|
||||
same rule the resolver already uses for account ∩ profile, extended one
|
||||
layer up. Group grants are an upper bound; per-user settings can only
|
||||
tighten further:
|
||||
- Libraries: intersection (NULL = unrestricted at that layer).
|
||||
- Playback quality: `MinQuality` across layers.
|
||||
- Booleans (downloads, transcoded downloads, requests): AND.
|
||||
- Stream/transcode limits: strictest positive value (0 = unlimited at that
|
||||
layer).
|
||||
- Assignable permissions: user's granted set ∩ group's allowed set
|
||||
(NULL = all assignable).
|
||||
- To give one user *more* than their group allows, move them to a more
|
||||
permissive group. This keeps every layer a narrowing and avoids tri-state
|
||||
"inherit vs override" semantics on existing user columns.
|
||||
- Content-rating ceilings stay a **profile** (parental) concern and are not a
|
||||
group field in v1.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Group facts merge **in Go, before policy inputs are built** — a shared
|
||||
`access.ApplyGroupPolicy(user, group)` computes the effective account-layer
|
||||
facts consumed by the viewer-scope resolver, the downloads/playback action
|
||||
inputs, the session-limit provider, the permission gates, and the request
|
||||
creation gate. The vendor Rego, its parity suites, and the decision-log
|
||||
pipeline are untouched; decision inputs simply carry the merged values.
|
||||
|
||||
Revision semantics mirror the existing per-user rule: changing a group's
|
||||
playback-quality ceiling bumps `access_policy_revision` for its members
|
||||
(invalidating profile PIN tokens); library/download/limit changes do not.
|
||||
|
||||
## Surface
|
||||
|
||||
- `access_groups` table + `users.access_group_id` (ON DELETE SET NULL).
|
||||
- Additive API: `/api/v1/admin/access-groups` CRUD (list includes member
|
||||
counts); `PUT /admin/users/{id}` and the user DTO gain `access_group_id`.
|
||||
- `policy.editor_enabled` server setting (default false, hot-reloaded) drives
|
||||
the capability endpoint's `editor_available`; the engine itself always runs.
|
||||
- Web: Groups admin page + group picker in the user editor; Policy nav entry
|
||||
shown only when the capability reports the editor available.
|
||||
|
||||
## Deferred
|
||||
|
||||
- Multiple groups per user, grant-above-group per-user overrides, per-group
|
||||
request quotas, group-scoped custom Rego.
|
||||
@@ -0,0 +1,439 @@
|
||||
# OPA Policy Engine — Design Spec
|
||||
|
||||
**Date:** 2026-07-02
|
||||
**Branch context:** `main` in `silo-server`
|
||||
**Status:** Approved — ready for implementation plan
|
||||
**Related plan:** [`../plans/2026-07-02-opa-policy-engine.md`](../plans/2026-07-02-opa-policy-engine.md)
|
||||
|
||||
## Goal
|
||||
|
||||
Embed Open Policy Agent (OPA) as Silo's authorization engine. All
|
||||
request-level access decisions — viewer scope resolution, admin/permission
|
||||
gates, and download/playback action checks — evaluate through one embedded
|
||||
policy engine running vendor-authored Rego, with a first-class admin policy
|
||||
editor for household-specific custom rules.
|
||||
|
||||
User-visible outcome:
|
||||
|
||||
- Server admins get a new **Policy** admin page: view the built-in (vendor)
|
||||
Rego, author custom override policies with inline compile validation,
|
||||
simulate decisions against sample inputs before activating, browse an
|
||||
immutable version history with one-click rollback, and query a structured
|
||||
decision audit log ("who was allowed/denied what, when, and why").
|
||||
- Custom policies unlock rules the current fixed schema cannot express:
|
||||
time-of-day / schedule-based parental controls, per-collection or
|
||||
per-library exceptions, device-based restrictions — all *narrowing-only*
|
||||
(a custom policy can tighten access, never widen it).
|
||||
- Non-admin users see no change. The existing profile/parental-control
|
||||
behavior is reproduced exactly by the vendor policy.
|
||||
|
||||
## Why OPA (research summary)
|
||||
|
||||
- **Embedding mode:** pure-Go library `github.com/open-policy-agent/opa/v1/rego`
|
||||
(Apache-2.0, CNCF-graduated, monthly release cadence). No sidecar, no WASM.
|
||||
Measured locally against OPA v1.18.1: **~+19 MB binary**, **~14 µs per
|
||||
prepared-query evaluation** for an RBAC/ABAC-shaped policy — negligible at
|
||||
household scale, acceptable binary cost.
|
||||
- **Rego v1** (OPA ≥ 1.0, Dec 2024): `if`/`contains` mandatory, strict mode
|
||||
default, import path `github.com/open-policy-agent/opa/v1/...`. Greenfield
|
||||
adoption means no v0 compatibility machinery.
|
||||
- **Ecosystem precedent** for self-hosted single-binary apps (Chef Automate,
|
||||
Pomerium, Flipt): vendor-authored, unit-tested Rego + admin extension is the
|
||||
winning pattern; sidecar deployments are the pattern to avoid.
|
||||
- **Security posture:** the one serious recent CVE (CVE-2025-46569) affects
|
||||
only the OPA *server REST API*, which we do not run. Untrusted admin Rego is
|
||||
sandboxed via `ast.Capabilities` lockdown (CVE-2022-36085 showed
|
||||
`WithUnsafeBuiltins` alone is bypassable — capabilities is the supported
|
||||
mechanism) plus per-eval context timeouts.
|
||||
- **Deferred but designed-for:** since OPA v1.9.0 the Compile API
|
||||
(`v1/rego/compile`) turns policies in the "filtering fragment" into SQL
|
||||
WHERE clauses (postgresql dialect). This could eventually replace the
|
||||
hand-built `catalog.AccessFilter` predicates; the PDP seam in this design is
|
||||
where that lands later.
|
||||
|
||||
## Hard constraints
|
||||
|
||||
- **`access.Scope` contract is frozen.** The resolved scope struct
|
||||
(`internal/access/types.go`) — `AllowedLibraryIDs` (nil = unrestricted),
|
||||
`DisabledLibraryIDs`, `LibrariesRestricted`, `MaxContentRating`,
|
||||
`MaxPlaybackQuality`, `PreferredMetadataLanguage`, `PolicyRevision`,
|
||||
`ProfileVerified` — keeps its exact shape and semantics. OPA is a drop-in
|
||||
behind the existing `middleware.ViewerResolver` interface.
|
||||
- **Crypto stays in Go.** PIN verification and profile-token validation
|
||||
(`internal/access/profile_token.go`) are never re-implemented in Rego; the
|
||||
policy receives `profile_verified` as an input fact.
|
||||
- **`access_policy_revision` semantics preserved:** bumps invalidate profile
|
||||
tokens; `users.library_ids` changes deliberately do NOT bump it. The new
|
||||
global policy generation counter is a *separate* concept and never bumps
|
||||
per-account revisions.
|
||||
- **Fail closed.** Any eval error, timeout, missing result, or malformed
|
||||
decision document is treated as the most-restrictive outcome for that
|
||||
surface. No adapter ever falls back to "allow" on engine error.
|
||||
- **`/api/v1` additive-only** + capability endpoint for feature detection
|
||||
(pattern: `internal/api/handlers/downloads.go` `HandleCapability`).
|
||||
- **Performance first.** Prepared queries only; input-document-only data
|
||||
strategy (no per-request DB reads inside Rego, no catalog mirroring into
|
||||
OPA's store); decision logging is async and never adds eval-path latency.
|
||||
- **Narrowing-only custom policy.** Admin overrides can tighten the vendor
|
||||
baseline decision but cannot widen access, by construction (see Layering).
|
||||
- **Direct replacement rollout.** No shadow mode, no legacy/OPA runtime
|
||||
toggle. Parity is proven by tests before each surface cuts over; cutover is
|
||||
staged per-surface across PRs; legacy Go logic is deleted only after a
|
||||
release of bake time.
|
||||
- Migrations are timestamped Goose files via `make migrate-create`; the
|
||||
decision-log table uses `internal/partman` partition management like
|
||||
`operational_logs` / `activity_log`.
|
||||
|
||||
## Scope
|
||||
|
||||
### In (v1)
|
||||
|
||||
1. **Viewer scope resolution** — `internal/access/resolver.go` logic ported
|
||||
to vendor Rego, evaluated behind the existing `ViewerResolver` interface.
|
||||
Covers the native API, jellycompat, ABS compat, notifications, and the
|
||||
reconciler (all five `access.NewResolver` construction sites).
|
||||
2. **Route/permission gates** — acting-admin (`RequireActingAdmin`) and the
|
||||
`marker_edit` / `metadata_curation` permission checks
|
||||
(`internal/auth/permissions.go`, `internal/api/middleware/permissions.go`).
|
||||
3. **Download/playback action decisions** — download allowed, transcode
|
||||
allowed, quality preset gating (`internal/downloads/policy.go`), and
|
||||
playback admission limits (`playback.SessionLimitProvider`). Go keeps
|
||||
ownership of live session *counting*; the policy decides given the counts.
|
||||
4. **Admin policy editor** — dedicated `/admin/policy` page: vendor viewer,
|
||||
CodeMirror-based custom policy editor with server-side compile validation,
|
||||
stateless simulate runner, immutable version history with activate/rollback,
|
||||
decision-log browser.
|
||||
5. **Decision audit logging** — partitioned `policy_decisions` table with
|
||||
sampling/verbosity controls and retention cleanup.
|
||||
|
||||
### Out (deferred, must not be precluded)
|
||||
|
||||
- **Compile-to-SQL list filtering** — `catalog.AccessFilter` SQL predicates
|
||||
stay hand-written Go. The `PDP`/`DecisionName` seam is where a
|
||||
`CompileToSQL` method lands later.
|
||||
- **Plugin route/RPC gating** — plugins keep their flat
|
||||
public/authenticated/admin route model.
|
||||
- **Widening ("grant") overrides** — e.g. "this device may exceed the profile
|
||||
quality cap". Requires a separately gated elevated-override path; v1 is
|
||||
narrowing-only.
|
||||
- **Persisted policy test suites** — the simulate runner is stateless in v1;
|
||||
a `policy_test_cases` table is a clean additive follow-up.
|
||||
- **Mobile/TV client surfaces** — v1 is server-side enforcement plus the web
|
||||
admin UI only. Clients need no changes (capability endpoint exists for
|
||||
future client awareness).
|
||||
|
||||
## Architecture overview
|
||||
|
||||
One new Go package, `internal/policy`, owns everything OPA. Nothing outside
|
||||
it imports `github.com/open-policy-agent/opa/v1/*`.
|
||||
|
||||
```
|
||||
internal/policy/
|
||||
engine.go // compiled bundle + prepared queries, atomic swap, Evaluate
|
||||
pdp.go // typed decision methods over Engine (one per surface)
|
||||
input.go // input/output document structs (the policy-author contract)
|
||||
store.go // Postgres CRUD: documents, versions, active pointer, generation
|
||||
compile.go // CompileCheck (capabilities lockdown, package-path enforcement)
|
||||
simulate.go // stateless test runner (throwaway bundle, never the live engine)
|
||||
decisionlog.go // async sampled writer -> policy_decisions
|
||||
decisionlog_repo.go // cursor-paginated query API for the admin log viewer
|
||||
system.go // System lifecycle (mirrors notifications.System): Start, reload,
|
||||
// EventBus subscribe, poll fallback
|
||||
vendor.go // go:embed vendor bundle assembly
|
||||
errors.go
|
||||
vendor/ // embedded Rego source
|
||||
scope.rego scope_test.rego
|
||||
permission.rego permission_test.rego
|
||||
action.rego action_test.rego // downloads + playback actions
|
||||
lib/ratings.rego lib/quality.rego // rank tables ported from internal/access
|
||||
```
|
||||
|
||||
Thin adapters live where they are consumed (matching how
|
||||
`jellycompat.NewScopeAccessFilter` wraps the resolver today):
|
||||
|
||||
- `internal/access/policy_resolver.go` — implements `middleware.ViewerResolver`.
|
||||
- `internal/api/middleware/policy_gates.go` — acting-admin + permission
|
||||
middleware backed by the PDP.
|
||||
- `internal/downloads` / playback limit provider — call the PDP's action
|
||||
decision.
|
||||
|
||||
### PDP: typed decision methods
|
||||
|
||||
Three typed methods on a `PDP` struct (not a generic `map[string]any`
|
||||
`Decide`): `ResolveViewerScope(ctx, ScopeInput) (ScopeDecision, Meta, error)`,
|
||||
`CheckPermission(ctx, PermissionInput) (PermissionDecision, Meta, error)`,
|
||||
`CheckAction(ctx, ActionInput) (ActionDecision, Meta, error)`. Each builds
|
||||
the input document, evaluates the corresponding prepared query
|
||||
(`data.silo.scope.decision`, `data.silo.permission.decision`,
|
||||
`data.silo.action.decision`) under a `context.WithTimeout` (default 25 ms,
|
||||
setting `policy.eval_timeout_ms`), fires an async decision-log write, and
|
||||
returns a typed result. Callers translate errors into their surface's
|
||||
fail-closed outcome.
|
||||
|
||||
A future surface = one new Rego package + one input struct + one PDP method.
|
||||
|
||||
### Vendor vs custom layering — the override contract
|
||||
|
||||
- Vendor packages (`data.silo.scope`, `data.silo.permission`,
|
||||
`data.silo.action`) ship via `go:embed`, are never DB rows, never editable,
|
||||
and upgrade atomically with the binary.
|
||||
- Admin documents must declare `package silo_custom.<domain>` (enforced at
|
||||
compile-check by inspecting the parsed module's package path; mismatch is a
|
||||
422).
|
||||
- Each vendor terminal rule computes `base_decision` (exactly reproducing
|
||||
today's Go logic), then applies the extension point:
|
||||
|
||||
```rego
|
||||
decision := custom if {
|
||||
custom := data.silo_custom.scope.override(base_decision, input)
|
||||
} else := base_decision
|
||||
```
|
||||
|
||||
Admins implement `override(base, input)` against a documented, versioned
|
||||
contract. An absent/undefined custom document is a no-op by construction.
|
||||
- **Narrowing is enforced in the vendor Rego itself**, not by heuristics: the
|
||||
vendor rule merges the override result by intersecting library sets, taking
|
||||
the minimum of quality/rating ceilings, AND-ing boolean allows — i.e. the
|
||||
vendor consults the override *only in the tightening direction*. A custom
|
||||
policy that returns a wider decision simply has no effect. This is cheap,
|
||||
runs at eval time, and cannot be bypassed by rule-shadowing because the
|
||||
custom namespace is disjoint from the queried vendor namespace.
|
||||
- Input documents carry a `schema_version` field plus request-context fields
|
||||
the vendor policy ignores but overrides may use (`request_time` RFC3339,
|
||||
`device_id`, `client_ip`, `is_api_key`) — this is what enables schedule and
|
||||
device rules without vendor changes.
|
||||
|
||||
### Engine lifecycle
|
||||
|
||||
- **Compile:** vendor modules (full capabilities) + all enabled custom
|
||||
documents' active versions (locked capabilities) compile into one bundle;
|
||||
three prepared queries are built from it. A custom document that fails
|
||||
compile at reload time is skipped with a WARN and excluded — one bad row
|
||||
never takes the engine down (save-time compile-check makes this path
|
||||
near-unreachable).
|
||||
- **Atomic swap:** prepared queries + revision swap under a single RWMutex
|
||||
pointer replace; readers never see a half-compiled bundle.
|
||||
- **Revision:** a single-row `policy_generation` table holds a monotonic
|
||||
counter, bumped transactionally with every activate/rollback/enable/disable.
|
||||
Decision-log rows record the generation that produced them.
|
||||
- **Cross-node invalidation:** a new `cache.EventPolicyChanged` constant
|
||||
published on the existing `cache.ChannelAdmin` after every activation; every
|
||||
node's `policy.System` subscriber reloads from the store. A 60 s poll
|
||||
fallback compares `policy_generation.generation` to the engine's loaded
|
||||
generation (mirrors `nodeconfig.Watcher`) for Redis-less deployments.
|
||||
- **Fatal vs degraded:** vendor bundle failing to compile is startup-fatal
|
||||
(it ships with the binary; this is a build defect). A reload failure at
|
||||
runtime keeps serving the last known-good bundle and logs an error.
|
||||
|
||||
### Sandboxing untrusted admin Rego
|
||||
|
||||
1. `ast.Capabilities` lockdown: strip `http.send`, all `net.*`,
|
||||
`opa.runtime`, `rego.parse_module` and any other non-pure builtins from
|
||||
`ast.CapabilitiesForThisVersion()`; applied to custom-module compilation at
|
||||
save, activate, and reload (defense in depth).
|
||||
2. Compile-check before persist: `POST .../versions` compiles the candidate
|
||||
layered over vendor + other active documents; failures return structured
|
||||
`{row, col, message}` errors and nothing is persisted as activatable.
|
||||
3. Eval timeout: 25 ms default per decision (measured eval is ~14 µs; the
|
||||
timeout is a circuit breaker for pathological comprehensions).
|
||||
4. Editor reachable only through `requireActingAdmin`.
|
||||
|
||||
## Input/output document contracts
|
||||
|
||||
Defined as JSON-tagged Go structs in `internal/policy/input.go`; rendered in
|
||||
the editor's reference panel. All inputs carry `schema_version: 1`. Breaking
|
||||
changes to these documents follow the same additive-only discipline as
|
||||
`/api/v1`.
|
||||
|
||||
**ScopeInput** (per authenticated request): user facts
|
||||
(`account_library_ids` nil=unrestricted, `account_restricted`,
|
||||
`account_max_playback_quality`, `access_policy_revision`,
|
||||
`disabled_library_ids`), profile facts (`profile_present`, rating/quality
|
||||
ceilings, `profile_library_restricted`, `profile_allowed_library_ids`,
|
||||
`profile_has_pin`, `profile_verified` — precomputed in Go), request context
|
||||
(`session_id`, `request_time`, `device_id`, `client_ip`, `is_api_key`).
|
||||
**ScopeDecision** mirrors `access.Scope`'s policy-derived fields plus an
|
||||
explicit `unrestricted` boolean — the adapter maps `unrestricted: true` to Go
|
||||
`nil` `AllowedLibraryIDs`, otherwise the (possibly empty) slice. This is the
|
||||
single most dangerous JSON round-trip (Rego has no nil-vs-empty distinction)
|
||||
and gets dedicated combinatorial tests.
|
||||
|
||||
**PermissionInput:** `user_id`, `role`, `enabled`, `assigned_permissions`,
|
||||
`permission` (`acting_admin` | `marker_edit` | `metadata_curation`),
|
||||
`acting_as_primary` (profile-primary DB lookup stays in Go),
|
||||
`target_library_ids` / `user_library_ids` for item-scoped checks.
|
||||
**PermissionDecision:** `{allowed, reason}`.
|
||||
|
||||
**ActionInput:** `action` (`download` | `download_transcode` | `stream` |
|
||||
`transcode`), user flags (`download_allowed`, `download_transcode_allowed`,
|
||||
`max_streams`, `max_transcodes`), live counts (`current_active_streams`,
|
||||
`current_active_transcodes` — computed by Go exactly as today), quality/rating
|
||||
context (`requested_quality`, `file_quality`, `max_playback_quality`,
|
||||
`content_rating`, `max_content_rating`), `library_id`, `request_time`,
|
||||
`device_id`. **ActionDecision:** `{allowed, reason, quality_ceiling}` (the
|
||||
ceiling may tighten further than Scope).
|
||||
|
||||
## Data model
|
||||
|
||||
Four tables in one migration (`make migrate-create NAME=policy_foundation`):
|
||||
|
||||
- **`policy_documents`** — `id`, `domain` (`scope`|`permission`|`action`),
|
||||
`name`, `enabled`, `active_version_id` (FK, nullable), timestamps.
|
||||
UNIQUE(domain, name).
|
||||
- **`policy_document_versions`** — immutable: `document_id`, `version_number`
|
||||
(per-document, UNIQUE together), `rego_source`, `source_sha256`,
|
||||
`compiled_ok`, `compile_error`, `created_by_user_id`, `comment`,
|
||||
`created_at`. Every save inserts a new row; rollback flips
|
||||
`active_version_id`. The versions table is the authoring audit trail.
|
||||
- **`policy_generation`** — single-row monotonic counter (see Lifecycle).
|
||||
- **`policy_decisions`** — decision log, `PARTITION BY RANGE ("timestamp")`
|
||||
with a DEFAULT partition, managed by `partman.NewManager(pool,
|
||||
"policy_decisions", partman.Daily, 3)`. Columns: `decision_name`,
|
||||
`policy_generation`, `user_id`, `profile_id`, `session_id`, `request_id`,
|
||||
`node_id`, `allowed` (NULL for scope), `eval_time_ns`, `input_digest`
|
||||
(sha256 of canonical input JSON), `input_sample` / `result_sample` JSONB
|
||||
(verbosity-gated), `error`. Indexes on (timestamp DESC, id DESC),
|
||||
(decision_name, timestamp DESC), (user_id, timestamp DESC), and a partial
|
||||
index WHERE allowed = false.
|
||||
|
||||
Custom Rego source is **not** a secret — plaintext rows, no
|
||||
`SensitiveSettingKeys` entry (consistent with `theme` custom CSS).
|
||||
|
||||
## Decision logging
|
||||
|
||||
- Async writer: buffered channel + batch-insert flush goroutine (shape of
|
||||
`activitylog`'s writer). A full buffer drops-and-counts; logging failure
|
||||
never blocks or fails a decision.
|
||||
- **Sampling/verbosity** (hot-reloaded `server_settings` keys):
|
||||
- `policy.decision_log_verbosity`: `digest` (default; no payloads) |
|
||||
`verbose` (adds input/result JSONB, gated by sample rate).
|
||||
- `policy.decision_log_scope_sample_rate`: scope decisions run on every
|
||||
authenticated request; default 1-in-50 sampling. Permission/action
|
||||
decisions log at 100 %.
|
||||
- **Denials and eval errors always log**, at full fidelity when verbosity is
|
||||
`verbose` — denials are the audit trail's whole point.
|
||||
- Retention: `policy.decision_log_retention_days` (default 14) enforced by a
|
||||
`taskmanager` cleanup task dropping expired partitions, registered beside
|
||||
`ActivityLogCleanupTask` / `OperationalLogCleanupTask`.
|
||||
|
||||
## API design (all additive)
|
||||
|
||||
`GET /api/v1/policy/capability` — authenticated, not admin-gated:
|
||||
`{enabled, editor_available, decision_types, generation}`.
|
||||
|
||||
Under `/api/v1/admin/policy/*` (acting-admin gated):
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET /vendor` | read-only embedded vendor Rego, per module |
|
||||
| `GET /documents` / `POST /documents` | list / create (empty) document |
|
||||
| `GET /documents/{id}` | document + active version source |
|
||||
| `GET /documents/{id}/versions` (+ `/{version}`) | version history / single source |
|
||||
| `POST /documents/{id}/versions` | compile-check + persist new version (NOT activated); 422 with `{errors:[{row,col,message}]}` on failure |
|
||||
| `POST /documents/{id}/versions/{version}/activate` | flip pointer, bump generation, publish invalidation (rollback = activate an older version) |
|
||||
| `POST /documents/{id}/enabled` | kill switch without deleting history |
|
||||
| `DELETE /documents/{id}` | only when no version is active |
|
||||
| `POST /validate` | stateless compile-check (editor's Validate button) |
|
||||
| `POST /simulate` | `{domain, source?, decision_name, input}` → decision + `eval_time_ns` + trace, against a throwaway bundle; no decision-log row |
|
||||
| `GET /decisions` (+ `/{id}`) | cursor-paginated decision log query (`decision_name`, `user_id`, `allowed`, time range) |
|
||||
|
||||
## Frontend
|
||||
|
||||
Dedicated admin page (`/admin/policy`, new entry in
|
||||
`web/src/lib/adminNavigation.ts` under System) — not a settings tab; this is
|
||||
a multi-pane authoring workspace. Scalar knobs (verbosity, sample rate,
|
||||
retention, eval timeout) live in the existing admin settings area.
|
||||
|
||||
```
|
||||
web/src/pages/admin-policy/
|
||||
AdminPolicyLayout.tsx // sub-nav: Documents | Vendor | Decision Log
|
||||
PolicyDocumentList.tsx // by domain; enabled toggle; active-version badge
|
||||
PolicyEditorPanel.tsx // CodeMirror 6 editor + Validate + Save version + Activate
|
||||
PolicyVendorViewer.tsx // read-only vendor modules
|
||||
PolicySimulatePanel.tsx // input JSON (seeded example per decision type) + Run → result/trace
|
||||
PolicyVersionHistory.tsx // versions, diff vs active, rollback
|
||||
PolicyDecisionLogTable.tsx // filterable, cursor-paginated
|
||||
web/src/hooks/queries/admin/policy.ts
|
||||
```
|
||||
|
||||
**Editor: CodeMirror 6** via `@uiw/react-codemirror` (~60–120 KB gz — the
|
||||
first code-editor dependency in `web/`; decided deliberately). A ~60-line
|
||||
hand-rolled `StreamLanguage` Rego mode (keywords, comments, strings/numbers)
|
||||
provides highlighting; `/validate` responses map to `@codemirror/lint`
|
||||
diagnostics so compile errors render as inline squiggles at the reported
|
||||
line/col. Save-as-version requires a passing Validate; Activate is a separate
|
||||
explicit action — nothing goes live without compile-check + (by UI flow)
|
||||
simulation.
|
||||
|
||||
## Vendor policy content
|
||||
|
||||
- `vendor/scope.rego` reproduces `access.Resolver.Resolve` (library
|
||||
intersection per `effectiveLibraries`, disabled-library subtraction,
|
||||
rating/quality min-merge, `profile_verified` passthrough).
|
||||
- `vendor/permission.rego` reproduces `auth.HasEffectivePermission`,
|
||||
acting-admin gating (`actingAdminAllowed`), and the library-scoped
|
||||
`metadata_curation` check.
|
||||
- `vendor/action.rego` reproduces `downloads` gating
|
||||
(`ensureTranscodeAllowed`, `PresetsFor` eligibility, quality/rating
|
||||
ceilings) and playback admission math against provided counts/limits.
|
||||
- `vendor/lib/{ratings,quality}.rego` port the rank tables from
|
||||
`internal/access/rating.go` / `quality.go`. This is deliberate, test-pinned
|
||||
duplication until the Go tables are deleted in the cleanup phase.
|
||||
- Rego unit tests (`*_test.rego`) run through `opa/v1/tester` inside
|
||||
`go test ./internal/policy/...` — no `opa` CLI dependency in CI.
|
||||
|
||||
## Rollout & parity strategy
|
||||
|
||||
Direct replacement, staged per surface, gated by dual-execution parity tests:
|
||||
|
||||
1. Engine + vendor Rego + parity suite land as dead code (nothing wired).
|
||||
2. Parity tests run the *same fixtures* through the legacy Go function and
|
||||
the PDP, asserting identical output — including a combinatorial battery
|
||||
over `account × profile × disabled` library-set shapes targeting the
|
||||
nil-vs-empty boundary.
|
||||
3. Cutover PRs swap constructors behind unchanged interfaces. **All five**
|
||||
`access.NewResolver` sites cut over for the scope surface:
|
||||
`internal/api/router.go` (viewer middleware), `cmd/silo/main.go`
|
||||
(notifications scopes, reconciler, jellycompat filter), and
|
||||
`internal/audiobooks/access_resolver.go` (ABS).
|
||||
4. Legacy implementations (`access/resolver.go` logic, `rating.go`,
|
||||
`quality.go` tables, downloads/permission inline checks) stay compiled and
|
||||
tested for one release as the reference implementation, then are deleted in
|
||||
a cleanup PR. `access.Scope`/`ResolveInput` types and `profile_token.go`
|
||||
remain permanently (wire contract + crypto).
|
||||
5. A CI-visible benchmark asserts scope-resolution p99 (eval + input
|
||||
marshaling) stays under 200 µs.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Direct replacement has no production fallback.** The safety net is
|
||||
entirely the parity suite; a fixture gap ships live. Mitigations: staged
|
||||
per-surface cutover (each PR independently revertable), legacy code retained
|
||||
one release, always-logged denials making regressions observable quickly.
|
||||
- **Nil-vs-empty library-set round-trip** is the highest-risk single detail;
|
||||
addressed with the explicit `unrestricted` field + combinatorial tests.
|
||||
- **Admin foot-guns:** narrowing-only merge prevents accidental widening, but
|
||||
a bad override can still lock a household out (deny-everything). Mitigations:
|
||||
simulate-before-activate UI flow, one-click rollback, the `enabled` kill
|
||||
switch, and vendor behavior as the guaranteed floor when the custom document
|
||||
is disabled.
|
||||
- **Eval-timeout DoS surface:** a pathological custom policy costs up to
|
||||
25 ms per decision server-wide until rolled back. Accepted for v1 (editor is
|
||||
acting-admin-only); a repeated-timeout circuit breaker is a follow-up
|
||||
candidate.
|
||||
- **Hand-rolled Rego highlighting** will lag full Rego syntax; accepted (it
|
||||
is cosmetic; validation is server-side).
|
||||
- **Playback admission counting stays in Go** — the policy decides given
|
||||
counts, so "unify authz" is unification of *decisions*, not of session
|
||||
bookkeeping. Documented as intentional.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- Elevated (widening) override path — needs its own product decision and a
|
||||
stricter gate.
|
||||
- Compile-to-SQL data filtering — revisit once v1 has baked; requires the
|
||||
filtering-fragment policy style and a symbolic-store compile path.
|
||||
- Plugin policy gating — candidate follow-up: a `DecisionName` for plugin
|
||||
route access consulted by `plugins.HTTPProxy`.
|
||||
- Persisted regression suites (`policy_test_cases`) and
|
||||
simulate-against-recent-decisions rollback preview.
|
||||
@@ -0,0 +1,355 @@
|
||||
# Split Versions / Per-Folder Reassign with History Reattribution — Design Spec
|
||||
|
||||
**Date:** 2026-07-06
|
||||
**Status:** Proposed
|
||||
**Scope:** scanner / catalog / metadata / admin API / web admin
|
||||
**Related:** `docs/architecture/deterministic-content-id.md`,
|
||||
`migrations/sql/078_content_groups_and_locations.sql`,
|
||||
`migrations/sql/20260614120000_content_id_online_reid.sql`
|
||||
|
||||
Commands and paths assume the repository root is the cwd.
|
||||
|
||||
## Problem
|
||||
|
||||
The scanner groups files into logical items by `ContentGroupKey`
|
||||
(normalized title + year, `internal/naming/group_identity.go`) unless a
|
||||
structured provider tag (`{tmdb-…}`, `[tvdbid-…]`, `tt…`) anchors the
|
||||
identity. When two different titles parse to the same key (remakes,
|
||||
sequels with sloppy names, "Movie (2019)" vs "Movie (2019) Directors Cut"
|
||||
folders that are actually different films), their files are merged into a
|
||||
single item as "versions".
|
||||
|
||||
Today there is **no way to fix a wrong merge in-app**:
|
||||
|
||||
- Item-level rematch (`POST /admin/items/{id}/match/search|apply`,
|
||||
`internal/api/handlers/admin_match.go`) re-identifies the *whole* item —
|
||||
every file follows.
|
||||
- Group/root overrides (`internal/scanner/group_override_repo.go`,
|
||||
`HandleUpsertRootOverride` in `internal/api/handlers/libraries.go`) force
|
||||
an identity for an *entire* group, and the root-override endpoints
|
||||
explicitly refuse ambiguous roots with 409 `ambiguous_root` — telling the
|
||||
admin to "override the group after splitting", an operation that does not
|
||||
exist.
|
||||
- The only real fix is renaming folders on disk with provider tags and
|
||||
rescanning — and even then, watch state accrued under the merged item is
|
||||
not reattributed.
|
||||
|
||||
Symmetrically, when a **wrong split** is merged back (two items that are
|
||||
one title), watch state on the losing item is silently orphaned.
|
||||
|
||||
## Goal
|
||||
|
||||
An admin operation that:
|
||||
|
||||
1. **Splits** a selected subset of an item's files (typically one folder)
|
||||
into a different logical item — an existing item, a newly identified
|
||||
one, or an unmatched `local-` placeholder.
|
||||
2. Makes the assignment **durable across rescans** (path-scoped identity
|
||||
overrides, not just a one-time row edit).
|
||||
3. **Reattributes user watch state** (progress, history, downloads,
|
||||
session records) to the item that actually owns the plays, using
|
||||
per-file evidence where it exists.
|
||||
4. Provides the same reattribution when items are **merged**, so the
|
||||
existing `rebindItemToExistingItem` path stops orphaning state.
|
||||
|
||||
Non-goals: automatic detection of wrong merges (ambiguity flagging already
|
||||
exists and stays), cross-server state sync, file-level dedup.
|
||||
|
||||
## Existing machinery this builds on
|
||||
|
||||
| Asset | Where | Reused for |
|
||||
| ----- | ----- | ---------- |
|
||||
| Deterministic `content_id` (`movie-tmdb-…`) | `internal/contentid` | The split target's id is *derived*, never minted — splitting to TMDB 603 lands on `movie-tmdb-603` whether or not it already exists. |
|
||||
| `silo_rename_content_id` + `ON UPDATE CASCADE` on the content-id FK family | `migrations/sql/20260614120000_content_id_online_reid.sql` | Whole-item renames during merge; the FK cascade also means per-row `UPDATE … SET media_item_id` is safe everywhere. |
|
||||
| `rebindItemToExistingItem` / `canonicalizeLocalContentID` | `internal/metadata/canonicalize.go` | Merge path; gains reattribution. |
|
||||
| Skeleton creation + match apply | `internal/metadata/service.go`, `internal/api/handlers/admin_match.go` | Creating/identifying the split-off item; candidate search UI already exists (`web/src/components/MatchItemDialog.tsx`). |
|
||||
| `playback_history_admin` (per-session `media_file_id`, `media_item_id`, user/profile, timestamps, `completed`) | `migrations/sql/001_schema.sql` | The evidence table for attributing item-level history rows to files. |
|
||||
| `user_watch_progress.last_file_id` | `migrations/sql/001_schema.sql` | Direct per-file attribution of resume state. |
|
||||
| Group override application during inference | `internal/scanner/group_inference.go` (`applyGroupOverrides`) | Extended to path-scoped overrides so splits survive rescans. |
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Unit of operation: files, addressed by folder
|
||||
|
||||
The API operates on explicit `media_files.id` sets. The UI presents the
|
||||
item's files grouped by `observed_root_path` with the parsed identity
|
||||
evidence (`base_title`, `base_year`, `identity_confidence`,
|
||||
`identity_json`), because "this folder is a different movie" is the
|
||||
overwhelmingly common case. Selecting a folder selects its files.
|
||||
|
||||
### 2. Durability: path-scoped identity overrides
|
||||
|
||||
Wrong merges are produced by inference, so a one-time row edit would be
|
||||
undone by the next scan. The split persists **identity overrides scoped to
|
||||
paths**, generalizing the existing group override:
|
||||
|
||||
New table `media_identity_overrides` (new timestamped migration via
|
||||
`make migrate-create NAME=media_identity_overrides`):
|
||||
|
||||
```sql
|
||||
media_identity_overrides (
|
||||
id bigserial PRIMARY KEY,
|
||||
media_folder_id int NOT NULL REFERENCES media_folders(id) ON DELETE CASCADE,
|
||||
scope text NOT NULL CHECK (scope IN ('root', 'file')),
|
||||
root_path text NOT NULL DEFAULT '', -- scope='root'
|
||||
file_path text NOT NULL DEFAULT '', -- scope='file'
|
||||
forced_type text NOT NULL DEFAULT '',
|
||||
forced_title text NOT NULL DEFAULT '',
|
||||
forced_year int NOT NULL DEFAULT 0,
|
||||
forced_tmdb_id text NOT NULL DEFAULT '',
|
||||
forced_imdb_id text NOT NULL DEFAULT '',
|
||||
forced_tvdb_id text NOT NULL DEFAULT '',
|
||||
note text NOT NULL DEFAULT '',
|
||||
created_by_user_id int NULL,
|
||||
updated_by_user_id int NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (media_folder_id, scope, root_path, file_path)
|
||||
)
|
||||
```
|
||||
|
||||
Application point: `internal/naming/group_identity.go` /
|
||||
`internal/scanner/group_inference.go`. Before bucketing, each file checks
|
||||
for an override — `file` scope wins over `root` scope, which wins over the
|
||||
existing group-key override. A forced provider ID acts exactly like a
|
||||
structured tag in the name (`hasStructuredIDAnchor` semantics): it anchors
|
||||
identity, resolves title conflicts, and — because `makeContentGroupKey`
|
||||
must incorporate the anchor for overridden files — guarantees the file
|
||||
lands in its own group even when its parsed title+year collides with the
|
||||
neighbor it was wrongly merged with.
|
||||
|
||||
The existing `media_group_overrides` table stays for whole-group forcing;
|
||||
`HandleUpsertRootOverride`'s 409 `ambiguous_root` branch is retired in
|
||||
favor of pointing at the split flow. (Optionally, a follow-up migrates
|
||||
group overrides into this table with `scope='group'`; not required for
|
||||
v1 of this feature.)
|
||||
|
||||
Overrides are visible and deletable in the admin UI (extend the existing
|
||||
library "roots" listing, `HandleListLibraryRoots`, which already joins
|
||||
overrides).
|
||||
|
||||
### 3. The split operation
|
||||
|
||||
`POST /admin/items/{id}/split` (admin-gated, additive API):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"file_ids": [123, 124], // required, non-empty, all owned by {id}
|
||||
"target": { // one of:
|
||||
"provider_ids": {"tmdb": "603"}, // identify (from match-candidate search)
|
||||
"content_id": "movie-tmdb-603", // attach to an existing item
|
||||
"unmatched": true // detach to a local- placeholder
|
||||
},
|
||||
"history_mode": "evidence", // evidence | keep | move_all (default: evidence)
|
||||
"persist_override": true, // default true; scope inferred (root if the
|
||||
// selection covers whole roots, else file)
|
||||
"dry_run": false
|
||||
}
|
||||
```
|
||||
|
||||
Validation guards:
|
||||
|
||||
- Selecting **all** of the item's files is rejected with a hint to use
|
||||
`match/apply` — that is a rematch, not a split.
|
||||
- Target identity equal to the source item's identity is a no-op error.
|
||||
- For series items, the selection must be root-aligned per series (you
|
||||
split a show's folder, not half a season); episode-level moves between
|
||||
two shows use the same call with `file` scope.
|
||||
|
||||
Execution (single transaction, then async follow-ups):
|
||||
|
||||
1. **Resolve target item.** Derive the deterministic `content_id` from the
|
||||
target provider IDs (`internal/contentid`). If the item exists, use it;
|
||||
otherwise create a skeleton via the metadata service (same path as
|
||||
scanner skeleton creation). `unmatched` targets get a `local-` id
|
||||
derived from the primary root path.
|
||||
2. **Persist overrides** (unless `persist_override=false`): one `root`
|
||||
override per fully-selected root, `file` overrides for partial roots.
|
||||
3. **Re-point files.** `UPDATE media_files SET content_id = <target>` for
|
||||
the selection. For series: re-derive `episode_id` for each moved file
|
||||
from the target series anchor + parsed S/E numbers
|
||||
(`contentid.ForEpisode`), creating season/episode skeleton rows as
|
||||
needed — the same shape the scanner produces.
|
||||
4. **Reattribute user state** (§4).
|
||||
5. **Reconcile scanner state.** Recompute `scanned_media_groups`,
|
||||
`media_group_locations`, `observed_media_locations`, and
|
||||
`media_item_groups` for the affected folder/roots by re-running group
|
||||
inference on the affected paths (not a full library scan). Locations
|
||||
that were `ambiguous` because of the merge resolve to their overridden
|
||||
groups.
|
||||
6. **Post-commit:** queue a metadata refresh for the target item (and the
|
||||
source item, whose aggregate fields — runtime, editions, trailers —
|
||||
may have been polluted by the foreign files); invalidate jellycompat
|
||||
caches for both ids; emit an audit log entry (actor, item ids, file
|
||||
ids, history_mode, counts).
|
||||
|
||||
`POST /admin/items/{id}/split` with `"dry_run": true` (or a sibling
|
||||
`/split/preview`) returns the full plan without writing: target
|
||||
content_id (and whether it already exists), files moved, overrides to be
|
||||
written, and the per-table reattribution counts including the list of
|
||||
history rows classified `ambiguous` (§4.2) — this is what the
|
||||
confirmation UI renders.
|
||||
|
||||
### 4. History reattribution
|
||||
|
||||
All user-state moves live in one shared helper so split and merge use the
|
||||
same logic — proposed package `internal/catalog/reattribute`:
|
||||
|
||||
```go
|
||||
// Moves user state tied to fromItem onto toItem, for the given moved files.
|
||||
// movedFiles empty + wholeItem=true means "everything" (the merge case).
|
||||
Reattribute(ctx, tx, fromItem, toItem string, movedFileIDs []int64,
|
||||
mode HistoryMode) (Report, error)
|
||||
```
|
||||
|
||||
#### 4.1 Rows with per-file evidence — exact moves
|
||||
|
||||
| Table | Key | Rule |
|
||||
| ----- | --- | ---- |
|
||||
| `playback_history_admin` | `media_file_id` | `SET media_item_id = to` where `media_file_id` moved. Exact. |
|
||||
| `user_downloads` | `media_file_id` | Same. |
|
||||
| `user_playback_sessions`, `playback_sessions_sync` | `media_file_id` | Same; live sessions keep playing (file path unchanged), their item association is simply corrected. |
|
||||
| `user_watch_progress` | `last_file_id` | Move rows whose `last_file_id` moved. PK is `(user_id, profile_id, media_item_id)`: on conflict with an existing progress row for the target item, keep the row with the newer `updated_at` and drop the other. Rows with NULL/unmoved `last_file_id` stay. |
|
||||
|
||||
For **series splits** the mapping is even stronger: moved files carry
|
||||
parsed S/E numbers, and episode content ids are
|
||||
`episode-<provider>-<seriesId>-<s>-<e>` — so every episode-level state row
|
||||
maps old→new episode id deterministically. Progress/history/favorites
|
||||
keyed on the *episode* ids of moved episodes move wholesale; ambiguity
|
||||
only exists for movie-level rows.
|
||||
|
||||
#### 4.2 `user_watch_history` — evidence-based attribution
|
||||
|
||||
History rows carry no file reference; attribution uses
|
||||
`playback_history_admin` as the evidence source. For each history row of
|
||||
the source item (per user+profile):
|
||||
|
||||
- **moved**: that profile's play sessions for the source item exist and
|
||||
*all* of them are on moved files → row's `media_item_id` updated to the
|
||||
target.
|
||||
- **stays**: sessions exist and none are on moved files → row untouched.
|
||||
- **ambiguous**: mixed sessions, or no session evidence (rows predating
|
||||
`playback_history_admin`, imported history, retention gaps).
|
||||
|
||||
`history_mode` controls ambiguous rows: `evidence` (default) leaves them
|
||||
on the source item and reports the count; `keep` leaves everything
|
||||
(evidence moves still apply to the exact tables in §4.1); `move_all`
|
||||
moves every history row — for the "this item was 100% the other movie all
|
||||
along" case where the admin knows better than the evidence. The dry-run
|
||||
report lists ambiguous rows (user, watched_at) so the admin can decide.
|
||||
|
||||
Watched/completed flags derived from history (continue-watching, watchlist
|
||||
auto-removal via `watchlist.Maintainer`) recompute from the moved rows on
|
||||
their normal paths; the helper fires the same completion-observer
|
||||
notifications a rematch does, if any.
|
||||
|
||||
#### 4.3 Item-level rows with no file dimension — deliberate defaults
|
||||
|
||||
`user_favorites`, `user_watchlist`, `user_ratings`,
|
||||
`user_personal_collection_items`, `library_collection_items`,
|
||||
`user_home_item_dismissals`, `user_history_hidden_items`: a wrong merge
|
||||
gives no signal about which title the user favorited. Default: **stay on
|
||||
the source item**, counts surfaced in the report. `move_all` mode moves
|
||||
these too (dedup on conflict). No copy-to-both — duplicating user intent
|
||||
is worse than asking the user to re-favorite.
|
||||
|
||||
Non-user state that references the item (`media_item_embeddings`, cached
|
||||
artwork, localizations, trailers, credits) is *not* migrated — it is
|
||||
metadata, and the post-split refresh rebuilds it for both items.
|
||||
|
||||
#### 4.4 Merge reattribution
|
||||
|
||||
`rebindItemToExistingItem` (and the admin-facing merge this spec enables:
|
||||
`POST /admin/items/{id}/merge {"into": "<content_id>"}`) calls the same
|
||||
helper with `wholeItem=true`: every state row moves, with the same
|
||||
PK-conflict rule (newer `updated_at` wins for progress; history rows
|
||||
simply re-key; favorites/watchlist dedup on conflict). This closes the
|
||||
existing orphaning bug where merged items strand their watch state.
|
||||
|
||||
### 5. Admin UI
|
||||
|
||||
- **Item page:** alongside "Fix match" (existing `MatchItemDialog`), a
|
||||
"Split versions…" action, enabled when the item has >1 file. Dialog:
|
||||
file list grouped by folder with identity evidence and per-file
|
||||
checkboxes → identity picker (reuses the match-candidate search) →
|
||||
dry-run confirmation showing the reattribution report → execute.
|
||||
- **Library roots page:** ambiguous locations
|
||||
(`observed_media_locations.content_group_count > 1` or group state
|
||||
`ambiguous`) get a "Resolve…" action that opens the same dialog scoped
|
||||
to that root.
|
||||
- Overrides listed and revocable on the roots page (revoking does not
|
||||
undo a past split; it only frees future scans to re-infer).
|
||||
|
||||
### 6. Client / compat impact
|
||||
|
||||
Split-off files become a *new* item id; jellycompat packs `content_id`
|
||||
into the client-visible UUID, so clients simply see one item's version
|
||||
list shrink and a new item appear on next library sync — no client-side
|
||||
changes required in `silo-android` / `silo-apple`. Resume state moves
|
||||
server-side, so "Continue Watching" follows the correct title
|
||||
automatically. Active playback sessions are unaffected (keyed by file).
|
||||
|
||||
### 7. Failure & idempotency
|
||||
|
||||
- The split transaction is atomic: overrides + file re-point +
|
||||
reattribution + scanner-state reconcile commit together; metadata
|
||||
refresh and cache invalidation are post-commit and self-healing (refresh
|
||||
debt already retries).
|
||||
- Re-running the same split is a no-op (files already on target; override
|
||||
upsert idempotent).
|
||||
- A rescan after a crash converges: overrides are already persisted, and
|
||||
group inference reproduces the same assignment. If the crash landed
|
||||
*before* the transaction committed, nothing changed.
|
||||
- `playback_history_admin` is best-effort evidence (subject to its
|
||||
retention/cleanup, `internal/catalog/orphan_cleanup.go`); absence of
|
||||
evidence degrades to `ambiguous`, never to a wrong move.
|
||||
|
||||
### 8. Risks / open questions
|
||||
|
||||
- **Group-key incorporation of overrides** must be deterministic and
|
||||
stable (`makeContentGroupKey` gains an anchor component for overridden
|
||||
files). Bump `group_key_version` handling is *not* needed — overridden
|
||||
files just occupy distinct keys within v1.
|
||||
- **Series episode re-derivation** assumes parsed S/E numbers are correct
|
||||
for moved files; files with unparsable numbering are rejected from the
|
||||
selection with a per-file reason (fix naming first).
|
||||
- **`local-` targets** key on path; a later rename of the split-off folder
|
||||
re-IDs the local item (accepted limitation, same as scanner behavior).
|
||||
- **Retention window** of `playback_history_admin` bounds history
|
||||
attribution quality; if it proves too short in practice, a follow-up
|
||||
adds `media_file_id` (nullable) to `user_watch_history` written at
|
||||
playback-stop time, making future attribution exact.
|
||||
|
||||
## Addendum (2026-07-06): structured tags now anchor group keys
|
||||
|
||||
A field report validated during implementation showed the highest-frequency
|
||||
wrong-merge cause is worse than assumed: two *correctly tagged* folders —
|
||||
"Passenger (2026) `[tmdb-1368314]`" and "The Passenger (2026)
|
||||
`[tmdb-1285959]`" — merged into one item with state `resolved`, because
|
||||
`makeContentGroupKey` is title+year only and title normalization strips the
|
||||
leading article. The explicit provider tags were parsed but never entered the
|
||||
group key.
|
||||
|
||||
Fix shipped with this feature: `InferGroupIdentity` now derives the same
|
||||
anchored key shape used for identity overrides (`v1|movie|anchor|tmdb-…`)
|
||||
whenever structured provider IDs are present. Files tagged with the same
|
||||
provider ID always share a group (cross-folder versions keep working); files
|
||||
tagged apart can never merge. Untagged files keep title+year keys, so
|
||||
untagged libraries see no key churn. Tagged libraries re-key on next scan;
|
||||
content ids are unaffected (they are deterministic from the same tags), so
|
||||
this is scanner-internal state churn only. Items merged *before* this fix
|
||||
still need the split flow to repair.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Migration: `media_identity_overrides`; inference application in
|
||||
`internal/naming` / `internal/scanner` (+ tests: forced-ID splits a
|
||||
colliding key, file scope beats root scope, rescan convergence).
|
||||
2. `internal/catalog/reattribute` helper + tests (per-table rules,
|
||||
PK-conflict handling, evidence classification) — wire into
|
||||
`rebindItemToExistingItem` first (pure win, no new API).
|
||||
3. Split endpoint (+ dry run) and merge endpoint in
|
||||
`internal/api/handlers`; scanner-state reconcile.
|
||||
4. Web admin dialog + roots-page "Resolve…" action; retire the
|
||||
`ambiguous_root` 409 dead-end.
|
||||
5. Audit log + docs; follow-up issue for `user_watch_history.media_file_id`.
|
||||
@@ -0,0 +1,265 @@
|
||||
# S3 Provider Change: Artwork Cache Reconciliation Design
|
||||
|
||||
Commands and paths in this document assume the repository root is the cwd.
|
||||
|
||||
## Goal
|
||||
|
||||
When an admin changes the public S3 provider (endpoint, bucket, or key prefix), cached
|
||||
artwork must heal automatically. Today the change silently breaks every cached image
|
||||
forever: the database keeps pointing at object keys that only exist in the old bucket,
|
||||
the caching pipeline permanently skips already-cached rows, and clients eat the 404s
|
||||
directly from S3 so the server never even observes the breakage.
|
||||
|
||||
Design goals, in priority order:
|
||||
|
||||
1. **Users never see broken images.** During and after a provider change, every image
|
||||
either serves from the new bucket, falls back to its provider source URL, or falls
|
||||
back to its thumbhash placeholder / generated artwork.
|
||||
2. **The admin makes no decisions and cannot get it wrong.** No "did you migrate your
|
||||
data?" dialog. Silo verifies reality and does the right thing for migrated,
|
||||
unmigrated, and partially migrated buckets alike.
|
||||
3. **No wasted work.** An admin who migrated their objects to the new bucket must not
|
||||
trigger a full catalog re-download (provider rate limits, bandwidth, time).
|
||||
|
||||
## Background: how caching works today
|
||||
|
||||
- The image cache pipeline (`internal/imagecache`, `internal/metadata/image_cache_processor.go`)
|
||||
downloads provider artwork, generates variants (`original`, `w500`, `w300`, ...), and
|
||||
uploads them to the **public** S3 bucket under bucket-relative keys such as
|
||||
`tmdb/movies/550/poster/original.webp`.
|
||||
- On success, the target row's path column (`media_items.poster_path`,
|
||||
`episodes.still_path`, `people.photo_path`, ...) is rewritten from the provider URL to
|
||||
the cached relative key. The original provider URL is preserved in the matching
|
||||
`*_source_path` column. Thumbhashes are stored in their own columns and are
|
||||
content-derived, so they remain valid across a re-cache of the same source.
|
||||
- The recurring `cache_metadata_images` task (60s interval,
|
||||
`internal/taskmanager/tasks/cache_metadata_images.go`) calls
|
||||
`EnqueueExistingProviderArtwork` (`internal/metadata/image_cache_job_repo.go`), which
|
||||
only enqueues rows whose destination path is still a provider URL (`LIKE '%://%'`) or
|
||||
empty. **The cached path in the row is the durable dedup marker** — once set, the row
|
||||
is never enqueued again.
|
||||
- Serving: stored relative keys are resolved to presigned / read-endpoint URLs against
|
||||
the *current* S3 config at request time (`internal/metadata/image_resolver.go`,
|
||||
`PresignURLWithExpiry` in `internal/catalog/detail.go`). Absolute `http(s)://` paths
|
||||
pass through to clients unchanged. Clients then fetch from S3 directly.
|
||||
- All `s3.` settings keys are restart-required (`internal/config/restart_keys.go`), so a
|
||||
provider change only takes effect at the next server start.
|
||||
|
||||
Consequences of a provider change with no migration: every resolved URL points into the
|
||||
new bucket where nothing exists → broken images; nothing re-enqueues; recovery requires
|
||||
hand-written SQL. If the admin *did* migrate objects, everything keeps working — which is
|
||||
why a blind "wipe and re-cache on settings change" would be strictly worse than today.
|
||||
|
||||
## Approach overview
|
||||
|
||||
Three pieces:
|
||||
|
||||
1. **Storage identity fingerprint** persisted in `server_settings`. At startup a new
|
||||
reconcile task compares the fingerprint against the live config; a mismatch means the
|
||||
storage identity changed and a verification sweep is needed.
|
||||
2. **`reconcile_artwork_cache` task** (task manager, startup trigger + manually
|
||||
runnable). Probes the new bucket, verifies cached objects, and resets rows whose
|
||||
objects are missing so the existing pipeline rebuilds them. Recovery action depends on
|
||||
the image's source scheme (see table below).
|
||||
3. **Admin messaging**: an informational note when saving changed S3 settings, live task
|
||||
progress in the existing Tasks UI, and a completion notification summarizing what was
|
||||
verified, re-queued, and unrecoverable.
|
||||
|
||||
There is deliberately **no new serving logic**: resetting a path back to its provider
|
||||
source URL restores the pre-cache pass-through behavior, so images render (hotlinked)
|
||||
immediately and flip back to cached S3 URLs as the queue drains.
|
||||
|
||||
## Storage identity fingerprint
|
||||
|
||||
- Value: normalized `endpoint|bucket|key_prefix` of the **public write** config
|
||||
(`cfg.S3.Public.Endpoint`, `.Bucket`, `.KeyPrefix`), lowercased, trimmed, stored as a
|
||||
plain (non-encrypted) `server_settings` row, e.g. key `s3.public_storage_identity`.
|
||||
The read endpoint and URL-auth settings are excluded on purpose: changing how objects
|
||||
are *served* (CDN, token auth) does not move the stored data.
|
||||
- Bootstrap: if no fingerprint row exists (first run after upgrade), adopt the current
|
||||
identity without reconciling. Admins who changed providers before this feature existed
|
||||
can run the task manually.
|
||||
- The fingerprint is updated to the new identity **only after a sweep completes**. An
|
||||
interrupted sweep therefore re-triggers at the next startup; the sweep is idempotent.
|
||||
|
||||
## The `reconcile_artwork_cache` task
|
||||
|
||||
Task manager registration: key `reconcile_artwork_cache`, category Metadata, visible,
|
||||
default trigger `TriggerTypeStartup`, manually runnable from the Tasks UI. On startup it
|
||||
compares fingerprints and exits immediately (fast no-op) when they match. A manual run
|
||||
skips the fingerprint check and always sweeps — this doubles as disaster recovery for
|
||||
"my bucket was wiped / partially lost" with no provider change at all.
|
||||
|
||||
### Phase 1 — probe
|
||||
|
||||
The probe runs first (before any counting): HEAD (via the existing
|
||||
`s3client.Client.ObjectExists`) a sample of ~200 stored cached keys across all
|
||||
surfaces, taken with plain `LIMIT` sampling — `ORDER BY random()` would full-scan and
|
||||
sort every surface, and the probe only has to answer "does this bucket hold the cache
|
||||
at all". Because the DB stores the full key of the `original` variant (e.g.
|
||||
`tmdb/movies/550/poster/original.webp`), the stored path is exactly the key to check —
|
||||
no guessing. Progress-denominator `count(*)` queries run only in verify mode; bulk
|
||||
mode reports `RowsAffected` and never pays for counts. Probe errors are reported but
|
||||
tracked separately (`sweep_errors` vs `errors`) so they cannot consume the sweep's
|
||||
error budget. Two reliability guards protect bulk mode from a degraded probe: a run
|
||||
aborts outright when more than half the probe requests error (an outage is not a
|
||||
miss), and bulk reset requires a minimum number of *successful* samples
|
||||
(`artworkReconcileBulkMinSample`) — a probe thinned out by transport errors takes the
|
||||
safe per-row path instead.
|
||||
|
||||
- **≥95% missing** → "bulk reset" mode: skip per-row verification and reset all cached
|
||||
rows by SQL alone (the data plainly was not migrated). The threshold is not 100% so a
|
||||
handful of coincidentally-present keys cannot force millions of pointless HEADs.
|
||||
- **All present** → data was migrated; run the full sweep anyway (it is one cheap HEAD
|
||||
per image and catches partially-missed prefixes) but expect near-zero resets.
|
||||
- **Mixed** → full per-row sweep.
|
||||
|
||||
### Phase 2 — sweep and reset
|
||||
|
||||
Iterate every surface that stores cached public-bucket keys, in batches with keyset
|
||||
pagination and a bounded HEAD concurrency (`artworkReconcileHeadWorkers`, 16 in
|
||||
flight, each attempt under its own timeout), reporting progress
|
||||
through the task's `ProgressReporter`:
|
||||
|
||||
| Surface | Cached columns | Recovery when object is missing |
|
||||
| --- | --- | --- |
|
||||
| `media_items` | `poster_path`, `backdrop_path`, `logo_path` | Reset column to its `*_source_path` when the source is a re-downloadable provider URL |
|
||||
| `media_item_localizations` | `poster_path`, `backdrop_path`, `logo_path` | Same |
|
||||
| `seasons`, `season_localizations` | `poster_path` | Same |
|
||||
| `episodes` | `still_path` | Same |
|
||||
| `people` | `photo_path` | Same |
|
||||
| Chapter thumbnails (`media_files.chapters` JSONB) | per-chapter `thumbnail_path` | Clear path + thumbhash + retry state; the 6-hour `chapter_thumbnail_backfill` task regenerates from the media file |
|
||||
| Collections (`library_collections`, `user_personal_collections`) | `poster_url` / `backdrop_url` | Clear (plus `poster_auto_generated` / `poster_from_template` flags) → UI falls back to the generated collage/poster; report. The original template reference is not stored, so template posters are not re-uploaded automatically |
|
||||
| Server branding (`internal/branding`) | `server_settings` refs (`branding.*_ref`) | Verified by `branding.Service.ReconcileMissingAssets`; missing refs cleared → built-in defaults; report |
|
||||
| Library artwork (`media_folders.poster_path`) | `library-posters/{id}.ext` | Clear → default tile; report (no auto-refill) |
|
||||
| Audiobook / ebook covers (`local/...` keys in `media_items`) | `poster_path` | Clear + null `last_refreshed`; the enrichment sweeps re-extract embedded covers |
|
||||
|
||||
Profile avatars are stored in the **private** bucket (`deps.S3Private`), not the public
|
||||
one, and are therefore out of scope for this reconcile.
|
||||
|
||||
Reset semantics for provider-sourced artwork (the overwhelming majority):
|
||||
|
||||
- Single guarded UPDATE per row/column:
|
||||
`SET poster_path = poster_source_path WHERE content_id = $1 AND poster_path = $2` —
|
||||
the `poster_path = $2` guard makes the sweep safe against concurrent metadata
|
||||
refreshes (lost-update protection).
|
||||
- **Thumbhash columns are left untouched.** Placeholders keep rendering throughout.
|
||||
- Retained `metadata_image_cache_jobs` rows need no special handling:
|
||||
`EnqueueExistingProviderArtwork` calls its upsert with `requeueSucceeded=true`, which
|
||||
flips even `succeeded` rows back to `queued`. (An earlier draft of this spec called
|
||||
for deleting job rows; implementation showed it is unnecessary.)
|
||||
- Sources with schemes `upload://`, `s3://`, `file://`, `local://`, `generated://` are
|
||||
never "reset to source" — those rows are cleared instead, and clearing `media_items`
|
||||
artwork also nulls `last_refreshed` so the book-enrichment sweeps (which require
|
||||
`last_refreshed IS NULL`) re-extract embedded covers.
|
||||
|
||||
### Phase 3 — rebuild (existing machinery, no new code)
|
||||
|
||||
Nothing re-downloads inside the reconcile task itself. After reset, the 60-second
|
||||
`cache_metadata_images` loop sees provider URLs in destination columns again, enqueues
|
||||
them, and the processor re-caches into the new bucket. The processor's per-variant
|
||||
`ObjectExists` checks (`internal/imagecache/imagecache.go`) make rebuilds idempotent and
|
||||
partial-migration-safe: variants that survived migration are skipped, missing ones are
|
||||
uploaded.
|
||||
|
||||
### Phase 4 — report and finalize
|
||||
|
||||
- Update the fingerprint row to the new identity — but only when the sweep completed
|
||||
with zero sweep errors. Skipped-on-error rows were never verified, so a run with
|
||||
sweep errors reports its partial results (applied resets are durable) and leaves
|
||||
the fingerprint stale for the next startup to retry.
|
||||
- Task completion message: verified / re-queued / cleared counts, plus structured
|
||||
result data (JSON) persisted with the execution record for the Tasks UI.
|
||||
- No durable inbox notification: the notifications system has no free-form admin
|
||||
message type (deliveries are content-keyed and per-profile), and adding one for this
|
||||
feature is not worth the payload-renderer plumbing. The persisted task result plus
|
||||
warn logs carry the report.
|
||||
- Log unrecoverable items individually at `warn` with content IDs so an admin can
|
||||
re-upload the specific artwork that was lost.
|
||||
|
||||
## Admin UX
|
||||
|
||||
1. Admin edits S3 settings → existing connection check (`admin_settings_checks.go`)
|
||||
validates the new provider before saving, as today.
|
||||
2. When the saved values change the storage identity, the settings UI adds one
|
||||
informational callout next to the existing restart-required notice:
|
||||
*"Artwork is cached in object storage. After restart, Silo verifies the cache against
|
||||
the new storage and automatically re-caches anything missing. Uploaded artwork
|
||||
(custom posters, avatars, branding) cannot be re-downloaded — migrate your bucket
|
||||
contents if you want to keep them."*
|
||||
No confirmation dialog, no migration question.
|
||||
3. After restart the reconcile task appears in the Tasks UI with live progress, and
|
||||
finishes with the summary + notification described above. It can be re-run manually
|
||||
at any time.
|
||||
|
||||
## End-user UX
|
||||
|
||||
- Before the sweep touches a row: broken S3 URL, but the thumbhash placeholder renders.
|
||||
The in-memory resolved-URL cache is empty after the (required) restart, so no stale
|
||||
presigned URLs outlive the config change.
|
||||
- After reset: the API serves the provider's original URL via the existing absolute-URL
|
||||
pass-through — image present immediately, just hotlinked and un-resized.
|
||||
- After re-cache: back to normal S3-served variants. Users should not be able to tell a
|
||||
migration happened.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- **New provider unreachable at startup**: probe HEADs fail with errors (not
|
||||
"missing"). The task must distinguish `ObjectExists == false` from a transport error
|
||||
and abort without resetting anything, leaving the fingerprint stale so it retries next
|
||||
startup / next manual run. Never mass-reset on the basis of errors.
|
||||
- **Sweep interrupted** (restart, crash): fingerprint was not updated; the task re-runs
|
||||
at next startup. Already-reset rows are simply in the "provider URL" state the enqueue
|
||||
loop handles; already-verified rows get re-HEADed. Idempotent.
|
||||
- **Key-prefix-only change**: `ObjectExists` resolves keys under the current prefix, so
|
||||
objects under the old prefix correctly count as missing and re-cache under the new
|
||||
prefix.
|
||||
- **Provider rate limits during rebuild**: unchanged from any large-catalog initial
|
||||
cache; the existing job queue's retry/backoff behavior applies.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The **private** S3 bucket (subtitles, downloads, transcode artifacts). A private-
|
||||
provider change has a different blast radius and mostly regenerable content; it
|
||||
deserves its own pass. This spec's fingerprint/task mechanism is deliberately reusable
|
||||
for it later.
|
||||
- An S3→S3 object migration tool. Admins who want to keep uploaded artwork migrate with
|
||||
standard tooling (`rclone`, `aws s3 sync`); Silo's job is to heal what it can and
|
||||
report what it cannot.
|
||||
- Serve-time 404 fallback. Clients fetch from S3 directly via presigned/read-endpoint
|
||||
URLs, so the server never sees the misses; a proxying fallback would put every image
|
||||
request through the server and is rejected on performance grounds.
|
||||
|
||||
## Implementation notes (as built)
|
||||
|
||||
- `internal/metadata/artwork_reconcile.go` — `ArtworkCacheReconciler`: table-driven
|
||||
sweep surfaces, probe, bulk/verify modes, and the bespoke chapter-thumbnail sweep.
|
||||
Small "precious upload" tables (collections, library posters) are marked
|
||||
`alwaysVerify` so a bulk reset can never blind-clear an upload that survived
|
||||
migration.
|
||||
- `internal/taskmanager/tasks/reconcile_artwork_cache.go` — task registration,
|
||||
`ArtworkStorageIdentity` fingerprint helper, and the `ShouldRun` gate
|
||||
(`ScheduledConditionalTask` suppresses the startup trigger while the fingerprint
|
||||
matches; manual runs always sweep).
|
||||
- `internal/branding/service.go` — `ReconcileMissingAssets` verifies and clears the
|
||||
four branding refs; composed into the task rather than the reconciler so `metadata`
|
||||
does not import `branding`. It runs *after* the fingerprint is certified and its
|
||||
errors are non-fatal (reported in the task message): a transient failure on a
|
||||
4-object check must never discard a completed catalog sweep and force it to repeat.
|
||||
- Fingerprint lives in `server_settings` under `s3.public_storage_identity`
|
||||
(plaintext; encrypted values are GCM-bound to their key name, which complicates any
|
||||
future rename for zero benefit). It is seeded with `SetIfAbsent` at wiring time in
|
||||
`cmd/silo`, so first boot adopts the current identity without a sweep. Normalization
|
||||
mirrors real S3 semantics: endpoint/bucket are case-folded, but the key prefix keeps
|
||||
its case (S3 keys are case-sensitive — a case-only prefix edit is a real move) and is
|
||||
slash-trimmed exactly like `s3client.NormalizeKeyPrefix` applies it.
|
||||
- The task manager's conditional-task preflight fails **closed**: a `ShouldRun` error
|
||||
skips the run (and the task's `ShouldRun` retries transient settings reads), so a
|
||||
startup DB blip cannot fail open into a full catalog sweep.
|
||||
- No schema migration: the fingerprint is a `server_settings` row and all reset
|
||||
operations use existing columns.
|
||||
- `s3client.Client` prepends `KeyPrefix` internally, so the reconciler passes DB-stored
|
||||
logical keys straight to `ObjectExists`.
|
||||
- Web UI: conditional callout in
|
||||
`web/src/pages/admin-settings/StorageSettings.tsx`, shown while any of
|
||||
`s3.public_endpoint` / `s3.public_bucket` / `s3.public_key_prefix` is dirty.
|
||||
@@ -0,0 +1,661 @@
|
||||
# Autoscan Sonarr/Radarr Webhook Intake
|
||||
|
||||
**Status:** Reviewed 2026-07-09 — decisions locked (see "Resolved review decisions")
|
||||
**Date:** 2026-07-09
|
||||
**Area:** `internal/autoscan`, `internal/api`, `web/src/pages/AdminAutoscan.tsx`, `web/src/pages/admin/autoscan/`, `migrations/sql/`
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Summary
|
||||
|
||||
Silo's current Sonarr/Radarr Autoscan integration is pull-based. The installed
|
||||
`silo.autoscan.arr` plugin implements `scan_source.v1`; the host polls it on an
|
||||
interval, passes a resolved `{base_url, api_key}` connection, the plugin reads
|
||||
arr `/api/v3/history`, and the host applies path rewrites, resolves scan
|
||||
targets, suppresses duplicates, enqueues scans, and records poll events.
|
||||
|
||||
That design works, but it means a Sonarr/Radarr Autoscan source needs an arr API
|
||||
key. This spec adds a **webhook delivery mode** so Sonarr/Radarr can POST import,
|
||||
upgrade, rename, and delete notifications directly to Silo. Webhook mode should
|
||||
not require an arr connection or API key. The host still owns all security,
|
||||
path-rewrite, scan-resolution, scan-queue, suppression, and event-bookkeeping
|
||||
behavior.
|
||||
|
||||
The recommended v1 is **host-native webhook intake attached to Autoscan sources**,
|
||||
using a **host-discovered built-in source identity** (`silo.autoscan.arr-webhook`)
|
||||
so webhook sources require neither an arr API key nor an installed plugin.
|
||||
No SDK change is required for v1. The existing pull plugin remains available for
|
||||
polling mode and for operators who prefer API-based history polling.
|
||||
|
||||
## Current state
|
||||
|
||||
- `autoscan_sources` binds a source to `plugin_id`, `capability_id`, an optional
|
||||
`connection_id`, `enabled`, optional `poll_interval_seconds`, `path_rewrites`,
|
||||
`source_config`, label, marker, last run time, and last error.
|
||||
- `autoscan_connections` can store own credentials or a soft link to a Requests
|
||||
integration. Keys are encrypted by `internal/secret`.
|
||||
- `autoscan.Service.PollOnce` loads enabled sources, optionally resolves their
|
||||
connection, calls the plugin `PollChanges`, applies rewrites, resolves scan
|
||||
targets, suppresses duplicate work, enqueues scans, advances the marker on
|
||||
success, and writes `autoscan_events`.
|
||||
- `scan_source.v1` is pull only. The SDK has no "plugin received a webhook;
|
||||
submit these paths to the host" RPC.
|
||||
- Plugin HTTP routes exist and can be public, but they route by installation id
|
||||
and are not currently connected to Autoscan enqueueing.
|
||||
- The current ARR plugin has no config. It reads connection data only from
|
||||
`PollChangesRequest.connection` and returns raw arr-side paths.
|
||||
|
||||
## Goals
|
||||
|
||||
- Let operators configure Sonarr/Radarr webhooks without storing an arr API key
|
||||
in Silo.
|
||||
- Keep polling mode intact and compatible with existing Autoscan sources.
|
||||
- Reuse the existing host path rewrite, resolution, suppression, enqueue, and
|
||||
event-history behavior for both poll and webhook deliveries.
|
||||
- Give each webhook source a stable public callback URL with a rotatable secret.
|
||||
- Support Sonarr/Radarr import, upgrade, rename, file-delete, and test events.
|
||||
- Keep `/api/v1` additive: new fields and endpoints only.
|
||||
- Avoid leaking webhook secrets or raw payloads in logs, events, or frontend
|
||||
responses beyond admin-only setup surfaces.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No automatic creation of Sonarr/Radarr webhook connections inside arr. That
|
||||
would require the arr API key this feature is trying to avoid.
|
||||
- No SDK change for v1.
|
||||
- No plugin-to-host push RPC for v1.
|
||||
- No broad webhook framework for every future provider. This spec only defines
|
||||
Sonarr/Radarr webhook intake.
|
||||
- No webhook signing requirement from Sonarr/Radarr beyond the generated Silo
|
||||
URL secret. If a future arr version supports request signing, add it
|
||||
additively.
|
||||
- No replacement of the poll plugin. Polling remains useful for fallback,
|
||||
history recovery, and operators who prefer not to expose a public callback.
|
||||
|
||||
## Approach comparison
|
||||
|
||||
### Option A: Host-native webhook mode on Autoscan sources (recommended)
|
||||
|
||||
Silo adds a public secret-bearing endpoint such as
|
||||
`POST /api/v1/autoscan/webhooks/{token}`. The endpoint resolves the token to an
|
||||
Autoscan source, parses the Sonarr/Radarr payload in the host, turns it into
|
||||
`autoscan.Change` values, and calls a new host-side `IngestChanges` path that
|
||||
shares the same rewrite/resolve/suppress/enqueue/event logic as `PollOnce`.
|
||||
|
||||
**Pros**
|
||||
|
||||
- No SDK release or plugin release needed for v1.
|
||||
- No arr API key needed for webhook-only sources.
|
||||
- Stable callback URL is source-owned, not plugin-installation-id-owned.
|
||||
- Host has direct access to scan queue, resolver, suppressor, DB, event logs,
|
||||
secret encryption, and rate limiting.
|
||||
- Existing Autoscan activity UI and source rows remain the operator surface.
|
||||
- The security boundary is simpler: one host-owned bearer secret, one host-owned
|
||||
parser, one host-owned enqueue path.
|
||||
|
||||
**Cons**
|
||||
|
||||
- Sonarr/Radarr payload parsing becomes host code, so provider-specific logic is
|
||||
no longer purely in plugins.
|
||||
- Future non-arr webhook providers would need host work unless a later generic
|
||||
push contract is added.
|
||||
|
||||
A variant of Option A would reuse the `silo.autoscan.arr` plugin identity for
|
||||
webhook sources. That was considered and rejected: `HandleCreateSource` hard
|
||||
rejects any source whose `(plugin_id, capability_id)` is not a
|
||||
currently-installed capability, so plugin-bound identity would force operators
|
||||
to install a plugin the webhook path never calls — quietly defeating the
|
||||
feature's own pitch. The built-in identity below avoids that at near-zero cost
|
||||
(see "Recommended v1").
|
||||
|
||||
### Option B: SDK push RPC plus plugin HTTP route
|
||||
|
||||
Add an SDK/runtime-host RPC such as `SubmitScanSourceChanges`, update
|
||||
`silo-server` to implement it, update the ARR plugin to declare `http_routes.v1`
|
||||
and receive public webhook requests, then have the plugin parse payloads and
|
||||
push changes to the host.
|
||||
|
||||
**Pros**
|
||||
|
||||
- Provider-specific webhook parsing lives in the provider plugin.
|
||||
- The host can stay more generic once the push RPC exists.
|
||||
- Future push-style providers can reuse the same plugin-to-host contract.
|
||||
|
||||
**Cons**
|
||||
|
||||
- Requires a coordinated SDK release, server update, plugin update, and catalog
|
||||
update before the feature exists.
|
||||
- Plugin public URLs include installation id, so reinstalling or replacing the
|
||||
plugin can change callback URLs unless the host adds another stable alias.
|
||||
- The plugin still needs durable webhook secret/config state or host-provided
|
||||
secret state.
|
||||
- More moving parts in the most security-sensitive path.
|
||||
- The feature cannot ship as a narrow server/API/UI change.
|
||||
|
||||
### Option C: Separate `arr-webhook` plugin with internal buffer
|
||||
|
||||
Create a new plugin that exposes a public webhook route, stores received paths
|
||||
in an internal buffer, and returns them when the host later polls
|
||||
`scan_source.v1`.
|
||||
|
||||
**Pros**
|
||||
|
||||
- No SDK push RPC required.
|
||||
- Provider-specific parsing stays outside the host.
|
||||
- Reuses the existing pull contract.
|
||||
|
||||
**Cons**
|
||||
|
||||
- Still has poll latency even though arr sent a real-time webhook.
|
||||
- The plugin needs durable queue/buffer storage, idempotency, and cleanup. The
|
||||
current plugin model is not designed around plugin-owned durable event queues.
|
||||
- Reinstalling the plugin risks losing buffered events or changing callback URLs.
|
||||
- More operationally fragile than letting the host enqueue immediately.
|
||||
- Creates a second ARR Autoscan plugin with overlapping purpose.
|
||||
|
||||
### Option D: Keep polling only
|
||||
|
||||
Improve docs or setup around current polling, but still require an arr API key.
|
||||
|
||||
**Pros**
|
||||
|
||||
- No implementation work.
|
||||
- Pulling `/history` remains robust for recovery after Silo downtime.
|
||||
|
||||
**Cons**
|
||||
|
||||
- Does not satisfy the request.
|
||||
- Still requires API keys.
|
||||
- Adds latency compared to direct arr webhook delivery.
|
||||
|
||||
## Recommended v1
|
||||
|
||||
Implement Option A with a built-in source identity.
|
||||
|
||||
Add webhook delivery as a mode on Autoscan source rows. Existing poll sources
|
||||
remain `delivery_mode = "poll"`. Webhook-only sources use
|
||||
`delivery_mode = "webhook"` and do not require `connection_id`.
|
||||
|
||||
Webhook sources bind to a **host-discovered built-in source identity**,
|
||||
`silo.autoscan.arr-webhook`, rather than the installed ARR plugin's capability.
|
||||
`ScanSourceLister` is a narrow interface with a single adapter
|
||||
(`PluginScanSourceLister` in `internal/api/autoscan_wiring.go`); wrap it in a
|
||||
composite lister that appends one synthetic `DiscoveredSource` entry for the
|
||||
built-in identity. Because the Add-source picker, create-time validation,
|
||||
labels, and events all consume only `(plugin_id, capability_id, display_name)`,
|
||||
they work unchanged. The provider is never invoked for webhook sources —
|
||||
`PollOnce` skips them — so no `PollChanges` implementation exists for the
|
||||
built-in identity, and none is needed.
|
||||
|
||||
For v1, `delivery_mode = "webhook"` is accepted only for the built-in
|
||||
`silo.autoscan.arr-webhook` identity, and `delivery_mode = "poll"` is rejected
|
||||
for it (it has no plugin to poll). Existing ARR plugin sources stay poll-mode.
|
||||
|
||||
## Data model
|
||||
|
||||
Add a Goose migration.
|
||||
|
||||
### `autoscan_sources`
|
||||
|
||||
Add:
|
||||
|
||||
| column | type / default | purpose |
|
||||
|---|---|---|
|
||||
| `delivery_mode` | text NOT NULL DEFAULT `'poll'` | `poll` or `webhook`; future `hybrid` can be added later |
|
||||
|
||||
Check constraint:
|
||||
|
||||
```sql
|
||||
delivery_mode = ANY (ARRAY['poll'::text, 'webhook'::text])
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Poll mode keeps today's behavior.
|
||||
- Webhook mode skips `PollChanges` and does not require a connection.
|
||||
- Webhook mode is valid only for the built-in `silo.autoscan.arr-webhook`
|
||||
identity; poll mode is invalid for it.
|
||||
- Existing rows default to poll.
|
||||
- `source_config.webhook_provider` may be `sonarr`, `radarr`, or `auto`.
|
||||
Use `auto` by default and infer from payload shape when possible.
|
||||
|
||||
### `autoscan_webhook_endpoints`
|
||||
|
||||
One active webhook endpoint per source for v1.
|
||||
|
||||
| column | type / default | purpose |
|
||||
|---|---|---|
|
||||
| `source_id` | uuid PK REFERENCES `autoscan_sources(id)` ON DELETE CASCADE | webhook owner |
|
||||
| `secret_hash` | text UNIQUE NOT NULL | lookup hash of the bearer URL token |
|
||||
| `secret_ref` | text NOT NULL | encrypted token, admin-display only |
|
||||
| `secret_suffix` | text NOT NULL | last 6-8 chars for UI/debug |
|
||||
| `created_at` | timestamptz NOT NULL DEFAULT now() | creation time |
|
||||
| `rotated_at` | timestamptz | last token rotation |
|
||||
| `last_received_at` | timestamptz | latest valid delivery |
|
||||
| `last_error_at` | timestamptz | latest parse/ingest error |
|
||||
| `last_error_message` | text NOT NULL DEFAULT '' | bounded sanitized error |
|
||||
|
||||
Secret handling:
|
||||
|
||||
- Generate at least 32 random bytes and encode URL-safe.
|
||||
- Store a plain SHA-256 hash for lookup. For a 32-byte random token this is
|
||||
standard and sufficient (preimage resistance is what matters); an HMAC-keyed
|
||||
variant adds key-management complexity for no practical gain here.
|
||||
- Store encrypted `secret_ref` using `internal/secret.Cipher` and AAD bound to
|
||||
`autoscan_webhook_endpoints.source_id`.
|
||||
- Never log the token or full URL.
|
||||
|
||||
### `autoscan_webhook_deliveries`
|
||||
|
||||
Persist each actionable parsed delivery before returning `202`. Rows store the
|
||||
source, provider event type, normalized changes, original receive time, retry
|
||||
attempt count/backoff, and a bounded lease owner/time. The request attempts the
|
||||
delivery immediately; transient resolver/database/queue failures release it for
|
||||
an internal retry task. Workers claim due rows with `FOR UPDATE SKIP LOCKED`, so
|
||||
multiple API nodes can drain the queue without double-owning a delivery. A
|
||||
successful consume deletes the row; deleting the source cascades its pending
|
||||
deliveries.
|
||||
|
||||
## API
|
||||
|
||||
All admin endpoints are additive under `/api/v1/admin/autoscan`.
|
||||
|
||||
### Source response additions
|
||||
|
||||
Add fields to `AutoscanSource` responses:
|
||||
|
||||
```json
|
||||
{
|
||||
"delivery_mode": "poll",
|
||||
"webhook_configured": true,
|
||||
"webhook_url": "https://example/api/v1/autoscan/webhooks/...",
|
||||
"webhook_secret_suffix": "abc123",
|
||||
"webhook_last_received_at": "2026-07-09T12:34:56Z",
|
||||
"webhook_last_error_at": null,
|
||||
"webhook_last_error_message": ""
|
||||
}
|
||||
```
|
||||
|
||||
`webhook_url` is admin-only and stays redisplayable in admin source responses
|
||||
(the encrypted `secret_ref` exists for exactly this). The token's blast radius
|
||||
is small — the worst an attacker can do with it is trigger scans of paths that
|
||||
already resolve inside Silo libraries — so one-time display would buy little
|
||||
security while costing real operator pain (re-pasting into arr after a config
|
||||
wipe would force a rotation).
|
||||
|
||||
### Source write additions
|
||||
|
||||
Extend create/update payloads:
|
||||
|
||||
```json
|
||||
{
|
||||
"delivery_mode": "webhook",
|
||||
"source_config": {
|
||||
"webhook_provider": "sonarr"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
- `delivery_mode = "poll"`: current validation stays. If enabled, a connection
|
||||
should be bound for ARR poll sources.
|
||||
- `delivery_mode = "webhook"`: only valid with the built-in
|
||||
`silo.autoscan.arr-webhook` identity. No connection is required. A bound
|
||||
connection is allowed only for rewrite suggestions.
|
||||
- The installed-capability check treats the built-in identity as always
|
||||
available (it comes from the composite lister, not plugin installations).
|
||||
- `source_config.webhook_provider`: optional; when present must be `sonarr`,
|
||||
`radarr`, or `auto`.
|
||||
|
||||
### Webhook endpoint management
|
||||
|
||||
Add:
|
||||
|
||||
- `POST /admin/autoscan/sources/{id}/webhook`
|
||||
- Creates a webhook endpoint if missing and returns URL/status.
|
||||
- `POST /admin/autoscan/sources/{id}/webhook/rotate`
|
||||
- Replaces the token, invalidates the old URL, returns the new URL.
|
||||
- `DELETE /admin/autoscan/sources/{id}/webhook`
|
||||
- Deletes the endpoint and makes future deliveries return not found.
|
||||
|
||||
### Public delivery endpoint
|
||||
|
||||
Add:
|
||||
|
||||
```http
|
||||
POST /api/v1/autoscan/webhooks/{token}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Responses:
|
||||
|
||||
- `202 Accepted`: valid token; event accepted, ignored, or enqueued. Use 202 for
|
||||
valid test events and valid disabled sources so arr setup tests do not fail
|
||||
noisily.
|
||||
- `400 Bad Request`: valid token but malformed/unsupported payload.
|
||||
- `404 Not Found`: token not found. Do not reveal whether a source exists.
|
||||
- `405 Method Not Allowed`: non-POST.
|
||||
- `413 Payload Too Large`: body exceeds the configured cap.
|
||||
- `429 Too Many Requests`: per-token or per-IP rate limit exceeded.
|
||||
- `500 Internal Server Error`: the delivery could not be durably persisted.
|
||||
|
||||
Sonarr/Radarr do not replay failed notification events. Silo must therefore
|
||||
persist actionable deliveries before `202` and retry ingest failures internally;
|
||||
HTTP error responses are not a delivery retry mechanism.
|
||||
|
||||
The body should be small; cap at 256 KiB unless real payload fixtures prove that
|
||||
is too low.
|
||||
|
||||
## Host service design
|
||||
|
||||
Factor the existing `PollOnce` consume path so polling and webhooks share it.
|
||||
|
||||
Proposed shape:
|
||||
|
||||
```go
|
||||
type ChangeIngest struct {
|
||||
SourceID string
|
||||
DeliveryMode string // "poll" or "webhook"
|
||||
ProviderEventType string
|
||||
Changes []autoscan.Change
|
||||
ReceivedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Service) IngestChanges(ctx context.Context, input ChangeIngest) (IngestResult, error)
|
||||
```
|
||||
|
||||
Internally, introduce an unexported helper that both `PollOnce` and
|
||||
`IngestChanges` call after changes are known:
|
||||
|
||||
```go
|
||||
func (s *Service) consumeSourceChanges(ctx context.Context, src Source, changes []Change, opts consumeOptions) (consumeResult, error)
|
||||
```
|
||||
|
||||
It should reuse:
|
||||
|
||||
- `rewriteChanges`
|
||||
- `resolveAndClaim`
|
||||
- `collapseTargetsToLibraryScans`
|
||||
- `enqueueScanTargets`
|
||||
- `releaseClaims`
|
||||
- event creation/finish logic
|
||||
|
||||
Differences from polling:
|
||||
|
||||
- No marker is advanced for webhook deliveries.
|
||||
- Webhook events use marker fields as empty/null.
|
||||
- Webhook event creation skips the running-event single-flight exclusion (see
|
||||
"Security and reliability") — concurrent deliveries each ingest; the
|
||||
suppressor claim is the duplicate guard.
|
||||
- `changes_returned`, `changes_resolved`, `targets_claimed`,
|
||||
`scans_created`, `scans_reused`, and `scans_suppressed` should be recorded
|
||||
exactly like poll events.
|
||||
- If any resolve attempt fails transiently, return an error and record the
|
||||
event as error. Already-enqueued targets are allowed; a duplicate later
|
||||
delivery is safe.
|
||||
- If all paths are outside Silo folders, finish as `unresolved`, same as poll.
|
||||
|
||||
`PollOnce` changes:
|
||||
|
||||
- Skip sources with `delivery_mode = "webhook"`.
|
||||
- Continue current behavior for `delivery_mode = "poll"`.
|
||||
- If a future `hybrid` mode is added, poll only when a connection is bound.
|
||||
|
||||
## Payload parsing
|
||||
|
||||
Implement parsing in a small ARR-specific host package, for example
|
||||
`internal/autoscan/arrwebhook`.
|
||||
|
||||
Input:
|
||||
|
||||
```go
|
||||
func Parse(provider string, body []byte) (ParsedWebhook, error)
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```go
|
||||
type ParsedWebhook struct {
|
||||
Provider string
|
||||
EventType string
|
||||
Test bool
|
||||
Changes []autoscan.Change
|
||||
}
|
||||
```
|
||||
|
||||
Provider inference:
|
||||
|
||||
- If `source_config.webhook_provider` is `sonarr` or `radarr`, use it.
|
||||
- If `auto`, infer from top-level `series` vs `movie`, or from top-level field
|
||||
prefixes if supporting flattened payloads.
|
||||
- If inference fails on a non-test event, return a 400 with a sanitized message.
|
||||
|
||||
Events:
|
||||
|
||||
- `Test`: no-op success. Update `last_received_at`; do not enqueue.
|
||||
- `Download`, `Import`, `Upgrade`, `DownloadComplete`: enqueue imported file
|
||||
paths as `ChangeScopeFile`.
|
||||
- `Rename`: enqueue new and previous file paths as `ChangeScopeFile`. Previous
|
||||
paths may no longer exist; `resolveChange` already falls back through
|
||||
`ResolveVanishedPath` for file changes.
|
||||
- `EpisodeFileDelete`, `MovieFileDelete`: enqueue deleted file path as
|
||||
`ChangeScopeFile` when present.
|
||||
- Unknown event types: valid token, unsupported event. Return 202 no-op and
|
||||
record a low-noise status rather than making arr mark the webhook unhealthy.
|
||||
|
||||
Path extraction should support the common webhook JSON shapes and should be
|
||||
covered by fixtures:
|
||||
|
||||
- Sonarr:
|
||||
- `episodeFile.path`
|
||||
- `episodeFiles[].path`
|
||||
- `episodeFile.previousPath`, `episodeFiles[].previousPath`
|
||||
- `series.path` as a subtree fallback only when no file path exists
|
||||
- Radarr:
|
||||
- `movieFile.path`
|
||||
- `movieFiles[].path`
|
||||
- `movieFile.previousPath`, `movieFiles[].previousPath`
|
||||
- `movie.path` or `movie.folderPath` as a subtree fallback only when no file
|
||||
path exists
|
||||
|
||||
The parser should dedupe exact paths before returning changes.
|
||||
|
||||
## Security and reliability
|
||||
|
||||
- Public endpoint is unauthenticated but token-bearing.
|
||||
- Token lookup should be constant-shape: invalid tokens return 404 without
|
||||
distinguishing disabled/deleted sources.
|
||||
- Valid token but disabled source/global Autoscan disabled should return 202
|
||||
and perform no enqueue. This prevents arr retries or setup failures while
|
||||
respecting Silo's disabled state.
|
||||
- Apply a per-token rate limit and a coarse per-IP limit. Build on the existing
|
||||
`internal/ratelimit` middleware (it already supports per-endpoint and per-IP
|
||||
limits) rather than introducing a new mechanism.
|
||||
- Use `http.MaxBytesReader` or equivalent for body cap.
|
||||
- Do not log request bodies. If an error needs context, log only source id,
|
||||
event type, provider, path count, and sanitized bounded error text.
|
||||
- Never include token in event logs or regular app logs.
|
||||
- Preserve current duplicate suppression. Duplicate webhook deliveries should
|
||||
at worst create suppressed Autoscan events, not duplicate scan storms.
|
||||
- Honor configured burst capacity in both in-memory and Redis rate-limit
|
||||
backends; season-pack imports commonly deliver more than one event per second.
|
||||
- Keep transient ingest failures in the durable delivery queue with bounded
|
||||
exponential backoff. A short-interval hidden task reclaims due or abandoned
|
||||
leases and retries them independently of the poll cadence.
|
||||
- **Webhook deliveries must NOT be single-flighted and dropped.** The
|
||||
running-event exclusion in `CreateEvent` (advisory lock + running-row check)
|
||||
exists to stop overlapping polls of the same marker window. Poll cycles can
|
||||
safely be skipped because the marker window is re-read next cycle; a webhook
|
||||
delivery has no marker, so a dropped delivery is lost forever. Sonarr fires
|
||||
one webhook per import in rapid succession (a season pack is many imports in
|
||||
seconds), so "another event is running" is the normal case during a burst.
|
||||
`CreateEvent` takes a flag (or a separate path) that skips the running-event
|
||||
exclusion for webhook deliveries; the duplicate-scan guard for concurrent
|
||||
ingests is the suppressor's atomic `ShouldScan` claim, which webhook
|
||||
ingestion reuses. Poll-mode behavior is unchanged. If a `hybrid` mode ships
|
||||
later, the rule is "poll defers to webhook", never "webhook drops".
|
||||
|
||||
## Admin UI
|
||||
|
||||
Extend `AdminAutoscan` / `SourcesPanel`.
|
||||
|
||||
Source row/edit controls:
|
||||
|
||||
- Delivery mode segmented control: `Poll` / `Webhook`.
|
||||
- Poll mode:
|
||||
- Existing connection picker, poll interval, rewrites, sync suggestions.
|
||||
- Webhook mode:
|
||||
- Provider selector: `Auto`, `Sonarr`, `Radarr`.
|
||||
- Webhook URL display with copy button.
|
||||
- Rotate URL action.
|
||||
- Last received / last error status.
|
||||
- Connection picker hidden or clearly optional. If a connection is bound, it
|
||||
is used only for existing rewrite suggestions, not for ingestion.
|
||||
- Keep path rewrites visible in both modes; webhook payload paths are arr-side
|
||||
paths and still need host-side rewrites in Docker/mount-mismatch setups.
|
||||
|
||||
Connections panel:
|
||||
|
||||
- No required changes, except copy should not imply a connection is mandatory
|
||||
for webhook-only sources.
|
||||
|
||||
Add-source picker:
|
||||
|
||||
- The built-in `silo.autoscan.arr-webhook` entry appears alongside installed
|
||||
plugin capabilities (supplied by the composite lister) with a display name
|
||||
like "Sonarr/Radarr Webhook". Picking it creates a webhook-mode source.
|
||||
|
||||
Activity panel:
|
||||
|
||||
- Show delivery mode and provider event type on event rows (the schema adds
|
||||
both fields in v1).
|
||||
|
||||
## Event metadata (required in v1)
|
||||
|
||||
Add fields to `autoscan_events`:
|
||||
|
||||
| column | type / default | purpose |
|
||||
|---|---|---|
|
||||
| `delivery_mode` | text NOT NULL DEFAULT `'poll'` | poll/webhook event source |
|
||||
| `provider_event_type` | text NOT NULL DEFAULT '' | arr event type |
|
||||
|
||||
These ship in v1, in the same migration as the source/endpoint changes. They
|
||||
are not required for the enqueue path, but debugging webhook intake without
|
||||
knowing which delivery produced an event is exactly the pain this feature hits
|
||||
in week one, and the cost is two columns in a migration being written anyway.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add migrations for source delivery mode, webhook endpoint table, and
|
||||
`autoscan_events` delivery-mode/provider-event-type columns.
|
||||
2. Add the composite `ScanSourceLister` exposing the built-in
|
||||
`silo.autoscan.arr-webhook` identity.
|
||||
3. Add repository methods for webhook endpoint create/get-by-hash/rotate/delete
|
||||
and source delivery-mode fields, plus the `CreateEvent` no-exclusion path
|
||||
for webhook deliveries.
|
||||
4. Refactor `autoscan.Service` to share the consume path and add
|
||||
`IngestChanges`.
|
||||
5. Add ARR webhook parser fixtures.
|
||||
6. Add public route and handler.
|
||||
7. Add admin endpoint fields and endpoint-management routes.
|
||||
8. Update frontend types/hooks and `SourcesPanel`.
|
||||
9. Keep all existing source rows as poll mode.
|
||||
10. Update docs or setup text after review.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend:
|
||||
|
||||
- Migration applies and rolls back.
|
||||
- Existing poll-mode service tests still pass.
|
||||
- `PollOnce` skips webhook-mode sources.
|
||||
- The composite lister exposes the built-in identity; source creation accepts
|
||||
it for webhook mode and rejects poll mode for it.
|
||||
- `IngestChanges` applies rewrites and enqueues the same targets as polling for
|
||||
equivalent changes.
|
||||
- Actionable deliveries are persisted before processing; transient failures
|
||||
return `202`, remain queued, and are consumed by the retry task.
|
||||
- Delivery leases are ownership-guarded and reclaimable after worker failure.
|
||||
- Concurrent/burst webhook deliveries for one source all ingest — none are
|
||||
dropped by the running-event exclusion; duplicate targets are suppressed by
|
||||
the suppressor claim.
|
||||
- Duplicate webhook deliveries are suppressed by existing debounce logic.
|
||||
- Webhook events record `delivery_mode` and `provider_event_type`.
|
||||
- Disabled source and disabled global setting return accepted/no-op behavior.
|
||||
- Invalid token returns 404; malformed payload for valid token returns 400.
|
||||
- Body cap returns 413.
|
||||
- Rate limit returns 429.
|
||||
- Redis rate limiting honors the configured webhook burst allowance.
|
||||
- Upgrade payloads reconcile both the new file and arr's `deletedFiles` paths.
|
||||
- Token rotation invalidates old token and accepts new token.
|
||||
- Parser fixture tests for Sonarr Download, Rename, EpisodeFileDelete, Test.
|
||||
- Parser fixture tests for Radarr Download, Rename, MovieFileDelete, Test.
|
||||
- Unknown event type is accepted/no-op.
|
||||
- No logs include the token or raw request body.
|
||||
|
||||
Frontend:
|
||||
|
||||
- Source can switch from poll to webhook mode without selecting a connection.
|
||||
- Webhook URL renders only for configured endpoint.
|
||||
- Rotate action replaces the URL.
|
||||
- Provider selector writes `source_config.webhook_provider`.
|
||||
- Poll mode behavior remains unchanged.
|
||||
- Rewrite editor remains available in webhook mode.
|
||||
|
||||
Validation commands:
|
||||
|
||||
```bash
|
||||
GOWORK=off go test ./internal/autoscan ./internal/api/handlers
|
||||
cd web && pnpm run lint
|
||||
cd web && pnpm run format:check
|
||||
make verify-local-paths
|
||||
```
|
||||
|
||||
Broader validation after implementation:
|
||||
|
||||
```bash
|
||||
GOWORK=off go test ./...
|
||||
cd web && pnpm run build
|
||||
```
|
||||
|
||||
## Resolved review decisions (2026-07-09)
|
||||
|
||||
1. **Built-in source identity.** Webhook mode uses a host-discovered
|
||||
`silo.autoscan.arr-webhook` identity, not the installed ARR plugin's
|
||||
capability. Create-time validation requires an installed capability, so
|
||||
plugin-bound identity would force installing a plugin the webhook path never
|
||||
calls; the composite-lister alternative is ~20 lines and everything
|
||||
downstream consumes only `(plugin_id, capability_id, display_name)`.
|
||||
2. **Webhook URL stays redisplayable** in admin-only responses. Low token blast
|
||||
radius (worst case: spurious scans of paths already inside Silo libraries);
|
||||
one-time display would force rotations after any config loss.
|
||||
3. **No `hybrid` in v1.** Purely additive later; shipping it now forces
|
||||
poll/webhook overlap semantics to be solved before any field experience.
|
||||
4. **Unknown event types return 202 no-op.** Arr adds event types over time; a
|
||||
400 makes arr mark the webhook unhealthy and nag the operator about
|
||||
something Silo intentionally ignores.
|
||||
5. **`autoscan_events.delivery_mode` and `provider_event_type` ship in v1.**
|
||||
Two columns in a migration being written anyway; needed for week-one
|
||||
debugging of webhook intake.
|
||||
6. **Parser lives in `internal/autoscan/arrwebhook`.** Fixture-testable, keeps
|
||||
handlers thin, matches the repo's domain-package organization.
|
||||
7. **Webhook deliveries are never single-flight-dropped** (raised during
|
||||
review, not an original question). Poll skips are safe because the marker
|
||||
window is re-read; webhooks have no marker, and Sonarr bursts (season packs)
|
||||
make a running event the normal case. See "Security and reliability".
|
||||
|
||||
## References
|
||||
|
||||
- Current Autoscan plugin architecture:
|
||||
`docs/superpowers/specs/2026-06-02-autoscan-plugin-architecture-design.md`
|
||||
- Current ARR polling design:
|
||||
`docs/superpowers/specs/2026-06-02-autoscan-arr-polling-design.md`
|
||||
- Current rewrite sync design:
|
||||
`docs/superpowers/specs/2026-06-02-autoscan-rewrite-sync-design.md`
|
||||
- Current SDK pull contract: sibling `silo-plugin-sdk` repo,
|
||||
`proto/silo/plugin/v1/scan_source.proto`
|
||||
- Current ARR plugin: sibling `silo-plugin-autoscan-arr` repo
|
||||
- Servarr path/event references:
|
||||
- Sonarr custom scripts: <https://wiki.servarr.com/sonarr/custom-scripts>
|
||||
- Radarr custom scripts: <https://wiki.servarr.com/radarr/custom-scripts>
|
||||
@@ -0,0 +1,81 @@
|
||||
# Ebook Backfill Automation + Rate-Limit Cooldown Floor
|
||||
|
||||
Date: 2026-07-20
|
||||
Status: approved
|
||||
|
||||
## Problem
|
||||
|
||||
The legacy ebook enrichment lane (priority -100, ~65k items on the first
|
||||
production install) only drains when an operator manually runs the
|
||||
`backfill_ebook_metadata` task. Worse, the first production canary run showed
|
||||
that rate-limited items become claimable again almost immediately: the
|
||||
ebook-metadata plugin attaches a `RetryInfo` of about one second to its
|
||||
`ResourceExhausted` errors — request-pacing advice for its internal token
|
||||
bucket — and `enrichmentRetryDelay` adopts that hint verbatim as the queue
|
||||
re-eligibility horizon. Successive runs therefore re-claim and re-defer the
|
||||
same saturated tail instead of letting the provider's quota recover.
|
||||
|
||||
Evidence (production, 2026-07-20): 100 claimed, 30 enriched, 1 no-match,
|
||||
69 deferred with `last_error_class = 'rate_limited'` and
|
||||
`next_attempt_at ≈ last_attempt_at + 1s`.
|
||||
|
||||
## Design
|
||||
|
||||
Two independent pieces that compose into self-pacing automation.
|
||||
|
||||
### 1. Rate-limit cooldown floor
|
||||
|
||||
`internal/ebooks/enrichment_queue.go`, `enrichmentRetryDelay`:
|
||||
|
||||
- Rate-limited branch: resolve the candidate delay as today (provider
|
||||
`retryAfter` if positive, else the transient backoff for the attempt
|
||||
count), then clamp it to no less than the cooldown floor, and cap at
|
||||
`maxEnrichmentRetry` (24h) as today.
|
||||
- Floor default: **15 minutes**.
|
||||
- Env override: `SILO_EBOOK_RATE_LIMIT_COOLDOWN` (Go duration string,
|
||||
e.g. `30m`). Invalid or non-positive values fall back to the default,
|
||||
matching the tolerant parsing style of the other `SILO_EBOOK_*` knobs.
|
||||
- Provider hints longer than the floor are honored unchanged (up to the
|
||||
24h cap). Only pacing-grade short hints are raised.
|
||||
- Transient and permanent classes are unchanged.
|
||||
|
||||
### 2. Default interval trigger for the backfill task
|
||||
|
||||
`internal/taskmanager/tasks/sync_ebook_metadata.go`:
|
||||
|
||||
- `BackfillEbookMetadataTask` gains a default trigger:
|
||||
`TriggerConfig{Type: TriggerTypeInterval, IntervalMs: 15 * 60 * 1000}`.
|
||||
- The task manager applies defaults whenever no triggers are persisted for
|
||||
the task key (`internal/taskmanager/manager.go`), and this task has never
|
||||
persisted any, so existing installs pick the trigger up on upgrade with no
|
||||
migration or manual step.
|
||||
- Operators can retune or disable the trigger through the existing admin
|
||||
task-triggers UI/API; the manual run endpoint keeps working.
|
||||
- The canary claim cap (`SILO_EBOOK_BACKFILL_MAX_CLAIMS`) and batch delay
|
||||
(`SILO_EBOOK_BACKFILL_BATCH_DELAY`) keep their semantics; unset means
|
||||
uncapped runs bounded by the 4-minute execution budget.
|
||||
|
||||
### Emergent behavior
|
||||
|
||||
Each 15-minute run claims what is ready under the execution budget and the
|
||||
claim cap. Rate-limited items sleep at least the floor, so the next run meets
|
||||
a fresh ready-set instead of the same saturated tail. A fully saturated batch
|
||||
trips the existing zero-progress circuit breaker and ends the run early. An
|
||||
empty ready-set makes the run exit in milliseconds. The backlog drains at
|
||||
whatever pace the provider allows, unattended.
|
||||
|
||||
## Testing
|
||||
|
||||
- `enrichmentRetryDelay`: short hint raised to the floor; hint above the
|
||||
floor honored; no hint yields at least the floor; 24h cap still applies.
|
||||
- Cooldown env parsing: valid duration honored, invalid/non-positive falls
|
||||
back to the default.
|
||||
- `BackfillEbookMetadataTask.DefaultTriggers()` returns the 15-minute
|
||||
interval trigger; the sync task's triggers are unchanged.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Plugin-side changes to what `RetryInfo` the ebook-metadata plugin sends
|
||||
(its 1s hint remains correct for in-process pacing).
|
||||
- Any change to the scheduled priority-0 sync task.
|
||||
- Migrations and API surface: none needed.
|
||||
@@ -63,9 +63,10 @@ The default registry covers TMDB and Trakt's public discovery feeds plus a
|
||||
| Editorial | Trakt Recommended Movies/Shows (per profile), Mindfuck Movies, Top Documentary Movies, Top Horror Movies (MDBList) |
|
||||
| Custom | MDBList — bring your own JSON URL |
|
||||
|
||||
TMDB and Trakt templates default to 20 items; MDBList templates default to 50
|
||||
because the underlying lists tend to be longer (IMDb Top 250, full streaming
|
||||
catalogs, etc.). Every template ships with a conservative sync cadence — every
|
||||
Templates default to 100 items. Finite canonical lists override that so the
|
||||
collection can hold what the title promises: the IMDb Top 250 templates use
|
||||
250, and catalog lists (Criterion Collection, A24) ship with no limit at all
|
||||
so they match every owned title. Every template ships with a conservative sync cadence — every
|
||||
6 hours for trending, daily for popular and streaming services, weekly for
|
||||
top-rated and editorial picks — and a "featured" hint where appropriate. All
|
||||
defaults are editable in the small confirmation drawer before the collection
|
||||
|
||||
@@ -199,13 +199,39 @@ 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 movie supplemental content is intentionally skipped, such as:
|
||||
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:
|
||||
|
||||
- `Trailers`, `Teasers`
|
||||
- `Featurettes`
|
||||
- `Behind the Scenes`
|
||||
- `Deleted Scenes`
|
||||
- `Trailers`
|
||||
- `Sample.mkv`
|
||||
- `Clips`, `Shorts`, `Interviews`, `Scenes`
|
||||
- `Extras`, `Other`
|
||||
|
||||
Filename suffixes on files sitting next to the movie are also recognized:
|
||||
`Movie (2020)-trailer.mkv`, `-teaser`, `-featurette`, `-clip`,
|
||||
`-behindthescenes`, `-deleted`, `-interview`, `-short`, `-other`.
|
||||
|
||||
Extras never appear as versions of the main title; they show in the item's
|
||||
Extras section and play like any other file. Extras are bound to the item
|
||||
owning the surrounding folder, so an extras directory at the library root is
|
||||
ignored. For series libraries, `Extras/` files carrying a valid `SxxExx`
|
||||
token keep their documented season-`0` mapping and are not treated as extras.
|
||||
|
||||
Noise content is still intentionally skipped:
|
||||
|
||||
- `Sample` / `Samples` directories and `Sample.mkv`-style files
|
||||
- `Subs` / `Subtitles` directories (handled by subtitle detection)
|
||||
|
||||
## What The Dev Anime Library Validated
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -29,29 +30,46 @@ require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mmcdole/gofeed v1.3.0
|
||||
github.com/oklog/ulid/v2 v2.1.0
|
||||
github.com/open-policy-agent/opa v1.18.2
|
||||
github.com/pgvector/pgvector-go v0.3.0
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
github.com/tetratelabs/wazero v1.12.0
|
||||
github.com/wneessen/go-mail v0.7.3
|
||||
github.com/zishang520/socket.io/v2 v2.5.0
|
||||
go.n16f.net/thumbhash v1.1.0
|
||||
golang.org/x/image v0.41.0
|
||||
golang.org/x/net v0.55.0
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.8.0 // indirect
|
||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||
github.com/dunglas/httpsfv v1.1.0 // indirect
|
||||
github.com/fatih/color v1.13.0 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/gookit/color v1.5.4 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||
github.com/hashicorp/yamux v0.1.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
|
||||
github.com/lestrrat-go/dsig v1.2.1 // indirect
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1 // indirect
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
|
||||
@@ -61,31 +79,46 @@ require (
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||
github.com/quic-go/webtransport-go v0.10.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/tchap/go-patricia/v2 v2.3.3 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/vektah/gqlparser/v2 v2.5.34 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
|
||||
github.com/yashtewari/glob-intersection v0.2.0 // indirect
|
||||
github.com/zishang520/engine.io-go-parser v1.3.2 // indirect
|
||||
github.com/zishang520/engine.io/v2 v2.5.0 // indirect
|
||||
github.com/zishang520/socket.io-go-parser/v2 v2.5.0 // indirect
|
||||
github.com/zishang520/webtransport-go v0.9.1 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.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/Silo-Server/silo-plugin-sdk v0.12.0
|
||||
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
|
||||
@@ -94,16 +127,25 @@ require (
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sony/sonyflake v1.3.0
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
|
||||
go.opentelemetry.io/otel v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0
|
||||
go.opentelemetry.io/otel/log v0.20.0
|
||||
go.opentelemetry.io/otel/sdk v1.44.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
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.82.1
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
|
||||
@@ -4,38 +4,56 @@ github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0g
|
||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0 h1:Gi9TdH9kt7b8X4xRXH493/nSYb9n0GO4VCWmlll0hKI=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.12.0 h1:7MnaSIJBVdRE8FhfLXWx7Pe+Q2apJiaPMotfhb1VHoE=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.12.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
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/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.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=
|
||||
@@ -44,25 +62,43 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
|
||||
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
|
||||
github.com/bytecodealliance/wasmtime-go/v44 v44.0.0 h1:WRZXnLPIer/TWs5aYPaMlmVcOlzmR6Ur6wjLRIQOhTQ=
|
||||
github.com/bytecodealliance/wasmtime-go/v44 v44.0.0/go.mod h1:GP93piU+39CoFVCQ5xfHrPOUtL0APlMnkbblJ2d3YY0=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dgraph-io/badger/v4 v4.9.2 h1:Wb5qw8gElqwV1a8msHTeQKova9b1V10heFKMIiPd80E=
|
||||
github.com/dgraph-io/badger/v4 v4.9.2/go.mod h1:nJjaJTUOSsQEBhsq209FmwCvMJzEA3e74RjZw6V2pQI=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54=
|
||||
github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
|
||||
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
|
||||
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
|
||||
github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0=
|
||||
github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
@@ -71,11 +107,17 @@ github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0=
|
||||
github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA=
|
||||
github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU=
|
||||
github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
|
||||
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
@@ -86,6 +128,8 @@ github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
|
||||
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||
github.com/h2non/bimg v1.1.9 h1:WH20Nxko9l/HFm4kZCA3Phbgu2cbHvYzxwxn9YROEGg=
|
||||
github.com/h2non/bimg v1.1.9/go.mod h1:R3+UiYwkK4rQl6KVFTOFJHitgLbZXBZNFh2cv3AEbp8=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
@@ -124,11 +168,26 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/dsig v1.2.1 h1:MwxzZhE4+4fguHi+uDALKVlC3Cn+O1QU1Q/F8D7hVIc=
|
||||
github.com/lestrrat-go/dsig v1.2.1/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw=
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
@@ -137,6 +196,8 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
|
||||
github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
|
||||
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
||||
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
|
||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
|
||||
@@ -154,6 +215,8 @@ github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
|
||||
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
|
||||
github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
|
||||
github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/open-policy-agent/opa v1.18.2 h1:VBiLJpioTuk7XTW1JoQi4ILo+FVxD2/8uD8iP9/OcxY=
|
||||
github.com/open-policy-agent/opa v1.18.2/go.mod h1:9GY+hER4ZEXtxPlMjftVbqJJY9xLtCD3Q0oufRCfAKo=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc=
|
||||
github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA=
|
||||
@@ -165,8 +228,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||
@@ -177,26 +240,37 @@ github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+
|
||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
|
||||
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sony/sonyflake v1.3.0 h1:tiB4Dlp0lnmKp/h6BLXA14P8Qi+LYS9+0QRpcrKHvg4=
|
||||
github.com/sony/sonyflake v1.3.0/go.mod h1:LORtCywH/cq10ZbyfhKrHYgAUGH7mOBa76enV9txy/Y=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc=
|
||||
github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k=
|
||||
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
|
||||
github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ=
|
||||
@@ -205,6 +279,10 @@ github.com/uptrace/bun/dialect/pgdialect v1.1.12 h1:m/CM1UfOkoBTglGO5CUTKnIKKOAp
|
||||
github.com/uptrace/bun/dialect/pgdialect v1.1.12/go.mod h1:Ij6WIxQILxLlL2frUBxUBOZJtLElD2QQNDcu/PWDHTc=
|
||||
github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibggiLdTqjTtfHc1w=
|
||||
github.com/uptrace/bun/driver/pgdriver v1.1.12/go.mod h1:ssYUP+qwSEgeDDS1xm2XBip9el1y9Mi5mTAvLoiADLM=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/vektah/gqlparser/v2 v2.5.34 h1:MEea5P0qhdcqfBL45ghKE+qr9laidVHTMHjav5h7ckk=
|
||||
github.com/vektah/gqlparser/v2 v2.5.34/go.mod h1:mFdHLGCio7OGX1fby9ZjTW6FN+qxgmbnBcRIeeScE5s=
|
||||
github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94=
|
||||
github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
@@ -217,10 +295,16 @@ github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK
|
||||
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
|
||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg=
|
||||
github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
@@ -236,16 +320,36 @@ go.n16f.net/thumbhash v1.1.0 h1:aBEvuAd4yiwzeQ7Sm4BZoHJYbrQ1ewjrmrRlCE79snk=
|
||||
go.n16f.net/thumbhash v1.1.0/go.mod h1:mo9pP7WtfdV9ojIamGFR/Vc0PaPA2l0CUtmYQf/SweU=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
|
||||
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
|
||||
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
@@ -254,8 +358,10 @@ go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
@@ -271,6 +377,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
@@ -289,8 +397,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -329,23 +437,27 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.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=
|
||||
@@ -370,3 +482,5 @@ modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
|
||||
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Group is an access group with its admin-facing member count.
|
||||
type Group struct {
|
||||
ID int64
|
||||
Name string
|
||||
Description string
|
||||
LibraryIDs []int
|
||||
MaxPlaybackQuality string
|
||||
DownloadAllowed bool
|
||||
DownloadTranscodeAllowed bool
|
||||
MaxStreams int
|
||||
MaxTranscodes int
|
||||
AllowedPermissions []string
|
||||
RequestsAllowed bool
|
||||
IsDefault bool
|
||||
MemberCount int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateGroupInput contains the required fields for creating an access group.
|
||||
type CreateGroupInput struct {
|
||||
Name string
|
||||
Description string
|
||||
LibraryIDs []int
|
||||
MaxPlaybackQuality string
|
||||
DownloadAllowed bool
|
||||
DownloadTranscodeAllowed bool
|
||||
MaxStreams int
|
||||
MaxTranscodes int
|
||||
AllowedPermissions []string
|
||||
RequestsAllowed bool
|
||||
IsDefault bool
|
||||
}
|
||||
|
||||
// UpdateGroupInput contains optional fields for updating an access group.
|
||||
type UpdateGroupInput struct {
|
||||
Name *string
|
||||
Description *string
|
||||
LibraryIDs *[]int
|
||||
MaxPlaybackQuality *string
|
||||
DownloadAllowed *bool
|
||||
DownloadTranscodeAllowed *bool
|
||||
MaxStreams *int
|
||||
MaxTranscodes *int
|
||||
AllowedPermissions *[]string
|
||||
RequestsAllowed *bool
|
||||
IsDefault *bool
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrGroupNotFound reports that an access group does not exist.
|
||||
ErrGroupNotFound = errors.New("access group not found")
|
||||
// ErrGroupDuplicate reports a unique-name conflict.
|
||||
ErrGroupDuplicate = errors.New("access group already exists")
|
||||
// ErrDefaultGroupRequired reports an operation that would leave the server
|
||||
// without a default access group. New non-admin users are assigned to the
|
||||
// default group at creation, so losing it would create them ungrouped and
|
||||
// uncapped (the legacy per-user column defaults were retired).
|
||||
ErrDefaultGroupRequired = errors.New("a default access group is required")
|
||||
)
|
||||
|
||||
// GroupStore persists access groups in Postgres.
|
||||
type GroupStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewGroupStore creates a Postgres-backed access-group store.
|
||||
func NewGroupStore(pool *pgxpool.Pool) *GroupStore {
|
||||
return &GroupStore{pool: pool}
|
||||
}
|
||||
|
||||
const accessGroupSelectColumns = `g.id, g.name, g.description, g.library_ids, g.max_playback_quality,
|
||||
g.download_allowed, g.download_transcode_allowed, g.max_streams, g.max_transcodes,
|
||||
g.allowed_permissions, g.requests_allowed, g.is_default, g.created_at, g.updated_at`
|
||||
|
||||
type groupScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanGroup(row groupScanner) (*Group, error) {
|
||||
var g Group
|
||||
if err := row.Scan(
|
||||
&g.ID,
|
||||
&g.Name,
|
||||
&g.Description,
|
||||
&g.LibraryIDs,
|
||||
&g.MaxPlaybackQuality,
|
||||
&g.DownloadAllowed,
|
||||
&g.DownloadTranscodeAllowed,
|
||||
&g.MaxStreams,
|
||||
&g.MaxTranscodes,
|
||||
&g.AllowedPermissions,
|
||||
&g.RequestsAllowed,
|
||||
&g.IsDefault,
|
||||
&g.CreatedAt,
|
||||
&g.UpdatedAt,
|
||||
&g.MemberCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
func scanGroupPolicy(row groupScanner) (*GroupPolicy, error) {
|
||||
var p GroupPolicy
|
||||
if err := row.Scan(
|
||||
&p.ID,
|
||||
&p.LibraryIDs,
|
||||
&p.MaxPlaybackQuality,
|
||||
&p.DownloadAllowed,
|
||||
&p.DownloadTranscodeAllowed,
|
||||
&p.MaxStreams,
|
||||
&p.MaxTranscodes,
|
||||
&p.AllowedPermissions,
|
||||
&p.RequestsAllowed,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// List returns all access groups with member counts.
|
||||
func (s *GroupStore) List(ctx context.Context) ([]Group, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+accessGroupSelectColumns+`, COUNT(u.id)::int AS member_count
|
||||
FROM access_groups g
|
||||
LEFT JOIN users u ON u.access_group_id = g.id
|
||||
GROUP BY g.id
|
||||
ORDER BY lower(g.name), g.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing access groups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
groups := []Group{}
|
||||
for rows.Next() {
|
||||
group, err := scanGroup(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning access group: %w", err)
|
||||
}
|
||||
groups = append(groups, *group)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating access groups: %w", err)
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// Get returns one access group with its member count.
|
||||
func (s *GroupStore) Get(ctx context.Context, id int64) (*Group, error) {
|
||||
group, err := scanGroup(s.pool.QueryRow(ctx, `
|
||||
SELECT `+accessGroupSelectColumns+`, COUNT(u.id)::int AS member_count
|
||||
FROM access_groups g
|
||||
LEFT JOIN users u ON u.access_group_id = g.id
|
||||
WHERE g.id = $1
|
||||
GROUP BY g.id`, id))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrGroupNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading access group: %w", err)
|
||||
}
|
||||
return group, nil
|
||||
}
|
||||
|
||||
// Create inserts a new access group.
|
||||
func (s *GroupStore) Create(ctx context.Context, input CreateGroupInput) (*Group, error) {
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("access group name is required")
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("beginning access group create: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if input.IsDefault {
|
||||
if _, err := tx.Exec(ctx, `UPDATE access_groups SET is_default = false WHERE is_default`); err != nil {
|
||||
return nil, fmt.Errorf("clearing previous default access group: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var id int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO access_groups (
|
||||
name, description, library_ids, max_playback_quality,
|
||||
download_allowed, download_transcode_allowed, max_streams, max_transcodes,
|
||||
allowed_permissions, requests_allowed, is_default
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id`,
|
||||
name,
|
||||
input.Description,
|
||||
input.LibraryIDs,
|
||||
NormalizePlaybackQuality(input.MaxPlaybackQuality),
|
||||
input.DownloadAllowed,
|
||||
input.DownloadTranscodeAllowed,
|
||||
input.MaxStreams,
|
||||
input.MaxTranscodes,
|
||||
input.AllowedPermissions,
|
||||
input.RequestsAllowed,
|
||||
input.IsDefault,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
if isGroupDuplicate(err) {
|
||||
return nil, ErrGroupDuplicate
|
||||
}
|
||||
return nil, fmt.Errorf("creating access group: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("committing access group create: %w", err)
|
||||
}
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
// Update modifies an access group. Setting IsDefault true clears the previous
|
||||
// default, and changing max_playback_quality bumps member access-policy
|
||||
// revisions in the same transaction. The current default cannot be demoted
|
||||
// directly (which would leave no default) — promote another group instead.
|
||||
func (s *GroupStore) Update(ctx context.Context, id int64, input UpdateGroupInput) (*Group, error) {
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
arg := 1
|
||||
|
||||
if input.Name != nil {
|
||||
sets = append(sets, fmt.Sprintf("name = $%d", arg))
|
||||
args = append(args, strings.TrimSpace(*input.Name))
|
||||
arg++
|
||||
}
|
||||
if input.Description != nil {
|
||||
sets = append(sets, fmt.Sprintf("description = $%d", arg))
|
||||
args = append(args, *input.Description)
|
||||
arg++
|
||||
}
|
||||
if input.LibraryIDs != nil {
|
||||
sets = append(sets, fmt.Sprintf("library_ids = $%d", arg))
|
||||
args = append(args, *input.LibraryIDs)
|
||||
arg++
|
||||
}
|
||||
if input.MaxPlaybackQuality != nil {
|
||||
normalized := NormalizePlaybackQuality(*input.MaxPlaybackQuality)
|
||||
sets = append(sets, fmt.Sprintf("max_playback_quality = $%d", arg))
|
||||
args = append(args, normalized)
|
||||
arg++
|
||||
}
|
||||
if input.DownloadAllowed != nil {
|
||||
sets = append(sets, fmt.Sprintf("download_allowed = $%d", arg))
|
||||
args = append(args, *input.DownloadAllowed)
|
||||
arg++
|
||||
}
|
||||
if input.DownloadTranscodeAllowed != nil {
|
||||
sets = append(sets, fmt.Sprintf("download_transcode_allowed = $%d", arg))
|
||||
args = append(args, *input.DownloadTranscodeAllowed)
|
||||
arg++
|
||||
}
|
||||
if input.MaxStreams != nil {
|
||||
sets = append(sets, fmt.Sprintf("max_streams = $%d", arg))
|
||||
args = append(args, *input.MaxStreams)
|
||||
arg++
|
||||
}
|
||||
if input.MaxTranscodes != nil {
|
||||
sets = append(sets, fmt.Sprintf("max_transcodes = $%d", arg))
|
||||
args = append(args, *input.MaxTranscodes)
|
||||
arg++
|
||||
}
|
||||
if input.AllowedPermissions != nil {
|
||||
sets = append(sets, fmt.Sprintf("allowed_permissions = $%d", arg))
|
||||
args = append(args, *input.AllowedPermissions)
|
||||
arg++
|
||||
}
|
||||
if input.RequestsAllowed != nil {
|
||||
sets = append(sets, fmt.Sprintf("requests_allowed = $%d", arg))
|
||||
args = append(args, *input.RequestsAllowed)
|
||||
arg++
|
||||
}
|
||||
if input.IsDefault != nil {
|
||||
sets = append(sets, fmt.Sprintf("is_default = $%d", arg))
|
||||
args = append(args, *input.IsDefault)
|
||||
arg++
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("beginning access group update: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
qualityChanged := false
|
||||
if input.MaxPlaybackQuality != nil {
|
||||
var current string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT max_playback_quality
|
||||
FROM access_groups
|
||||
WHERE id = $1
|
||||
FOR UPDATE`, id).Scan(¤t); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrGroupNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("loading access group for update: %w", err)
|
||||
}
|
||||
qualityChanged = NormalizePlaybackQuality(current) != NormalizePlaybackQuality(*input.MaxPlaybackQuality)
|
||||
}
|
||||
if input.IsDefault != nil && *input.IsDefault {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE access_groups
|
||||
SET is_default = false
|
||||
WHERE is_default
|
||||
AND id <> $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("clearing previous default access group: %w", err)
|
||||
}
|
||||
}
|
||||
if input.IsDefault != nil && !*input.IsDefault {
|
||||
var isDefault bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT is_default
|
||||
FROM access_groups
|
||||
WHERE id = $1
|
||||
FOR UPDATE`, id).Scan(&isDefault); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrGroupNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("loading access group for update: %w", err)
|
||||
}
|
||||
if isDefault {
|
||||
return nil, ErrDefaultGroupRequired
|
||||
}
|
||||
}
|
||||
|
||||
sets = append(sets, "updated_at = NOW()")
|
||||
query := fmt.Sprintf("UPDATE access_groups SET %s WHERE id = $%d", strings.Join(sets, ", "), arg)
|
||||
args = append(args, id)
|
||||
tag, err := tx.Exec(ctx, query, args...)
|
||||
if err != nil {
|
||||
if isGroupDuplicate(err) {
|
||||
return nil, ErrGroupDuplicate
|
||||
}
|
||||
return nil, fmt.Errorf("updating access group: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return nil, ErrGroupNotFound
|
||||
}
|
||||
if qualityChanged {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET access_policy_revision = access_policy_revision + 1
|
||||
WHERE access_group_id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("bumping access group member revisions: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("committing access group update: %w", err)
|
||||
}
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
// Delete removes an access group. User memberships are cleared by the FK.
|
||||
// The default group cannot be deleted — promote another group to default
|
||||
// first — so new-user creation always finds a governing group.
|
||||
func (s *GroupStore) Delete(ctx context.Context, id int64) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM access_groups WHERE id = $1 AND NOT is_default`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting access group: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
var isDefault bool
|
||||
err := s.pool.QueryRow(ctx, `SELECT is_default FROM access_groups WHERE id = $1`, id).Scan(&isDefault)
|
||||
switch {
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
return ErrGroupNotFound
|
||||
case err != nil:
|
||||
return fmt.Errorf("checking access group default flag: %w", err)
|
||||
case isDefault:
|
||||
return ErrDefaultGroupRequired
|
||||
default:
|
||||
// The row reappeared between DELETE and the check; treat the
|
||||
// original miss as not-found rather than retrying.
|
||||
return ErrGroupNotFound
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPolicyForUser returns the access-group policy for a user, or nil when
|
||||
// the user has no group.
|
||||
func (s *GroupStore) GetPolicyForUser(ctx context.Context, userID int) (*GroupPolicy, error) {
|
||||
policy, err := scanGroupPolicy(s.pool.QueryRow(ctx, `
|
||||
SELECT g.id, g.library_ids, g.max_playback_quality, g.download_allowed,
|
||||
g.download_transcode_allowed, g.max_streams, g.max_transcodes,
|
||||
g.allowed_permissions, g.requests_allowed
|
||||
FROM users u
|
||||
JOIN access_groups g ON g.id = u.access_group_id
|
||||
WHERE u.id = $1`, userID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading access group policy for user %d: %w", userID, err)
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func isGroupDuplicate(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestGroupStoreCRUDAndMemberCountsDB(t *testing.T) {
|
||||
ctx, pool, store, suffix := newGroupStoreDBTest(t)
|
||||
group := createTestGroup(t, ctx, store, suffix, "crud")
|
||||
insertAccessGroupTestUser(t, ctx, pool, suffix, &group.ID, 1)
|
||||
insertAccessGroupTestUser(t, ctx, pool, suffix, &group.ID, 2)
|
||||
|
||||
got, err := store.Get(ctx, group.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error: %v", err)
|
||||
}
|
||||
if got.MemberCount != 2 {
|
||||
t.Fatalf("member_count = %d, want 2", got.MemberCount)
|
||||
}
|
||||
if !reflect.DeepEqual(got.LibraryIDs, []int{1, 3}) {
|
||||
t.Fatalf("library_ids = %#v, want [1 3]", got.LibraryIDs)
|
||||
}
|
||||
|
||||
groups, err := store.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("List() error: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, listed := range groups {
|
||||
if listed.ID == group.ID {
|
||||
found = true
|
||||
if listed.MemberCount != 2 {
|
||||
t.Fatalf("listed member_count = %d, want 2", listed.MemberCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("created group %d not found in List()", group.ID)
|
||||
}
|
||||
|
||||
description := "updated"
|
||||
maxStreams := 1
|
||||
updated, err := store.Update(ctx, group.ID, UpdateGroupInput{
|
||||
Description: &description,
|
||||
MaxStreams: &maxStreams,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Update() error: %v", err)
|
||||
}
|
||||
if updated.Description != "updated" || updated.MaxStreams != 1 {
|
||||
t.Fatalf("updated group = %#v, want description/max_streams update", updated)
|
||||
}
|
||||
|
||||
if err := store.Delete(ctx, group.ID); err != nil {
|
||||
t.Fatalf("Delete() error: %v", err)
|
||||
}
|
||||
var assigned int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM users
|
||||
WHERE username LIKE $1 AND access_group_id IS NOT NULL`,
|
||||
"access-group-test-"+suffix+"%",
|
||||
).Scan(&assigned); err != nil {
|
||||
t.Fatalf("count assigned users after delete: %v", err)
|
||||
}
|
||||
if assigned != 0 {
|
||||
t.Fatalf("assigned users after delete = %d, want 0", assigned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupStoreGetPolicyForUserDB(t *testing.T) {
|
||||
ctx, pool, store, suffix := newGroupStoreDBTest(t)
|
||||
group := createTestGroup(t, ctx, store, suffix, "policy")
|
||||
memberID := insertAccessGroupTestUser(t, ctx, pool, suffix, &group.ID, 1)
|
||||
noGroupID := insertAccessGroupTestUser(t, ctx, pool, suffix, nil, 1)
|
||||
|
||||
policy, err := store.GetPolicyForUser(ctx, memberID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPolicyForUser(member) error: %v", err)
|
||||
}
|
||||
if policy == nil || policy.ID != group.ID || !reflect.DeepEqual(policy.LibraryIDs, []int{1, 3}) {
|
||||
t.Fatalf("policy = %#v, want group policy", policy)
|
||||
}
|
||||
policy, err = store.GetPolicyForUser(ctx, noGroupID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPolicyForUser(no group) error: %v", err)
|
||||
}
|
||||
if policy != nil {
|
||||
t.Fatalf("policy = %#v, want nil", policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupStoreQualityUpdateBumpsMemberRevisionsDB(t *testing.T) {
|
||||
ctx, pool, store, suffix := newGroupStoreDBTest(t)
|
||||
group := createTestGroup(t, ctx, store, suffix, "quality")
|
||||
memberID := insertAccessGroupTestUser(t, ctx, pool, suffix, &group.ID, 10)
|
||||
nonMemberID := insertAccessGroupTestUser(t, ctx, pool, suffix, nil, 20)
|
||||
|
||||
description := "no revision bump"
|
||||
if _, err := store.Update(ctx, group.ID, UpdateGroupInput{Description: &description}); err != nil {
|
||||
t.Fatalf("Update(description) error: %v", err)
|
||||
}
|
||||
if got := accessPolicyRevisionForUser(t, ctx, pool, memberID); got != 10 {
|
||||
t.Fatalf("member revision after description update = %d, want 10", got)
|
||||
}
|
||||
|
||||
quality := PlaybackQualityStandard
|
||||
if _, err := store.Update(ctx, group.ID, UpdateGroupInput{MaxPlaybackQuality: &quality}); err != nil {
|
||||
t.Fatalf("Update(max quality) error: %v", err)
|
||||
}
|
||||
if got := accessPolicyRevisionForUser(t, ctx, pool, memberID); got != 11 {
|
||||
t.Fatalf("member revision after quality update = %d, want 11", got)
|
||||
}
|
||||
if got := accessPolicyRevisionForUser(t, ctx, pool, nonMemberID); got != 20 {
|
||||
t.Fatalf("non-member revision after quality update = %d, want 20", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAccessGroupSeedAndUniqueDB(t *testing.T) {
|
||||
ctx, pool, store, suffix := newGroupStoreDBTest(t)
|
||||
seedID := defaultAccessGroupSeedID(t, ctx, pool)
|
||||
t.Cleanup(func() {
|
||||
restoreDefaultAccessGroup(t, ctx, pool, seedID)
|
||||
})
|
||||
|
||||
assertDefaultGroupSeed(t, ctx, pool)
|
||||
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO access_groups (name, is_default)
|
||||
VALUES ($1, true)`,
|
||||
"Access Group Test "+suffix+" second default",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("second default access group insert succeeded, want unique violation")
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) || pgErr.Code != "23505" {
|
||||
t.Fatalf("second default insert error = %v, want unique violation", err)
|
||||
}
|
||||
|
||||
group := createTestGroup(t, ctx, store, suffix, "swap-default")
|
||||
isDefault := true
|
||||
updated, err := store.Update(ctx, group.ID, UpdateGroupInput{IsDefault: &isDefault})
|
||||
if err != nil {
|
||||
t.Fatalf("Update(is_default true) error: %v", err)
|
||||
}
|
||||
if !updated.IsDefault {
|
||||
t.Fatalf("updated IsDefault = false, want true")
|
||||
}
|
||||
assertSingleDefaultGroup(t, ctx, pool, group.ID)
|
||||
|
||||
isDefault = false
|
||||
if _, err := store.Update(ctx, group.ID, UpdateGroupInput{IsDefault: &isDefault}); !errors.Is(err, ErrDefaultGroupRequired) {
|
||||
t.Fatalf("Update(is_default false) on the default group error = %v, want ErrDefaultGroupRequired", err)
|
||||
}
|
||||
assertSingleDefaultGroup(t, ctx, pool, group.ID)
|
||||
}
|
||||
|
||||
func TestGroupStoreDeleteDefaultRejectedDB(t *testing.T) {
|
||||
ctx, pool, store, suffix := newGroupStoreDBTest(t)
|
||||
seedID := defaultAccessGroupSeedID(t, ctx, pool)
|
||||
t.Cleanup(func() {
|
||||
restoreDefaultAccessGroup(t, ctx, pool, seedID)
|
||||
})
|
||||
|
||||
group := createTestGroup(t, ctx, store, suffix, "delete-default")
|
||||
isDefault := true
|
||||
if _, err := store.Update(ctx, group.ID, UpdateGroupInput{IsDefault: &isDefault}); err != nil {
|
||||
t.Fatalf("Update(is_default true) error: %v", err)
|
||||
}
|
||||
userID := insertAccessGroupTestUser(t, ctx, pool, suffix, &group.ID, 1)
|
||||
|
||||
if err := store.Delete(ctx, group.ID); !errors.Is(err, ErrDefaultGroupRequired) {
|
||||
t.Fatalf("Delete(default) error = %v, want ErrDefaultGroupRequired", err)
|
||||
}
|
||||
var hasGroup bool
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT access_group_id IS NOT NULL
|
||||
FROM users
|
||||
WHERE id = $1`, userID).Scan(&hasGroup); err != nil {
|
||||
t.Fatalf("load default group member: %v", err)
|
||||
}
|
||||
if !hasGroup {
|
||||
t.Fatalf("user access_group_id cleared by rejected default-group delete")
|
||||
}
|
||||
assertSingleDefaultGroup(t, ctx, pool, group.ID)
|
||||
|
||||
// Deleting a non-default group still clears memberships through the FK.
|
||||
other := createTestGroup(t, ctx, store, suffix, "delete-non-default")
|
||||
otherUserID := insertAccessGroupTestUser(t, ctx, pool, suffix, &other.ID, 2)
|
||||
if err := store.Delete(ctx, other.ID); err != nil {
|
||||
t.Fatalf("Delete(non-default) error: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT access_group_id IS NOT NULL
|
||||
FROM users
|
||||
WHERE id = $1`, otherUserID).Scan(&hasGroup); err != nil {
|
||||
t.Fatalf("load deleted group member: %v", err)
|
||||
}
|
||||
if hasGroup {
|
||||
t.Fatalf("user access_group_id remained set after deleting non-default group")
|
||||
}
|
||||
}
|
||||
|
||||
func newGroupStoreDBTest(t *testing.T) (context.Context, *pgxpool.Pool, *GroupStore, string) {
|
||||
t.Helper()
|
||||
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)
|
||||
|
||||
var tableName *string
|
||||
if err := pool.QueryRow(ctx, `SELECT to_regclass('public.access_groups')::text`).Scan(&tableName); err != nil {
|
||||
t.Fatalf("check access_groups table: %v", err)
|
||||
}
|
||||
if tableName == nil || *tableName == "" {
|
||||
t.Skip("test database has not applied access groups migration")
|
||||
}
|
||||
if !accessGroupDefaultColumnExists(t, ctx, pool) {
|
||||
t.Skip("test database has not applied default access group migration")
|
||||
}
|
||||
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE username LIKE $1`, "access-group-test-"+suffix+"%")
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM access_groups WHERE name LIKE $1`, "Access Group Test "+suffix+"%")
|
||||
})
|
||||
return ctx, pool, NewGroupStore(pool), suffix
|
||||
}
|
||||
|
||||
func accessGroupDefaultColumnExists(t *testing.T, ctx context.Context, pool *pgxpool.Pool) bool {
|
||||
t.Helper()
|
||||
var exists bool
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'access_groups'
|
||||
AND column_name = 'is_default'
|
||||
)`).Scan(&exists); err != nil {
|
||||
t.Fatalf("check access_groups.is_default column: %v", err)
|
||||
}
|
||||
return exists
|
||||
}
|
||||
|
||||
func defaultAccessGroupSeedID(t *testing.T, ctx context.Context, pool *pgxpool.Pool) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT id
|
||||
FROM access_groups
|
||||
WHERE name = 'Default Group'
|
||||
AND is_default`).Scan(&id); err != nil {
|
||||
t.Fatalf("load seeded default access group: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func assertDefaultGroupSeed(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
var (
|
||||
description string
|
||||
libraryIDsNull bool
|
||||
maxPlaybackQuality string
|
||||
downloadAllowed bool
|
||||
downloadTranscodeAllowed bool
|
||||
maxStreams int
|
||||
maxTranscodes int
|
||||
allowedPermissions []string
|
||||
requestsAllowed bool
|
||||
)
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT description, library_ids IS NULL, max_playback_quality,
|
||||
download_allowed, download_transcode_allowed, max_streams,
|
||||
max_transcodes, allowed_permissions, requests_allowed
|
||||
FROM access_groups
|
||||
WHERE name = 'Default Group'
|
||||
AND is_default`).Scan(
|
||||
&description,
|
||||
&libraryIDsNull,
|
||||
&maxPlaybackQuality,
|
||||
&downloadAllowed,
|
||||
&downloadTranscodeAllowed,
|
||||
&maxStreams,
|
||||
&maxTranscodes,
|
||||
&allowedPermissions,
|
||||
&requestsAllowed,
|
||||
); err != nil {
|
||||
t.Fatalf("load seeded default access group details: %v", err)
|
||||
}
|
||||
if description != "Applied automatically to newly created users." ||
|
||||
!libraryIDsNull ||
|
||||
maxPlaybackQuality != "" ||
|
||||
!downloadAllowed ||
|
||||
downloadTranscodeAllowed ||
|
||||
maxStreams != 5 ||
|
||||
maxTranscodes != 5 ||
|
||||
!slices.Equal(allowedPermissions, []string{"marker_edit"}) ||
|
||||
!requestsAllowed {
|
||||
t.Fatalf("seeded default group does not match the migration's starter policy")
|
||||
}
|
||||
}
|
||||
|
||||
func assertSingleDefaultGroup(t *testing.T, ctx context.Context, pool *pgxpool.Pool, wantID int64) {
|
||||
t.Helper()
|
||||
var (
|
||||
gotID int64
|
||||
count int
|
||||
)
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(MIN(id), 0), COUNT(*)::int
|
||||
FROM access_groups
|
||||
WHERE is_default`).Scan(&gotID, &count); err != nil {
|
||||
t.Fatalf("count default access groups: %v", err)
|
||||
}
|
||||
if count != 1 || gotID != wantID {
|
||||
t.Fatalf("default groups = count %d id %d, want count 1 id %d", count, gotID, wantID)
|
||||
}
|
||||
}
|
||||
|
||||
func restoreDefaultAccessGroup(t *testing.T, ctx context.Context, pool *pgxpool.Pool, seedID int64) {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(ctx, `
|
||||
UPDATE access_groups
|
||||
SET is_default = false
|
||||
WHERE is_default
|
||||
AND id <> $1`, seedID); err != nil {
|
||||
t.Fatalf("clear non-seed default groups: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
UPDATE access_groups
|
||||
SET is_default = true
|
||||
WHERE id = $1`, seedID); err != nil {
|
||||
t.Fatalf("restore seeded default group: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createTestGroup(t *testing.T, ctx context.Context, store *GroupStore, suffix, label string) *Group {
|
||||
t.Helper()
|
||||
group, err := store.Create(ctx, CreateGroupInput{
|
||||
Name: "Access Group Test " + suffix + " " + label,
|
||||
Description: "test group",
|
||||
LibraryIDs: []int{1, 3},
|
||||
MaxPlaybackQuality: PlaybackQuality4K,
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
MaxStreams: 3,
|
||||
MaxTranscodes: 2,
|
||||
AllowedPermissions: []string{"marker_edit"},
|
||||
RequestsAllowed: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
func insertAccessGroupTestUser(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
suffix string,
|
||||
groupID *int64,
|
||||
revision int64,
|
||||
) int {
|
||||
t.Helper()
|
||||
username := fmt.Sprintf("access-group-test-%s-%d", suffix, time.Now().UnixNano())
|
||||
var id int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO users (username, role, enabled, access_group_id, access_policy_revision)
|
||||
VALUES ($1, 'user', true, $2, $3)
|
||||
RETURNING id`,
|
||||
username,
|
||||
groupID,
|
||||
revision,
|
||||
).Scan(&id); err != nil {
|
||||
t.Fatalf("insert test user: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func accessPolicyRevisionForUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool, userID int) int64 {
|
||||
t.Helper()
|
||||
var revision int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT access_policy_revision
|
||||
FROM users
|
||||
WHERE id = $1`, userID).Scan(&revision); err != nil {
|
||||
t.Fatalf("load access_policy_revision for user %d: %v", userID, err)
|
||||
}
|
||||
return revision
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// GroupPolicyProvider loads the access-group restriction layer for a user.
|
||||
type GroupPolicyProvider interface {
|
||||
GetPolicyForUser(ctx context.Context, userID int) (*GroupPolicy, error)
|
||||
}
|
||||
|
||||
// GroupPolicy is the restriction layer contributed by a user's access group.
|
||||
type GroupPolicy struct {
|
||||
ID int64
|
||||
LibraryIDs []int // nil = unrestricted
|
||||
MaxPlaybackQuality string
|
||||
DownloadAllowed bool
|
||||
DownloadTranscodeAllowed bool
|
||||
MaxStreams int // 0 = no group cap
|
||||
MaxTranscodes int
|
||||
AllowedPermissions []string // nil = all assignable
|
||||
RequestsAllowed bool
|
||||
}
|
||||
|
||||
// EffectiveUserPolicy is the account layer after the group restriction is applied.
|
||||
type EffectiveUserPolicy struct {
|
||||
LibraryIDs []int // nil = unrestricted
|
||||
MaxPlaybackQuality string
|
||||
DownloadAllowed bool
|
||||
DownloadTranscodeAllowed bool
|
||||
TranscodeAllowed bool
|
||||
AudioTranscodeAllowed bool
|
||||
MaxStreams int
|
||||
MaxTranscodes int
|
||||
Permissions []string
|
||||
RequestsAllowed bool
|
||||
}
|
||||
|
||||
// EffectivePolicyForUser loads a user's group policy and returns the merged
|
||||
// restriction layer. Nil providers are treated as "no group".
|
||||
func EffectivePolicyForUser(ctx context.Context, user *models.User, provider GroupPolicyProvider) (EffectiveUserPolicy, error) {
|
||||
if provider == nil || user == nil {
|
||||
return ApplyGroupPolicy(user, nil), nil
|
||||
}
|
||||
group, err := provider.GetPolicyForUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
return EffectiveUserPolicy{}, err
|
||||
}
|
||||
return ApplyGroupPolicy(user, group), nil
|
||||
}
|
||||
|
||||
// ApplyGroupPolicy restricts user account policy by the optional access group.
|
||||
func ApplyGroupPolicy(user *models.User, group *GroupPolicy) EffectiveUserPolicy {
|
||||
if user == nil {
|
||||
return EffectiveUserPolicy{RequestsAllowed: true}
|
||||
}
|
||||
|
||||
effective := EffectiveUserPolicy{
|
||||
LibraryIDs: cloneInts(user.LibraryIDs),
|
||||
MaxPlaybackQuality: user.MaxPlaybackQuality,
|
||||
DownloadAllowed: user.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: user.DownloadTranscodeAllowed,
|
||||
TranscodeAllowed: user.TranscodeAllowed,
|
||||
AudioTranscodeAllowed: user.AudioTranscodeAllowed,
|
||||
MaxStreams: user.MaxStreams,
|
||||
MaxTranscodes: user.MaxTranscodes,
|
||||
Permissions: cloneStrings(user.Permissions),
|
||||
RequestsAllowed: true,
|
||||
}
|
||||
if group == nil {
|
||||
return effective
|
||||
}
|
||||
|
||||
effective.LibraryIDs = restrictLibraryIDs(user.LibraryIDs, group.LibraryIDs)
|
||||
effective.MaxPlaybackQuality = MinQuality(user.MaxPlaybackQuality, group.MaxPlaybackQuality)
|
||||
effective.DownloadAllowed = user.DownloadAllowed && group.DownloadAllowed
|
||||
effective.DownloadTranscodeAllowed = user.DownloadTranscodeAllowed && group.DownloadTranscodeAllowed
|
||||
effective.MaxStreams = strictestPositive(user.MaxStreams, group.MaxStreams)
|
||||
effective.MaxTranscodes = strictestPositive(user.MaxTranscodes, group.MaxTranscodes)
|
||||
if group.AllowedPermissions != nil {
|
||||
effective.Permissions = intersectStrings(user.Permissions, group.AllowedPermissions)
|
||||
}
|
||||
effective.RequestsAllowed = group.RequestsAllowed
|
||||
return effective
|
||||
}
|
||||
|
||||
func restrictLibraryIDs(userLibraryIDs, groupLibraryIDs []int) []int {
|
||||
switch {
|
||||
case userLibraryIDs != nil && groupLibraryIDs != nil:
|
||||
return intersectInts(userLibraryIDs, groupLibraryIDs)
|
||||
case userLibraryIDs != nil:
|
||||
return cloneInts(userLibraryIDs)
|
||||
case groupLibraryIDs != nil:
|
||||
return sortedUniqueInts(groupLibraryIDs)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func strictestPositive(userValue, groupValue int) int {
|
||||
switch {
|
||||
case userValue <= 0:
|
||||
return groupValue
|
||||
case groupValue <= 0:
|
||||
return userValue
|
||||
case userValue <= groupValue:
|
||||
return userValue
|
||||
default:
|
||||
return groupValue
|
||||
}
|
||||
}
|
||||
|
||||
func intersectStrings(left, right []string) []string {
|
||||
if len(left) == 0 || len(right) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
allowed := make(map[string]struct{}, len(right))
|
||||
for _, raw := range right {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
allowed[value] = struct{}{}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(left))
|
||||
out := make([]string, 0, len(left))
|
||||
for _, raw := range left {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[value]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStrings(values []string) []string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, len(values))
|
||||
copy(out, values)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestApplyGroupPolicyNoGroupMirrorsUser(t *testing.T) {
|
||||
user := &models.User{
|
||||
ID: 7,
|
||||
LibraryIDs: []int{3, 1, 3},
|
||||
MaxPlaybackQuality: "2160P",
|
||||
DownloadAllowed: false,
|
||||
DownloadTranscodeAllowed: true,
|
||||
MaxStreams: 6,
|
||||
MaxTranscodes: 2,
|
||||
Permissions: []string{"metadata_curation", "marker_edit", "marker_edit"},
|
||||
}
|
||||
got := ApplyGroupPolicy(user, nil)
|
||||
want := EffectiveUserPolicy{
|
||||
LibraryIDs: []int{3, 1, 3},
|
||||
MaxPlaybackQuality: "2160P",
|
||||
DownloadAllowed: false,
|
||||
DownloadTranscodeAllowed: true,
|
||||
MaxStreams: 6,
|
||||
MaxTranscodes: 2,
|
||||
Permissions: []string{"metadata_curation", "marker_edit", "marker_edit"},
|
||||
RequestsAllowed: true,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ApplyGroupPolicy(no group) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyGroupPolicyRules(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
user *models.User
|
||||
group *GroupPolicy
|
||||
want EffectiveUserPolicy
|
||||
}{
|
||||
{
|
||||
name: "group libraries restrict unrestricted user",
|
||||
user: &models.User{DownloadAllowed: true, DownloadTranscodeAllowed: true},
|
||||
group: &GroupPolicy{
|
||||
LibraryIDs: []int{4, 2, 4},
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
RequestsAllowed: true,
|
||||
},
|
||||
want: EffectiveUserPolicy{
|
||||
LibraryIDs: []int{2, 4},
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
RequestsAllowed: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user libraries pass through unrestricted group",
|
||||
user: &models.User{
|
||||
LibraryIDs: []int{5, 1},
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
},
|
||||
group: &GroupPolicy{
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
RequestsAllowed: true,
|
||||
},
|
||||
want: EffectiveUserPolicy{
|
||||
LibraryIDs: []int{5, 1},
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
RequestsAllowed: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "libraries intersect with empty boundary",
|
||||
user: &models.User{
|
||||
LibraryIDs: []int{1},
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
},
|
||||
group: &GroupPolicy{
|
||||
LibraryIDs: []int{2},
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
RequestsAllowed: true,
|
||||
},
|
||||
want: EffectiveUserPolicy{
|
||||
LibraryIDs: []int{},
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
RequestsAllowed: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "quality and booleans use strictest values",
|
||||
user: &models.User{
|
||||
MaxPlaybackQuality: "4k",
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
},
|
||||
group: &GroupPolicy{
|
||||
MaxPlaybackQuality: "standard",
|
||||
DownloadAllowed: false,
|
||||
DownloadTranscodeAllowed: true,
|
||||
RequestsAllowed: false,
|
||||
},
|
||||
want: EffectiveUserPolicy{
|
||||
MaxPlaybackQuality: PlaybackQualityStandard,
|
||||
DownloadAllowed: false,
|
||||
DownloadTranscodeAllowed: true,
|
||||
RequestsAllowed: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zero limits inherit positive layer",
|
||||
user: &models.User{
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
MaxStreams: 0,
|
||||
MaxTranscodes: 3,
|
||||
},
|
||||
group: &GroupPolicy{
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
MaxStreams: 4,
|
||||
MaxTranscodes: 0,
|
||||
RequestsAllowed: true,
|
||||
},
|
||||
want: EffectiveUserPolicy{
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
MaxStreams: 4,
|
||||
MaxTranscodes: 3,
|
||||
RequestsAllowed: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "positive limits pick min",
|
||||
user: &models.User{
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
MaxStreams: 6,
|
||||
MaxTranscodes: 2,
|
||||
},
|
||||
group: &GroupPolicy{
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
MaxStreams: 4,
|
||||
MaxTranscodes: 5,
|
||||
RequestsAllowed: true,
|
||||
},
|
||||
want: EffectiveUserPolicy{
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
MaxStreams: 4,
|
||||
MaxTranscodes: 2,
|
||||
RequestsAllowed: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "permissions intersect sorted deduped",
|
||||
user: &models.User{
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
Permissions: []string{"metadata_curation", "marker_edit", "marker_edit"},
|
||||
},
|
||||
group: &GroupPolicy{
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
AllowedPermissions: []string{"marker_edit", "marker_edit"},
|
||||
RequestsAllowed: true,
|
||||
},
|
||||
want: EffectiveUserPolicy{
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
Permissions: []string{"marker_edit"},
|
||||
RequestsAllowed: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty permission mask removes all",
|
||||
user: &models.User{
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
Permissions: []string{"marker_edit"},
|
||||
},
|
||||
group: &GroupPolicy{
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
AllowedPermissions: []string{},
|
||||
RequestsAllowed: true,
|
||||
},
|
||||
want: EffectiveUserPolicy{
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
Permissions: []string{},
|
||||
RequestsAllowed: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil permission mask leaves user set unchanged",
|
||||
user: &models.User{
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
Permissions: []string{"metadata_curation", "marker_edit", "marker_edit"},
|
||||
},
|
||||
group: &GroupPolicy{
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
AllowedPermissions: nil,
|
||||
RequestsAllowed: true,
|
||||
},
|
||||
want: EffectiveUserPolicy{
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
Permissions: []string{"metadata_curation", "marker_edit", "marker_edit"},
|
||||
RequestsAllowed: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ApplyGroupPolicy(tt.user, tt.group)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("ApplyGroupPolicy() = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+53
-22
@@ -29,14 +29,20 @@ type Resolver struct {
|
||||
users UserRepository
|
||||
storeFactory userstore.UserStoreProvider
|
||||
tokens ProfileTokenValidator
|
||||
groups GroupPolicyProvider
|
||||
}
|
||||
|
||||
// NewResolver creates a new scope resolver.
|
||||
func NewResolver(users UserRepository, storeFactory userstore.UserStoreProvider, tokens ProfileTokenValidator) *Resolver {
|
||||
func NewResolver(users UserRepository, storeFactory userstore.UserStoreProvider, tokens ProfileTokenValidator, groups ...GroupPolicyProvider) *Resolver {
|
||||
var groupProvider GroupPolicyProvider
|
||||
if len(groups) > 0 {
|
||||
groupProvider = groups[0]
|
||||
}
|
||||
return &Resolver{
|
||||
users: users,
|
||||
storeFactory: storeFactory,
|
||||
tokens: tokens,
|
||||
groups: groupProvider,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,13 +52,17 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
|
||||
if err != nil {
|
||||
return Scope{}, fmt.Errorf("loading user %d: %w", input.UserID, err)
|
||||
}
|
||||
effective, err := EffectivePolicyForUser(ctx, user, r.groups)
|
||||
if err != nil {
|
||||
return Scope{}, fmt.Errorf("loading access group policy for user %d: %w", input.UserID, err)
|
||||
}
|
||||
|
||||
scope := Scope{
|
||||
UserID: user.ID,
|
||||
ProfileID: input.ProfileID,
|
||||
AllowedLibraryIDs: cloneInts(user.LibraryIDs),
|
||||
LibrariesRestricted: user.LibraryIDs != nil,
|
||||
MaxPlaybackQuality: NormalizePlaybackQuality(user.MaxPlaybackQuality),
|
||||
AllowedLibraryIDs: cloneInts(effective.LibraryIDs),
|
||||
LibrariesRestricted: effective.LibraryIDs != nil,
|
||||
MaxPlaybackQuality: NormalizePlaybackQuality(effective.MaxPlaybackQuality),
|
||||
PolicyRevision: user.AccessPolicyRevision,
|
||||
ProfileVerified: input.ProfileID == "",
|
||||
}
|
||||
@@ -74,26 +84,16 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
|
||||
scope.MaxContentRating = profile.MaxContentRating
|
||||
scope.MaxPlaybackQuality = MinQuality(scope.MaxPlaybackQuality, NormalizePlaybackQuality(profile.MaxPlaybackQuality))
|
||||
scope.PreferredMetadataLanguage = profile.PreferredMetadataLanguage
|
||||
scope.AllowedLibraryIDs, scope.LibrariesRestricted = effectiveLibraries(user.LibraryIDs, profile)
|
||||
scope.ProfileVerified = profile.PINHash == "" || input.SkipPINVerification
|
||||
|
||||
if profile.PINHash != "" && !input.SkipPINVerification {
|
||||
if r.tokens == nil {
|
||||
return Scope{}, ErrProfileUnverified
|
||||
}
|
||||
claims, err := r.tokens.Validate(input.ProfileToken)
|
||||
if err != nil {
|
||||
return Scope{}, err
|
||||
}
|
||||
if claims.UserID != user.ID || claims.SessionID != input.SessionID || claims.ProfileID != profile.ID || claims.PolicyRevision != user.AccessPolicyRevision {
|
||||
return Scope{}, ErrProfileUnverified
|
||||
}
|
||||
scope.ProfileVerified = true
|
||||
scope.AllowedLibraryIDs, scope.LibrariesRestricted = effectiveLibraries(effective.LibraryIDs, profile)
|
||||
verified, err := VerifyProfileForRequest(profile, input, user.ID, user.AccessPolicyRevision, r.tokens)
|
||||
if err != nil {
|
||||
return Scope{}, err
|
||||
}
|
||||
scope.ProfileVerified = verified
|
||||
}
|
||||
|
||||
// Apply user-level disabled library IDs setting.
|
||||
disabled := loadDisabledLibraryIDs(ctx, store)
|
||||
disabled := DisabledLibraryIDs(ctx, store)
|
||||
if len(disabled) > 0 {
|
||||
if scope.AllowedLibraryIDs != nil {
|
||||
// Restricted user: subtract disabled IDs from the allowed set.
|
||||
@@ -108,8 +108,39 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
// loadDisabledLibraryIDs reads and parses the disabled_library_ids user setting.
|
||||
func loadDisabledLibraryIDs(ctx context.Context, store userstore.UserStore) []int {
|
||||
// VerifyProfileForRequest applies the legacy profile PIN/token verification
|
||||
// checks for a resolved profile and returns whether the profile is verified for
|
||||
// the request.
|
||||
func VerifyProfileForRequest(
|
||||
profile *userstore.Profile,
|
||||
input ResolveInput,
|
||||
userID int,
|
||||
policyRevision int64,
|
||||
tokens ProfileTokenValidator,
|
||||
) (bool, error) {
|
||||
if profile == nil {
|
||||
return input.ProfileID == "", nil
|
||||
}
|
||||
|
||||
profileVerified := profile.PINHash == "" || input.SkipPINVerification
|
||||
if profile.PINHash != "" && !input.SkipPINVerification {
|
||||
if tokens == nil {
|
||||
return false, ErrProfileUnverified
|
||||
}
|
||||
claims, err := tokens.Validate(input.ProfileToken)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if claims.UserID != userID || claims.SessionID != input.SessionID || claims.ProfileID != profile.ID || claims.PolicyRevision != policyRevision {
|
||||
return false, ErrProfileUnverified
|
||||
}
|
||||
profileVerified = true
|
||||
}
|
||||
return profileVerified, nil
|
||||
}
|
||||
|
||||
// DisabledLibraryIDs reads and parses the disabled_library_ids user setting.
|
||||
func DisabledLibraryIDs(ctx context.Context, store userstore.UserStore) []int {
|
||||
raw, err := store.GetSetting(ctx, settingKeyDisabledLibraryIDs)
|
||||
if err != nil || raw == "" {
|
||||
return nil
|
||||
|
||||
@@ -86,9 +86,15 @@ func (s stubStore) GetProgress(context.Context, string, string) (*userstore.Watc
|
||||
func (s stubStore) ListProgress(context.Context, string, string, int, int) ([]userstore.WatchProgress, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) ListProgressFiltered(context.Context, string, string, []string, *int, int, int) ([]userstore.WatchProgress, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) ListProgressByMediaItems(context.Context, string, []string) (map[string]userstore.WatchProgress, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) ListProgressSince(context.Context, string, string) ([]userstore.WatchProgress, string, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) AddHistory(context.Context, userstore.WatchHistoryEntry) error { panic("unused") }
|
||||
func (s stubStore) AddHistoryIfMissing(context.Context, userstore.WatchHistoryEntry) (bool, error) {
|
||||
panic("unused")
|
||||
@@ -99,6 +105,9 @@ func (s stubStore) ListHistory(context.Context, string, int, int) ([]userstore.W
|
||||
func (s stubStore) ListCompletedHistory(context.Context, userstore.CompletedHistoryQuery) ([]userstore.WatchHistoryEntry, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) ListCompletedHistoryItems(context.Context, userstore.CompletedHistoryItemQuery) ([]userstore.CompletedHistoryItem, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) RemoveHistoryItems(context.Context, string, []string, time.Time) error {
|
||||
panic("unused")
|
||||
}
|
||||
@@ -115,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 {
|
||||
@@ -127,9 +136,18 @@ func (s stubStore) ListFavorites(context.Context, string, int, int) ([]userstore
|
||||
func (s stubStore) ListFavoritesByMediaItems(context.Context, string, []string) (map[string]bool, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) IsFavorite(context.Context, string, string) (bool, error) { panic("unused") }
|
||||
func (s stubStore) AddToWatchlist(context.Context, string, string) error { panic("unused") }
|
||||
func (s stubStore) IsFavorite(context.Context, string, string) (bool, error) { panic("unused") }
|
||||
func (s stubStore) AddToWatchlist(context.Context, string, string) error { panic("unused") }
|
||||
func (s stubStore) AddToWatchlistAt(context.Context, string, string, time.Time) (bool, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) RemoveWatchedFromWatchlist(context.Context, string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
func (s stubStore) RemoveFromWatchlist(context.Context, string, string) error { panic("unused") }
|
||||
func (s stubStore) ReplaceWatchlistOrder(context.Context, string, []string) error {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) ListWatchlist(context.Context, string, int, int) ([]userstore.WatchlistEntry, error) {
|
||||
panic("unused")
|
||||
}
|
||||
@@ -408,3 +426,43 @@ func TestResolver_DisabledLibraries_NoProfile(t *testing.T) {
|
||||
t.Fatalf("DisabledLibraryIDs = %v, want [7]", scope.DisabledLibraryIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_AppliesGroupPolicy(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{
|
||||
ID: 1,
|
||||
LibraryIDs: []int{1, 2, 3},
|
||||
MaxPlaybackQuality: PlaybackQuality4K,
|
||||
AccessPolicyRevision: 5,
|
||||
}},
|
||||
stubStoreProvider{store: stubStore{}},
|
||||
nil,
|
||||
stubGroupProvider{group: &GroupPolicy{
|
||||
LibraryIDs: []int{2, 4},
|
||||
MaxPlaybackQuality: PlaybackQualityStandard,
|
||||
DownloadAllowed: true,
|
||||
DownloadTranscodeAllowed: true,
|
||||
RequestsAllowed: true,
|
||||
}},
|
||||
)
|
||||
|
||||
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error: %v", err)
|
||||
}
|
||||
if !scope.LibrariesRestricted || len(scope.AllowedLibraryIDs) != 1 || scope.AllowedLibraryIDs[0] != 2 {
|
||||
t.Fatalf("scope libraries = restricted %t ids %#v, want [2]", scope.LibrariesRestricted, scope.AllowedLibraryIDs)
|
||||
}
|
||||
if scope.MaxPlaybackQuality != PlaybackQualityStandard {
|
||||
t.Fatalf("MaxPlaybackQuality = %q, want %q", scope.MaxPlaybackQuality, PlaybackQualityStandard)
|
||||
}
|
||||
}
|
||||
|
||||
type stubGroupProvider struct {
|
||||
group *GroupPolicy
|
||||
err error
|
||||
}
|
||||
|
||||
func (p stubGroupProvider) GetPolicyForUser(context.Context, int) (*GroupPolicy, error) {
|
||||
return p.group, p.err
|
||||
}
|
||||
|
||||
@@ -71,35 +71,35 @@ func CleanupOnce(ctx context.Context, pool *pgxpool.Pool, store SettingsStore, p
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -days)
|
||||
if pm != nil {
|
||||
if err := pm.EnsureFuturePartitions(ctx); err != nil {
|
||||
slog.Warn("activitylog ensure future partitions error", "error", err)
|
||||
slog.WarnContext(ctx, "activitylog ensure future partitions error", "component", "activitylog", "error", err)
|
||||
}
|
||||
|
||||
partitionCleanupFailed := false
|
||||
totalDeleted := int64(0)
|
||||
if dropped, err := pm.DropExpiredPartitions(ctx, cutoff); err != nil {
|
||||
slog.Warn("activitylog partition cleanup error", "error", err)
|
||||
slog.WarnContext(ctx, "activitylog partition cleanup error", "component", "activitylog", "error", err)
|
||||
partitionCleanupFailed = true
|
||||
} else if len(dropped) > 0 {
|
||||
slog.Info("activitylog dropped expired partitions", "partitions", dropped)
|
||||
slog.InfoContext(ctx, "activitylog dropped expired partitions", "component", "activitylog", "partitions", dropped)
|
||||
}
|
||||
|
||||
if deleted, err := pm.DeleteExpiredRowsFromDefault(ctx, cutoff); err != nil {
|
||||
slog.Warn("activitylog default partition cleanup error", "error", err)
|
||||
slog.WarnContext(ctx, "activitylog default partition cleanup error", "component", "activitylog", "error", err)
|
||||
partitionCleanupFailed = true
|
||||
} else if deleted > 0 {
|
||||
totalDeleted += deleted
|
||||
slog.Info("activitylog default partition cleanup completed", "deleted", deleted, "retention_days", days)
|
||||
slog.InfoContext(ctx, "activitylog default partition cleanup completed", "component", "activitylog", "deleted", deleted, "retention_days", days)
|
||||
}
|
||||
|
||||
if !partitionCleanupFailed {
|
||||
return totalDeleted
|
||||
}
|
||||
slog.Warn("activitylog partition cleanup degraded, falling back to row deletes", "retention_days", days)
|
||||
slog.WarnContext(ctx, "activitylog partition cleanup degraded, falling back to row deletes", "component", "activitylog", "retention_days", days)
|
||||
}
|
||||
|
||||
total := deleteExpiredRowsBefore(ctx, pool, cutoff)
|
||||
if total > 0 {
|
||||
slog.Info("activitylog cleanup completed", "deleted", total, "retention_days", days)
|
||||
slog.InfoContext(ctx, "activitylog cleanup completed", "component", "activitylog", "deleted", total, "retention_days", days)
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func deleteExpiredRowsBefore(ctx context.Context, pool *pgxpool.Pool, cutoff tim
|
||||
)
|
||||
`, cutoff, cleanupBatchSize)
|
||||
if err != nil {
|
||||
slog.Warn("activitylog cleanup error", "error", err)
|
||||
slog.WarnContext(ctx, "activitylog cleanup error", "component", "activitylog", "error", err)
|
||||
return total
|
||||
}
|
||||
deleted := result.RowsAffected()
|
||||
|
||||
@@ -69,14 +69,14 @@ func (c *Consumer) drainRedis(ctx context.Context) {
|
||||
for {
|
||||
entries, err := c.popRedisBatch(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("activitylog: Redis pop error", "error", err)
|
||||
slog.WarnContext(ctx, "activitylog: Redis pop error", "component", "activitylog", "error", err)
|
||||
return
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
if err := c.insertBatchWithRetry(ctx, entries); err != nil {
|
||||
slog.Error("activitylog: batch insert failed after retries, dropping batch",
|
||||
slog.ErrorContext(ctx, "activitylog: batch insert failed after retries, dropping batch", "component", "activitylog",
|
||||
"error", err, "count", len(entries))
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func (c *Consumer) popRedisBatch(ctx context.Context) ([]LogEntry, error) {
|
||||
for _, raw := range result {
|
||||
var entry LogEntry
|
||||
if err := json.Unmarshal([]byte(raw), &entry); err != nil {
|
||||
slog.Warn("activitylog: skipping malformed entry", "error", err)
|
||||
slog.WarnContext(ctx, "activitylog: skipping malformed entry", "component", "activitylog", "error", err)
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
@@ -108,7 +108,7 @@ func (c *Consumer) insertBatchWithRetry(ctx context.Context, entries []LogEntry)
|
||||
for attempt := 0; attempt < c.maxRetries; attempt++ {
|
||||
if err := c.insertBatch(ctx, entries); err != nil {
|
||||
lastErr = err
|
||||
slog.Warn("activitylog: batch insert attempt failed",
|
||||
slog.WarnContext(ctx, "activitylog: batch insert attempt failed", "component", "activitylog",
|
||||
"attempt", attempt+1, "error", err)
|
||||
time.Sleep(time.Duration(attempt+1) * time.Second)
|
||||
continue
|
||||
@@ -131,7 +131,7 @@ func (c *Consumer) RunMemory(ctx context.Context, ch <-chan LogEntry) {
|
||||
case <-ctx.Done():
|
||||
if len(batch) > 0 {
|
||||
if err := c.insertBatch(context.Background(), batch); err != nil {
|
||||
slog.Warn("activity log batch insert failed", "entries", len(batch), "error", err)
|
||||
slog.WarnContext(ctx, "activity log batch insert failed", "component", "activitylog", "entries", len(batch), "error", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -139,7 +139,7 @@ func (c *Consumer) RunMemory(ctx context.Context, ch <-chan LogEntry) {
|
||||
if !ok {
|
||||
if len(batch) > 0 {
|
||||
if err := c.insertBatch(context.Background(), batch); err != nil {
|
||||
slog.Warn("activity log batch insert failed", "entries", len(batch), "error", err)
|
||||
slog.WarnContext(ctx, "activity log batch insert failed", "component", "activitylog", "entries", len(batch), "error", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -147,14 +147,14 @@ func (c *Consumer) RunMemory(ctx context.Context, ch <-chan LogEntry) {
|
||||
batch = append(batch, entry)
|
||||
if len(batch) >= c.batchSize {
|
||||
if err := c.insertBatch(ctx, batch); err != nil {
|
||||
slog.Warn("activity log batch insert failed", "entries", len(batch), "error", err)
|
||||
slog.WarnContext(ctx, "activity log batch insert failed", "component", "activitylog", "entries", len(batch), "error", err)
|
||||
}
|
||||
batch = batch[:0]
|
||||
}
|
||||
case <-ticker.C:
|
||||
if len(batch) > 0 {
|
||||
if err := c.insertBatch(ctx, batch); err != nil {
|
||||
slog.Warn("activity log batch insert failed", "entries", len(batch), "error", err)
|
||||
slog.WarnContext(ctx, "activity log batch insert failed", "component", "activitylog", "entries", len(batch), "error", err)
|
||||
}
|
||||
batch = batch[:0]
|
||||
}
|
||||
@@ -220,7 +220,7 @@ func (c *Consumer) insertBatch(ctx context.Context, entries []LogEntry) error {
|
||||
|
||||
for _, entry := range inserted {
|
||||
if err := c.streamHub.PublishAppend(ctx, logstream.StreamAudit, entry); err != nil {
|
||||
slog.Warn("activitylog: failed to publish log stream append", "error", err, "id", entry.ID)
|
||||
slog.WarnContext(ctx, "activitylog: failed to publish log stream append", "component", "activitylog", "error", err, "id", entry.ID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user