fix(924): media-server music videos read statistics from playback info #928

Merged
timothy merged 4 commits from 924-musicvideo-stream-stats into main 2026-10-22 08:24:20 +02:00
Owner

fixes #924

Root cause

Jellyfin's library-items projection builds every music video's MediaVersion with Streams = [] and no width/height; the server reports those only through per-item PlaybackInfo. MediaServerMovieLibraryScanner fetches them via GetMediaServerStatistics, but MediaServerMusicVideoLibraryScanner.UpdateStatistics (introduced in #496) wrote incoming.GetHeadVersion() straight through, which also removes any streams a stored row already had. HLS start then threw Sequence contains no matching element from FFmpegStreamSelector.SelectVideoStream.

Measured

Prod 2026-10-21, Jellyfin "Music Videos" library (id 16): 1520/1520 MusicVideo versions with zero MediaStream rows, Width/Height 0 (movies 0 affected, episodes 4).

Change

  • MediaServerMusicVideoLibraryScanner.UpdateStatistics mirrors the movie scanner (deepScan / added / etag changed / no stored streams -> ask the abstract GetMediaServerStatistics; write nothing when the server cannot answer).
  • JellyfinMusicVideoLibraryScanner implements it via GetPlaybackInfo; chapters come from the incoming projection.
  • FFmpegStreamSelector.SelectVideoStream fails with the version id, file and remediation instead of a bare First() throw.
  • Other-video and TV scanners checked: no such gap.
  • Record scan.mediaserver-statistics-from-server + regenerated catalog.

Tests

NUnit tests in JellyfinMusicVideoLibraryScannerTests and FFmpegStreamSelectorTests (clause sweep done). Local gate: ErsatzTV.Tests 2142 passed / 6 skipped (pre-existing), Core.Tests 719/1 skipped, Scanner.Tests 1504, FFmpeg.Tests 83, Architecture.Tests 7; 0 failures; no BOMs; dotnet format clean; doc-narrative 0 warnings.

Review

<<REVIEW HISTORY
round 1: 2 lens(es); 0 blocking, 0 should-fix, 5 nit; clean — loop ended
REVIEW HISTORY>>

No fix commit exists because round one was clean.

Cross-family review: not required (routine risk class under process.independent-review-rubric)

Deliberately left

  • Prod deep-scan of library 16 (channel operation): timothy/media-management#88.
  • etag recorded even when the statistics read fails (shared with movies/episodes): #927.

🤖 Generated with Claude Code

fixes #924 ## Root cause Jellyfin's library-items projection builds every music video's `MediaVersion` with `Streams = []` and no width/height; the server reports those only through per-item PlaybackInfo. `MediaServerMovieLibraryScanner` fetches them via `GetMediaServerStatistics`, but `MediaServerMusicVideoLibraryScanner.UpdateStatistics` (introduced in #496) wrote `incoming.GetHeadVersion()` straight through, which also removes any streams a stored row already had. HLS start then threw `Sequence contains no matching element` from `FFmpegStreamSelector.SelectVideoStream`. ## Measured Prod 2026-10-21, Jellyfin "Music Videos" library (id 16): 1520/1520 MusicVideo versions with zero `MediaStream` rows, Width/Height 0 (movies 0 affected, episodes 4). ## Change - `MediaServerMusicVideoLibraryScanner.UpdateStatistics` mirrors the movie scanner (deepScan / added / etag changed / no stored streams -> ask the abstract `GetMediaServerStatistics`; write nothing when the server cannot answer). - `JellyfinMusicVideoLibraryScanner` implements it via `GetPlaybackInfo`; chapters come from the incoming projection. - `FFmpegStreamSelector.SelectVideoStream` fails with the version id, file and remediation instead of a bare `First()` throw. - Other-video and TV scanners checked: no such gap. - Record `scan.mediaserver-statistics-from-server` + regenerated catalog. ## Tests NUnit tests in `JellyfinMusicVideoLibraryScannerTests` and `FFmpegStreamSelectorTests` (clause sweep done). Local gate: ErsatzTV.Tests 2142 passed / 6 skipped (pre-existing), Core.Tests 719/1 skipped, Scanner.Tests 1504, FFmpeg.Tests 83, Architecture.Tests 7; 0 failures; no BOMs; dotnet format clean; doc-narrative 0 warnings. ## Review <<REVIEW HISTORY round 1: 2 lens(es); 0 blocking, 0 should-fix, 5 nit; clean — loop ended REVIEW HISTORY>> No fix commit exists because round one was clean. Cross-family review: not required (routine risk class under process.independent-review-rubric) ## Deliberately left - Prod deep-scan of library 16 (channel operation): timothy/media-management#88. - etag recorded even when the statistics read fails (shared with movies/episodes): #927. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 3 commits 2026-10-21 23:19:09 +02:00
Jellyfin's library-items projection builds every music video's version with
Streams = [] and no width/height; the server reports those only through
per-item PlaybackInfo. MediaServerMovieLibraryScanner fetches them via
GetMediaServerStatistics, but MediaServerMusicVideoLibraryScanner (#496)
wrote incoming.GetHeadVersion() straight into UpdateStatistics. On prod
(2026-10-21) all 1520 music videos in library 8 had zero MediaStream rows,
and HLS start on channels 402/403 threw "Sequence contains no matching
element" from FFmpegStreamSelector.SelectVideoStream.

- MediaServerMusicVideoLibraryScanner.UpdateStatistics now mirrors the movie
  scanner: under deepScan / added / etag changed / no stored streams it asks
  the abstract GetMediaServerStatistics, and writes nothing when the server
  cannot answer. Writing the projection also removed any streams a row
  already had, since UpdateStatistics reconciles against the given version.
  Abstract, not virtual-None: a None default is this bug.
- JellyfinMusicVideoLibraryScanner implements it with GetPlaybackInfo and
  takes chapters from the incoming projection (GetByItemId does not load
  chapters, so the movie scanner's result.Item source would have none).
- FFmpegStreamSelector.SelectVideoStream fails with an error naming the
  version id and file and the remediation (rescan; deep scan for a
  media-server library) instead of First()'s bare throw. File de-BOMed and
  whitespace-normalized as touched.

Other-video (Plex only, ServerReturnsStatisticsWithMetadata) and TV
(Jellyfin/Emby override GetMediaServerStatistics, Plex returns statistics
with metadata) have no such gap.

The rows already stored without streams heal only on a deep scan of the
library (a regular scan skips unchanged etags) — a channel operation owned
by media-management.

Refs #924

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: 99d1dedb-f027-4e05-baab-2111f3a7d951
A clause sweep showed `deepScan ||` survived (the deep-scan test only ran
against an empty stream list, which the streams arm covers) and that
`result.IsAdded ||` is implied by the empty-streams arm while the
projection carries no streams. The deep-scan test now re-reads statistics
for an item that already has them; a returning-Unavailable test covers the
streams arm alone. IsAdded is kept for parity with the movie scanner and
says so.

Refs #924

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: 99d1dedb-f027-4e05-baab-2111f3a7d951
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
9d3989140f
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
timothy added 1 commit 2026-10-21 23:26:28 +02:00
fix(924): the no-video-stream error allows for a file that really has none, and the record names library 16
review-verdict/h10 Review-verdict: MERGEABLE @ 304040d (base: main)
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 16s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 10s
Review verdict / Set review-verdict status (pull_request_target) Successful in 7s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m44s
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m6s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 7m19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 40s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m5s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
304040de4d
Review round 1 nits:
- SelectVideoStream's message stated "its media statistics have not been
  read" as fact. A file that genuinely has no video stream (an audio-only
  local file, or a Jellyfin item whose playback info has no video, where
  ProjectToMediaVersion returns None) would send an operator round a
  deep-scan loop that can never succeed. The message now covers both cases.
- scan.mediaserver-statistics-from-server said "library 8". Prod's Jellyfin
  "Music Videos" library is id 16; no Library row has Id 8 (measured by
  media-management, 2026-10-21).
- The ersatztv skill's symptom note named "images before the #924 fix"
  instead of the release that carries it (v26.16.0).

Refs #924
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: 99d1dedb-f027-4e05-baab-2111f3a7d951
Author
Owner

Review-verdict: MERGEABLE @ 304040d

Round 1: two cold lenses (conformance, correctness) at 9d3989140, verdict merge, 0 blocking, 0 should-fix, 5 nits. Fix commit 304040de4 (message wording, library id 16, skill note) re-reviewed cold in an isolated worktree: merge, 0 blocking, 0 should-fix. Deep-scan path on unchanged-etag rows traced to GetMediaServerStatistics. Tests: FFmpegStreamSelector 6/6, JellyfinMusicVideoLibraryScanner 23/23.

Review-verdict: MERGEABLE @ 304040d Round 1: two cold lenses (conformance, correctness) at 9d3989140, verdict merge, 0 blocking, 0 should-fix, 5 nits. Fix commit 304040de4 (message wording, library id 16, skill note) re-reviewed cold in an isolated worktree: merge, 0 blocking, 0 should-fix. Deep-scan path on unchanged-etag rows traced to GetMediaServerStatistics. Tests: FFmpegStreamSelector 6/6, JellyfinMusicVideoLibraryScanner 23/23.
timothy merged commit 1f7b763a7b into main 2026-10-22 08:24:20 +02:00
timothy deleted branch 924-musicvideo-stream-stats 2026-10-22 08:24:27 +02:00
Sign in to join this conversation.