Files
memby/server/internal/credits/README.md
T

172 lines
10 KiB
Markdown
Raw Normal View History

2026-08-15 09:23:26 +12:00
# Credits detection
Where an episode's closing credits begin, discovered for the small number of episodes a
household is actually about to watch.
This is not a library scanner and the distinction is the whole design. A scanner asks "what
is in the library" and answers by reading all of it. This asks "what will somebody press Play
on over the next few evenings", which Tracearr already knows, and reads almost nothing:
```
2026-08-16 12:13:51 +12:00
Tracearr demand → priority queue → marker/cooldown check → tiny tail scan → history + marker
2026-08-15 09:23:26 +12:00
```
## Why it exists
Emby's `CreditsStart` marker is in its `MarkerType` enumeration and Emby 4.10 does not write
it. A survey of this household's 20,000-item library found `Chapter`, `IntroStart` and
`IntroEnd` and **no `CreditsStart` at all**. The existing feature's coverage comes entirely
from chapters *named* like credits — about 5% of items. This fills in the rest.
Emby still wins wherever it has an answer. `api/intro.go` reads its chapters first and only
falls through to a discovered marker when there is nothing to defer to.
## What was reused rather than built
The investigation that preceded this found more already in place than expected, and none of
it was duplicated:
| Needed | Already there |
|---|---|
| Tracearr ingestion | `internal/tracearr` + the `tracearr_sessions` table, imported by the For You pipeline |
| Emby item ids for sessions | `emby_item_id` / `emby_series_id`, backfilled by `UpdateTracearrSessionMapping` |
| Stop positions and next-episode transitions | `progress_ms`, `total_duration_ms`, `started_at`/`stopped_at` on the same table |
| Episode numbering | `library_items`, via `ParentIndexNumber`/`IndexNumber` in the payload |
| Delivery to Android TV | `introResponse.creditsAvailable` / `creditsStartMs`**no client changes at all** |
| Background scheduling | `internal/scheduler`, so the interval is the operator's and the last run is in the console |
| Live playback signal | the playback reports televisions already send |
Two database reads produce the entire candidate queue for the whole household, whatever it
is watching. Nothing here is per-candidate and nothing polls Tracearr directly.
## The files
```
service.go the worker, the cache check, and when scanning is allowed
queue.go bounded in-RAM priority queue; nothing about it is persisted
candidates.go velocity, look-ahead, decay, multi-user merging — all pure
tracearr.go demand → candidates, and the in-memory episode index
behaviour.go stop clustering; can write a marker with no media access at all
confidence.go signal combination and the rule that stops repeated rewrites
window.go where to scan, and how season history narrows it
detector.go the two-stage sparse visual scan and its changepoint
sampler.go ffmpeg, used surgically
fingerprint.go media version identity, so a replaced file invalidates itself
resolver.go one Emby request per candidate, cached
postgres.go the store adapter
load.go whether the server is too busy for speculative work
```
2026-08-16 12:13:51 +12:00
## Operator controls and history
Admin Console → Credits detection controls three different bounds: the number of candidates
allowed to wait, the ordinary and maximum episode look-ahead, and the retry delay after an
inconclusive or failed speculative scan. The environment values are first-run defaults; a
saved console choice is restored on restart.
The retry delay is important. A successful marker excludes an episode naturally, but an old
worker forgot a no-match result and selected the episode again at the next ten-minute
refresh. Completed attempts now have a small durable history and no-match/failed episodes
cool down before prediction may select them again. Live playback may still raise an episode
immediately because a viewer is waiting.
2026-08-15 09:23:26 +12:00
## Two detectors, and the cheap one is often better
**Behavioural** clustering costs nothing: no file is opened, no decoder runs, no new row is
written anywhere. Where three viewers independently stopped an episode within seconds of each
other, near the end but not at it, that agreement is evidence no decoder can produce. Two
viewers is enough only when both rolled into the next episode, which is unambiguous about why
they left.
**Visual** scanning finds a sustained structural transition — dark, flat, textured with thin
2026-08-16 21:20:55 +12:00
text, and staying that way for about half a minute. Darkness is *multiplied* rather than added
into the frame score, which is the one modelling decision worth defending: under a weighted
sum a night exterior reaches the credit-like floor on darkness and flatness alone, which is
exactly how a final scene comes to be reported as a credits roll. The candidate frame itself
must also be genuinely dark, and at least 65% of the remaining frames must stay credit-like;
those two guards stop a dim final scene or a temporary title card becoming an early marker.
Unit tests pin both cases.
2026-08-15 09:23:26 +12:00
The two fail in unrelated ways, so agreement between them is worth far more than either
alone — hence a probabilistic union rather than an average. Disagreement beyond 20 seconds
costs a 0.3 penalty, which usually means storing nothing. **Prefer no marker to a wrong
marker** is the governing rule throughout: a missing Skip Credits button is an absence nobody
notices, a button during the final scene is a fault they remember.
## Where the bytes come from
The gateway has no filesystem access to the media, so a scan reads Emby's own stream route
over HTTP and `-ss` ahead of `-i` is what makes that a ranged request rather than a download.
That optimisation is only worth anything if the range survives the trip, which is why the
address is configurable separately from every other way the gateway reaches Emby.
`MEMBY_EMBY_MEDIA_URL` is the address media bytes are read from; unset it falls back to
`MEMBY_EMBY_URL` and nothing changes. The distinction is not cosmetic in this deployment: the
stack runs on the NAS, Emby and the media live on the HTPC, and `MEMBY_EMBY_URL` is a public
DDNS name. Left to fall back, every ranged read leaves the host for the internet-facing edge,
pays TLS and returns through a reverse proxy — and a proxy that buffers its upstream turns
the ranged read into a whole-file read, which is the single thing this package is built not to
do. Naming the LAN address instead keeps the read on the wire between the two machines.
It is **stated rather than inferred** for the usual reason: there is no way to look at a URL
and tell whether it happens to resolve locally, and guessing wrong either sends scans the long
way round or points them at a host that is not there. `media_url` is on the `credits detection
enabled` start-up line so an operator can read back which path is in force, and the benchmark
below sets it too — measuring the public route and deploying the internal one would report a
number belonging to neither.
2026-08-15 09:23:26 +12:00
## Cost, and what is still unmeasured
**Derived arithmetic** (checkable without hardware):
| | |
|---|---|
| Generic tail window | `clamp(runtime × 0.20, 5min, 12min)` — 8m50s on a 44-minute episode, **20% of the file** |
| Season-narrowed window | ±90s around the expected position — **3 minutes, 6.8% of the file** |
| Coarse pass | one 160×90 grey frame every 4s — ~45 frames over a narrowed window |
| Fine pass | 750ms over ±30s — ~80 frames |
| Frame buffer | 14,400 bytes, allocated once and reused for the whole pass |
| Writes, cached marker | **0** |
2026-08-16 12:13:51 +12:00
| Writes, completed scan | **1 history row**, plus **1 marker** only when accepted |
2026-08-15 09:23:26 +12:00
Against scanning the full library: 20,000 items read end to end versus a queue capped at 20
candidates, most of which are rejected by the marker check before any media is touched. Once
a household settles, the steady state is one indexed read per candidate and nothing else.
**Not yet measured, and it needs the NAS.** Bytes actually read, wall-clock analysis time,
CPU and peak RSS all depend on the container, the network path to Emby and the media itself,
and no figure taken anywhere else would mean anything. The tool is built and ships in the
image:
```
docker compose exec server /app/memby-credits benchmark <emby-item-id>
```
It prints the runtime, the scan region **and its provenance**, an estimated byte count,
frames sampled, allocation, elapsed time, the detection and what would be stored. The
provenance line is the one to read: a run reporting `generic-tail-window` every time is a run
in which demand-driven narrowing is doing nothing, and the design would need revisiting.
## Things to preserve
- **The queue is deliberately not durable.** Candidate priorities are rebuilt from one
Tracearr query on restart, which is cheaper and simpler than a second persistent job
2026-08-16 12:13:51 +12:00
scheduler. Completed scan history is durable because it is an operator record and the
source of retry cooldowns; it does not preserve or resume queue state.
2026-08-15 09:23:26 +12:00
- **A single worker, and it is not a placeholder for a pool.** Concurrent scans multiply the
two costs this exists to minimise on a machine whose real job is streaming video.
- **Live playback does not scan immediately.** `livePlaybackDelay` (45s) is what stops a
curious button press becoming disk activity; `AbandonPlayback` withdraws it.
- **The marker is keyed on a media fingerprint**, so a file Sonarr replaces stops matching
with nothing having to notice the swap. A fingerprint too weak to detect a replacement
(runtime only) causes the marker to be *withheld* rather than stored un-invalidatable.
- **Season history needs two markers, not one.** One is an anecdote and may itself be the
mis-detection; narrowing a scan onto it is how one wrong marker propagates through a season.
- **`ShouldRewrite` is the only thing between "one write per episode, ever" and a row updated
on every playback.** Readings wobble by seconds; a ±12s difference is not news.
- **The detector never learns why an episode was chosen.** That boundary is what stops it
being tuned to agree with the predictor rather than with the media.
2026-08-16 21:20:55 +12:00
- **An `ffmpeg` decoder crash gets one conservative retry.** The retry is single-threaded and
discards corrupt packets; ordinary network, authentication and timeout failures are not
retried by the sampler.