Independent cold review (Codex) returned BLOCKED. Findings 1, 3 and 4 are fixed here;
each has a regression test proven non-vacuous by a negative control.
1. Blocker — the replaced local path was discarded. The scanner computed localPath but
GetOrAdd only received `incoming`, so the repository re-derived the path from the
UNREPLACED projection. On any install with path replacements, adoption hashed the
server-side path, missed the existing row, ALSO slipped past MediaFileAlreadyExists
(which hashes that same wrong string) and inserted a duplicate row under a server path,
leaving the original collection-linked row identity-less forever. The test harness hid
this because its path-replacement stub was an identity function.
→ GetOrAdd now takes localPath explicitly and never reads the projection's path;
BuildPathReplacement takes a real mapping and the new test genuinely replaces.
3. Medium — GetByItemId matched on ItemId alone, so two media sources presenting the same
item id (cloned Jellyfin DB) resolved to each other's row, letting one library repoint
another's. → filtered by LibraryPath.LibraryId.
4. Medium — a row predating the identity that the server had ALREADY stopped reporting was
never adopted (adoption only runs for an incoming item) and carried no identity, so the
itemId diff could not see it either: it sat Normal and schedulable forever, strictly
worse than the hard delete it replaced. → GetExistingLegacyMusicVideoPaths +
FlagFileNotFoundByPaths reconcile legacy rows by local path, and they are counted into
the #477 empty-fetch guard (on the first scan after this ships they ARE the whole
library, so a guard counting only identity rows would sweep all of them on a transient
empty fetch).
Finding 2 (the issue's Done-when #2) is a scope question, not a defect, and is unchanged:
one file path is still one MediaItem row globally, so this lands music videos at parity
with movies rather than eliminating shared-row trashing. Recorded honestly in the decision
record; raised for an explicit call before the issue is closed.
fixes #496
393 lines
16 KiB
C#
393 lines
16 KiB
C#
using System.Collections.Immutable;
|
|
using System.IO.Abstractions;
|
|
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Domain.MediaServer;
|
|
using ErsatzTV.Core.Errors;
|
|
using ErsatzTV.Core.Extensions;
|
|
using ErsatzTV.Core.Interfaces.Repositories;
|
|
using ErsatzTV.Core.Metadata;
|
|
using ErsatzTV.Scanner.Core.Interfaces;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ErsatzTV.Scanner.Core.Metadata;
|
|
|
|
// ersatztv#496: music videos used to be reconciled by a LIBRARY-SCOPED local-path diff plus hard delete, because
|
|
// they carried no server identity. They now carry one (JellyfinMusicVideo.ItemId/Etag), so this scanner is the
|
|
// music-video sibling of MediaServer{Movie,Television,OtherVideo}LibraryScanner: it diffs on the server item id
|
|
// and soft-trashes (FileNotFound) instead of deleting, which is reversible and EmptyTrash-governed.
|
|
public abstract class MediaServerMusicVideoLibraryScanner<TConnectionParameters, TLibrary, TMusicVideo, TEtag>
|
|
where TConnectionParameters : MediaServerConnectionParameters
|
|
where TLibrary : Library
|
|
where TMusicVideo : MusicVideo
|
|
where TEtag : MediaServerItemEtag
|
|
{
|
|
private const string UnknownArtist = "Unknown Artist";
|
|
|
|
private readonly IArtistRepository _artistRepository;
|
|
private readonly IFileSystem _fileSystem;
|
|
private readonly ILibraryRepository _libraryRepository;
|
|
private readonly ILogger _logger;
|
|
private readonly IMetadataRepository _metadataRepository;
|
|
private readonly IScannerProxy _scannerProxy;
|
|
|
|
protected MediaServerMusicVideoLibraryScanner(
|
|
IScannerProxy scannerProxy,
|
|
IFileSystem fileSystem,
|
|
IArtistRepository artistRepository,
|
|
ILibraryRepository libraryRepository,
|
|
IMetadataRepository metadataRepository,
|
|
ILogger logger)
|
|
{
|
|
_scannerProxy = scannerProxy;
|
|
_fileSystem = fileSystem;
|
|
_artistRepository = artistRepository;
|
|
_libraryRepository = libraryRepository;
|
|
_metadataRepository = metadataRepository;
|
|
_logger = logger;
|
|
}
|
|
|
|
protected async Task<Either<BaseError, Unit>> ScanLibrary(
|
|
IMediaServerMusicVideoRepository<TLibrary, TMusicVideo, TEtag> musicVideoRepository,
|
|
TConnectionParameters connectionParameters,
|
|
TLibrary library,
|
|
LibraryPath libraryPath,
|
|
Func<TMusicVideo, string> getLocalPath,
|
|
bool deepScan,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
return await ScanLibrary(
|
|
musicVideoRepository,
|
|
connectionParameters,
|
|
library,
|
|
libraryPath,
|
|
getLocalPath,
|
|
GetMusicVideoLibraryItems(connectionParameters, library),
|
|
deepScan,
|
|
cancellationToken);
|
|
}
|
|
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
|
{
|
|
return new ScanCanceled();
|
|
}
|
|
}
|
|
|
|
private async Task<Either<BaseError, Unit>> ScanLibrary(
|
|
IMediaServerMusicVideoRepository<TLibrary, TMusicVideo, TEtag> musicVideoRepository,
|
|
TConnectionParameters connectionParameters,
|
|
TLibrary library,
|
|
LibraryPath libraryPath,
|
|
Func<TMusicVideo, string> getLocalPath,
|
|
IAsyncEnumerable<Tuple<TMusicVideo, int>> musicVideoEntries,
|
|
bool deepScan,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var incomingItemIds = new List<string>();
|
|
var incomingLocalPaths = new List<string>();
|
|
var existingMusicVideos = (await musicVideoRepository.GetExistingMusicVideos(library))
|
|
.ToImmutableDictionary(e => e.MediaServerItemId, e => e);
|
|
|
|
await foreach ((TMusicVideo incoming, int totalMusicVideoCount) in musicVideoEntries.WithCancellation(
|
|
cancellationToken))
|
|
{
|
|
if (cancellationToken.IsCancellationRequested)
|
|
{
|
|
return new ScanCanceled();
|
|
}
|
|
|
|
incomingItemIds.Add(MediaServerItemId(incoming));
|
|
|
|
decimal percentCompletion = totalMusicVideoCount == 0
|
|
? 1
|
|
: Math.Clamp((decimal)incomingItemIds.Count / totalMusicVideoCount, 0, 1);
|
|
if (!await _scannerProxy.UpdateProgress(percentCompletion, cancellationToken))
|
|
{
|
|
return new ScanCanceled();
|
|
}
|
|
|
|
string localPath = getLocalPath(incoming);
|
|
incomingLocalPaths.Add(localPath);
|
|
|
|
if (!await ShouldScanItem(
|
|
musicVideoRepository,
|
|
library,
|
|
existingMusicVideos,
|
|
incoming,
|
|
localPath,
|
|
deepScan))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
Either<BaseError, MediaItemScanResult<TMusicVideo>> maybeMusicVideo =
|
|
await ProcessMusicVideo(
|
|
musicVideoRepository,
|
|
library,
|
|
libraryPath,
|
|
incoming,
|
|
localPath,
|
|
deepScan,
|
|
cancellationToken);
|
|
|
|
if (maybeMusicVideo.IsLeft)
|
|
{
|
|
foreach (BaseError error in maybeMusicVideo.LeftToSeq())
|
|
{
|
|
_logger.LogWarning("Error processing music video: {Error}", error.Value);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
foreach (MediaItemScanResult<TMusicVideo> result in maybeMusicVideo.RightToSeq())
|
|
{
|
|
await musicVideoRepository.SetEtag(result.Item, MediaServerEtag(incoming));
|
|
|
|
if (_fileSystem.File.Exists(result.LocalPath))
|
|
{
|
|
Option<int> flagResult = await musicVideoRepository.FlagNormal(library, result.Item);
|
|
if (flagResult.IsSome)
|
|
{
|
|
result.IsUpdated = true;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Option<int> flagResult = await musicVideoRepository.FlagUnavailable(library, result.Item);
|
|
if (flagResult.IsSome)
|
|
{
|
|
result.IsUpdated = true;
|
|
}
|
|
}
|
|
|
|
if (result.IsAdded || result.IsUpdated)
|
|
{
|
|
if (!await _scannerProxy.ReindexMediaItems([result.Item.Id], cancellationToken))
|
|
{
|
|
_logger.LogWarning("Failed to reindex media items from scanner process");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
await TrashMissingMusicVideos(
|
|
musicVideoRepository,
|
|
library,
|
|
libraryPath,
|
|
incomingItemIds,
|
|
incomingLocalPaths,
|
|
existingMusicVideos,
|
|
cancellationToken);
|
|
|
|
return Unit.Default;
|
|
}
|
|
|
|
// Soft-trash music videos the media server no longer reports. Identity is the SERVER ITEM ID, scoped to this
|
|
// library by GetExistingMusicVideos/FlagFileNotFound (both join LibraryPath.LibraryId), so a file served by two
|
|
// libraries with overlapping local paths is two independent identities and one library's sweep can no longer
|
|
// reach the other's row — the ersatztv#496 cross-library false-trash this replaced.
|
|
private async Task TrashMissingMusicVideos(
|
|
IMediaServerMusicVideoRepository<TLibrary, TMusicVideo, TEtag> musicVideoRepository,
|
|
TLibrary library,
|
|
LibraryPath libraryPath,
|
|
List<string> incomingItemIds,
|
|
List<string> incomingLocalPaths,
|
|
ImmutableDictionary<string, TEtag> existingMusicVideos,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Legacy rows (no identity yet) are reconciled by local path. They are counted into the guard's existing
|
|
// set below, because on the FIRST scan after the identity landed they are the entire library — a guard
|
|
// that only saw the (empty) identity set would let a zero-item fetch sweep every one of them.
|
|
List<string> existingLegacyPaths = await musicVideoRepository.GetExistingLegacyMusicVideoPaths(library);
|
|
|
|
// ersatztv#477: refuse the sweep when a successful fetch returned zero items but rows exist locally — an
|
|
// empty incoming set is indistinguishable from a transient error and would otherwise flag a whole library.
|
|
if (!MediaServerReconciliationGuard.ShouldFlagMissing(
|
|
_logger,
|
|
library.Name,
|
|
incomingItemIds.Count,
|
|
existingMusicVideos.Count + existingLegacyPaths.Count))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var fileNotFoundItemIds = existingMusicVideos.Keys.Except(incomingItemIds).ToList();
|
|
List<int> ids = await musicVideoRepository.FlagFileNotFound(library, fileNotFoundItemIds);
|
|
|
|
// A legacy row whose file IS still reported was adopted during this scan's loop, so it is no longer
|
|
// legacy; what remains here is only what the server has genuinely stopped reporting.
|
|
var fileNotFoundPaths = existingLegacyPaths.Except(incomingLocalPaths).ToList();
|
|
ids = ids.Concat(await musicVideoRepository.FlagFileNotFoundByPaths(library, fileNotFoundPaths)).ToList();
|
|
|
|
if (ids.Count > 0 && !await _scannerProxy.ReindexMediaItems(ids.ToArray(), cancellationToken))
|
|
{
|
|
_logger.LogWarning("Failed to reindex media items from scanner process");
|
|
}
|
|
|
|
// Trashed music videos are still rows, so an artist only becomes empty once the trash is emptied (or its
|
|
// items are removed some other way); this keeps ersatztv#494's empty-artist cleanup without the delete.
|
|
List<int> artistIds = await _artistRepository.DeleteEmptyArtists(libraryPath);
|
|
if (artistIds.Count > 0 && !await _scannerProxy.RemoveMediaItems(artistIds.ToArray(), cancellationToken))
|
|
{
|
|
_logger.LogWarning("Failed to remove empty artists from scanner process");
|
|
}
|
|
}
|
|
|
|
private async Task<Either<BaseError, MediaItemScanResult<TMusicVideo>>> ProcessMusicVideo(
|
|
IMediaServerMusicVideoRepository<TLibrary, TMusicVideo, TEtag> musicVideoRepository,
|
|
TLibrary library,
|
|
LibraryPath libraryPath,
|
|
TMusicVideo incoming,
|
|
string localPath,
|
|
bool deepScan,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// ersatztv#488: resolve the owning folder from the DB, never from libraryPath.LibraryFolders (that
|
|
// navigation is only eager-loaded on the local scan path and is null for media-server callers).
|
|
string folder = Path.GetDirectoryName(localPath) ?? libraryPath.Path;
|
|
Option<int> maybeParentFolder =
|
|
await _libraryRepository.GetParentFolderId(libraryPath, folder, cancellationToken);
|
|
LibraryFolder libraryFolder = await _libraryRepository.GetOrAddFolder(libraryPath, maybeParentFolder, folder);
|
|
|
|
return await GetOrAddArtist(libraryPath, incoming)
|
|
.BindT(artist => musicVideoRepository.GetOrAdd(
|
|
library,
|
|
artist,
|
|
libraryFolder,
|
|
incoming,
|
|
localPath,
|
|
deepScan,
|
|
cancellationToken))
|
|
.MapT(result =>
|
|
{
|
|
result.LocalPath = localPath;
|
|
return result;
|
|
})
|
|
.BindT(result => UpdateMetadata(result, incoming, cancellationToken))
|
|
.BindT(result => UpdateStatistics(result, incoming));
|
|
}
|
|
|
|
private async Task<Either<BaseError, Artist>> GetOrAddArtist(LibraryPath libraryPath, TMusicVideo musicVideo)
|
|
{
|
|
string artistName = musicVideo.MusicVideoMetadata
|
|
.HeadOrNone()
|
|
.Bind(metadata => Optional(metadata.Artists).Flatten().HeadOrNone())
|
|
.Map(artist => artist.Name)
|
|
.IfNone(UnknownArtist);
|
|
|
|
var metadata = new ArtistMetadata
|
|
{
|
|
MetadataKind = MetadataKind.External,
|
|
Title = artistName,
|
|
SortTitle = SortTitle.GetSortTitle(artistName),
|
|
DateAdded = DateTime.UtcNow,
|
|
Genres = [],
|
|
Styles = [],
|
|
Moods = [],
|
|
Artwork = [],
|
|
Guids = [],
|
|
Subtitles = []
|
|
};
|
|
|
|
Option<Artist> maybeArtist = await _artistRepository.GetArtistByMetadata(libraryPath.Id, metadata);
|
|
foreach (Artist artist in maybeArtist)
|
|
{
|
|
return artist;
|
|
}
|
|
|
|
Either<BaseError, MediaItemScanResult<Artist>> result =
|
|
await _artistRepository.AddArtist(libraryPath.Id, artistName, metadata);
|
|
return result.Map(scanResult => scanResult.Item);
|
|
}
|
|
|
|
private async Task<Either<BaseError, MediaItemScanResult<TMusicVideo>>> UpdateStatistics(
|
|
MediaItemScanResult<TMusicVideo> result,
|
|
TMusicVideo incoming)
|
|
{
|
|
if (await _metadataRepository.UpdateStatistics(result.Item, incoming.GetHeadVersion()))
|
|
{
|
|
result.IsUpdated = true;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private async Task<bool> ShouldScanItem(
|
|
IMediaServerMusicVideoRepository<TLibrary, TMusicVideo, TEtag> musicVideoRepository,
|
|
TLibrary library,
|
|
ImmutableDictionary<string, TEtag> existingMusicVideos,
|
|
TMusicVideo incoming,
|
|
string localPath,
|
|
bool deepScan)
|
|
{
|
|
// deep scan will always pull every music video
|
|
if (deepScan)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string existingEtag = string.Empty;
|
|
MediaItemState existingState = MediaItemState.Normal;
|
|
if (existingMusicVideos.TryGetValue(MediaServerItemId(incoming), out TEtag? existingEntry))
|
|
{
|
|
existingEtag = existingEntry.Etag;
|
|
existingState = existingEntry.State;
|
|
}
|
|
|
|
if (existingState is MediaItemState.Unavailable or MediaItemState.FileNotFound &&
|
|
existingEtag == MediaServerEtag(incoming))
|
|
{
|
|
// skip scanning unavailable/file not found items that are unchanged and still don't exist locally
|
|
if (!_fileSystem.File.Exists(localPath))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
else if (existingEtag == MediaServerEtag(incoming))
|
|
{
|
|
// item is unchanged, but file does not exist
|
|
// don't scan, but mark as unavailable
|
|
if (!_fileSystem.File.Exists(localPath))
|
|
{
|
|
if (existingState is not MediaItemState.Unavailable)
|
|
{
|
|
foreach (int id in await musicVideoRepository.FlagUnavailable(library, incoming))
|
|
{
|
|
if (!await _scannerProxy.ReindexMediaItems([id], CancellationToken.None))
|
|
{
|
|
_logger.LogWarning("Failed to reindex media items from scanner process");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
if (existingEntry is null)
|
|
{
|
|
_logger.LogDebug("INSERT: new music video {Path}", localPath);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogDebug("UPDATE: Etag has changed for music video {Path}", localPath);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
protected abstract string MediaServerItemId(TMusicVideo musicVideo);
|
|
protected abstract string MediaServerEtag(TMusicVideo musicVideo);
|
|
|
|
protected abstract IAsyncEnumerable<Tuple<TMusicVideo, int>> GetMusicVideoLibraryItems(
|
|
TConnectionParameters connectionParameters,
|
|
TLibrary library);
|
|
|
|
protected abstract Task<Either<BaseError, MediaItemScanResult<TMusicVideo>>> UpdateMetadata(
|
|
MediaItemScanResult<TMusicVideo> result,
|
|
TMusicVideo incoming,
|
|
CancellationToken cancellationToken);
|
|
}
|