Files
ersatztv/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMusicVideoLibraryScanner.cs
T
timothyandClaude Opus 5.5 1f7b763a7b
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 8m19s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Skipped
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 45s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m22s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m30s
fix(924): media-server music videos read their statistics from the server's playback info (#928)
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. MediaServerMusicVideoLibraryScanner.UpdateStatistics wrote that empty projection straight through (also removing any streams a stored row already had), so every Jellyfin music video was stored without streams and FFmpegStreamSelector.SelectVideoStream threw "Sequence contains no matching element" on play. Prod 2026-10-21: the Jellyfin "Music Videos" library (id 16), 1520/1520 versions with zero MediaStream rows.

- UpdateStatistics mirrors the movie scanner: on deep scan, new item, changed etag or no stored streams it asks the abstract GetMediaServerStatistics, and writes nothing when the server cannot answer.
- JellyfinMusicVideoLibraryScanner implements it via GetPlaybackInfo; chapters come from the incoming projection.
- SelectVideoStream fails with the version id, file and remediation (deep scan, or remove a file that has no video stream) instead of a bare First() throw.
- Other-video and TV scanners checked: no such gap.
- Record scan.mediaserver-statistics-from-server.

Existing rows heal only on a DEEP scan (a regular scan skips unchanged etags): POST /api/v1/libraries/16/scan?deep=true after deploy.

fixes #924

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: 99d1dedb-f027-4e05-baab-2111f3a7d951
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-10-22 06:24:18 +00:00

282 lines
12 KiB
C#

