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
349 lines
15 KiB
C#
349 lines
15 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());
|
|
}
|
|
|
|
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,
|
|
bool deepScan,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
|
|
string path = item.GetHeadVersion().MediaFiles.Head().Path;
|
|
|
|
Option<JellyfinMusicVideo> maybeExisting = await GetByItemId(dbContext, 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, 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.
|
|
private static async Task<Option<JellyfinMusicVideo>> GetByItemId(
|
|
TvContext dbContext,
|
|
string itemId,
|
|
CancellationToken cancellationToken) =>
|
|
await dbContext.JellyfinMusicVideos
|
|
.AsNoTracking()
|
|
.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());
|
|
}
|
|
}
|
|
}
|