docs(924): record that media-server statistics come from the server, and how stream-less rows heal
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 13s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Failing after 1s
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Failing after 49s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Failing after 1s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 49s
PR Gates / Docs update reminder (pull_request) Successful in 53s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Failing after 1s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 1m1s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 4s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
review-verdict/h10 Awaiting review verdict for 9d39891
Review verdict / Set review-verdict status (pull_request_target) Successful in 23s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Canceled after 5m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 13s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Failing after 1s
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Failing after 49s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Failing after 1s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 49s
PR Gates / Docs update reminder (pull_request) Successful in 53s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Failing after 1s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 1m1s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 4s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
review-verdict/h10 Awaiting review verdict for 9d39891
Review verdict / Set review-verdict status (pull_request_target) Successful in 23s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Canceled after 5m54s
New record scan.mediaserver-statistics-from-server (catalog regenerated): the projection's empty-stream version is never written as statistics, the music-video hook is abstract, chapters come from the projection, and rows already stored without streams heal only on a deep scan. The ersatztv skill gains the troubleshooting entry for "has no video stream on record" and the ?deep=true flag on the library-scan endpoint, since the remediation is run from media-management. Refs #924 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: 99d1dedb-f027-4e05-baab-2111f3a7d951
This commit is contained in:
@@ -176,7 +176,7 @@ docker compose -f $D/compose.yaml up -d --no-deps ersatztv-test
|
||||
|
||||
### Mutation Endpoints (POST)
|
||||
```bash
|
||||
# Library scan
|
||||
# Library scan (?deep=true also rescans items a media server reports unchanged)
|
||||
POST /api/v1/libraries/{id}/scan
|
||||
|
||||
# Scan single show
|
||||
@@ -472,6 +472,11 @@ docker start ersatztv
|
||||
### Common Mistakes (check every time)
|
||||
- **Playout not building**: Three things must all be correct: (1) `ProgramScheduleOneItem` row exists for the schedule item, (2) `PlaybackOrder=3` (Shuffle), (3) `ScheduleKind=1` on Playout. Missing any one results in 0 playout items — this is the most common issue.
|
||||
- **Collection queries fail**: `CollectionItem` has no `Id` column — use `rowid` for counting. Content lookup goes through `MediaVersion.MovieId` → `MediaFile.Path` (not a generic MediaItemId join).
|
||||
- **Channel won't start — "has no video stream on record"** (images before the ersatztv#924 fix log a bare
|
||||
`Sequence contains no matching element` at `FFmpegStreamSelector.SelectVideoStream`): the playing item's
|
||||
`MediaVersion` has no `MediaStream` rows. Deep-scan its library — `POST /api/v1/libraries/{id}/scan?deep=true`;
|
||||
a regular scan skips items whose server etag is unchanged and cannot heal them. Jellyfin music videos scanned
|
||||
before that fix were all stored without streams (`scan.mediaserver-statistics-from-server`).
|
||||
- **Channel logos forgotten**: After creating a channel, add an Artwork row (ArtworkKind=2) + logo file, then run `fix_logos.py` to push to Jellyfin. Without this, the channel shows no logo in the EPG.
|
||||
- **Playout reset required**: After any schedule/collection change, run `POST /api/v1/channels/{id}/playout/reset`. Wait 5-10s for the playout to build before verifying item count.
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `scan.getoraddfolder-db-lookup` | `ILibraryRepository.GetOrAddFolder` resolves the existing folder via a DB query on `(LibraryPathId, Path)`, not the caller's `LibraryPath.LibraryFolders` in-memory navigation, since that navigation is only eager-loaded on the local scan path and is null on remote (Jellyfin) callers. | 2026-07-20 | [link](records/scan/getoraddfolder-db-lookup.md) |
|
||||
| `scan.jellyfin-mixed-content-library` | A Jellyfin library whose collection type is `mixed` (or absent) maps to one ErsatzTV library of `LibraryMediaKind.Mixed`, scanned by running the movie/television/music-video scanners in sequence against that single library — a library is a place, not a media kind. | 2026-07-20 | [link](records/scan/jellyfin-mixed-content-library.md) |
|
||||
| `scan.libraryfolder-unique-identity` | `LibraryFolder` uniqueness per `(LibraryPathId, Path)` is enforced by a database unique index over a SHA-256 `PathHash` (Path is unbounded and not portably indexable), and `LibraryRepository.GetOrAddFolder`/`SetEtag` tolerate the constraint violation by re-reading and adopting the winner's row. | 2026-07-25 | [link](records/scan/libraryfolder-unique-identity.md) |
|
||||
| `scan.mediaserver-statistics-from-server` | Every media-server scanner writes a head `MediaVersion`'s statistics (streams, width/height, aspect, frame rate) from what the server reports per item — `GetMediaServerStatistics` (Jellyfin/Emby playback info) or the metadata call that carries them (`ServerReturnsStatisticsWithMetadata`, Plex) — and NEVER writes the library-items projection's version, whose `Streams` list is empty. When the server cannot supply them, nothing is written. In `MediaServerMusicVideoLibraryScanner` the hook is `abstract`, not a virtual returning `None`. | 2026-10-21 | [link](records/scan/mediaserver-statistics-from-server.md) |
|
||||
| `scan.musicvideo-server-identity` | Jellyfin music videos carry a per-library server identity (`JellyfinMusicVideo : MusicVideo` with `ItemId`/`Etag`, TPT table + ItemId index), so `JellyfinMusicVideoLibraryScanner` folds onto a shared `MediaServerMusicVideoLibraryScanner` base that diffs the **server item id** and soft-trashes (`FlagFileNotFound`) instead of diffing local paths and hard-deleting. Rows predating the identity are **adopted in place** — the identity row is inserted against the same `MediaItem` id, scoped to the scanned library's own `LibraryPath` — never deleted and re-added. | 2026-07-25 | [link](records/scan/musicvideo-server-identity.md) |
|
||||
| `scan.projection-failure-sweep-guard` | `MediaServerReconciliationGuard` takes a per-enumeration projection-failure count and refuses the file-not-found sweep (logged loudly) whenever it is non-zero against a non-empty existing set — at all six sweeps, including the nested per-show season and per-season episode ones #477 left unguarded (via `ShouldFlagMissingDescendants`, which applies the failure refusal but not #477's empty-fetch branch). Deliberate guard-clause skips (STRM, virtual, unsupported type) are explicitly **not** failures and never suppress a sweep. The missing-fraction / ratio threshold floated by #477 is **rejected**, not deferred. | 2026-07-25 | [link](records/scan/projection-failure-sweep-guard.md) |
|
||||
| `scan.zero-item-fetch-guard` | A media-server library sweep refuses to flag missing items when a successful fetch returns zero incoming items against a non-empty existing set (`MediaServerReconciliationGuard.ShouldFlagMissing`), rather than treating an ambiguous empty result as a full-library deletion. | 2026-07-19 | [link](records/scan/zero-item-fetch-guard.md) |
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
key: scan.mediaserver-statistics-from-server
|
||||
title: 2026-10-21 — A media-server scanner reads a version's statistics from the server, never from the library-items projection (#924)
|
||||
status: active
|
||||
since: '2026-10-21'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'Every media-server scanner writes a head `MediaVersion`''s statistics (streams, width/height, aspect, frame rate) from what the server reports per item — `GetMediaServerStatistics` (Jellyfin/Emby playback info) or the metadata call that carries them (`ServerReturnsStatisticsWithMetadata`, Plex) — and NEVER writes the library-items projection''s version, whose `Streams` list is empty. When the server cannot supply them, nothing is written. In `MediaServerMusicVideoLibraryScanner` the hook is `abstract`, not a virtual returning `None`.'
|
||||
signals: 'zero MediaStream rows, Width/Height 0, Sequence contains no matching element, SelectVideoStream, playback info, GetPlaybackInfo, projection Streams = [], deep scan to heal, music videos will not play · paths: `MediaServerMusicVideoLibraryScanner.UpdateStatistics`, `JellyfinMusicVideoLibraryScanner.GetMediaServerStatistics`, `MediaServerMovieLibraryScanner.UpdateStatistics`, `IMetadataRepository.UpdateStatistics`, `FFmpegStreamSelector.SelectVideoStream` · issues: #924, #496'
|
||||
mechanics: '`JellyfinMusicVideoLibraryScannerTests` (`ScanLibrary_Should_Store_PlaybackInfo_Statistics_For_A_New_MusicVideo`, `..._Deep_Scan_Should_Read_Statistics_For_An_Unchanged_MusicVideo_Without_Streams`, `..._Should_Read_Statistics_For_A_Returning_Unavailable_MusicVideo_Without_Streams`, `..._Should_Keep_Stored_Statistics_When_PlaybackInfo_Fails_On_Rescan`, `..._Should_Refresh_Statistics_And_Projection_Chapters_When_The_Etag_Changes`, against the real `MetadataRepository`); `FFmpegStreamSelectorTests.SelectVideoStream`'
|
||||
---
|
||||
|
||||
The Jellyfin and Emby library-items projections build every movie, episode and music-video version with
|
||||
`Streams = []` and no dimensions; only the per-item playback info carries them. The movie and TV scanners fetch
|
||||
statistics when the item is new, changed, deep-scanned or has no stored streams. The music-video scanner wrote
|
||||
the projection's version instead, so every Jellyfin music video was stored without streams and
|
||||
`FFmpegStreamSelector.SelectVideoStream` threw on play. Measured on prod 2026-10-21 (#924): library 8, 1520 of
|
||||
1520 music-video versions with zero `MediaStream` rows (movies 0, episodes 4).
|
||||
|
||||
- **Writing the projection is worse than writing nothing.** `IMetadataRepository.UpdateStatistics` reconciles
|
||||
the stored streams against the version it is handed, so an empty stream list REMOVES every stream an existing
|
||||
row has. A failed statistics read therefore leaves the row untouched, matching the movie scanner.
|
||||
- **The refresh condition is the movie scanner's**: `deepScan || IsAdded || etag changed || no stored streams`.
|
||||
For music videos `IsAdded` is implied by the empty-streams arm while the projection carries no streams; it is
|
||||
kept so the two conditions read the same.
|
||||
- **`abstract`, not virtual-`None`.** The movie base defaults `GetMediaServerStatistics` to `None`; Jellyfin and
|
||||
Emby override it, and Plex takes statistics from its metadata call (`ServerReturnsStatisticsWithMetadata`).
|
||||
Music videos have one implementation, and a `None` default is the defect itself, so a future Emby/Plex
|
||||
music-video scanner must answer the question to compile.
|
||||
- **Chapters come from the incoming projection**, not from the stored item as in
|
||||
`JellyfinMovieLibraryScanner`: `JellyfinMusicVideoRepository.GetByItemId` does not eager-load chapters, so the
|
||||
stored item has none to write.
|
||||
- **Healing stored rows takes a deep scan.** A regular scan skips an item whose etag is unchanged before the
|
||||
refresh condition is reached, so rows already stored without streams stay that way until their library is
|
||||
deep-scanned or the server changes the item. On a live instance that scan is a channel operation
|
||||
(media-management), not part of shipping the fix.
|
||||
- **The player names the gap.** `SelectVideoStream` on a version with no video stream fails with the version id,
|
||||
its file and the remediation (rescan; deep scan for a media-server library) instead of `First()`'s bare
|
||||
`Sequence contains no matching element`.
|
||||
- **Known residual, shared with movies:** when the server cannot answer, the item's etag is still recorded, so a
|
||||
transient playback-info failure on a new or changed item leaves it without streams until the next deep scan or
|
||||
server-side change. Not changed here: skipping the etag write would retry the item on every scan, and the same
|
||||
trade-off already holds for movies and episodes.
|
||||
Reference in New Issue
Block a user