Files
ersatztv/ErsatzTV.Infrastructure/Data/Repositories/JellyfinMusicVideoRepository.cs
T
timothy 147166b053 fix(496): harden the legacy path sweep against MySQL collation (re-review Low)
Re-review returned MERGEABLE with one Low, MySQL-collation-dependent edge in
FlagFileNotFoundByPaths: the C#-side Except diff is ordinal, but `MF.Path IN @LocalPaths`
runs under MySQL's case-insensitive default collation, so a still-reported identified row
differing only in case from an absent legacy row could be matched and flagged missing.

Match on the indexed PathHash instead of collated Path text, and re-state the
NOT EXISTS (JellyfinMusicVideo) guard so the identity pass and the legacy pass are disjoint
by construction rather than by the caller's diff being correct. SQLite was unaffected.
2026-07-25 17:20:53 +02:00

417 lines
18 KiB
C#

using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Infrastructure.Data.Repositories;
public class JellyfinMusicVideoRepository : IJellyfinMusicVideoRepository
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILogger<JellyfinMusicVideoRepository> _logger;
public JellyfinMusicVideoRepository(
IDbContextFactory<TvContext> dbContextFactory,
ILogger<JellyfinMusicVideoRepository> logger)
{
_dbContextFactory = dbContextFactory;
_logger = logger;
}
public async Task<List<JellyfinItemEtag>> GetExistingMusicVideos(JellyfinLibrary library)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<JellyfinItemEtag>(
@"SELECT ItemId, Etag, MI.State FROM JellyfinMusicVideo
INNER JOIN MusicVideo M on JellyfinMusicVideo.Id = M.Id
INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId",
new { LibraryId = library.Id })
.Map(result => result.ToList());
}
// ersatztv#496: music videos in this library that carry NO identity row yet — everything written by the
// pre-#496 path-keyed scanner. They are invisible to GetExistingMusicVideos, so without this they could
// never be reconciled: a legacy row Jellyfin had already stopped reporting would never be adopted (adoption
// only ever runs for an INCOMING item) and never swept, leaving it Normal and schedulable forever.
public async Task<List<string>> GetExistingLegacyMusicVideoPaths(JellyfinLibrary library)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<string>(
@"SELECT MF.Path
FROM MediaFile MF
INNER JOIN MediaVersion MV on MF.MediaVersionId = MV.Id
INNER JOIN MusicVideo M on MV.MusicVideoId = M.Id
INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId
AND NOT EXISTS (SELECT 1 FROM JellyfinMusicVideo J WHERE J.Id = M.Id)",
new { LibraryId = library.Id })
.Map(result => result.ToList());
}
public async Task<List<int>> FlagFileNotFoundByPaths(JellyfinLibrary library, List<string> localPaths)
{
if (localPaths.Count == 0)
{
return [];
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
// Matched on the indexed PathHash, not the collated Path text, and re-stating the identity-less guard so
// the two flag passes are disjoint BY CONSTRUCTION rather than by the caller's diff being correct. Under
// MySQL's case-insensitive default collation a `Path IN (...)` comparison can match a row the ordinal
// C#-side Except had already excluded — which without the NOT EXISTS would flag a still-reported,
// already-adopted row as missing (ersatztv#496 re-review).
var pathHashes = localPaths.Map(PathUtils.GetPathHash).Distinct().ToList();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT M.Id
FROM MusicVideo M
INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN MediaVersion MV on M.Id = MV.MusicVideoId
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId
AND MF.PathHash IN @PathHashes
AND NOT EXISTS (SELECT 1 FROM JellyfinMusicVideo J WHERE J.Id = M.Id)",
new { LibraryId = library.Id, PathHashes = pathHashes })
.Map(result => result.ToList());
if (ids.Count > 0)
{
await dbContext.Connection.ExecuteAsync(
"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids AND State != 1",
new { Ids = ids });
}
return ids;
}
public async Task<Option<int>> FlagNormal(JellyfinLibrary library, JellyfinMusicVideo musicVideo)
{
if (musicVideo.State is MediaItemState.Normal)
{
return Option<int>.None;
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
musicVideo.State = MediaItemState.Normal;
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT JellyfinMusicVideo.Id FROM JellyfinMusicVideo
INNER JOIN MediaItem MI ON MI.Id = JellyfinMusicVideo.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE JellyfinMusicVideo.ItemId = @ItemId",
new { LibraryId = library.Id, musicVideo.ItemId });
foreach (int id in maybeId)
{
return await dbContext.Connection.ExecuteAsync(
"UPDATE MediaItem SET State = 0 WHERE Id = @Id AND State != 0",
new { Id = id }).Map(count => count > 0 ? Some(id) : None);
}
return None;
}
public async Task<Option<int>> FlagUnavailable(JellyfinLibrary library, JellyfinMusicVideo musicVideo)
{
if (musicVideo.State is MediaItemState.Unavailable)
{
return Option<int>.None;
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
musicVideo.State = MediaItemState.Unavailable;
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT JellyfinMusicVideo.Id FROM JellyfinMusicVideo
INNER JOIN MediaItem MI ON MI.Id = JellyfinMusicVideo.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE JellyfinMusicVideo.ItemId = @ItemId",
new { LibraryId = library.Id, musicVideo.ItemId });
foreach (int id in maybeId)
{
return await dbContext.Connection.ExecuteAsync(
"UPDATE MediaItem SET State = 2 WHERE Id = @Id AND State != 2",
new { Id = id }).Map(count => count > 0 ? Some(id) : None);
}
return None;
}
public async Task<List<int>> FlagFileNotFound(JellyfinLibrary library, List<string> musicVideoItemIds)
{
if (musicVideoItemIds.Count == 0)
{
return [];
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT M.Id
FROM MediaItem M
INNER JOIN JellyfinMusicVideo ON JellyfinMusicVideo.Id = M.Id
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
WHERE JellyfinMusicVideo.ItemId IN @MusicVideoItemIds",
new { LibraryId = library.Id, MusicVideoItemIds = musicVideoItemIds })
.Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids AND State != 1",
new { Ids = ids });
return ids;
}
public async Task<Either<BaseError, MediaItemScanResult<JellyfinMusicVideo>>> GetOrAdd(
JellyfinLibrary library,
Artist artist,
LibraryFolder libraryFolder,
JellyfinMusicVideo item,
string localPath,
bool deepScan,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
// NEVER item.GetHeadVersion()...Path — that is the path the media server reported, which differs from the
// local path on any install with path replacements configured (ersatztv#496).
string path = localPath;
Option<JellyfinMusicVideo> maybeExisting =
await GetByItemId(dbContext, library.Id, item.ItemId, cancellationToken);
// ersatztv#496: rows created before this library gained per-item identity (and rows created by the
// pre-#496 path-keyed scanner) carry no JellyfinMusicVideo row, so they can never be found by ItemId.
// Adopt them IN PLACE — insert the identity row against the SAME MediaItem id — rather than deleting and
// re-adding: the MediaItem id is referenced by CollectionItem, playouts, artwork and the search index, so
// a delete/re-add would silently drop collection membership and break built playouts.
//
// Adoption is scoped to THIS library's own library path, which is the whole point of the issue: a music
// video owned by a local library (MusicVideoFolderScanner uses the same MusicVideo table) or by a second
// Jellyfin library must never be hijacked into this library's identity.
if (maybeExisting.IsNone)
{
Option<int> maybeAdopted = await AdoptExistingMusicVideo(
dbContext,
library.Paths.Head().Id,
path,
item.ItemId);
if (maybeAdopted.IsSome)
{
_logger.LogDebug(
"ADOPT: existing music video {Path} now carries Jellyfin item id {ItemId}",
path,
item.ItemId);
// the identity row is written with an empty etag, so the existing-item path below always sees an
// etag mismatch and refreshes the adopted row exactly like any other changed item
maybeExisting = await GetByItemId(dbContext, library.Id, item.ItemId, cancellationToken);
}
}
foreach (JellyfinMusicVideo existing in maybeExisting)
{
var result = new MediaItemScanResult<JellyfinMusicVideo>(existing) { IsAdded = false };
// identity is now the server item id, so the file behind it can move; keep the local path, owning
// folder and artist in sync when the server says the item changed
if (existing.Etag != item.Etag || deepScan)
{
await UpdateMusicVideoFile(dbContext, existing.Id, artist.Id, libraryFolder.Id, path);
existing.ArtistId = artist.Id;
existing.Artist = artist;
result.IsUpdated = true;
}
else if (existing.ArtistId != artist.Id)
{
await dbContext.Connection.ExecuteAsync(
"UPDATE MusicVideo SET ArtistId = @ArtistId WHERE Id = @Id",
new { Id = existing.Id, ArtistId = artist.Id });
existing.ArtistId = artist.Id;
existing.Artist = artist;
result.IsUpdated = true;
}
return result;
}
return await AddMusicVideo(dbContext, library, artist, libraryFolder, item, path, cancellationToken);
}
public async Task<Unit> SetEtag(JellyfinMusicVideo musicVideo, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE JellyfinMusicVideo SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, musicVideo.Id }).Map(_ => Unit.Default);
}
// Mirrors MusicVideoRepository.GetOrAdd's eager-load set: the scanner's metadata reconcile (ersatztv#497)
// only reconciles collections that are loaded here, so this list is load-bearing, not cosmetic.
// Scoped to the scanning library, not just the item id: the seam's contract is per-library identity, and two
// media sources can present the same item id (a cloned Jellyfin database), where a global match would let one
// library silently rewrite another library's row (ersatztv#496 review finding).
private static async Task<Option<JellyfinMusicVideo>> GetByItemId(
TvContext dbContext,
int libraryId,
string itemId,
CancellationToken cancellationToken) =>
await dbContext.JellyfinMusicVideos
.AsNoTracking()
.Filter(mv => mv.LibraryPath.LibraryId == libraryId)
.Include(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artwork)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artists)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Genres)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Tags)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Studios)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Directors)
.Include(mv => mv.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(mv => mv.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mv => mv.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mv => mv.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.SelectOneAsync(mv => mv.ItemId, mv => mv.ItemId == itemId, cancellationToken);
private static async Task<Option<int>> AdoptExistingMusicVideo(
TvContext dbContext,
int libraryPathId,
string path,
string itemId)
{
Option<int> maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync<int?>(
@"SELECT M.Id
FROM MusicVideo M
INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN MediaVersion MV on M.Id = MV.MusicVideoId
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
WHERE MI.LibraryPathId = @LibraryPathId
AND MF.PathHash = @PathHash
AND NOT EXISTS (SELECT 1 FROM JellyfinMusicVideo J WHERE J.Id = M.Id)",
new { LibraryPathId = libraryPathId, PathHash = PathUtils.GetPathHash(path) })
.Map(Optional);
foreach (int id in maybeId)
{
await dbContext.Connection.ExecuteAsync(
"INSERT INTO JellyfinMusicVideo (Id, ItemId, Etag) VALUES (@Id, @ItemId, '')",
new { Id = id, ItemId = itemId });
return id;
}
return None;
}
private static async Task UpdateMusicVideoFile(
TvContext dbContext,
int musicVideoId,
int artistId,
int libraryFolderId,
string path)
{
await dbContext.Connection.ExecuteAsync(
"UPDATE MusicVideo SET ArtistId = @ArtistId WHERE Id = @Id",
new { Id = musicVideoId, ArtistId = artistId });
await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaFile SET Path = @Path, PathHash = @PathHash, LibraryFolderId = @LibraryFolderId
WHERE Id IN (SELECT MF.Id
FROM MediaFile MF
INNER JOIN MediaVersion MV on MF.MediaVersionId = MV.Id
WHERE MV.MusicVideoId = @Id)",
new
{
Id = musicVideoId,
Path = path,
PathHash = PathUtils.GetPathHash(path),
LibraryFolderId = libraryFolderId
});
}
private async Task<Either<BaseError, MediaItemScanResult<JellyfinMusicVideo>>> AddMusicVideo(
TvContext dbContext,
JellyfinLibrary library,
Artist artist,
LibraryFolder libraryFolder,
JellyfinMusicVideo musicVideo,
string path,
CancellationToken cancellationToken)
{
try
{
if (await MediaItemRepository.MediaFileAlreadyExists(
path,
library.Paths.Head().Id,
dbContext,
_logger,
cancellationToken))
{
return new MediaFileAlreadyExists();
}
// blank out etag for initial save in case other updates fail
string etag = musicVideo.Etag;
musicVideo.Etag = string.Empty;
musicVideo.ArtistId = artist.Id;
musicVideo.LibraryPathId = library.Paths.Head().Id;
// Music videos store the PATH-REPLACED local path (they always have — the pre-#496 scanner passed the
// replaced path straight into GetOrAdd), while the projection fills Path/PathHash from the path the
// media server reported. Normalize both here, or the stored hash would be the hash of the server-side
// path and every later lookup by PathHash — MediaFileAlreadyExists, and #496's adoption probe — would
// miss the row it is meant to find.
MediaFile file = musicVideo.GetHeadVersion().MediaFiles.Head();
file.Path = path;
file.PathHash = PathUtils.GetPathHash(path);
file.LibraryFolderId = libraryFolder.Id;
await dbContext.AddAsync(musicVideo, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
// restore etag
musicVideo.Etag = etag;
await dbContext.Entry(musicVideo).Reference(mv => mv.Artist).LoadAsync(cancellationToken);
await dbContext.Entry(musicVideo.Artist).Collection(a => a.ArtistMetadata).LoadAsync(cancellationToken);
await dbContext.Entry(musicVideo).Reference(mv => mv.LibraryPath).LoadAsync(cancellationToken);
await dbContext.Entry(musicVideo.LibraryPath).Reference(lp => lp.Library).LoadAsync(cancellationToken);
return new MediaItemScanResult<JellyfinMusicVideo>(musicVideo) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
}