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.
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)
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
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
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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
fixes #924
Root cause
Jellyfin's library-items projection builds every music video's
MediaVersionwithStreams = []and no width/height; the server reports those only through per-item PlaybackInfo.MediaServerMovieLibraryScannerfetches them viaGetMediaServerStatistics, butMediaServerMusicVideoLibraryScanner.UpdateStatistics(introduced in #496) wroteincoming.GetHeadVersion()straight through, which also removes any streams a stored row already had. HLS start then threwSequence contains no matching elementfromFFmpegStreamSelector.SelectVideoStream.Measured
Prod 2026-10-21, Jellyfin "Music Videos" library (id 16): 1520/1520 MusicVideo versions with zero
MediaStreamrows, Width/Height 0 (movies 0 affected, episodes 4).Change
MediaServerMusicVideoLibraryScanner.UpdateStatisticsmirrors the movie scanner (deepScan / added / etag changed / no stored streams -> ask the abstractGetMediaServerStatistics; write nothing when the server cannot answer).JellyfinMusicVideoLibraryScannerimplements it viaGetPlaybackInfo; chapters come from the incoming projection.FFmpegStreamSelector.SelectVideoStreamfails with the version id, file and remediation instead of a bareFirst()throw.scan.mediaserver-statistics-from-server+ regenerated catalog.Tests
NUnit tests in
JellyfinMusicVideoLibraryScannerTestsandFFmpegStreamSelectorTests(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
🤖 Generated with Claude Code
Review-verdict: MERGEABLE @
304040dRound 1: two cold lenses (conformance, correctness) at
9d3989140, verdict merge, 0 blocking, 0 should-fix, 5 nits. Fix commit304040de4(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.