Files
ersatztv/ErsatzTV.Scanner/Core/Metadata/MediaServerMusicVideoLibraryScanner.cs
T
timothy 64decd492e fix(496): give music videos a per-library server identity; itemId diff + soft trash
Music videos carried no server identity, so JellyfinMusicVideoLibraryScanner had to
reconcile by a (LibraryPathId, path) diff and HARD-delete the remainder. A file served
by two libraries with overlapping local paths is a single row owned by whichever library
scanned it first, so that owner's sweep destroyed a row another library still served —
taking collection membership and playout references with it, irreversibly.

This is #494's deferred "option 2":

- New JellyfinMusicVideo : MusicVideo (ItemId/Etag), mirroring JellyfinMovie — TPT table,
  varchar(36), ItemId index. Dual-provider migration Add_JellyfinMusicVideo.
- New IMediaServerMusicVideoRepository + JellyfinMusicVideoRepository: itemId-keyed
  existing-set/lookup and Flag{Normal,Unavailable,FileNotFound} seams, all scoped per
  library via LibraryPath.LibraryId.
- New MediaServerMusicVideoLibraryScanner base; JellyfinMusicVideoLibraryScanner folds
  onto it and keeps the #177/#488/#497/#500 metadata-reconcile logic verbatim.
- The sweep now soft-trashes (FileNotFound) instead of deleting, so removal is reversible
  and EmptyTrash-governed. DeleteEmptyArtists consequently no longer fires from a sweep.
- Pre-identity rows are ADOPTED in place: the identity row is inserted against the same
  MediaItem id, scoped to the scanned library's own LibraryPath, so collection membership
  survives and a local/second-library row is never hijacked.
- AddMusicVideo normalizes Path/PathHash to the path-REPLACED local path; the projection
  fills them from the server-reported path, which would break every later PathHash lookup.

Docs: scan.musicvideo-reconciliation relocated to docs/decisions/archive/scan.md as
superseded; new active record scan.musicvideo-server-identity.

fixes #496
2026-07-25 17:20:53 +02:00

377 lines
14 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 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);
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,
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,
ImmutableDictionary<string, TEtag> existingMusicVideos,
CancellationToken cancellationToken)
{
// 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))
{
return;
}
var fileNotFoundItemIds = existingMusicVideos.Keys.Except(incomingItemIds).ToList();
List<int> ids = await musicVideoRepository.FlagFileNotFound(library, fileNotFoundItemIds);
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,
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);
}