Files
ersatztv/ErsatzTV.Core/Interfaces/Repositories/IMediaServerMusicVideoRepository.cs
T
timothy 6bd1d954bd fix(496): review fixes — thread the replaced local path, scope identity per library, sweep legacy rows [decisions-edit]
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
2026-07-25 17:20:53 +02:00

38 lines
1.9 KiB
C#

using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IMediaServerMusicVideoRepository<in TLibrary, TMusicVideo, TEtag> where TLibrary : Library
where TMusicVideo : MusicVideo
where TEtag : MediaServerItemEtag
{
Task<List<TEtag>> GetExistingMusicVideos(TLibrary library);
Task<Option<int>> FlagNormal(TLibrary library, TMusicVideo musicVideo);
Task<Option<int>> FlagUnavailable(TLibrary library, TMusicVideo musicVideo);
Task<List<int>> FlagFileNotFound(TLibrary library, List<string> musicVideoItemIds);
// Unlike the movie/other-video seams, GetOrAdd takes the resolved Artist and LibraryFolder: a music video is
// owned by an Artist (FK, not null) and ersatztv#488 requires the media file to carry its LibraryFolderId.
//
// `localPath` is the PATH-REPLACED local path and is the only path this seam may store or match on. The
// `item` argument still carries the path the media server reported, which for any install with configured
// path replacements is a DIFFERENT string — matching or storing that one would miss the row it is meant to
// find and duplicate it under a server-side path (ersatztv#496).
Task<Either<BaseError, MediaItemScanResult<TMusicVideo>>> GetOrAdd(
TLibrary library,
Artist artist,
LibraryFolder libraryFolder,
TMusicVideo item,
string localPath,
bool deepScan,
CancellationToken cancellationToken);
// ersatztv#496: music videos that predate per-item identity carry no TEtag row, so they are invisible to the
// itemId diff above. They are reconciled by their local path until a scan adopts them.
Task<List<string>> GetExistingLegacyMusicVideoPaths(TLibrary library);
Task<List<int>> FlagFileNotFoundByPaths(TLibrary library, List<string> localPaths);
Task<Unit> SetEtag(TMusicVideo musicVideo, string etag);
}