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.
417 lines
18 KiB
C#
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());
|
|
}
|
|
}
|
|
}
|