using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces;
using ErsatzTV.Scanner.Core.Metadata;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Scanner.Core.Jellyfin;
public class JellyfinMusicVideoLibraryScanner :
MediaServerMusicVideoLibraryScanner<JellyfinConnectionParameters, JellyfinLibrary, JellyfinMusicVideo,
JellyfinItemEtag>,
IJellyfinMusicVideoLibraryScanner
{
private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IJellyfinMusicVideoRepository _jellyfinMusicVideoRepository;
private readonly ILogger<JellyfinMusicVideoLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMetadataRepository _metadataRepository;
private readonly IMusicVideoRepository _musicVideoRepository;
private readonly IJellyfinPathReplacementService _pathReplacementService;
public JellyfinMusicVideoLibraryScanner(
IScannerProxy scannerProxy,
IJellyfinApiClient jellyfinApiClient,
IJellyfinMusicVideoRepository jellyfinMusicVideoRepository,
IJellyfinPathReplacementService pathReplacementService,
IMediaSourceRepository mediaSourceRepository,
IArtistRepository artistRepository,
IMusicVideoRepository musicVideoRepository,
ILibraryRepository libraryRepository,
IMetadataRepository metadataRepository,
IFileSystem fileSystem,
ILogger<JellyfinMusicVideoLibraryScanner> logger)
: base(
scannerProxy,
fileSystem,
artistRepository,
libraryRepository,
metadataRepository,
logger)
{
_jellyfinApiClient = jellyfinApiClient;
_jellyfinMusicVideoRepository = jellyfinMusicVideoRepository;
_pathReplacementService = pathReplacementService;
_mediaSourceRepository = mediaSourceRepository;
_musicVideoRepository = musicVideoRepository;
_metadataRepository = metadataRepository;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanLibrary(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
bool deepScan,
CancellationToken cancellationToken)
{
Option<LibraryPath> maybeLibraryPath = Optional(library.Paths).Flatten().HeadOrNone();
foreach (LibraryPath libraryPath in maybeLibraryPath)
{
List<JellyfinPathReplacement> pathReplacements =
await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId);
string GetLocalPath(JellyfinMusicVideo musicVideo) =>
_pathReplacementService.GetReplacementJellyfinPath(
pathReplacements,
musicVideo.GetHeadVersion().MediaFiles.Head().Path,
false);
return await ScanLibrary(
_jellyfinMusicVideoRepository,
connectionParameters,
library,
libraryPath,
GetLocalPath,
deepScan,
cancellationToken);
}
return BaseError.New($"Jellyfin library {library.Id} has no library path");
}
protected override string MediaServerItemId(JellyfinMusicVideo musicVideo) => musicVideo.ItemId;
protected override string MediaServerEtag(JellyfinMusicVideo musicVideo) => musicVideo.Etag;
protected override IAsyncEnumerable<Tuple<JellyfinMusicVideo, int>> GetMusicVideoLibraryItems(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
MediaServerProjectionFailureCounter projectionFailures) =>
_jellyfinApiClient.GetMusicVideoLibraryItems(
connectionParameters.Address,
connectionParameters.AuthorizationHeader,
library,
projectionFailures);
protected override async Task<Option<MediaVersion>> GetMediaServerStatistics(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
MediaItemScanResult<JellyfinMusicVideo> result,
JellyfinMusicVideo incoming)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Jellyfin Statistics", result.LocalPath);
Either<BaseError, MediaVersion> maybeVersion =
await _jellyfinApiClient.GetPlaybackInfo(
connectionParameters.Address,
connectionParameters.AuthorizationHeader,
library,
incoming.ItemId);
foreach (BaseError error in maybeVersion.LeftToSeq())
{
_logger.LogWarning(
"Failed to get music video statistics from Jellyfin for {Path}: {Error}",
result.LocalPath,
error.ToString());
}
// Chapters arrive with the library-items projection, not with playback info. They are taken from the
// INCOMING projection, not from result.Item as JellyfinMovieLibraryScanner does: the music-video
// repository's GetByItemId does not eager-load chapters, so on an existing row result.Item's head version
// carries none to write.
foreach (MediaVersion version in maybeVersion.RightToSeq())
{
version.Chapters = incoming.GetHeadVersion().Chapters ?? [];
}
return maybeVersion.ToOption();
}
protected override async Task<Either<BaseError, MediaItemScanResult<JellyfinMusicVideo>>> UpdateMetadata(
MediaItemScanResult<JellyfinMusicVideo> result,
JellyfinMusicVideo incoming,
CancellationToken cancellationToken)
{
if (await UpdateMetadata(result.Item, incoming.MusicVideoMetadata.Head()))
{
result.IsUpdated = true;
}
return result;
}
private async Task<bool> UpdateMetadata(MusicVideo musicVideo, MusicVideoMetadata incoming)
{
Option<MusicVideoMetadata> maybeExisting = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone();
foreach (MusicVideoMetadata existing in maybeExisting)
{
existing.Title = incoming.Title;
existing.SortTitle = incoming.SortTitle;
existing.Plot = incoming.Plot;
existing.Year = incoming.Year;
// ersatztv#177: Album/Track are scalars, so they must be copied here too — otherwise the
// projection fix only ever reaches music videos ADDED after it, and an existing item whose
// album/track is set (or corrected) in Jellyfin never picks it up. Same shape as the #497
// collection bug below, one layer up.
existing.Album = incoming.Album;
existing.Track = incoming.Track;
existing.ReleaseDate = incoming.ReleaseDate;
existing.DateUpdated = DateTime.UtcNow;
existing.MetadataKind = MetadataKind.External;
bool updated = await _metadataRepository.Update(existing);
// ersatztv#497: the scalar Update above marks only the metadata row Modified; it does NOT touch
// child collections, and the repository's GetOrAdd loads them AsNoTracking — so tag/genre/studio/
// artist edits made in Jellyfin never reached an EXISTING music video (only the Add path persisted
// them). Reconcile the collections that BOTH the Add path persists AND GetOrAdd eager-loads:
// Genres, Tags, Studios, Artists. (Guids are add-persisted but not eager-loaded here — reconciling
// them would see an empty `existing` and duplicate-insert every scan; Directors are eager-loaded
// but not add-persisted for music videos — both are deliberately out of scope.) Mirrors the
// remove-stale + add-new idiom PlexMovieLibraryScanner.UpdateMetadata uses.
updated = await ReconcileGenres(existing, incoming) || updated;
updated = await ReconcileTags(existing, incoming) || updated;
updated = await ReconcileStudios(existing, incoming) || updated;
updated = await ReconcileArtists(existing, incoming) || updated;
return updated;
}
incoming.MusicVideoId = musicVideo.Id;
musicVideo.MusicVideoMetadata = [incoming];
return await _metadataRepository.Add(incoming);
}
private async Task<bool> ReconcileGenres(MusicVideoMetadata existing, MusicVideoMetadata incoming)
{
existing.Genres ??= [];
// ersatztv#500: dedup on Name — the add filter below is materialized (.ToList()) BEFORE the loop
// mutates existing.Genres, so two identically-named incoming entries would both pass and both insert.
// Deduping here (not just in the add loop) is safe: the remove filter only asks "is this name present".
List<Genre> incomingGenres = (incoming.Genres ?? []).DistinctBy(g => g.Name).ToList();
var updated = false;
foreach (Genre genre in existing.Genres.Filter(g => incomingGenres.All(g2 => g2.Name != g.Name)).ToList())
{
existing.Genres.Remove(genre);
updated = await _metadataRepository.RemoveGenre(genre) || updated;
}
foreach (Genre genre in incomingGenres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name)).ToList())
{
existing.Genres.Add(genre);
updated = await _musicVideoRepository.AddGenre(existing, genre) || updated;
}
return updated;
}
private async Task<bool> ReconcileTags(MusicVideoMetadata existing, MusicVideoMetadata incoming)
{
existing.Tags ??= [];
List<Tag> incomingTags = (incoming.Tags ?? []).DistinctBy(t => t.Name).ToList();
var updated = false;
foreach (Tag tag in existing.Tags.Filter(t => incomingTags.All(t2 => t2.Name != t.Name)).ToList())
{
existing.Tags.Remove(tag);
updated = await _metadataRepository.RemoveTag(tag) || updated;
}
foreach (Tag tag in incomingTags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name)).ToList())
{
existing.Tags.Add(tag);
updated = await _musicVideoRepository.AddTag(existing, tag) || updated;
}
return updated;
}
private async Task<bool> ReconcileStudios(MusicVideoMetadata existing, MusicVideoMetadata incoming)
{
existing.Studios ??= [];
List<Studio> incomingStudios = (incoming.Studios ?? []).DistinctBy(s => s.Name).ToList();
var updated = false;
foreach (Studio studio in existing.Studios.Filter(s => incomingStudios.All(s2 => s2.Name != s.Name)).ToList())
{
existing.Studios.Remove(studio);
updated = await _metadataRepository.RemoveStudio(studio) || updated;
}
foreach (Studio studio in incomingStudios.Filter(s => existing.Studios.All(s2 => s2.Name != s.Name)).ToList())
{
existing.Studios.Add(studio);
updated = await _musicVideoRepository.AddStudio(existing, studio) || updated;
}
return updated;
}
private async Task<bool> ReconcileArtists(MusicVideoMetadata existing, MusicVideoMetadata incoming)
{
existing.Artists ??= [];
List<MusicVideoArtist> incomingArtists = (incoming.Artists ?? []).DistinctBy(a => a.Name).ToList();
var updated = false;
foreach (MusicVideoArtist artist in existing.Artists
.Filter(a => incomingArtists.All(a2 => a2.Name != a.Name)).ToList())
{
existing.Artists.Remove(artist);
updated = await _musicVideoRepository.RemoveArtist(artist) || updated;
}
foreach (MusicVideoArtist artist in incomingArtists
.Filter(a => existing.Artists.All(a2 => a2.Name != a.Name)).ToList())
{
existing.Artists.Add(artist);
updated = await _musicVideoRepository.AddArtist(existing, artist) || updated;
}
return updated;
}
}