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
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>
282 lines
12 KiB
C#
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;
|
|
}
|
|
}
|